feat(insights): ISQ deterministic story ranking + theater pipeline fixes (#2434)

* feat(insights): ISQ deterministic story ranking + theater pipeline fixes

Replace keyword-based getImportanceScore() with ISQ (Investment Signal
Quality), a deterministic 4-dimension scorer using CII scores, focal
point correlation, velocity, and source confidence — no browser ML
dependency.

Also fixes 4 bugs in the theater posture pipeline that caused Iran and
other theater-attributed countries to silently disappear from AI Insights
focal points:

- coordsToCountry() → coordsToCountryWithFallback() so vessels/flights
  over international waters (Persian Gulf, Taiwan Strait) are attributed
  to their theater country instead of returning XX
- getCachedPosture() is now the primary source for theater postures in
  both updateFromServer and updateFromClient, removing the timing-
  dependent guard that skipped ingestion when client flights hadn't
  loaded yet
- Removed hasFlight/hasVessel deduplication guards from
  ingestTheaterPostures that prevented re-ingestion when a misattributed
  signal existed
- Both AI Insights and AI Strategic Posture now consume the same
  getCachedPosture() source, eliminating dual-pipeline divergence

ISQ details:
- New src/utils/signal-quality.ts: normalizeThreatLevel(), computeISQ()
  with 4 weight profiles, handles freeform server threatLevel strings
  (elevated, moderate) not in the TypeScript union
- Focal context passed as functions not singletons to avoid stale-state
  timing bugs; ranking happens AFTER focal+CII refresh
- Signal-backed focal points only (signalCount > 0 || active_strike)
  used for expectation gap — matches renderFocalPoints() filter
- isFocalDataAvailableFn tri-state prevents false novelty bonus when
  analyze() runs with empty clusters
- Server path re-sorts stories by ISQ before sentiment classification
  (positional index alignment preserved via shallow copy)
- ML DETECTED section gated behind localStorage debug flag
- 23 unit tests covering normalization, tri-state gap, tier bounds, all
  weight profiles

* fix(isq): theater posture double-count and server velocity omission

Two bugs surfaced in PR review:

1. ingestTheaterPostures() was not idempotent — it pushed new
   military_flight/vessel signals on every InsightsPanel refresh
   without clearing the previous ones, inflating focal scores and
   CII on every rerender. Fix: track theater-added signals in a
   private array and remove them at the start of each call.

2. Server-path ISQ sort omitted velocity, causing spike/elevated
   stories to fall back to normal timeliness baseline and diverge
   from client-path ranking. Fix: pass a.velocity/b.velocity into
   computeISQ; make velocity.trend optional in SignalQualityInput
   since ServerInsightStory does not carry trend.

* fix(isq): three review findings — theater dedup, actor attribution, brief context

P1 — Within-cycle double-count in ingestTheaterPostures:
coordsToCountryWithFallback already attributes real flights/vessels
to theater countries (IR, TW, etc). Theater posture was adding a
second military_flight/vessel signal on top. Fix: collect active
theater codes first, remove any existing military_flight/vessel
signals for those countries before adding the theater summary.
Theater posture is now the single authoritative source for its
target theaters.

P2 — Wrong country assigned via shared-actor keywords:
extractISQInput took the first country entity from title extraction,
but keyword matches (confidence 0.7) expand shared terms like
"hezbollah" to both IR and IL, and "hamas" to both IL and QA.
Fix: only accept alias-matched country entities (direct country name
mentioned, confidence >= 0.85) for ISQ country attribution. Titles
with only actor-keyword matches get countryCode null (neutral gap),
which is the correct conservative behavior.

P3 — World Brief prompt did not see cached theater posture:
getTheaterPostureContext() returned early if lastMilitaryFlights was
empty, so in the "flights not loaded yet, cached posture exists" case
the brief had no theater context while scoring did. Fix: use same
getCachedPosture() ?? flights fallback pattern as the scoring path.

* fix(signal-aggregator): type-specific theater dedup to prevent undercount

Previous fix cleared both military_flight and military_vessel signals
for any active theater country, but the re-add is conditional per type
(only when totalAircraft > 0 / totalVessels > 0). An aircraft-only
theater posture would drop real vessel signals for that country with
no replacement.

Fix: track activeFlightCodes and activeVesselCodes separately, then
filter by matching country+type pair. Each signal type is only removed
when theater posture will actually replace it.

* fix(insights): cached postures empty-array must not suppress live-flight fallback

getCachedPosture()?.postures ?? fallback only falls back on null/undefined.
An empty array [] from a backend that returned no theaters writes to
localStorage, rehydrates as fresh, and blocks the live-flight path
indefinitely.

Fix all three call sites (getTheaterPostureContext, updateInsights x2):
guard on .length so an empty cached array still triggers the fallback
to getTheaterPostureSummaries(lastMilitaryFlights).
This commit is contained in:
Elie Habib
2026-03-28 19:52:59 +04:00
committed by GitHub
parent b83bf415dc
commit 0989f99ae3
4 changed files with 353 additions and 169 deletions

View File

@@ -5,8 +5,9 @@ import { parallelAnalysis, type AnalyzedHeadline } from '@/services/parallel-ana
import { signalAggregator, type RegionalConvergence } from '@/services/signal-aggregator';
import { focalPointDetector } from '@/services/focal-point-detector';
import { stripOrefLabels } from '@/services/oref-alerts';
import { ingestNewsForCII } from '@/services/country-instability';
import { ingestNewsForCII, getCountryScore } from '@/services/country-instability';
import { getTheaterPostureSummaries } from '@/services/military-surge';
import { getCachedPosture } from '@/services/cached-theater-posture';
import { isMobileDevice } from '@/utils';
import { escapeHtml, sanitizeUrl } from '@/utils/sanitize';
import { SITE_VARIANT } from '@/config';
@@ -18,6 +19,10 @@ import { getActiveFrameworkForPanel, subscribeFrameworkChange } from '@/services
import { hasPremiumAccess } from '@/services/panel-gating';
import { FrameworkSelector } from './FrameworkSelector';
import { getServerInsights, type ServerInsights, type ServerInsightStory } from '@/services/insights-loader';
import { computeISQ, type SignalQuality, type SignalQualityInput } from '@/utils/signal-quality';
import { extractEntitiesFromTitle } from '@/services/entity-extraction';
import { getEntityIndex } from '@/services/entity-index';
import type { ClusteredEvent, FocalPoint, MilitaryFlight } from '@/types';
export class InsightsPanel extends Panel {
@@ -65,11 +70,11 @@ export class InsightsPanel extends Panel {
}
private getTheaterPostureContext(): string {
if (this.lastMilitaryFlights.length === 0) {
return '';
}
const cachedPostures = getCachedPosture()?.postures;
const postures = cachedPostures?.length
? cachedPostures
: (this.lastMilitaryFlights.length > 0 ? getTheaterPostureSummaries(this.lastMilitaryFlights) : []);
const postures = getTheaterPostureSummaries(this.lastMilitaryFlights);
const significant = postures.filter(
(p) => p.postureLevel === 'critical' || p.postureLevel === 'elevated' || p.strikeCapable
);
@@ -100,149 +105,59 @@ export class InsightsPanel extends Panel {
this.lastBriefUpdate = entry.updatedAt;
return true;
}
// High-priority military/conflict keywords (huge boost)
private static readonly MILITARY_KEYWORDS = [
'war', 'armada', 'invasion', 'airstrike', 'strike', 'missile', 'troops',
'deployed', 'offensive', 'artillery', 'bomb', 'combat', 'fleet', 'warship',
'carrier', 'navy', 'airforce', 'deployment', 'mobilization', 'attack',
];
// Violence/casualty keywords (huge boost - human cost stories)
private static readonly VIOLENCE_KEYWORDS = [
'killed', 'dead', 'death', 'shot', 'blood', 'massacre', 'slaughter',
'fatalities', 'casualties', 'wounded', 'injured', 'murdered', 'execution',
'crackdown', 'violent', 'clashes', 'gunfire', 'shooting',
];
// Civil unrest keywords (high boost)
private static readonly UNREST_KEYWORDS = [
'protest', 'protests', 'uprising', 'revolt', 'revolution', 'riot', 'riots',
'demonstration', 'unrest', 'dissent', 'rebellion', 'insurgent', 'overthrow',
'coup', 'martial law', 'curfew', 'shutdown', 'blackout',
];
// Geopolitical flashpoints (major boost)
private static readonly FLASHPOINT_KEYWORDS = [
'iran', 'tehran', 'russia', 'moscow', 'china', 'beijing', 'taiwan', 'ukraine', 'kyiv',
'north korea', 'pyongyang', 'israel', 'gaza', 'west bank', 'syria', 'damascus',
'yemen', 'hezbollah', 'hamas', 'kremlin', 'pentagon', 'nato', 'wagner',
];
// Crisis keywords (moderate boost)
private static readonly CRISIS_KEYWORDS = [
'crisis', 'emergency', 'catastrophe', 'disaster', 'collapse', 'humanitarian',
'sanctions', 'ultimatum', 'threat', 'retaliation', 'escalation', 'tensions',
'breaking', 'urgent', 'developing', 'exclusive',
];
// Business/tech context that should REDUCE score (demote business news with military words)
private static readonly DEMOTE_KEYWORDS = [
'ceo', 'earnings', 'stock', 'startup', 'data center', 'datacenter', 'revenue',
'quarterly', 'profit', 'investor', 'ipo', 'funding', 'valuation',
];
private getImportanceScore(cluster: ClusteredEvent): number {
let score = 0;
const titleLower = cluster.primaryTitle.toLowerCase();
// Source confirmation (base signal)
score += cluster.sourceCount * 10;
// Violence/casualty keywords: highest priority (+100 base, +25 per match)
// "Pools of blood" type stories should always surface
const violenceMatches = InsightsPanel.VIOLENCE_KEYWORDS.filter(kw => titleLower.includes(kw));
if (violenceMatches.length > 0) {
score += 100 + (violenceMatches.length * 25);
}
// Military keywords: highest priority (+80 base, +20 per match)
const militaryMatches = InsightsPanel.MILITARY_KEYWORDS.filter(kw => titleLower.includes(kw));
if (militaryMatches.length > 0) {
score += 80 + (militaryMatches.length * 20);
}
// Civil unrest: high priority (+70 base, +18 per match)
const unrestMatches = InsightsPanel.UNREST_KEYWORDS.filter(kw => titleLower.includes(kw));
if (unrestMatches.length > 0) {
score += 70 + (unrestMatches.length * 18);
}
// Flashpoint keywords: high priority (+60 base, +15 per match)
const flashpointMatches = InsightsPanel.FLASHPOINT_KEYWORDS.filter(kw => titleLower.includes(kw));
if (flashpointMatches.length > 0) {
score += 60 + (flashpointMatches.length * 15);
}
// COMBO BONUS: Violence/unrest + flashpoint location = critical story
// e.g., "Iran protests" + "blood" = huge boost
if ((violenceMatches.length > 0 || unrestMatches.length > 0) && flashpointMatches.length > 0) {
score *= 1.5; // 50% bonus for flashpoint unrest
}
// Crisis keywords: moderate priority (+30 base, +10 per match)
const crisisMatches = InsightsPanel.CRISIS_KEYWORDS.filter(kw => titleLower.includes(kw));
if (crisisMatches.length > 0) {
score += 30 + (crisisMatches.length * 10);
}
// Demote business/tech news that happens to contain military words
const demoteMatches = InsightsPanel.DEMOTE_KEYWORDS.filter(kw => titleLower.includes(kw));
if (demoteMatches.length > 0) {
score *= 0.3; // Heavy penalty for business context
}
// Velocity multiplier
const velMultiplier: Record<string, number> = {
'viral': 3,
'spike': 2.5,
'elevated': 1.5,
'normal': 1
private extractISQInput(cluster: ClusteredEvent): SignalQualityInput {
const entities = extractEntitiesFromTitle(cluster.primaryTitle);
const idx = getEntityIndex();
// Keyword matches (confidence 0.7) are ambiguous for shared-actor terms like
// "hezbollah" (→ IR + IL) or "hamas" (→ IL + QA). Only trust alias matches
// (direct country name mention, confidence ≥ 0.85) for ISQ country attribution.
const countryEntity = entities.find(
e => e.matchType === 'alias' && idx.byId.get(e.entityId)?.type === 'country'
);
return {
sourceCount: cluster.sourceCount,
isAlert: cluster.isAlert,
sourceTier: cluster.topSources?.[0]?.tier ?? undefined,
threatLevel: cluster.threat?.level ?? undefined,
velocity: cluster.velocity ?? undefined,
countryCode: countryEntity?.entityId ?? null,
};
score *= velMultiplier[cluster.velocity?.level ?? 'normal'] ?? 1;
// Alert bonus
if (cluster.isAlert) score += 50;
// Recency bonus (decay over 12 hours)
const ageMs = Date.now() - cluster.firstSeen.getTime();
const ageHours = ageMs / 3600000;
const recencyMultiplier = Math.max(0.5, 1 - (ageHours / 12));
score *= recencyMultiplier;
return score;
}
private selectTopStories(clusters: ClusteredEvent[], maxCount: number): ClusteredEvent[] {
// Score ALL clusters first - high-scoring stories override source requirements
const allScored = clusters
.map(c => ({ cluster: c, score: this.getImportanceScore(c) }));
private selectTopStories(
clusters: ClusteredEvent[],
maxCount: number,
focalFn: (code: string) => { focalScore: number; urgency: string } | null,
ciiFn: (code: string) => number | null,
isFocalReadyFn: () => boolean,
): Array<{ cluster: ClusteredEvent; isq: SignalQuality }> {
const allScored = clusters.map(c => ({
cluster: c,
isq: computeISQ(this.extractISQInput(c), focalFn, ciiFn, isFocalReadyFn),
}));
// Filter: require at least 2 sources OR alert OR elevated velocity OR high score
// High score (>100) means critical keywords were matched - don't require multi-source
const candidates = allScored.filter(({ cluster: c, score }) =>
const candidates = allScored.filter(({ cluster: c, isq }) =>
c.sourceCount >= 2 ||
c.isAlert ||
(c.velocity && c.velocity.level !== 'normal') ||
score > 100 // Critical stories bypass source requirement
isq.composite > 0.55 ||
isq.tier === 'strong'
);
// Sort by score
const scored = candidates.sort((a, b) => b.score - a.score);
const sorted = candidates.sort((a, b) => b.isq.composite - a.isq.composite);
// Select with source diversity (max 3 from same primary source)
const selected: ClusteredEvent[] = [];
const selected: Array<{ cluster: ClusteredEvent; isq: SignalQuality }> = [];
const sourceCount = new Map<string, number>();
const MAX_PER_SOURCE = 3;
for (const { cluster } of scored) {
const source = cluster.primarySource;
const count = sourceCount.get(source) || 0;
for (const item of sorted) {
const source = item.cluster.primarySource;
const count = sourceCount.get(source) ?? 0;
if (count < MAX_PER_SOURCE) {
selected.push(cluster);
selected.push(item);
sourceCount.set(source, count + 1);
}
if (selected.length >= maxCount) break;
}
@@ -312,9 +227,12 @@ export class InsightsPanel extends Panel {
let focalSummary: ReturnType<typeof focalPointDetector.analyze>;
if (SITE_VARIANT === 'full') {
if (this.lastMilitaryFlights.length > 0) {
const postures = getTheaterPostureSummaries(this.lastMilitaryFlights);
signalAggregator.ingestTheaterPostures(postures);
const _cp = getCachedPosture()?.postures;
const theaterPostures = _cp?.length
? _cp
: (this.lastMilitaryFlights.length > 0 ? getTheaterPostureSummaries(this.lastMilitaryFlights) : []);
if (theaterPostures.length > 0) {
signalAggregator.ingestTheaterPostures(theaterPostures);
}
signalSummary = signalAggregator.getSummary();
this.lastConvergenceZones = signalSummary.convergenceZones;
@@ -331,9 +249,29 @@ export class InsightsPanel extends Panel {
if (this.updateGeneration !== thisGeneration) return;
// Step 2: Sentiment analysis on server story titles (fast browser ML)
// Step 2: Re-sort server stories by ISQ (shallow copy to avoid mutating cache)
this.setProgress(2, totalSteps, t('components.insights.analyzingSentiment'));
const titles = serverInsights.topStories.slice(0, 5).map(s => s.primaryTitle);
const focalFnServer = (code: string) => {
const fp = focalPointDetector.getFocalPointForCountry(code);
return (fp && (fp.signalCount > 0 || fp.signalTypes.includes('active_strike'))) ? fp : null;
};
const isFocalReadyServer = () => (focalPointDetector.getLastSummary()?.topCountries.some(
fp => fp.signalCount > 0 || fp.signalTypes.includes('active_strike')
) ?? false);
const sortedStories = [...serverInsights.topStories].sort((a, b) => {
const isqA = computeISQ(
{ sourceCount: a.sourceCount, isAlert: a.isAlert, threatLevel: a.threatLevel ?? undefined, countryCode: a.countryCode, velocity: a.velocity },
focalFnServer, getCountryScore, isFocalReadyServer,
);
const isqB = computeISQ(
{ sourceCount: b.sourceCount, isAlert: b.isAlert, threatLevel: b.threatLevel ?? undefined, countryCode: b.countryCode, velocity: b.velocity },
focalFnServer, getCountryScore, isFocalReadyServer,
);
return isqB.composite - isqA.composite;
});
// Sentiment classification uses positional indexing — must happen AFTER re-sort
const titles = sortedStories.slice(0, 5).map(s => s.primaryTitle);
let sentiments: Array<{ label: string; score: number }> | null = null;
if (mlWorker.isAvailable) {
sentiments = await mlWorker.classifySentiment(titles).catch(() => null);
@@ -342,7 +280,7 @@ export class InsightsPanel extends Panel {
if (this.updateGeneration !== thisGeneration) return;
this.setDataBadge('live');
this.renderServerInsights(serverInsights, sentiments);
this.renderServerInsights({ ...serverInsights, topStories: sortedStories }, sentiments);
} catch (error) {
console.error('[InsightsPanel] Server path error, falling back:', error);
await this.updateFromClient(clusters, thisGeneration);
@@ -367,45 +305,36 @@ export class InsightsPanel extends Panel {
const totalSteps = 4;
try {
// Step 1: Filter and rank stories by composite importance score
// Step 1: Signal aggregation + focal point detection (must run BEFORE ranking)
this.setProgress(1, totalSteps, t('components.insights.rankingStories'));
const importantClusters = this.selectTopStories(clusters, 8);
// Run parallel multi-perspective analysis in background
// This analyzes ALL clusters, not just the keyword-filtered ones
const parallelPromise = parallelAnalysis.analyzeHeadlines(clusters).then(report => {
this.lastMissedStories = report.missedByKeywords;
}).catch(err => {
console.warn('[ParallelAnalysis] Error:', err);
});
// Get geographic signal correlations (geopolitical variant only)
// Tech variant focuses on tech news, not military/protest signals
let signalSummary: ReturnType<typeof signalAggregator.getSummary>;
let focalSummary: ReturnType<typeof focalPointDetector.analyze>;
if (SITE_VARIANT === 'full') {
// Feed theater-level posture into signal aggregator so target nations
// (Iran, Taiwan, etc.) get credited for military activity in their theater,
// even when aircraft/vessels are physically over neighboring airspace/waters.
if (this.lastMilitaryFlights.length > 0) {
const postures = getTheaterPostureSummaries(this.lastMilitaryFlights);
signalAggregator.ingestTheaterPostures(postures);
const _cp = getCachedPosture()?.postures;
const theaterPostures = _cp?.length
? _cp
: (this.lastMilitaryFlights.length > 0 ? getTheaterPostureSummaries(this.lastMilitaryFlights) : []);
if (theaterPostures.length > 0) {
signalAggregator.ingestTheaterPostures(theaterPostures);
}
signalSummary = signalAggregator.getSummary();
this.lastConvergenceZones = signalSummary.convergenceZones;
// Run focal point detection (correlates news entities with map signals)
focalSummary = focalPointDetector.analyze(clusters, signalSummary);
this.lastFocalPoints = focalSummary.focalPoints;
if (focalSummary.focalPoints.length > 0) {
// Ingest news for CII BEFORE signaling (so CII has data when it calculates)
ingestNewsForCII(clusters);
// Signal CII to refresh now that focal points AND news data are available
window.dispatchEvent(new CustomEvent('focal-points-ready'));
}
} else {
// Tech variant: no geopolitical signals, just summarize tech news
signalSummary = {
timestamp: new Date(),
totalSignals: 0,
@@ -425,6 +354,17 @@ export class InsightsPanel extends Panel {
this.lastFocalPoints = [];
}
// Rank stories with fresh focal + CII context
const focalFn = (code: string) => {
const fp = focalPointDetector.getFocalPointForCountry(code);
return (fp && (fp.signalCount > 0 || fp.signalTypes.includes('active_strike'))) ? fp : null;
};
const isFocalReady = () => (focalPointDetector.getLastSummary()?.topCountries.some(
fp => fp.signalCount > 0 || fp.signalTypes.includes('active_strike')
) ?? false);
const importantItems = this.selectTopStories(clusters, 8, focalFn, getCountryScore, isFocalReady);
const importantClusters = importantItems.map(({ cluster }) => cluster);
if (importantClusters.length === 0) {
this.setContent(`<div class="insights-empty">${t('components.insights.noStories')}</div>`);
return;
@@ -488,7 +428,7 @@ export class InsightsPanel extends Panel {
if (this.updateGeneration !== thisGeneration) return;
this.renderInsights(importantClusters, sentiments, worldBrief);
this.renderInsights(importantItems, sentiments, worldBrief);
} catch (error) {
console.error('[InsightsPanel] Error:', error);
this.showError();
@@ -496,15 +436,16 @@ export class InsightsPanel extends Panel {
}
private renderInsights(
clusters: ClusteredEvent[],
items: Array<{ cluster: ClusteredEvent; isq: SignalQuality }>,
sentiments: Array<{ label: string; score: number }> | null,
worldBrief: string | null
): void {
const clusters = items.map(({ cluster }) => cluster);
const briefHtml = worldBrief ? this.renderWorldBrief(worldBrief) : '';
const focalPointsHtml = this.renderFocalPoints();
const convergenceHtml = this.renderConvergenceZones();
const sentimentOverview = this.renderSentimentOverview(sentiments);
const breakingHtml = this.renderBreakingStories(clusters, sentiments);
const breakingHtml = this.renderBreakingStories(items, sentiments);
const statsHtml = this.renderStats(clusters);
const missedHtml = this.renderMissedStories();
@@ -616,16 +557,25 @@ export class InsightsPanel extends Panel {
}
private renderBreakingStories(
clusters: ClusteredEvent[],
items: Array<{ cluster: ClusteredEvent; isq: SignalQuality }>,
sentiments: Array<{ label: string; score: number }> | null
): string {
return clusters.map((cluster, i) => {
const ISQ_BADGE_CLASS: Record<string, string> = {
strong: 'isq-strong', notable: 'isq-notable', weak: 'isq-weak', noise: 'isq-noise',
};
return items.map(({ cluster, isq }, i) => {
const sentiment = sentiments?.[i];
const sentimentClass = sentiment?.label === 'negative' ? 'negative' :
sentiment?.label === 'positive' ? 'positive' : 'neutral';
const badges: string[] = [];
if (isq.tier === 'strong' || isq.tier === 'notable') {
const cls = ISQ_BADGE_CLASS[isq.tier];
badges.push(`<span class="insight-badge ${cls}">${isq.tier.toUpperCase()}</span>`);
}
if (cluster.sourceCount >= 3) {
badges.push(`<span class="insight-badge confirmed">✓ ${cluster.sourceCount} sources</span>`);
} else if (cluster.sourceCount >= 2) {
@@ -719,8 +669,12 @@ export class InsightsPanel extends Panel {
`;
}
private get showMlDetected(): boolean {
try { return localStorage.getItem('wm:debug-ml') === '1'; } catch { return false; }
}
private renderMissedStories(): string {
if (this.lastMissedStories.length === 0) {
if (this.lastMissedStories.length === 0 || !this.showMlDetected) {
return '';
}

View File

@@ -108,6 +108,8 @@ class SignalAggregator {
private readonly WINDOW_MS = 24 * 60 * 60 * 1000;
// Tracks which source event type each temporal anomaly signal came from
private temporalSourceMap = new WeakMap<GeoSignal, string>();
// Tracks signals added by ingestTheaterPostures so they can be cleared on re-ingestion
private theaterPostureSignals: GeoSignal[] = [];
private clearSignalType(type: SignalType): void {
this.signals = this.signals.filter(s => s.type !== type);
@@ -135,7 +137,7 @@ class SignalAggregator {
this.clearSignalType('military_flight');
const countryCounts = new Map<string, number>();
for (const f of flights) {
const code = this.coordsToCountry(f.lat, f.lon);
const code = this.coordsToCountryWithFallback(f.lat, f.lon);
const count = countryCounts.get(code) || 0;
countryCounts.set(code, count + 1);
}
@@ -160,7 +162,7 @@ class SignalAggregator {
const regionCounts = new Map<string, { count: number; lat: number; lon: number }>();
for (const v of vessels) {
const code = this.coordsToCountry(v.lat, v.lon);
const code = this.coordsToCountryWithFallback(v.lat, v.lon);
const existing = regionCounts.get(code);
if (existing) {
existing.count++;
@@ -425,14 +427,37 @@ class SignalAggregator {
'Gaza': 'PS', 'Yemen': 'YE',
};
// Remove previously-added theater posture signals before re-ingesting (idempotency)
const prev = new Set(this.theaterPostureSignals);
this.signals = this.signals.filter(s => !prev.has(s));
this.theaterPostureSignals = [];
// Remove real signals only for the specific type that will be replaced by theater summary.
// Tracked per-type so that e.g. an aircraft-only posture doesn't erase real vessel signals.
const activeFlightCodes = new Set<string>();
const activeVesselCodes = new Set<string>();
for (const p of postures) {
if (!p.targetNation || p.postureLevel === 'normal') continue;
const code = TARGET_CODES[p.targetNation];
if (!code) continue;
if (p.totalAircraft > 0) activeFlightCodes.add(code);
if (p.totalVessels > 0) activeVesselCodes.add(code);
}
if (activeFlightCodes.size > 0 || activeVesselCodes.size > 0) {
this.signals = this.signals.filter(s => {
if (s.type === 'military_flight' && activeFlightCodes.has(s.country)) return false;
if (s.type === 'military_vessel' && activeVesselCodes.has(s.country)) return false;
return true;
});
}
for (const p of postures) {
if (!p.targetNation || p.postureLevel === 'normal') continue;
const code = TARGET_CODES[p.targetNation];
if (!code) continue;
const hasFlight = this.signals.some(s => s.country === code && s.type === 'military_flight');
if (!hasFlight && p.totalAircraft > 0) {
this.signals.push({
if (p.totalAircraft > 0) {
const sig: GeoSignal = {
type: 'military_flight',
country: code,
countryName: getCountryName(code),
@@ -441,12 +466,13 @@ class SignalAggregator {
severity: p.postureLevel === 'critical' ? 'high' : 'medium',
title: `${p.totalAircraft} military aircraft in ${p.theaterName}`,
timestamp: new Date(),
});
};
this.signals.push(sig);
this.theaterPostureSignals.push(sig);
}
const hasVessel = this.signals.some(s => s.country === code && s.type === 'military_vessel');
if (!hasVessel && p.totalVessels > 0) {
this.signals.push({
if (p.totalVessels > 0) {
const sig: GeoSignal = {
type: 'military_vessel',
country: code,
countryName: getCountryName(code),
@@ -455,7 +481,9 @@ class SignalAggregator {
severity: p.totalVessels >= 5 ? 'high' : 'medium',
title: `${p.totalVessels} naval vessels in ${p.theaterName}`,
timestamp: new Date(),
});
};
this.signals.push(sig);
this.theaterPostureSignals.push(sig);
}
}
}

View File

@@ -0,0 +1,92 @@
export interface SignalQualityInput {
sourceCount: number;
isAlert: boolean;
sourceTier?: number;
threatLevel?: string;
velocity?: { sourcesPerHour: number; level: string; trend?: string };
countryCode?: string | null;
}
export interface SignalQuality {
confidence: number;
intensity: number;
expectationGap: number;
timeliness: number;
composite: number;
tier: 'strong' | 'notable' | 'weak' | 'noise';
}
export type WeightProfile = 'default' | 'risk' | 'macro' | 'shortTerm';
const WEIGHTS: Record<WeightProfile, [number, number, number, number]> = {
default: [0.35, 0.30, 0.20, 0.15],
risk: [0.45, 0.25, 0.20, 0.10],
macro: [0.25, 0.40, 0.20, 0.15],
shortTerm: [0.30, 0.25, 0.20, 0.25],
};
export function normalizeThreatLevel(level: string | undefined): number {
switch (level?.toLowerCase()) {
case 'critical': return 1.0;
case 'high': return 0.75;
case 'elevated': return 0.55;
case 'moderate':
case 'medium': return 0.4;
case 'low': return 0.2;
case 'info': return 0.1;
default: return 0.3;
}
}
export function computeISQ(
input: SignalQualityInput,
focalPointFn: (code: string) => { focalScore: number; urgency: string } | null,
ciiScoreFn: (code: string) => number | null,
isFocalDataAvailableFn: () => boolean,
profile: WeightProfile = 'default',
): SignalQuality {
const [wConf, wIntensity, wGap, wTime] = WEIGHTS[profile];
const confidence = Math.min(1, (
(input.sourceCount >= 3 ? 1.0 : input.sourceCount === 2 ? 0.7 : 0.4) +
(input.isAlert ? 0.2 : 0) +
((input.sourceTier !== undefined && input.sourceTier <= 2) ? 0.1 : 0)
));
const threatNorm = normalizeThreatLevel(input.threatLevel);
let focalScore = 0;
let ciiScore = 0;
const fp = input.countryCode ? focalPointFn(input.countryCode) : null;
if (fp) focalScore = fp.focalScore / 100;
if (input.countryCode) {
const cii = ciiScoreFn(input.countryCode);
if (cii !== null) ciiScore = cii / 100;
}
const intensity = Math.max(threatNorm, focalScore, ciiScore);
let expectationGap: number;
if (!input.countryCode) {
expectationGap = 0.5;
} else if (fp !== null) {
expectationGap = 0.4;
} else if (isFocalDataAvailableFn()) {
expectationGap = 0.8;
} else {
expectationGap = 0.5;
}
const velLevel = input.velocity?.level ?? 'normal';
let timeliness = velLevel === 'spike' ? 1.0 : velLevel === 'elevated' ? 0.6 : 0.2;
if (input.velocity?.trend === 'rising') {
timeliness = Math.min(1.0, timeliness + 0.2);
}
const composite = wConf * confidence + wIntensity * intensity + wGap * expectationGap + wTime * timeliness;
const tier: SignalQuality['tier'] =
composite >= 0.75 ? 'strong' :
composite >= 0.50 ? 'notable' :
composite >= 0.25 ? 'weak' : 'noise';
return { confidence, intensity, expectationGap, timeliness, composite, tier };
}

View File

@@ -0,0 +1,110 @@
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import { computeISQ, normalizeThreatLevel, type SignalQualityInput } from '../src/utils/signal-quality.ts';
const noFocal = () => null;
const focalAvailable = false;
const noFocalFn = () => null;
const noFocalReady = () => false;
const focalReady = () => true;
function isq(input: SignalQualityInput, focalFn = noFocalFn, ciiFn: (c: string) => number | null = () => null, ready = noFocalReady) {
return computeISQ(input, focalFn, ciiFn, ready);
}
describe('normalizeThreatLevel', () => {
it('maps critical to 1.0', () => assert.equal(normalizeThreatLevel('critical'), 1.0));
it('maps high to 0.75', () => assert.equal(normalizeThreatLevel('high'), 0.75));
it('maps elevated to 0.55', () => assert.equal(normalizeThreatLevel('elevated'), 0.55));
it('maps moderate to 0.4', () => assert.equal(normalizeThreatLevel('moderate'), 0.4));
it('maps medium to 0.4', () => assert.equal(normalizeThreatLevel('medium'), 0.4));
it('maps low to 0.2', () => assert.equal(normalizeThreatLevel('low'), 0.2));
it('maps info to 0.1', () => assert.equal(normalizeThreatLevel('info'), 0.1));
it('maps unknown to 0.3', () => assert.equal(normalizeThreatLevel('unknown'), 0.3));
it('maps undefined to 0.3', () => assert.equal(normalizeThreatLevel(undefined), 0.3));
it('is case-insensitive', () => assert.equal(normalizeThreatLevel('CRITICAL'), 1.0));
});
describe('computeISQ — composite bounds', () => {
it('composite is always in [0, 1]', () => {
const inputs: SignalQualityInput[] = [
{ sourceCount: 0, isAlert: false },
{ sourceCount: 5, isAlert: true, threatLevel: 'critical', velocity: { sourcesPerHour: 10, level: 'spike', trend: 'rising' } },
{ sourceCount: 1, isAlert: false, threatLevel: 'info', countryCode: 'US' },
];
for (const input of inputs) {
const result = isq(input);
assert.ok(result.composite >= 0 && result.composite <= 1, `composite out of range: ${result.composite}`);
}
});
});
describe('computeISQ — no-country gap', () => {
it('gap is 0.5 when no countryCode', () => {
const result = isq({ sourceCount: 1, isAlert: false });
assert.equal(result.expectationGap, 0.5);
});
it('gap is 0.5 when null countryCode', () => {
const result = isq({ sourceCount: 1, isAlert: false, countryCode: null });
assert.equal(result.expectationGap, 0.5);
});
});
describe('computeISQ — expectation gap tri-state', () => {
it('gap is 0.4 when country is a signal-backed focal point (present)', () => {
const focalFn = () => ({ focalScore: 60, urgency: 'elevated' });
const result = isq({ sourceCount: 2, isAlert: false, countryCode: 'IR' }, focalFn);
assert.equal(result.expectationGap, 0.4);
});
it('gap is 0.8 when focal data available but country absent (novel)', () => {
const result = isq({ sourceCount: 2, isAlert: false, countryCode: 'DE' }, noFocalFn, () => null, focalReady);
assert.equal(result.expectationGap, 0.8);
});
it('gap is 0.5 when focal data unavailable (neutral)', () => {
const result = isq({ sourceCount: 2, isAlert: false, countryCode: 'DE' }, noFocalFn, () => null, noFocalReady);
assert.equal(result.expectationGap, 0.5);
});
});
describe('computeISQ — CII not warmed up', () => {
it('intensity falls back to threatLevel only when ciiScoreFn returns null', () => {
const result = isq({ sourceCount: 1, isAlert: false, threatLevel: 'high', countryCode: 'IR' }, noFocalFn, () => null);
assert.equal(result.intensity, 0.75);
});
});
describe('computeISQ — tiers', () => {
it('strong tier for high-confidence + high-intensity story', () => {
const focalFn = () => ({ focalScore: 90, urgency: 'critical' });
const result = isq(
{ sourceCount: 3, isAlert: true, threatLevel: 'critical', velocity: { sourcesPerHour: 5, level: 'spike', trend: 'rising' }, countryCode: 'IR' },
focalFn,
() => 85,
);
assert.equal(result.tier, 'strong');
});
it('weak tier for low-signal story (single source, info threat)', () => {
const result = isq({ sourceCount: 1, isAlert: false, threatLevel: 'info' });
assert.ok(result.tier === 'weak' || result.tier === 'noise', `expected weak/noise, got ${result.tier}`);
assert.ok(result.composite < 0.5);
});
});
describe('computeISQ — weight profiles sum to 1', () => {
const WEIGHTS: Record<string, [number, number, number, number]> = {
default: [0.35, 0.30, 0.20, 0.15],
risk: [0.45, 0.25, 0.20, 0.10],
macro: [0.25, 0.40, 0.20, 0.15],
shortTerm: [0.30, 0.25, 0.20, 0.25],
};
for (const [name, w] of Object.entries(WEIGHTS)) {
it(`${name} weights sum to 1.0`, () => {
const sum = w.reduce((a, b) => a + b, 0);
assert.ok(Math.abs(sum - 1.0) < 1e-10, `${name} weights sum to ${sum}`);
});
}
});