riprap-nyc / web /sveltekit /src /lib /client /cardAdapter.ts
seriffic's picture
Harden remote-ML routing + add address test suite
d2e48df
/**
* 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];
}