// Evidence → public badge derivation for the pipeline registry. // // Core design: we ship the evidence, not our opinion. `publicBadge` is a // deterministic function of the raw evidence bundle, versioned so consumers // can pin a reader to a classifier version and reproduce results. // // Shared between the server handler (server/worldmonitor/supply-chain/v1/ // list-pipelines.ts — attaches the derived badge to the RPC response) AND // the client panel's bootstrap path (src/components/PipelineStatusPanel.ts — // runs the same deriver client-side on bootstrap-hydrated raw registries // that don't carry publicBadge). Identical output in both paths is the // whole point — a bootstrap-first-paint badge must match the post-RPC badge // or the UI flickers from one color to another on hydration. // // Duck-typed input: the shared interface here intentionally does NOT import // from src/generated so it stays dependency-free; server + client both // assign their proto-typed evidence bundles to it by structural subtyping. // // See docs/methodology/pipelines.mdx §"How public badges move". export const PIPELINE_BADGE_DERIVER_VERSION = 'badge-deriver-v1'; export type PipelinePublicBadge = 'flowing' | 'reduced' | 'offline' | 'disputed'; export interface PipelineEvidenceInput { physicalState?: string; // 'flowing'|'reduced'|'offline'|'unknown' physicalStateSource?: string; // 'operator'|'regulator'|'press'|'satellite'|'ais-relay'|'gem' operatorStatement?: { text?: string; url?: string; date?: string } | null; commercialState?: string; // 'under_contract'|'expired'|'suspended'|'unknown' sanctionRefs?: ReadonlyArray<{ authority?: string; listId?: string; date?: string; url?: string }>; lastEvidenceUpdate?: string; classifierVersion?: string; classifierConfidence?: number; } // Days after which evidence is considered stale and confidence decays. // Registry fields (geometry, operator, capacity) are refreshed weekly by // seed-pipelines-{gas,oil}.mjs; evidence fields inherit the same cadence // from the same curated JSON. So the decay window intentionally matches // the seed-health maxStaleMin (14d) — anything older means the cron is // broken, not that the asset's state has actually drifted. const EVIDENCE_STALENESS_DAYS = 14; /** * Derive the public badge for a single pipeline from its evidence bundle. * * Rules (applied in order; first match wins): * 1. physical_state = "offline" AND (sanctionRefs.length > 0 OR commercialState ∈ {expired, suspended}) * → "offline" (high-confidence offline with paperwork) * 2. physical_state = "offline" AND operatorStatement != null * → "offline" (operator-disclosed outage) * 3. physical_state = "offline" AND physicalStateSource ∈ {press, ais-relay, satellite, gem} * → "disputed" (external-signal offline without operator/sanction confirmation) * 4. physical_state = "reduced" * → "reduced" * 5. physical_state = "flowing" * → "flowing" * 6. physical_state = "unknown" OR evidence missing * → "disputed" * * Freshness guard: if lastEvidenceUpdate is older than EVIDENCE_STALENESS_DAYS, * a non-"flowing" badge drops to "disputed" (we don't claim a pipeline is * offline on 3-week-old evidence; we say we're unsure). */ export function derivePipelinePublicBadge( evidence: PipelineEvidenceInput | null | undefined, nowMs: number = Date.now(), ): PipelinePublicBadge { if (!evidence) return 'disputed'; const stale = isStale(evidence.lastEvidenceUpdate, nowMs); const physical = evidence.physicalState; if (physical === 'offline') { const hasSanctionEvidence = (evidence.sanctionRefs?.length ?? 0) > 0; const hasCommercialHalt = evidence.commercialState === 'expired' || evidence.commercialState === 'suspended'; const hasOperatorStatement = evidence.operatorStatement != null && ((evidence.operatorStatement.text?.length ?? 0) > 0); const hasExternalSignal = ['press', 'ais-relay', 'satellite', 'gem'].includes( evidence.physicalStateSource ?? '', ); // Rule 1: paperwork + physical if (hasSanctionEvidence || hasCommercialHalt) { return stale ? 'disputed' : 'offline'; } // Rule 2: operator-disclosed if (hasOperatorStatement) { return stale ? 'disputed' : 'offline'; } // Rule 3: external signal only — always "disputed", regardless of staleness. // Single-source offline claims don't clear the bar for an "offline" public // badge; the asset may have resumed flow and the classifier hasn't caught up. if (hasExternalSignal) return 'disputed'; // Rule 6 fallthrough: offline without any supporting evidence → disputed return 'disputed'; } if (physical === 'reduced') { return stale ? 'disputed' : 'reduced'; } if (physical === 'flowing') { // Even on stale data, "flowing" is the safe default — we only demote to // disputed when the claim is a negative one we can't substantiate. return 'flowing'; } // physical === 'unknown' or malformed return 'disputed'; } function isStale(iso: string | undefined, nowMs: number): boolean { if (!iso) return true; const t = Date.parse(iso); if (Number.isNaN(t)) return true; const ageDays = (nowMs - t) / (1000 * 60 * 60 * 24); return ageDays > EVIDENCE_STALENESS_DAYS; } /** * Picks the newest classifier version across two registries. Gas and oil * are now seeded by separate Railway cron processes (seed-pipelines-gas.mjs * + seed-pipelines-oil.mjs), so mixed-version rollout windows are a real * expected state — saying "v1/v2" or picking the higher version is * correct; always preferring gas over oil is wrong. Versions are expected * to look like "v1", "v2", etc.; falls back to lexicographic for anything * else so odd data still returns SOMETHING deterministic. */ export function pickNewerClassifierVersion( a: string | undefined, b: string | undefined, ): string { const va = (a || '').trim(); const vb = (b || '').trim(); if (!va) return vb || 'v1'; if (!vb) return va; if (va === vb) return va; const numA = parseVNum(va); const numB = parseVNum(vb); if (numA != null && numB != null) { return numA >= numB ? va : vb; } // Fallback: lexicographic; stable and deterministic if not numeric. return va >= vb ? va : vb; } function parseVNum(v: string): number | null { const m = v.match(/^v(\d+)$/i); if (!m) return null; const n = Number(m[1]); return Number.isFinite(n) ? n : null; } /** * Picks the newer ISO8601 timestamp between two candidates. Returns an * ISO string (or empty). Used for aggregate fetchedAt across gas + oil * registries — the two seeders cron independently so the newer cycle * should be the reported timestamp, not whichever arbitrarily comes first. */ export function pickNewerIsoTimestamp( a: string | undefined, b: string | undefined, ): string { const ta = a ? Date.parse(a) : NaN; const tb = b ? Date.parse(b) : NaN; if (Number.isFinite(ta) && Number.isFinite(tb)) { return ta >= tb ? (a || '') : (b || ''); } if (Number.isFinite(ta)) return a || ''; if (Number.isFinite(tb)) return b || ''; return a || b || ''; }