File size: 30,925 Bytes
646ebaf
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1184305
646ebaf
 
 
 
 
1184305
 
 
 
 
 
 
 
646ebaf
1184305
 
 
 
646ebaf
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1184305
 
646ebaf
 
 
 
 
 
 
 
1184305
 
 
646ebaf
 
1184305
646ebaf
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1184305
646ebaf
 
 
1184305
 
 
646ebaf
1184305
646ebaf
 
 
1184305
646ebaf
 
 
1184305
 
 
 
 
 
 
 
 
 
 
646ebaf
 
 
1184305
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
646ebaf
 
1184305
 
 
 
646ebaf
1184305
 
646ebaf
1184305
646ebaf
 
 
 
 
 
 
 
 
 
 
 
1184305
646ebaf
1184305
646ebaf
 
1184305
 
646ebaf
 
 
 
 
 
 
 
 
 
 
 
 
1184305
 
 
646ebaf
1184305
646ebaf
 
 
 
 
 
1184305
 
646ebaf
 
1184305
 
 
 
 
646ebaf
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1184305
d2e48df
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1184305
646ebaf
 
 
 
 
 
 
1184305
 
 
 
646ebaf
1184305
646ebaf
 
 
 
 
fb54991
 
 
 
 
 
 
646ebaf
fb54991
646ebaf
 
fb54991
 
 
 
646ebaf
fb54991
646ebaf
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
fb54991
 
646ebaf
 
 
 
 
 
 
 
fb54991
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
/**
 * FSM-state → Findings Card[] adapter.
 *
 * The Findings region is rendered from a `FindingsData` shape:
 * `{ cards, stones, wallSeconds }`. The FSM produces a different
 * structure: a stream of step events plus a final payload with each
 * specialist's raw output keyed by state name (`sandy`, `dep`,
 * `nyc311`, `mta_entrances`, etc.).
 *
 * This module bridges the two. For each Stone we collect the relevant
 * state keys, render a card per non-silent specialist using the most
 * legible variant for the data shape, and build a per-Stone trace from
 * the TraceNode tree.
 *
 * Best-effort: a missing specialist drops out (silence over
 * confabulation); a specialist that fired with no usable shape becomes
 * a `meta` card listing whatever scalars it returned.
 */
import type {
  Card, FindingsData, StoneKey, StoneMember, StoneTrace
} from '$lib/types/card';
import type { TraceNode, TraceStatus } from '$lib/types/trace';
import type { FinalResult } from '$lib/client/agentStream';
import { fillRosterForStone } from '$lib/data/stoneRegistry';

/** Reasonable defaults — when the FSM doesn't supply a vintage, fall
 *  back to the Riprap publication date. */
const RIPRAP_VINTAGE = '2026-05';

/** Map the FSM trace's TraceStatus into the v0.4.5 5-state SpecialistStatus.
 *  Crucial split: a specialist that "returned no data" is `silent_by_design`,
 *  not `errored`. The FSM marks both as `silent` in the trace; we
 *  conservatively classify any successful trace-`silent` as
 *  silent_by_design (the spec voice). Anything that raised in the FSM
 *  becomes `errored`. `fan`/`merge` are structural; the few callers
 *  that look at them treat them as fired.
 */
function mapStatus(s: TraceStatus): StoneMember['status'] {
  if (s === 'fan' || s === 'merge') return 'fired';
  if (s === 'silent') return 'silent_by_design';
  if (s === 'error') return 'errored';
  return 'fired';
}

function flattenTrace(node: TraceNode): TraceNode[] {
  return [node, ...(node.children ?? []).flatMap(flattenTrace)];
}

/** Group leaf specialist nodes by the Stone their state-key belongs to.
 *  The trace's name often differs from the state key (e.g.
 *  `mta_entrance_exposure` vs state `mta_entrances`); we map both. */
function stoneForStep(name: string): StoneKey | null {
  const n = name.toLowerCase();
  // Single-address chain
  if (n === 'sandy_inundation' || n === 'sandy') return 'cornerstone';
  if (n === 'dep_stormwater' || n === 'dep') return 'cornerstone';
  if (n === 'ida_hwm_2021' || n === 'ida_hwm') return 'cornerstone';
  if (n === 'prithvi_eo_v2' || n === 'prithvi_water') return 'cornerstone';
  if (n === 'microtopo_lidar' || n === 'microtopo') return 'cornerstone';
  if (n === 'mta_entrance_exposure' || n === 'mta_entrances') return 'keystone';
  if (n === 'nycha_development_exposure' || n === 'nycha_developments') return 'keystone';
  if (n === 'doe_school_exposure' || n === 'doe_schools') return 'keystone';
  if (n === 'doh_hospital_exposure' || n === 'doh_hospitals') return 'keystone';
  if (n === 'terramind_synthesis' || n === 'terramind' || n === 'terramind_buildings' ||
      n === 'eo_chip_fetch') return 'keystone';
  if (n === 'floodnet') return 'touchstone';
  if (n === 'nyc311') return 'touchstone';
  if (n === 'nws_obs') return 'touchstone';
  if (n === 'noaa_tides') return 'touchstone';
  if (n === 'prithvi_eo_live' || n === 'prithvi_live' || n === 'terramind_lulc')
    return 'touchstone';
  if (n === 'nws_alerts') return 'lodestone';
  if (n === 'ttm_forecast' || n === 'ttm_311_forecast' || n === 'floodnet_forecast' ||
      n === 'ttm_battery_surge') return 'lodestone';
  if (n.startsWith('reconcile') || n.startsWith('mellea') ||
      n === 'rag_granite_embedding' || n === 'gliner_extract') return 'capstone';
  return null;
}

function buildStoneTraces(root: TraceNode | undefined | null): StoneTrace[] {
  const buckets: Record<StoneKey, StoneMember[]> = {
    cornerstone: [], keystone: [], touchstone: [], lodestone: [], capstone: [],
  };
  if (root) {
    for (const node of flattenTrace(root)) {
      const stone = stoneForStep(node.name);
      if (!stone) continue;
      buckets[stone].push({
        id: node.id || node.name,
        // Preserve the raw FSM step name here — the registry projection
        // matches against `stepNames`, not display names.
        name: node.name,
        status: mapStatus(node.status),
        tier: node.tier,
        ms: node.ms,
        note: node.note ?? node.error ?? undefined,
      });
    }
  }
  // v0.4.5 §3 — project the registry over each Stone so the provenance
  // expander shows the full inventory, with absent specialists as
  // not_invoked.
  return (Object.keys(buckets) as StoneKey[]).map((key) => ({
    key,
    members: fillRosterForStone(key, buckets[key]),
  }));
}

/* ── Per-specialist card builders. Each returns null if the specialist
   didn't fire, returned no usable data, or the shape doesn't exist. ── */

type Final = Record<string, unknown> & FinalResult;

function num(v: unknown): number | null {
  return typeof v === 'number' && Number.isFinite(v) ? v : null;
}

function str(v: unknown): string | null {
  return typeof v === 'string' ? v : null;
}

function obj(v: unknown): Record<string, unknown> | null {
  return v && typeof v === 'object' && !Array.isArray(v)
    ? (v as Record<string, unknown>) : null;
}

function buildSandy(state: Final, geocode: Record<string, unknown> | null): Card | null {
  if (state.sandy !== true) return null;
  const addr = geocode && str(geocode.address);
  return {
    id: 'fsm-sandy',
    stone: 'cornerstone', tier: 'empirical', variant: 'headline',
    source: 'NYC OEM', agency: 'NYC OpenData 5xsi-dfpx · Sandy 2012 inundation',
    vintage: '2012-10-29',
    title: 'Hurricane Sandy 2012 inundation',
    headline: 'Inside zone',
    subhead: addr ?? 'address inside the empirical 2012 extent',
    body: 'Address sits within the empirical Hurricane Sandy 2012 inundation extent. This is a historical fact, not a model prediction.',
    docId: 'sandy', citeId: 'sandy', mapLayer: 'sandy',
  };
}

function buildDep(state: Final): Card | null {
  const dep = obj(state.dep);
  if (!dep) return null;
  const rows: (string | number)[][] = [];
  for (const [scen, info] of Object.entries(dep)) {
    const i = obj(info);
    if (!i) continue;
    const cls = num(i.depth_class) ?? 0;
    if (cls <= 0) continue;
    rows.push([scen.replace('dep_', ''), str(i.depth_label) ?? '—', `class ${cls}`]);
  }
  if (!rows.length) return null;
  return {
    id: 'fsm-dep',
    stone: 'cornerstone', tier: 'modeled', variant: 'tabular',
    source: 'NYC DEP', agency: 'NYC Department of Environmental Protection · Stormwater Flood Maps',
    vintage: '2021',
    title: 'Stormwater flood scenarios at this address',
    columns: ['scenario', 'depth label', 'class'],
    rows,
    sub: `${rows.length} scenario${rows.length === 1 ? '' : 's'} place this lot in modeled flooding`,
    docId: 'dep_stormwater', citeId: 'dep', mapLayer: 'stormwater',
  };
}

function buildIdaHwm(state: Final): Card | null {
  const ida = obj(state.ida_hwm);
  if (!ida) return null;
  const n = num(ida.n_within_radius);
  if (!n || n <= 0) return null;
  const rows: (string | number)[][] = [];
  rows.push(['count', `${n}`, `${num(ida.radius_m) ?? 800} m radius`]);
  if (num(ida.max_height_above_gnd_ft) != null) {
    rows.push(['max above gnd', `${ida.max_height_above_gnd_ft} ft`, '—']);
  }
  if (num(ida.nearest_dist_m) != null) {
    rows.push(['nearest', str(ida.nearest_site) ?? 'HWM', `${ida.nearest_dist_m} m`]);
  }
  return {
    id: 'fsm-ida-hwm',
    stone: 'cornerstone', tier: 'empirical', variant: 'tabular',
    source: 'USGS', agency: 'USGS STN Hurricane Ida 2021 high-water marks (Event 312)',
    vintage: '2021-09',
    title: 'Hurricane Ida 2021 high-water marks nearby',
    columns: ['field', 'value', 'context'],
    rows,
    docId: 'ida_hwm', citeId: 'ida_hwm', mapLayer: 'hwm',
  };
}

function buildPrithviWater(state: Final): Card | null {
  const pw = obj(state.prithvi_water);
  if (!pw) return null;
  const dist = num(pw.nearest_distance_m);
  if (dist == null) return null;
  return {
    id: 'fsm-prithvi-water',
    stone: 'cornerstone', tier: 'modeled', variant: 'raster',
    source: 'Prithvi-EO 2.0', agency: 'IBM/NASA Prithvi-EO 2.0 · baked Hurricane Ida 2021 polygons',
    vintage: '2021-09-02',
    title: 'Hurricane Ida 2021 — satellite-attributable inundation',
    rasterKind: 'prithvi',
    headline: pw.inside_water_polygon ? 'Inside polygon' : `${dist} m away`,
    subhead: 'pre/post HLS Sentinel-2 segmentation',
    sub: `${num(pw.n_polygons_within_500m) ?? 0} distinct polygons within 500 m`,
    docId: 'prithvi_water', citeId: 'prithvi_water', mapLayer: 'prithvi',
  };
}

function buildMicrotopo(state: Final): Card | null {
  const mt = obj(state.microtopo);
  if (!mt) return null;
  const elev = num(mt.point_elev_m);
  if (elev == null) return null;
  const scalars = [
    { value: `${elev.toFixed(1)} m`, label: 'elevation' },
  ];
  if (num(mt.hand_m) != null) scalars.push({ value: `${(mt.hand_m as number).toFixed(1)} m`, label: 'HAND' });
  if (num(mt.twi) != null) scalars.push({ value: `${(mt.twi as number).toFixed(1)}`, label: 'TWI' });
  if (num(mt.rel_elev_pct_200m) != null) scalars.push({ value: `${mt.rel_elev_pct_200m}%`, label: 'pct lower 200m' });
  return {
    id: 'fsm-microtopo',
    stone: 'cornerstone', tier: 'proxy', variant: 'scalars',
    source: 'USGS 3DEP', agency: 'USGS 3DEP DEM (LiDAR-derived) + whitebox-workflows hydrology',
    vintage: '2018',
    title: 'Microtopography at this point',
    scalars,
    sub: 'Lower percentile = topographic low point; runoff routes here.',
    docId: 'microtopo', citeId: 'microtopo',
  };
}

/** Per-asset register. Each register-specialist's state has an `available`
 *  flag + a list of items; we render one card with N rows. */
function buildRegisters(state: Final): Card | null {
  const rows: NonNullable<Card['registers']> = [];
  const mta = obj(state.mta_entrances);
  if (mta?.available && Array.isArray(mta.entrances)) {
    for (const e of (mta.entrances as Record<string, unknown>[]).slice(0, 4)) {
      rows.push({
        reg: 'MTA', tier: 'empirical',
        label: str(e.station_name) ?? str(e.entrance_id) ?? 'entrance',
        detail: `${num(e.distance_m) ?? '—'} m · ${str(e.daytime_routes) ?? ''}`.trim(),
        sourceId: str(e.station_id) ?? 'MTA',
        note: null,
      });
    }
  } else if (mta && mta.available === false) {
    rows.push({
      reg: 'MTA', tier: 'empirical', label: null, detail: null, sourceId: null,
      note: 'no subway entrances within 1.0 mi (silent)',
    });
  }

  const nycha = obj(state.nycha_developments);
  if (nycha?.available && Array.isArray(nycha.developments)) {
    for (const d of (nycha.developments as Record<string, unknown>[]).slice(0, 3)) {
      rows.push({
        reg: 'NYCHA', tier: 'empirical',
        label: str(d.development) ?? 'development',
        detail: `${num(d.distance_m) ?? '—'} m · ${str(d.borough) ?? ''}`.trim(),
        sourceId: str(d.tds_num) ?? null,
        note: null,
      });
    }
  } else if (nycha && nycha.available === false) {
    rows.push({
      reg: 'NYCHA', tier: 'empirical', label: null, detail: null, sourceId: null,
      note: 'no NYCHA developments within 1.0 mi (silent)',
    });
  }

  const doe = obj(state.doe_schools);
  if (doe?.available && Array.isArray(doe.schools)) {
    for (const s of (doe.schools as Record<string, unknown>[]).slice(0, 3)) {
      rows.push({
        reg: 'DOE', tier: 'empirical',
        label: str(s.loc_name) ?? 'school',
        detail: `${num(s.distance_m) ?? '—'} m · ${str(s.borough) ?? ''}`.trim(),
        sourceId: str(s.loc_code) ?? null,
        note: null,
      });
    }
  } else if (doe && doe.available === false) {
    rows.push({
      reg: 'DOE', tier: 'empirical', label: null, detail: null, sourceId: null,
      note: 'no schools within 1.0 mi (silent)',
    });
  }

  const doh = obj(state.doh_hospitals);
  if (doh?.available && Array.isArray(doh.hospitals)) {
    for (const h of (doh.hospitals as Record<string, unknown>[]).slice(0, 3)) {
      rows.push({
        reg: 'DOH', tier: 'empirical',
        label: str(h.facility_name) ?? 'hospital',
        detail: `${num(h.distance_m) ?? '—'} m · ${str(h.borough) ?? ''}`.trim(),
        sourceId: str(h.fac_id) ?? null,
        note: null,
      });
    }
  } else if (doh && doh.available === false) {
    rows.push({
      reg: 'DOH', tier: 'empirical', label: null, detail: null, sourceId: null,
      note: 'no acute-care hospital within 1.0 mi (silent)',
    });
  }

  if (!rows.length) return null;
  return {
    id: 'fsm-registers',
    stone: 'keystone', tier: 'empirical', variant: 'register',
    source: 'NYC OpenData', agency: 'NYC OpenData · multi-agency join',
    vintage: RIPRAP_VINTAGE,
    title: 'Nearby exposed assets',
    registers: rows,
    sub: `${rows.filter((r) => r.label).length} of ${rows.length} registers fired · joined within 1.0 mi`,
    docId: 'registers', citeId: 'registers', mapLayer: 'registers',
  };
}

function buildTerramindBuildings(state: Final): Card | null {
  const tmb = obj(state.terramind_buildings);
  if (!tmb?.ok) return null;
  return {
    id: 'fsm-tm-buildings',
    stone: 'keystone', tier: 'modeled', variant: 'raster-pred',
    source: 'TerraMind-NYC', agency: 'msradam/TerraMind-NYC-Adapters · Buildings LoRA',
    vintage: '2026',
    title: 'NYC building footprints — TerraMind LoRA',
    rasterKind: 'buildings',
    headline: `${num(tmb.pct_buildings) ?? 0}%`,
    subhead: 'building-footprint coverage in chip',
    sub: `${num(tmb.n_building_components) ?? 0} distinct components · test mIoU 0.5511`,
    illustrative: true,
    docId: 'tm_buildings', citeId: 'tm_buildings', mapLayer: 'buildings',
  };
}

function buildFloodnet(state: Final): Card | null {
  const fn = obj(state.floodnet);
  if (!fn || (num(fn.n_sensors) ?? 0) <= 0) return null;
  const events = num(fn.n_flood_events_3y) ?? 0;
  return {
    id: 'fsm-floodnet',
    stone: 'touchstone', tier: 'empirical', variant: 'spark',
    source: 'FloodNet', agency: 'FloodNet NYC ultrasonic depth sensor network',
    vintage: '2026',
    title: 'FloodNet sensors near this address',
    headline: `${events} events`,
    subhead: `${num(fn.n_sensors) ?? 0} sensors · last 3 y`,
    spark: Array.from({ length: 24 }, (_, i) =>
      Math.max(0, Math.round((events / 24) * 1.4 * Math.exp(-Math.pow((i - 14) / 4, 2)) +
                              (events / 24)))
    ),
    sparkSub: 'Above-curb depth events ≥ 2 cm. Synthetic monthly distribution; raw deployment-id history is in the audit panel.',
    docId: 'floodnet', citeId: 'floodnet', mapLayer: 'floodnet',
  };
}

function buildNyc311(state: Final): Card | null {
  const n = obj(state.nyc311);
  if (!n) return null;
  const total = num(n.n) ?? 0;
  if (total <= 0) return null;
  // Synthesize a histogram that matches the seasonal pattern when by_year
  // / by_descriptor is present; otherwise distribute uniformly.
  const byYear = obj(n.by_year);
  const byDescriptor = obj(n.by_descriptor);
  const hist = byYear
    ? Object.values(byYear).map((v) => num(v) ?? 0)
    : Array.from({ length: 12 }, () => Math.round(total / 12));
  const top = byDescriptor
    ? Object.entries(byDescriptor).sort((a, b) => (num(b[1]) ?? 0) - (num(a[1]) ?? 0))[0]?.[0]
    : null;
  return {
    id: 'fsm-311',
    stone: 'touchstone', tier: 'proxy', variant: 'histogram',
    source: 'NYC 311', agency: 'NYC 311 service requests (Socrata erm2-nwe9)',
    vintage: RIPRAP_VINTAGE,
    title: 'Recent 311 flood complaints',
    headline: `${total} calls`,
    subhead: top ? `top descriptor: ${top}` : 'all flood-related descriptors',
    histogram: hist,
    sparkSub: `Within ${num(n.radius_m) ?? 200} m · ${num(n.years) ?? 5} y window. Filtered to flood-relevant descriptors.`,
    docId: 'nyc311', citeId: 'nyc311', mapLayer: 'complaints',
  };
}

function buildNwsObs(state: Final): Card | null {
  const obs = obj(state.nws_obs);
  if (!obs || obs.error || obs.station_id == null) return null;
  const scalars: NonNullable<Card['scalars']> = [];
  if (num(obs.precip_last_hour_mm) != null) scalars.push({ value: `${obs.precip_last_hour_mm} mm`, label: 'precip · 1h' });
  if (num(obs.precip_last_6h_mm) != null) scalars.push({ value: `${obs.precip_last_6h_mm} mm`, label: 'precip · 6h' });
  if (!scalars.length) return null;
  return {
    id: 'fsm-nws-obs',
    stone: 'touchstone', tier: 'empirical', variant: 'scalars',
    source: 'NWS', agency: `NWS ASOS station ${str(obs.station_id) ?? '?'}`,
    vintage: str(obs.obs_time)?.slice(0, 10) ?? RIPRAP_VINTAGE,
    title: 'Recent precipitation',
    scalars,
    sub: `Nearest hourly METAR: ${str(obs.station_name) ?? '?'} (${num(obs.distance_km) ?? '?'} km).`,
    docId: 'nws_obs', citeId: 'nws_obs', mapLayer: 'nws',
  };
}

function buildNoaaTides(state: Final): Card | null {
  const t = obj(state.noaa_tides);
  if (!t || t.error || num(t.observed_ft_mllw) == null) return null;
  const scalars: NonNullable<Card['scalars']> = [
    { value: `${t.observed_ft_mllw} ft`, label: 'observed (MLLW)' },
  ];
  if (num(t.predicted_ft_mllw) != null) scalars.push({ value: `${t.predicted_ft_mllw} ft`, label: 'predicted' });
  if (num(t.residual_ft) != null) scalars.push({ value: `${t.residual_ft} ft`, label: 'residual' });
  return {
    id: 'fsm-noaa',
    stone: 'touchstone', tier: 'empirical', variant: 'scalars',
    source: 'NOAA CO-OPS', agency: `NOAA tide gauge ${str(t.station_name) ?? str(t.station_id) ?? '?'}`,
    vintage: str(t.obs_time)?.slice(0, 10) ?? RIPRAP_VINTAGE,
    title: 'Live water level (nearest tide gauge)',
    scalars,
    sub: 'Residual = observed − astronomical tide; positive residual is wind / surge component.',
    docId: 'noaa_tides', citeId: 'noaa_tides', mapLayer: 'noaa',
  };
}

function buildPrithviLive(state: Final): Card | null {
  const p = obj(state.prithvi_live);
  if (!p?.ok) return null;
  const sceneDate = str(p.item_datetime)?.slice(0, 10);
  return {
    id: 'fsm-prithvi-live',
    stone: 'touchstone', tier: 'modeled', variant: 'raster-pred',
    source: 'Prithvi-NYC-Pluvial', agency: 'NASA-IBM Prithvi v2 · NYC fine-tune',
    vintage: sceneDate ? `${sceneDate} · Sentinel-2` : 'Sentinel-2',
    title: 'Pluvial flood prediction · Prithvi-NYC-Pluvial',
    rasterKind: 'prithvi',
    headline: `${num(p.pct_water_within_500m) ?? 0}% flooded`,
    subhead: `water within 500 m · cloud ${num(p.cloud_cover) ?? '?'}%`,
    sub: 'Test flood IoU 0.5979 on held-out NYC chips. Model interpretation, not a measurement.',
    illustrative: true,
    docId: 'prithvi_live', citeId: 'prithvi_live', mapLayer: 'prithvi-pluvial',
  };
}

/** Conventional LULC palette — matches the design handoff's LULC card
 *  visual (urban / water / vegetation / barren / wetland). The colors
 *  are layer conventions, NOT new tier signals. */
const LULC_PALETTE: Record<string, string> = {
  urban: '#C66',
  water: '#5B7FB4',
  vegetation: '#5B8A4A',
  barren: '#A89A78',
  wetland: '#D9C75A',
};

function buildTerramindLulc(state: Final): Card | null {
  const t = obj(state.terramind_lulc);
  if (!t?.ok) return null;
  // Translate the FSM's class_fractions dict into the design-system's
  // expected ordered class-mix (urban / water / vegetation / barren /
  // wetland). Unknown class names land in barren as a catch-all.
  const fractions = (obj(t.class_fractions) ?? {}) as Record<string, number>;
  const buckets: Record<keyof typeof LULC_PALETTE, number> = {
    urban: 0, water: 0, vegetation: 0, barren: 0, wetland: 0,
  };
  for (const [k, v] of Object.entries(fractions)) {
    const lk = k.toLowerCase();
    if (lk.includes('urban') || lk.includes('built') || lk.includes('impervious')) buckets.urban += v;
    else if (lk.includes('water')) buckets.water += v;
    else if (lk.includes('tree') || lk.includes('vegetation') || lk.includes('crop') || lk.includes('grass')) buckets.vegetation += v;
    else if (lk.includes('bare') || lk.includes('barren') || lk.includes('soil')) buckets.barren += v;
    else if (lk.includes('wet') || lk.includes('marsh')) buckets.wetland += v;
    else buckets.barren += v;
  }
  const classMix = (Object.entries(buckets) as [keyof typeof LULC_PALETTE, number][])
    .filter(([, v]) => v > 0)
    .map(([k, v]) => ({ k, pct: Math.round(v), color: LULC_PALETTE[k] }));

  return {
    id: 'fsm-tm-lulc',
    stone: 'touchstone', tier: 'synthetic', variant: 'lulc',
    source: 'TerraMind v1.2', agency: 'IBM TerraMind v1.2 · Sentinel-2 inputs',
    vintage: 'Sentinel-2',
    title: 'Land use / land cover · TerraMind v1.2',
    rasterKind: 'lulc',
    classMix: classMix.length ? classMix : undefined,
    sub: 'Synthetic prior. LULC palette is a layer convention, not a tier signal.',
    illustrative: true,
    docId: 'tm_lulc', citeId: 'tm_lulc', mapLayer: 'terramind-lulc',
  };
}

function buildTtmForecast(state: Final): Card | null {
  const t = obj(state.ttm_forecast);
  if (!t?.available || !t.interesting) return null;
  const peak = num(t.forecast_peak_ft);
  const ahead = num(t.forecast_peak_minutes_ahead);
  if (peak == null || ahead == null) return null;
  return {
    id: 'fsm-ttm-fc',
    stone: 'lodestone', tier: 'modeled', variant: 'timeseries',
    source: 'Granite TTM r2 (zero-shot)', agency: 'IBM Granite-TimeSeries · regional',
    vintage: RIPRAP_VINTAGE,
    title: 'Storm surge nowcast at The Battery — 9.6 h horizon (regional)',
    timeseries: { hours: 96, peak: { x: 38, y: 47 }, peakLabel: `${peak} ft @ +${Math.round(ahead/60)}h` },
    headline: `${peak} ft`,
    subhead: 'peak surge residual · 9.6h horizon · 6-min cadence',
    sub: 'Regional disclosure. Distinct from the fine-tuned Battery surge nowcast.',
    spatialNote: 'regional · Battery, not point-of-query',
    docId: 'ttm_forecast', citeId: 'ttm_forecast',
  };
}

function buildTtmBatterySurge(state: Final): Card | null {
  const t = obj(state.ttm_battery_surge);
  if (!t?.available || !t.interesting) return null;
  const peak = num(t.forecast_peak_m);
  const ahead = num(t.forecast_peak_hours_ahead);
  if (peak == null || ahead == null) return null;
  return {
    id: 'fsm-ttm-batt',
    stone: 'lodestone', tier: 'modeled', variant: 'timeseries-ft',
    source: 'msradam/Granite-TTM-r2-Battery-Surge',
    agency: 'Granite TTM r2 · NYC-specialized fine-tune',
    vintage: RIPRAP_VINTAGE,
    title: 'Storm surge nowcast at The Battery — 96 h horizon (NYC-specialized fine-tune)',
    timeseries: {
      hours: 96,
      peak: { x: ahead, y: Math.round(peak * 100) },
      peakLabel: `${(peak * 100).toFixed(0)} cm @ +${ahead}h`,
    },
    headline: `${(peak * 100).toFixed(0)} cm`,
    subhead: `peak surge · 96h horizon · hourly cadence`,
    sub: 'Fine-tuned on NYC tide-gauge history. Hourly cadence; applies city-wide via NOAA station 8518750.',
    spatialNote: 'regional · The Battery, not point-of-query',
    docId: 'ttm_battery', citeId: 'ttm_battery',
    // v0.4.5 §5 — fine-tuned-model footer chrome
    hfModelCard: 'huggingface.co/msradam/Granite-TTM-r2-Battery-Surge',
    rmse: '0.157 m',
    skillVsPersistence: '−35% vs persistence',
    hardwareBadge: 'MI300X',
  };
}

function buildNwsAlerts(state: Final): Card | null {
  const a = obj(state.nws_alerts);
  if (!a) return null;
  const n = num(a.n_active) ?? 0;
  if (n <= 0) return null;
  const alerts = Array.isArray(a.alerts) ? (a.alerts as Record<string, unknown>[]) : [];
  return {
    id: 'fsm-nws-alerts',
    stone: 'lodestone', tier: 'modeled', variant: 'tabular',
    source: 'NWS', agency: 'NWS Public Alerts API · flood-relevant filter',
    vintage: RIPRAP_VINTAGE,
    title: `${n} active flood-relevant alert${n === 1 ? '' : 's'}`,
    columns: ['event', 'severity', 'expires'],
    rows: alerts.slice(0, 4).map((al) => [
      str(al.event) ?? '?',
      str(al.severity) ?? '?',
      (str(al.expires) ?? '').slice(0, 16),
    ]),
    sub: 'Live NWS feed. If a FLOOD or FLASH FLOOD WARNING is in this list, foreground it.',
    docId: 'nws_alerts', citeId: 'nws_alerts',
  };
}

function buildCapstoneMeta(final: FinalResult, wallSeconds?: number): Card {
  // v0.4.5 §2 — wire the four metrics to the reconciler's actual state.
  // The FSM emits `mellea` as `{ rerolls, n_attempts, requirements_passed,
  // requirements_failed, requirements_total }` (the mellea_validator
  // shape). Earlier UI types used `{ passed, failed, attempts }`; we
  // accept both so cards keep rendering across backend versions.
  const m = (final.mellea ?? {}) as Record<string, unknown>;
  const passedArr = Array.isArray(m.requirements_passed)
    ? (m.requirements_passed as unknown[])
    : Array.isArray(m.passed) ? (m.passed as unknown[]) : [];
  const failedArr = Array.isArray(m.requirements_failed)
    ? (m.requirements_failed as unknown[])
    : Array.isArray(m.failed) ? (m.failed as unknown[]) : [];
  const passed = passedArr.length;
  const failed = failedArr.length;
  const totalChecks = (typeof m.requirements_total === 'number'
    ? (m.requirements_total as number)
    : (passed + failed)) || 4;
  const attempts = (typeof m.n_attempts === 'number'
    ? (m.n_attempts as number)
    : (typeof m.attempts === 'number' ? (m.attempts as number) : 0));
  const rerollsField = typeof m.rerolls === 'number' ? (m.rerolls as number) : null;
  const rerolls = rerollsField ?? Math.max(0, attempts - 1);
  const cites = final.citations?.length ?? 0;
  return {
    id: 'fsm-capstone-meta',
    stone: 'capstone', tier: 'modeled', variant: 'meta',
    source: 'Mellea', agency: 'Capstone synthesis · Granite 4.1 + Mellea grounding check',
    vintage: RIPRAP_VINTAGE,
    title: 'Briefing reconciliation',
    metaRows: [
      { k: 'mellea reroll',      v: `${rerolls} reroll${rerolls === 1 ? '' : 's'}` },
      { k: 'grounding checks',   v: `${passed}/${totalChecks} passed` },
      { k: 'citations resolved', v: `${cites}` },
      { k: 'wall-clock',         v: wallSeconds != null ? `${wallSeconds.toFixed(1)} s` : '—' },
    ],
    sub: 'Capstone produces prose, not cards. This meta-card is the integrity-narration UI for the entire pipeline.',
    docId: 'capstone',
  };
}

/** Public adapter. Combines per-specialist card builders with the trace
 *  → StoneTrace mapper into a single FindingsData payload.
 *
 *  Accepts either a real FinalResult (at end-of-stream) or a partial
 *  one synthesized from in-flight step events (during streaming). Each
 *  builder returns null when its slice of state is missing — so cards
 *  pop into the rail as their specialist completes, without waiting
 *  for the full reconcile. */
export function adaptFinalToFindings(
  final: FinalResult | Partial<FinalResult> | null | undefined,
  trace: TraceNode | undefined | null,
  wallSeconds?: number,
  /** When true, the Capstone meta card renders even with a stub final
   *  (we always want the run-summary). When false (no final at all),
   *  the meta card is skipped — there's nothing to summarise yet. */
  hasFinal: boolean = true,
): FindingsData {
  const f = (final ?? {}) as Final;
  const geocode = obj(f.geocode);
  const cards: (Card | null)[] = [
    // Cornerstone
    buildSandy(f, geocode),
    buildDep(f),
    buildIdaHwm(f),
    buildPrithviWater(f),
    buildMicrotopo(f),
    // Keystone
    buildRegisters(f),
    buildTerramindBuildings(f),
    // Touchstone
    buildFloodnet(f),
    buildNyc311(f),
    buildNwsObs(f),
    buildNoaaTides(f),
    buildPrithviLive(f),
    buildTerramindLulc(f),
    // Lodestone
    buildNwsAlerts(f),
    buildTtmForecast(f),
    buildTtmBatterySurge(f),
    // Capstone (only once we have something to summarise)
    hasFinal ? buildCapstoneMeta((final ?? { paragraph: '' }) as FinalResult, wallSeconds) : null,
  ];

  return {
    cards: cards.filter((c): c is Card => c != null),
    stones: buildStoneTraces(trace),
    wallSeconds,
  };
}

/** Per-step-event live-state mapper. The FSM action `step_X` writes to
 *  state key `X` (sometimes munged — e.g. `step_311` writes `nyc311`,
 *  `step_terramind` writes `terramind`). The SSE `step.result` payload
 *  is a slim summary (not the full doc body); cards adapt to whichever
 *  fields are present.
 *
 *  Mutates `live` in place and returns the keys that changed so callers
 *  can decide whether to re-render. */
export function applyStepEventToLiveState(
  live: Record<string, unknown>,
  stepName: string,
  result: unknown,
  ok: boolean,
): string[] {
  const STEP_TO_STATE: Record<string, string> = {
    sandy_inundation: 'sandy',
    dep_stormwater: 'dep',
    floodnet: 'floodnet',
    nyc311: 'nyc311',
    noaa_tides: 'noaa_tides',
    nws_alerts: 'nws_alerts',
    nws_obs: 'nws_obs',
    ttm_forecast: 'ttm_forecast',
    ttm_311_forecast: 'ttm_311_forecast',
    ttm_battery_surge: 'ttm_battery_surge',
    floodnet_forecast: 'floodnet_forecast',
    ida_hwm_2021: 'ida_hwm',
    prithvi_eo_v2: 'prithvi_water',
    prithvi_eo_live: 'prithvi_live',
    microtopo_lidar: 'microtopo',
    mta_entrance_exposure: 'mta_entrances',
    nycha_development_exposure: 'nycha_developments',
    doe_school_exposure: 'doe_schools',
    doh_hospital_exposure: 'doh_hospitals',
    terramind_synthesis: 'terramind',
    terramind_lulc: 'terramind_lulc',
    terramind_buildings: 'terramind_buildings',
    eo_chip_fetch: 'eo_chip',
    geocode: 'geocode',
  };
  const key = STEP_TO_STATE[stepName];
  if (!key) return [];

  // Translate the slim summary shapes the FSM emits into the
  // doc-payload shapes the card builders expect. Mostly identity
  // (the summaries already nest the relevant fields), with a few
  // exceptions documented inline.
  if (stepName === 'sandy_inundation') {
    // FSM summary: { inside: bool }. Adapter expects state.sandy === true.
    const r = result as Record<string, unknown> | null;
    live[key] = ok && r?.inside === true ? true : (ok ? false : null);
  } else if (stepName === 'dep_stormwater') {
    // FSM summary: { dep_extreme_2080: 'label', dep_moderate_2050: 'label', ... }.
    // Adapter expects state.dep[scen] = { depth_class, depth_label }.
    // Reconstruct depth_class>0 from any non-empty label.
    const r = (result as Record<string, unknown>) ?? {};
    const dep: Record<string, unknown> = {};
    for (const [scen, label] of Object.entries(r)) {
      const lbl = typeof label === 'string' ? label : '';
      if (!lbl) continue;
      dep[scen] = { depth_class: 1, depth_label: lbl };
    }
    live[key] = Object.keys(dep).length ? dep : null;
  } else if (ok && result != null) {
    live[key] = result;
  } else {
    live[key] = null;
  }

  return [key];
}