File size: 33,471 Bytes
ee888e1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
// Pure spec-builder module for the forecast resolvability contract (#4976 Bet 1).
//
// Every published forecast gets a machine-checkable ResolutionSpec: `hard`
// (auto-resolvable from a WorldMonitor feed the detector already scored from)
// or `judged` (a resolution question for a later LLM judge, Bet 2). This
// module owns that dispatch, the feed allowlist a hard spec's `sourceFeed`
// must belong to (R4), and the deadline math (R5).
//
// No console output in normal operation. No Date.now() anywhere β€” every
// timestamp is threaded in as `generatedAt` so output is deterministic and
// testable (same inputs -> identical spec across calls).
//
// metricKey format: '<feedKey>|<fn>(<field>==<value>)' β€” a path expression
// over the shape read from that feed, with REAL substituted values (region,
// title, ticker) and a unified '==' comparison grammar across every family,
// e.g. 'conflict:acled-resolution:v1:all:0:0|count(country==Mali)' or
// 'market:commodities-bootstrap:v1|price(symbol==CL=F)'. It documents where in
// the feed the metric lives and what to match; it is not executable code and
// is not parsed by this module or any consumer today (Bet 2's resolver
// interprets it). The grammar is frozen into 45-day history, so it must be
// consistent β€” no literal '<region>'/'<title>' placeholders, no '=' vs '=='
// drift between families. count() means NEW events dated within the spec's
// 'within-horizon' window ([emission, deadline]), with a horizon-scoped
// threshold (#5010) β€” never the feed's full 365-day trailing tally.

// ── Horizon -> deadline math (R5) ───────────────────────────────────────
//
// Production detectors and the state-derived path emit only '24h'/'7d'/'30d'
// (verified exhaustively against scripts/seed-forecasts.mjs β€” every
// makePrediction() call site and the state-derived domain ternary). '14d' is
// a deliberate superset entry: it appears nowhere in production emission but
// is kept here so a future detector can add it without a map update, and so
// fixtures that want to exercise a horizon distinct from the other three
// have one available.
const DAY_MS = 24 * 60 * 60 * 1000;

export const HORIZON_MS = {
  '24h': 24 * 60 * 60 * 1000,
  '7d': 7 * DAY_MS,
  '14d': 14 * DAY_MS,
  '30d': 30 * DAY_MS,
};

export const CONFLICT_COUNT_SOURCE_FEED = 'conflict:acled-resolution:v1:all:0:0';
export const UNREST_COUNT_SOURCE_FEED = 'unrest:events-resolution:v1';
export const CYBER_COUNT_SOURCE_FEED = 'cyber:threats-bootstrap:v2';

// Never returns null and never silently coerces an unrecognized horizon to a
// nearby one β€” a silent '7d' fallback would score a 14d forecast a full week
// early and corrupt the track record this bet exists to make trustworthy.
// An unrecognized horizon is a programming/config error and must surface
// loudly as a thrown error, caught by the drift-guard test below or, if it
// ever reaches production, a loud seeder failure.
export function deriveDeadline(generatedAt, timeHorizon) {
  const ms = HORIZON_MS[timeHorizon];
  if (!Number.isFinite(ms)) {
    throw new Error(`deriveDeadline: unrecognized horizon '${timeHorizon}' β€” not in HORIZON_MS`);
  }
  return generatedAt + ms;
}

// ── Resolution-feed allowlist (R4) ──────────────────────────────────────
//
// Every hard spec's `sourceFeed` must be a member of this set. Bare feed
// keys (not path expressions) β€” `metricKey` embeds the extraction path on
// top of one of these. The market entries are copied (string literals, not
// imported β€” seed-forecasts.mjs has top-level side effects) from
// MARKET_INPUT_KEYS; the other entries are the non-market feeds hard specs
// can actually emit and the resolver seeder can read by sourceFeed.
export const RESOLUTION_FEED_KEYS = new Set([
  'conflict:ucdp-events:v1',
  CONFLICT_COUNT_SOURCE_FEED,
  UNREST_COUNT_SOURCE_FEED,
  CYBER_COUNT_SOURCE_FEED,
  'supply_chain:chokepoints:v4',
  'prediction:markets-bootstrap:v1',
  'intelligence:gpsjam:v2',
  // MARKET_INPUT_KEYS (scripts/seed-forecasts.mjs :215-227) β€” copied, not imported.
  'market:stocks-bootstrap:v1',
  'market:commodities-bootstrap:v1',
  'market:sectors:v2',
  'market:gulf-quotes:v1',
  'market:etf-flows:v1',
  'market:crypto:v1',
  'market:stablecoins:v1',
  'economic:bis:eer:v1',
  'economic:bis:policy:v1',
  'supply_chain:shipping:v2',
  'correlation:cards-bootstrap:v1',
  // Energy bet-engine pilot (#5233): eia-petroleum stocks/prices. The resolver
  // seeder shapes {wti,brent,production,inventory} into records carrying
  // {metric, value}; bets read via `...|value(metric==<name>)`.
  'energy:eia-petroleum:v1',
  // Phase-2 bet engine (#5525). Market bets resolve against the DEDICATED
  // settlement feed β€” the bootstrap feed never carries settled prices (its
  // producer only publishes open markets and clips yesPrice to [10,90]); the
  // resolver populates this key from venue adjudications by slug.
  'prediction:markets-resolution:v1',
  // FRED macro series (exact keys β€” this allowlist is an exact-match Set and
  // seed-economy writes `economic:fred:v1:<SERIES>:0`). Read via
  // `value(metric==<SERIES>)` with calendar-derived settlement graces.
  'economic:fred:v1:FEDFUNDS:0',
  'economic:fred:v1:UNRATE:0',
  'economic:fred:v1:CPIAUCSL:0',
  'economic:fred:v1:DGS10:0',
]);

// ── Signal type -> hard family (D3) ──────────────────────────────────────
//
// The module's OWN table β€” deliberately NOT the seeder's SIGNAL_TO_SOURCE
// (scripts/seed-forecasts.mjs :4136), which has no entry for any market
// signal (market_transmission/market_divergence/market_calibration/
// commodity) and maps 'chokepoint' to the supply_chain source. Leaning on
// SIGNAL_TO_SOURCE would silently collapse the market family (a priority
// hard domain) to judged and mis-map chokepoint-bearing market forecasts to
// the supply_chain feed.
//
// Real detector signal-type vocabulary (read from seed-forecasts.mjs):
//  - detectMarketScenarios (:1086)      -> 'chokepoint', 'commodity', 'cii'
//  - buildStateDerivedForecast (:1425)  -> 'market_transmission' on EVERY
//    state-derived forecast (weight 0.24) β€” but origin-precedence (below)
//    intercepts state_derived before this table is ever consulted.
//  - caseFile-only (not pred.signals):  'market_divergence' (:4191),
//    'market_calibration' (:4466) β€” never appear in pred.signals today, but
//    mapped here defensively per the plan's dispatch instructions.
//  - detectSupplyChainScenarios (:1162) -> 'chokepoint', 'ais_gap', 'gps_jamming'
//  - detectUcdpConflictZones (:1892)    -> 'ucdp'
//  - detectGpsJammingScenarios (:1983)  -> 'gps_jamming'
//  - detectConflictScenarios (:1001)    -> 'cii', 'conflict_events'
//  - Polymarket/prediction-market pool  -> 'prediction_market'
export const SIGNAL_TO_HARD_FAMILY = {
  market_transmission: 'market',
  market_divergence: 'market',
  market_calibration: 'market',
  commodity: 'market',
  prediction_market: 'prediction_market',
  ucdp: 'ucdp_zone',
  unrest: 'unrest',
  unrest_events: 'unrest',
  cyber: 'cyber',
  gps_jamming: 'gps',
  conflict_events: 'conflict',
  cii: 'conflict',
  chokepoint: 'supply_chain',
  ais_gap: 'supply_chain',
};

// Domains whose forecasts are ALWAYS judged (R3), regardless of what signals
// they carry. Domain is the claim's SUBJECT; signals are only evidence.
// Political unrest and cyber concentration now have country/date feeds with a
// direct count metric. Military still lacks a stable theater id, while the
// legacy infrastructure family only measured outage presence rather than its
// claimed cascade risk (#5330). Keep both judged until they carry a crisp,
// claim-aligned metric identity.
// This gate is checked AFTER the state_derived origin check and the
// prediction_market exemption, and BEFORE the general SIGNAL_TO_HARD_FAMILY
// lookup.
export const JUDGED_DOMAINS = new Set(['infrastructure', 'military']);

// Which hard families a forecast's DOMAIN permits (R3, by-domain constraint).
// Domain is the claim's SUBJECT; signals are only evidence. A market-domain
// forecast carrying a 'cii' signal (evidence of instability driving a
// commodity move) must NOT resolve as a conflict spec scored against conflict
// event counts β€” 'conflict' is simply not an allowed family for domain
// 'market'. When resolving the family from signals, any family not listed for
// pred.domain is skipped; a domain absent from this table (after the
// JUDGED_DOMAINS gate) yields no hard family -> judged.
//
// Domains verified from real makePrediction call sites (seed-forecasts.mjs):
// conflict, market, supply_chain (the GPS detector emits domain 'supply_chain'),
// political, military, cyber; detectFromPredictionMarkets emits
// conflict|market|political (the prediction_market exemption runs BEFORE this
// gate, so those forecasts never reach the table).
export const DOMAIN_TO_HARD_FAMILIES = {
  conflict: ['conflict', 'ucdp_zone'],
  market: ['market', 'prediction_market'],
  supply_chain: ['supply_chain', 'gps'],
  political: ['unrest'],
  cyber: ['cyber'],
};

// The commodity price-MOVE threshold ratio (market family): threshold =
// emission baseline Γ— this. A 10% move is the "material price impact" bar.
// Named + exported so it is discoverable and a future per-commodity
// volatility model has one obvious knob to replace.
export const MARKET_PRICE_MOVE_RATIO = 1.1;

// The conflict escalation ratio (conflict/ucdp_zone count threshold, #5010):
// the horizon-scoped confirming count is the country's base event rate
// projected over the forecast horizon, escalated by this factor β€” "events
// materially above trend". The emission-time signal tally is a 365-day
// trailing count (seed-ucdp-events.mjs TRAILING_WINDOW_MS), so
//   threshold = max(1, round(tally365 Γ— horizonMs/365d Γ— this))
// counted over NEW events dated within [emission, deadline]. Like
// MARKET_PRICE_MOVE_RATIO, this is a named Bet-2 tuning knob.
export const CONFLICT_ESCALATION_RATIO = 1.5;

// The conflict/ucdp_zone hard-count families resolve against
// CONFLICT_COUNT_SOURCE_FEED (conflict:acled-resolution:v1), which only
// populates with ACLED credentials. Without them the feed is empty and every
// conflict count spec is unresolvable β€” it sits pending/VOID forever (#5136).
// Until a populated near-real-time event-count feed exists, route these
// families to judged resolution (#5087) instead of emitting a dead hard spec.
// Article volume (GDELT, #5099/#5134) is NOT a substitute: its scale is
// article count, not event count, so the horizon-scaled thresholds below would
// mis-resolve. Flip to true β€” and confirm the feed is actually seeded β€” to
// re-enable hard-count resolution; the threshold logic below is preserved.
export const CONFLICT_COUNT_FEED_AVAILABLE = false;

// UNREST_COUNT_SOURCE_FEED (unrest:events-resolution:v1) has the identical
// problem (#5091): seed-unrest-events only writes it from an ACLED resolution
// fetch (seed-unrest-events.mjs), which is empty without ACLED credentials, so
// unrest count specs are unresolvable. Same treatment as conflict β€” route to
// judged until a populated event-count feed exists. Flip to true once the feed
// is actually seeded; the threshold logic below is preserved.
export const UNREST_COUNT_FEED_AVAILABLE = false;
const YEAR_MS = 365 * 24 * 60 * 60 * 1000;

// FAMILY_FEED / FAMILY_WINDOW map each hard family to its default sourceFeed
// + resolution window. The market family has no single fixed feed β€” it is
// resolved per-forecast (commodities feed for a commodity signal, or the
// calibration fallback feed).
const FAMILY_FEED = {
  conflict: CONFLICT_COUNT_SOURCE_FEED,
  ucdp_zone: CONFLICT_COUNT_SOURCE_FEED,
  unrest: UNREST_COUNT_SOURCE_FEED,
  cyber: CYBER_COUNT_SOURCE_FEED,
  supply_chain: 'supply_chain:chokepoints:v4',
  prediction_market: 'prediction:markets-bootstrap:v1',
  gps: 'intelligence:gpsjam:v2',
  // market has no single fixed feed β€” resolved per-forecast below from
  // whichever MARKET_INPUT_KEYS-backed calibration source is available.
};

// Window vocabulary (#5010 amendment): every value must be establishable by
// the Bet-2 resolver from the feed it names. 'within-horizon' = the condition
// is checked over [emission, deadline] (dated-record feeds) or as a deadline
// point read (snapshot feeds); 'at-deadline' = a point read of the current-
// snapshot feed at the first resolver tick at/after the deadline;
// 'at-endDate' = the prediction market's own settlement. The previous
// sustained-window value for supply_chain/gps was removed (#5010) β€” a
// sustained condition is unestablishable from current-snapshot feeds without
// resolver-side sampling and forced permanent VOID.
const FAMILY_WINDOW = {
  conflict: 'within-horizon',
  ucdp_zone: 'within-horizon',
  unrest: 'within-horizon',
  cyber: 'within-horizon',
  supply_chain: 'at-deadline',
  prediction_market: 'at-endDate',
  gps: 'at-deadline',
  market: 'within-horizon',
};

// ── Commodity label -> future ticker (market family) ────────────────────
//
// The market:commodities-bootstrap:v1 feed is keyed by Yahoo-style future
// symbol (verified LIVE in the R12 walkthrough: `CL=F` WTI, `BZ=F` Brent,
// `TTF=F` EU gas, `NG=F` Henry Hub, `GC=F` gold, ZW=F wheat) β€” NOT by the
// human commodity label the forecast carries. A market forecast's `commodity`
// signal renders as "<label> sensitivity: <n>" (seed-forecasts.mjs :1113,
// :1155), where <label> is a CHOKEPOINT_COMMODITIES value (:165). The
// metricKey must encode the resolvable TICKER, so this map bridges the two.
//
// Labels with no clean single future ticker (Semiconductors, Trade goods,
// and the ambiguous compound Shipping/Oil, Gas/Oil) are deliberately absent:
// their market forecasts cannot derive a finite threshold and fall back to
// judged (R3 no-finite-threshold fallback β€” confirmed load-bearing on real
// Western Pacific / South China Sea regions by the walkthrough).
export const COMMODITY_LABEL_TO_SYMBOL = {
  Oil: 'CL=F',
  Gas: 'TTF=F',
  'Grain/Energy': 'ZW=F',
};

// Per-pass inputs index (FIX 7): findCommodityPrice + findPredictionMarketEndDate
// would otherwise linear-scan the feed arrays once per forecast. Build both
// lookup maps once and memoize them on the inputs object itself via a WeakMap,
// so buildResolutionSpec's signature stays (pred, inputs, generatedAt) and
// attachResolutionSpecs pays the scan cost once for the whole batch.
// Determinism is unaffected: the index is a pure function of inputs' content.
const _inputsIndexCache = new WeakMap();

function getInputsIndex(inputs) {
  if (!inputs || typeof inputs !== 'object') {
    return { priceBySymbol: new Map(), endDateByTitle: new Map() };
  }
  const cached = _inputsIndexCache.get(inputs);
  if (cached) return cached;

  // symbol -> emission price. Guard: require a finite price > 0 (a 0 price is
  // missing upstream data, not a baseline β€” FIX 3a). First writer wins.
  const priceBySymbol = new Map();
  const rawQuotes = inputs.commodityQuotes;
  const quotes = Array.isArray(rawQuotes) ? rawQuotes : (rawQuotes?.quotes || []);
  for (const q of quotes) {
    const symbol = q?.symbol;
    const p = Number(q?.price);
    if (symbol && Number.isFinite(p) && p > 0 && !priceBySymbol.has(symbol)) {
      priceBySymbol.set(symbol, p);
    }
  }

  // market title (truncated to the 100 chars the seeder stores as pred.title,
  // seed-forecasts.mjs :2240) -> settlement endDate epoch ms. Keying by the
  // truncated title lets the lookup be an exact Map.get on pred.title, which
  // subsumes the exact + prefix match cases (FIX 7). First writer wins.
  const endDateByTitle = new Map();
  // All three pools (#5733): settlement endDates must be resolvable for every
  // market-anchored forecast, not just the geopolitical ones. Reading only
  // `.geopolitical` was equivalent to "all markets" until the producer's pools
  // became a disjoint partition. Inlined rather than importing
  // allBootstrapMarkets from _prediction-classify.mjs so this module stays
  // import-free (see the header contract); tests/forecast-resolution.test.mjs
  // pins that both this and seed-forecasts read all three pools.
  const markets = [
    inputs.predictionMarkets?.geopolitical,
    inputs.predictionMarkets?.tech,
    inputs.predictionMarkets?.finance,
  ].filter(Array.isArray).flat();
  for (const m of markets) {
    const mt = String(m?.title ?? '');
    if (!mt) continue;
    const key = mt.slice(0, 100);
    const ms = Date.parse(m.endDate);
    if (Number.isFinite(ms) && !endDateByTitle.has(key)) {
      endDateByTitle.set(key, ms);
    }
  }

  const index = { priceBySymbol, endDateByTitle };
  _inputsIndexCache.set(inputs, index);
  return index;
}

// Emission-time price for a commodity future symbol (see getInputsIndex).
// Returns null when absent/zero/non-finite so the market builder falls back to
// judged rather than fabricating a baseline.
function findCommodityPrice(inputs, symbol) {
  return getInputsIndex(inputs).priceBySymbol.get(symbol) ?? null;
}

// The market's own settlement date is the ground truth for a prediction-market
// forecast (Polymarket resolves yesPrice to ~0/~100 at endDate). Exact lookup
// on pred.title (= m.title.slice(0,100)); null when unmatched so the builder
// falls back to the horizon deadline.
function findPredictionMarketEndDate(pred, inputs) {
  if (!pred.title) return null;
  return getInputsIndex(inputs).endDateByTitle.get(pred.title) ?? null;
}

// First numeric token in the value string of a matching signal β€” the generic
// count extractor (e.g. "14 UCDP conflict events" -> 14).
function firstFiniteSignalCount(pred, matchTypes) {
  for (const signal of pred.signals || []) {
    if (!matchTypes.has(signal.type)) continue;
    const match = /(-?\d+(?:\.\d+)?)/.exec(String(signal.value ?? ''));
    if (match) {
      const n = Number(match[1]);
      if (Number.isFinite(n)) return n;
    }
  }
  return null;
}

// Percent-anchored extractor for the prediction_market baseline (FIX 6): a
// source label can itself contain a digit (e.g. "Metaculus2: 62%"), so the
// generic first-number regex would grab 2. Prefer the number immediately
// before a '%'; fall back to the generic first number.
function firstPercentSignalValue(pred, matchTypes) {
  for (const signal of pred.signals || []) {
    if (!matchTypes.has(signal.type)) continue;
    const str = String(signal.value ?? '');
    const pct = /(\d+(?:\.\d+)?)\s*%/.exec(str);
    if (pct) {
      const n = Number(pct[1]);
      if (Number.isFinite(n)) return n;
    }
    const generic = /(-?\d+(?:\.\d+)?)/.exec(str);
    if (generic) {
      const n = Number(generic[1]);
      if (Number.isFinite(n)) return n;
    }
  }
  return null;
}

// Resolve the hard family for a forecast from its signals, constrained by the
// forecast's DOMAIN (DOMAIN_TO_HARD_FAMILIES). A signal maps to a family via
// SIGNAL_TO_HARD_FAMILY, but only families ALLOWED for pred.domain are eligible
// β€” so a market-domain forecast's 'cii' signal (-> conflict) or 'chokepoint'
// signal (-> supply_chain) is skipped, and it resolves to 'market' via its
// 'commodity' signal, never to a conflict/supply_chain feed. A market forecast
// with no eligible hard signal (no commodity) yields no hard family -> judged:
// there is no calibration.marketPrice fallback (a stocks feed cannot resolve a
// prediction-market title). A domain absent from the table yields no family.
function resolveHardFamily(pred) {
  const allowed = DOMAIN_TO_HARD_FAMILIES[pred.domain];
  if (!allowed) return null;

  for (const signal of pred.signals || []) {
    const family = SIGNAL_TO_HARD_FAMILY[signal.type];
    if (family && allowed.includes(family)) return family;
  }

  return null;
}

// Derive a finite threshold + operator + window (+ baselineValue for
// 'crosses') from the forecast's own scored signals. Pragmatic per-family
// extraction β€” pulls the first numeric token out of the matching signal's
// `value` string (the seeder already renders these as human-readable
// "N units" strings, e.g. "14 UCDP conflict events").
function deriveHardMetrics(pred, family, inputs, options = {}) {
  switch (family) {
    case 'conflict':
    case 'ucdp_zone': {
      // #5136: the conflict count feed (conflict:acled-resolution:v1) is empty
      // without ACLED credentials, so a hard spec here is unresolvable. Return
      // null β†’ buildHardSpec falls back to buildJudgedSpec (LLM judge, #5087).
      // The `conflictCountFeedAvailable` override lets callers (and the tests
      // that lock the preserved #5010 threshold logic) force the hard path.
      if (!(options.conflictCountFeedAvailable ?? CONFLICT_COUNT_FEED_AVAILABLE)) return null;
      // Count threshold comes ONLY from an actual event-count signal
      // (ucdp / conflict_events). A 'cii' value is a 0-100 composite INDEX,
      // not an event count β€” using it would emit a semantically wrong
      // `count(country==X) >= <ciiScore>` ground truth, and since detectors
      // emit the cii signal first it would shadow a real count. A conflict
      // forecast with only cii signals has no clean count metric -> judged.
      const tally = firstFiniteSignalCount(pred, new Set(['ucdp', 'conflict_events']));
      if (!Number.isFinite(tally)) return null;
      // Horizon-commensurable threshold (#5010): the signal tally is a
      // 365-day trailing count, but the forecast is a ~horizon claim β€” a raw
      // tally threshold would systematically resolve NO over the horizon
      // window (biased Brier). Scale the base rate to the horizon and apply
      // the escalation bar; count() means NEW events dated within
      // [emission, deadline] (the 'within-horizon' window).
      const horizonMs = HORIZON_MS[pred.timeHorizon];
      if (!Number.isFinite(horizonMs)) return null; // deriveDeadline throws for the judged path too
      const threshold = Math.max(1, Math.round(tally * (horizonMs / YEAR_MS) * CONFLICT_ESCALATION_RATIO));
      return {
        metricKey: `${CONFLICT_COUNT_SOURCE_FEED}|count(country==${pred.region})`,
        sourceFeed: CONFLICT_COUNT_SOURCE_FEED,
        operator: '>=',
        threshold,
        window: FAMILY_WINDOW[family],
      };
    }
    case 'unrest': {
      // #5091: unrest:events-resolution:v1 is empty without ACLED credentials
      // (same root cause as conflict, #5136) β†’ route to judged. The
      // `unrestCountFeedAvailable` override forces the hard path for the tests
      // that lock the preserved threshold logic.
      if (!(options.unrestCountFeedAvailable ?? UNREST_COUNT_FEED_AVAILABLE)) return null;
      const tally = firstFiniteSignalCount(pred, new Set(['unrest_events']));
      if (!Number.isFinite(tally)) return null;
      const horizonMs = HORIZON_MS[pred.timeHorizon];
      if (!Number.isFinite(horizonMs)) return null;
      const threshold = Math.max(1, Math.round(tally * (horizonMs / (30 * DAY_MS)) * 0.75));
      return {
        metricKey: `${UNREST_COUNT_SOURCE_FEED}|count(country==${pred.region})`,
        sourceFeed: UNREST_COUNT_SOURCE_FEED,
        operator: '>=',
        threshold,
        window: FAMILY_WINDOW[family],
      };
    }
    case 'cyber': {
      const tally = firstFiniteSignalCount(pred, new Set(['cyber']));
      if (!Number.isFinite(tally)) return null;
      const horizonMs = HORIZON_MS[pred.timeHorizon];
      if (!Number.isFinite(horizonMs)) return null;
      const threshold = Math.max(1, Math.round(tally * (horizonMs / (14 * DAY_MS)) * 0.75));
      return {
        metricKey: `${CYBER_COUNT_SOURCE_FEED}|count(country==${pred.region})`,
        sourceFeed: CYBER_COUNT_SOURCE_FEED,
        operator: '>=',
        threshold,
        window: FAMILY_WINDOW[family],
      };
    }
    case 'supply_chain': {
      // Threshold is a boolean-shaped condition (disruption present),
      // represented as riskScore >= 60 (the detector's own "disrupted"
      // gate threshold, seed-forecasts.mjs detectSupplyChainScenarios).
      return {
        metricKey: `supply_chain:chokepoints:v4|riskScore(route==${pred.region})`,
        operator: '>=',
        threshold: 60,
        window: FAMILY_WINDOW[family],
      };
    }
    case 'prediction_market': {
      // Percent-anchored so a digit-bearing source label doesn't skew the
      // baseline (FIX 6). Falls back to the emission probability.
      const baseline = firstPercentSignalValue(pred, new Set(['prediction_market']));
      // Deadline source (R5 amended): the market's own endDate is when the
      // metric becomes truth β€” the 30d detector horizon would read a still-
      // unsettled yesPrice and score a false NO. Fall back to the horizon
      // deadline when the market/endDate is not reachable.
      const endDate = findPredictionMarketEndDate(pred, inputs);
      return {
        metricKey: `prediction:markets-bootstrap:v1|yesPrice(market==${pred.title})`,
        operator: 'crosses',
        threshold: 50,
        baselineValue: Number.isFinite(baseline) ? baseline : (Number.isFinite(pred.probability) ? Math.round(pred.probability * 100) : null),
        window: FAMILY_WINDOW[family],
        deadlineOverride: Number.isFinite(endDate) ? endDate : null,
      };
    }
    case 'gps': {
      const hexes = firstFiniteSignalCount(pred, new Set(['gps_jamming']));
      if (!Number.isFinite(hexes)) return null;
      return {
        metricKey: `intelligence:gpsjam:v2|hexCount(region==${pred.region})`,
        operator: '>=',
        threshold: Math.max(1, Math.round(hexes)),
        window: FAMILY_WINDOW[family],
      };
    }
    case 'market': {
      // Production shape (R12 walkthrough): a market forecast carries a
      // `commodity` signal whose label maps to a future ticker priced in
      // market:commodities-bootstrap:v1. metricKey MUST encode the ticker
      // (the feed is symbol-keyed), and the spec is a price MOVE ("price
      // impact"), so operator 'crosses' + baselineValue = emission price.
      const commoditySignal = (pred.signals || []).find((s) => s.type === 'commodity');
      if (commoditySignal) {
        const label = String(commoditySignal.value ?? '').split(' sensitivity:')[0].trim();
        const symbol = COMMODITY_LABEL_TO_SYMBOL[label];
        if (symbol) {
          const price = findCommodityPrice(inputs, symbol); // finite & > 0 (guarded in the index)
          if (Number.isFinite(price)) {
            return {
              metricKey: `market:commodities-bootstrap:v1|price(symbol==${symbol})`,
              sourceFeed: 'market:commodities-bootstrap:v1',
              operator: 'crosses',
              threshold: +(price * MARKET_PRICE_MOVE_RATIO).toFixed(2),
              baselineValue: +price.toFixed(2),
              window: FAMILY_WINDOW[family],
            };
          }
        }
      }
      // No commodity-ticker hard path succeeded: an unmapped label
      // (Semiconductors, Trade goods, ambiguous compounds), no emission
      // price, or no commodity signal at all -> judged (R3). There is NO
      // calibration.marketPrice hard path: a stocks feed cannot resolve a
      // prediction-market title, and its threshold===baselineValue 'crosses'
      // spec was vacuous (fails the R12 sufficiency bar every other family
      // passed).
      return null;
    }
    default:
      return null;
  }
}

function buildQuestion(pred) {
  const title = pred.title || '(untitled forecast)';
  const region = pred.region || 'unspecified region';
  const domain = pred.domain || 'unspecified domain';
  const horizon = pred.timeHorizon || 'unspecified horizon';
  // Conflict (#5136) and unrest/political (#5091) forecasts are now judged. A
  // sharper, escalation-framed question resolves more reliably against the news
  // archive than the generic "resolve YES" phrasing.
  if (domain === 'conflict') {
    return `Within the ${horizon} horizon, did ${region} experience a materially escalated level of armed conflict versus its recent baseline, consistent with "${title}"?`;
  }
  if (domain === 'political') {
    return `Within the ${horizon} horizon, did ${region} experience a materially elevated level of civil unrest or political instability versus its recent baseline, consistent with "${title}"?`;
  }
  return `Will "${title}" (${domain}, ${region}) resolve YES within its ${horizon} horizon?`;
}

function buildJudgedSpec(pred, generatedAt) {
  return {
    kind: 'judged',
    metricKey: null,
    operator: null,
    threshold: null,
    baselineValue: null,
    window: null,
    deadline: deriveDeadline(generatedAt, pred.timeHorizon),
    sourceFeed: null,
    question: buildQuestion(pred),
  };
}

function buildHardSpec(pred, inputs, family, generatedAt, options = {}) {
  const metrics = deriveHardMetrics(pred, family, inputs, options);
  if (!metrics || !Number.isFinite(metrics.threshold)) {
    // Threshold fallback (R3/plan step 3): a hard family that cannot derive
    // a finite threshold emits a judged spec rather than an unresolvable
    // hard spec with a missing/NaN threshold.
    return buildJudgedSpec(pred, generatedAt);
  }
  if (metrics.operator === 'crosses' && !Number.isFinite(metrics.baselineValue)) {
    return buildJudgedSpec(pred, generatedAt);
  }
  const sourceFeed = metrics.sourceFeed || FAMILY_FEED[family];
  if (!sourceFeed || !RESOLUTION_FEED_KEYS.has(sourceFeed)) {
    return buildJudgedSpec(pred, generatedAt);
  }
  // Always compute the horizon deadline (validates the horizon, preserving
  // deriveDeadline's throw semantics for every family), then let a family
  // override it with a truth-time source (prediction_market -> market endDate).
  const horizonDeadline = deriveDeadline(generatedAt, pred.timeHorizon);
  const deadline = Number.isFinite(metrics.deadlineOverride) ? metrics.deadlineOverride : horizonDeadline;
  return {
    kind: 'hard',
    metricKey: metrics.metricKey,
    operator: metrics.operator,
    threshold: metrics.threshold,
    baselineValue: metrics.operator === 'crosses' ? metrics.baselineValue : null,
    window: metrics.window,
    deadline,
    sourceFeed,
    question: null,
  };
}

// Build the resolution spec for one forecast. Deterministic: identical
// (pred, inputs, generatedAt) always yields an identical spec.
//
// Dispatch order (load-bearing β€” see plan D3 + Addendum + R12 walkthroughs):
//  1. state_derived origin -> ALWAYS judged, before any family lookup.
//     buildStateDerivedForecast attaches a 'market_transmission' signal
//     (weight 0.24) to every state-derived forecast, so a family-first
//     dispatch would misclassify all of them as hard/market.
//  2. prediction_market family -> hard, BEFORE the JUDGED_DOMAINS gate. A
//     detectFromPredictionMarkets forecast's CLAIM *is* the market question
//     (seed-forecasts.mjs :2228-2242), so the market's own resolution is the
//     claim's ground truth regardless of the domain the detector assigned
//     (which can be political/conflict/market). This is unlike a 'cii' signal
//     on a political claim (evidence, not the claim) β€” hence the exemption.
//  3. JUDGED_DOMAINS (currently infrastructure and military) -> ALWAYS judged
//     until the forecast carries the stable, claim-aligned metric identity
//     needed for a hard feed lookup.
//  4. Other hard families resolved from pred.signals[].type via
//     SIGNAL_TO_HARD_FAMILY (with the market-domain chokepoint/ais_gap
//     exclusion + a calibration.marketPrice fallback for market-domain
//     forecasts with no direct market-signal match).
//  5. If a hard family is found but yields no finite threshold (or, for
//     'crosses', no finite baselineValue, or an unmapped sourceFeed) -> judged.
//  6. Otherwise (no-signal-match/unrecognized domain) -> judged.
// deriveDeadline is the only call that can throw (an unrecognized horizon);
// buildResolutionSpec itself never throws.
export function buildResolutionSpec(pred, inputs, generatedAt, options = {}) {
  if (pred.generationOrigin === 'state_derived') {
    return buildJudgedSpec(pred, generatedAt);
  }

  // prediction_market exemption (before the JUDGED_DOMAINS gate).
  const hasPredictionMarketSignal = (pred.signals || []).some(
    (s) => SIGNAL_TO_HARD_FAMILY[s.type] === 'prediction_market',
  );
  if (hasPredictionMarketSignal) {
    return buildHardSpec(pred, inputs, 'prediction_market', generatedAt, options);
  }

  if (JUDGED_DOMAINS.has(pred.domain)) {
    return buildJudgedSpec(pred, generatedAt);
  }

  const family = resolveHardFamily(pred);
  if (!family) {
    return buildJudgedSpec(pred, generatedAt);
  }

  return buildHardSpec(pred, inputs, family, generatedAt, options);
}

// The seam pass (D1): sets pred.resolution on every prediction in place and
// returns the (same) array for chaining, mirroring the existing
// calibrateWithMarkets / computeProjections enrichment-pass convention.
export function attachResolutionSpecs(predictions, inputs, generatedAt, options = {}) {
  for (const pred of predictions) {
    pred.resolution = buildResolutionSpec(pred, inputs, generatedAt, options);
  }
  return predictions;
}