File size: 4,598 Bytes
dbb1bf9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
// Evidence β†’ public badge derivation for the strategic storage registry.
//
// Same design as src/shared/pipeline-evidence.ts: 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.
//
// Shared between the server handler (server/worldmonitor/supply-chain/v1/
// list-storage-facilities.ts β€” attaches the derived badge to the RPC
// response) AND the client panel's bootstrap path
// (src/components/StorageFacilityMapPanel.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 β€”
// first-paint badge must match post-RPC badge or the UI flickers.
//
// 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/storage.mdx Β§"How public badges move".

export const STORAGE_BADGE_DERIVER_VERSION = 'storage-badge-deriver-v1';

export type StoragePublicBadge = 'operational' | 'reduced' | 'offline' | 'disputed';

export interface StorageEvidenceInput {
  physicalState?: string;               // 'operational'|'reduced'|'offline'|'under_construction'|'unknown'
  physicalStateSource?: string;         // 'operator'|'regulator'|'press'|'satellite'|'ais-relay'
  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 }>;
  fillDisclosed?: boolean;
  fillSource?: string | null;
  lastEvidenceUpdate?: string;
  classifierVersion?: string;
  classifierConfidence?: number;
}

// Same 14-day staleness window as pipelines β€” registry fields refreshed
// weekly by seed-storage-facilities.mjs, anything older means the cron is
// broken rather than the asset's state has drifted.
const EVIDENCE_STALENESS_DAYS = 14;

/**
 * Derive the public badge for a single storage facility.
 *
 * Rules (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}
 *      β†’ "disputed" (external-signal offline without operator/sanction confirmation)
 *   4. physical_state = "reduced"
 *      β†’ "reduced"
 *   5. physical_state = "operational"
 *      β†’ "operational"
 *   6. physical_state = "under_construction" OR "unknown" OR evidence missing
 *      β†’ "disputed"
 *
 * Freshness guard: if lastEvidenceUpdate is older than EVIDENCE_STALENESS_DAYS,
 * a non-"operational" badge drops to "disputed".
 */
export function deriveStoragePublicBadge(
  evidence: StorageEvidenceInput | null | undefined,
  nowMs: number = Date.now(),
): StoragePublicBadge {
  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'].includes(
      evidence.physicalStateSource ?? '',
    );

    if (hasSanctionEvidence || hasCommercialHalt) {
      return stale ? 'disputed' : 'offline';
    }
    if (hasOperatorStatement) {
      return stale ? 'disputed' : 'offline';
    }
    if (hasExternalSignal) return 'disputed';

    return 'disputed';
  }

  if (physical === 'reduced') {
    return stale ? 'disputed' : 'reduced';
  }
  if (physical === 'operational') {
    return 'operational';
  }

  // 'under_construction' | 'unknown' | 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;
}