seriffic Claude Opus 4.7 (1M context) commited on
Commit
f24976f
·
1 Parent(s): 84bb74d

Stones C1: add taxonomy modules without changing behaviour

Browse files

First atomic step of the Riprap → Stones migration described in
UPDATE_STONES.md. Adds app/stones/{cornerstone,keystone,touchstone,
lodestone,capstone}.py — each exposes NAME / TAGLINE / DESCRIPTION /
SOURCES / collect(state). The FSM is unchanged; nothing imports the
new modules yet. Capstone is a thin alias around app/reconcile.py.

Forward-looking SOURCES entries (terramind_buildings, terramind_lulc,
ttm_battery_surge) are included now and gated in the unit test against
a FUTURE_STATE_KEYS allowlist so commits 4 and 6 can land without
churn here.

tests/test_stones.py is pure-import (no server required) and covers:
- required attrs on every Stone
- SOURCES keys are valid FSM state writes (modulo future keys)
- SOURCES are disjoint across data-Stones
- collect() drops silent specialists
- canonical Stone iteration order
- uniform collect() arity

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

app/stones/__init__.py ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Five Stones — conceptual grouping over the FSM specialists.
2
+
3
+ Riprap's FSM runs ~20 atomic specialist actions; the Stones layer is a
4
+ thin re-grouping that gives the trace UI, the briefing prompt, and the
5
+ project's public framing five legible roles instead of 20 atomic
6
+ function calls.
7
+
8
+ Each Stone module exposes the same shape:
9
+
10
+ NAME — display name (e.g. "Cornerstone")
11
+ TAGLINE — single phrase used as a section header
12
+ DESCRIPTION — one-sentence description for the README / trace UI
13
+ SOURCES — list of FSM state keys this Stone aggregates from
14
+ collect(state) — pull this Stone's documents out of the state dict
15
+
16
+ Order is meaningful:
17
+ 1. Cornerstone — the hazard reader (static record)
18
+ 2. Keystone — the asset register (exposure)
19
+ 3. Touchstone — the live observer (current sensors + EO)
20
+ 4. Lodestone — the projector (forecast)
21
+ 5. Capstone — the synthesiser (Granite 4.1 + Mellea)
22
+
23
+ The first four are *data-Stones*; the Capstone IS the reconciler.
24
+ """
25
+ from __future__ import annotations
26
+
27
+ from app.stones import capstone, cornerstone, keystone, lodestone, touchstone
28
+
29
+ # Iteration order for the briefing prompt and trace UI.
30
+ DATA_STONES = [cornerstone, keystone, touchstone, lodestone]
31
+ ALL_STONES = DATA_STONES + [capstone]
32
+
33
+ __all__ = [
34
+ "ALL_STONES",
35
+ "DATA_STONES",
36
+ "capstone",
37
+ "cornerstone",
38
+ "keystone",
39
+ "lodestone",
40
+ "touchstone",
41
+ ]
app/stones/capstone.py ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Capstone — the Synthesiser.
2
+
3
+ Granite 4.1 (8B) writes the cited briefing under Mellea-validated
4
+ rejection sampling. Every numeric claim is anchored to a `[doc_id]`
5
+ citation pointing back into one of the four data-Stones; sentences
6
+ that fail the four grounding checks (`numerics_grounded`,
7
+ `no_placeholder_tokens`, `citations_dense`, `citations_resolve`) are
8
+ rolled with surgical feedback until the budget is exhausted.
9
+
10
+ This module is a thin alias around `app.reconcile` — the working code
11
+ stays in `app/reconcile.py` for git-blame continuity. The naming is in
12
+ the user-facing trace and the README.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ from typing import Any
17
+
18
+ from app import reconcile as _reconcile
19
+
20
+ NAME = "Capstone"
21
+ TAGLINE = "The Synthesiser"
22
+ DESCRIPTION = (
23
+ "Writes the cited briefing — Granite 4.1 + Mellea rejection sampling."
24
+ )
25
+
26
+ # Capstone consumes everything the four data-Stones produced; we don't
27
+ # enumerate state keys here because the reconciler reads the FSM state
28
+ # directly and `app/reconcile.py:build_documents()` is the source of
29
+ # truth for which keys it touches.
30
+ SOURCES: list[str] = []
31
+
32
+ # Re-export the reconciler entrypoints under the Stone name so callers
33
+ # can write `from app.stones import capstone; capstone.run(state)`.
34
+ build_documents = _reconcile.build_documents
35
+ trim_docs_to_plan = _reconcile.trim_docs_to_plan
36
+ verify_paragraph = _reconcile.verify_paragraph
37
+ run = _reconcile.reconcile
38
+ EXTRA_SYSTEM_PROMPT = _reconcile.EXTRA_SYSTEM_PROMPT
39
+
40
+
41
+ def collect(state: dict[str, Any]) -> dict[str, Any]:
42
+ """Return the Capstone's outputs from the state dict (for the trace)."""
43
+ out: dict[str, Any] = {}
44
+ for k in ("paragraph", "audit", "mellea"):
45
+ if state.get(k) is not None:
46
+ out[k] = state[k]
47
+ return out
app/stones/cornerstone.py ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Cornerstone — the Hazard Reader.
2
+
3
+ Reads what NYC's ground remembers about flooding: empirical 2012 Sandy
4
+ extent, modelled DEP scenarios, 2021 Ida USGS high-water marks, baked
5
+ Prithvi-EO Ida-attributable polygons, and LiDAR-derived microtopography
6
+ (elevation / HAND / TWI).
7
+
8
+ These are static records — they don't change between queries. They
9
+ ground the briefing in what already happened or has already been
10
+ modelled, and serve as the empirical anchor for everything the live
11
+ sensors and forecasts report.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ from typing import Any
16
+
17
+ NAME = "Cornerstone"
18
+ TAGLINE = "The Hazard Reader"
19
+ DESCRIPTION = "Reads what NYC's ground remembers about flooding."
20
+
21
+ # FSM state keys this Stone aggregates. The order here mirrors the order
22
+ # documents are emitted into the reconciler prompt today.
23
+ SOURCES = [
24
+ "sandy", # step_sandy — 2012 Sandy inundation extent
25
+ "dep", # step_dep — NYC DEP stormwater scenarios
26
+ "ida_hwm", # step_ida_hwm — USGS Ida 2021 high-water marks
27
+ "prithvi_water", # step_prithvi — baked Prithvi-EO Ida polygons
28
+ "microtopo", # step_microtopo — USGS 3DEP DEM + HAND/TWI
29
+ ]
30
+
31
+
32
+ def collect(state: dict[str, Any]) -> dict[str, Any]:
33
+ """Return {state_key: value} for every Cornerstone source that fired.
34
+
35
+ Drops keys whose value is None (the silence-over-confabulation
36
+ contract — specialists that didn't fire emit nothing).
37
+ """
38
+ return {k: state[k] for k in SOURCES if state.get(k) is not None}
app/stones/keystone.py ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Keystone — the Asset Register.
2
+
3
+ Counts what the city has built on top of those hazards: subway
4
+ entrances, NYCHA developments, DOE schools, NYS DOH hospitals, and
5
+ (via the TerraMind-NYC-Buildings adapter, fine-tuned on NYC building
6
+ footprints on AMD MI300X) the building stock visible in current EO.
7
+
8
+ These are the public-asset registers — the per-address briefing
9
+ quantifies how many of each asset class fall inside the hazard
10
+ footprints the Cornerstone established.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ from typing import Any
15
+
16
+ NAME = "Keystone"
17
+ TAGLINE = "The Asset Register"
18
+ DESCRIPTION = "Counts the public assets and built fabric exposed to the hazards."
19
+
20
+ # Existing register specialists + the new TerraMind-Buildings tool
21
+ # (added in commit 4 of the Stones migration). Stones layer is
22
+ # tolerant of state keys that don't exist yet — `collect` skips
23
+ # anything absent.
24
+ SOURCES = [
25
+ "mta_entrances", # step_mta_entrances — MTA entrance exposure
26
+ "nycha_developments", # step_nycha — NYCHA exposure
27
+ "doe_schools", # step_doe_schools — DOE schools exposure
28
+ "doh_hospitals", # step_doh_hospitals — NYS DOH hospitals
29
+ "terramind_buildings", # step_terramind_buildings (commit 4) — NYC LoRA
30
+ ]
31
+
32
+
33
+ def collect(state: dict[str, Any]) -> dict[str, Any]:
34
+ """Return {state_key: value} for every Keystone source that fired."""
35
+ return {k: state[k] for k in SOURCES if state.get(k) is not None}
app/stones/lodestone.py ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Lodestone — the Projector.
2
+
3
+ Projects what's coming next: NWS active flood-relevant alerts (the
4
+ National Weather Service's authoritative short-horizon watches /
5
+ warnings), Granite TimeSeries TTM r2 zero-shot forecasts of the Battery
6
+ surge residual and per-address NYC 311 complaint rates and per-sensor
7
+ FloodNet event recurrence, and (via the Granite-TTM-r2-Battery-Surge
8
+ fine-tune on AMD MI300X) a 96-hour surge nowcast.
9
+
10
+ The Lodestone is the forward-looking Stone — every cited number here
11
+ is a forecast, framed as such in the briefing.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ from typing import Any
16
+
17
+ NAME = "Lodestone"
18
+ TAGLINE = "The Projector"
19
+ DESCRIPTION = "Projects what's coming: alerts, surge, and recurrence forecasts."
20
+
21
+ # Existing forecast specialists + the new fine-tuned Battery surge
22
+ # nowcast (added in commit 6).
23
+ SOURCES = [
24
+ "nws_alerts", # step_nws_alerts — NWS public alerts
25
+ "ttm_forecast", # step_ttm_forecast — TTM r2 Battery zero-shot
26
+ "ttm_311_forecast", # step_ttm_311_forecast — TTM r2 311 weekly
27
+ "floodnet_forecast", # step_floodnet_forecast — TTM r2 FloodNet recurrence
28
+ "ttm_battery_surge", # step_ttm_battery_surge (commit 6) — fine-tuned
29
+ ]
30
+
31
+
32
+ def collect(state: dict[str, Any]) -> dict[str, Any]:
33
+ """Return {state_key: value} for every Lodestone source that fired."""
34
+ return {k: state[k] for k in SOURCES if state.get(k) is not None}
app/stones/touchstone.py ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Touchstone — the Live Observer.
2
+
3
+ Watches what's happening right now: FloodNet ultrasonic depth sensors,
4
+ NYC 311 flood-complaint history, NWS hourly METAR observations, NOAA
5
+ tide-gauge water levels, and per-query EO segmentation
6
+ (Prithvi-EO 2.0 NYC Pluvial fine-tune for water/flood; TerraMind-NYC
7
+ LULC adapter for current land cover).
8
+
9
+ The Touchstone is the "current state of the world" Stone. Its outputs
10
+ change minute to minute and are explicitly framed in the briefing as
11
+ right-now context, not historical record.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ from typing import Any
16
+
17
+ NAME = "Touchstone"
18
+ TAGLINE = "The Live Observer"
19
+ DESCRIPTION = "Watches the current state of the city's flood signals and EO."
20
+
21
+ # Live sensors + per-query EO. `prithvi_live` becomes the NYC Pluvial
22
+ # v2 fine-tune in commit 5; `terramind_lulc` is added in commit 4.
23
+ SOURCES = [
24
+ "floodnet", # step_floodnet — FloodNet sensor network
25
+ "nyc311", # step_311 — NYC 311 flood complaints
26
+ "nws_obs", # step_nws_obs — NWS hourly METAR obs
27
+ "noaa_tides", # step_noaa_tides — NOAA tide gauge water level
28
+ "prithvi_live", # step_prithvi_live — Prithvi-EO 2.0 (v2 in commit 5)
29
+ "terramind_lulc", # step_terramind_lulc (commit 4) — NYC LULC adapter
30
+ ]
31
+
32
+
33
+ def collect(state: dict[str, Any]) -> dict[str, Any]:
34
+ """Return {state_key: value} for every Touchstone source that fired."""
35
+ return {k: state[k] for k in SOURCES if state.get(k) is not None}
tests/test_stones.py ADDED
@@ -0,0 +1,128 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Unit tests for the Stones taxonomy layer.
2
+
3
+ Pure-import tests; no server / FSM required. Each data-Stone exposes
4
+ `NAME`, `TAGLINE`, `DESCRIPTION`, `SOURCES`, `collect()`. The SOURCES
5
+ keys must be a subset of the FSM's actual state keys so the migration
6
+ stays honest as new specialists land.
7
+
8
+ Some SOURCES entries are forward-looking (state keys added by later
9
+ commits in the migration). Those are explicitly listed in
10
+ `FUTURE_STATE_KEYS` and skipped from the validity check.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import inspect
15
+ import re
16
+
17
+ from app import fsm
18
+ from app.stones import (
19
+ ALL_STONES,
20
+ DATA_STONES,
21
+ capstone,
22
+ cornerstone,
23
+ keystone,
24
+ lodestone,
25
+ touchstone,
26
+ )
27
+
28
+ # State keys added by later migration commits (C4 / C5 / C6). The Stones
29
+ # taxonomy is allowed to declare them up-front so the SOURCES list stays
30
+ # stable as the specialists land.
31
+ FUTURE_STATE_KEYS = {
32
+ "terramind_buildings", # commit 4
33
+ "terramind_lulc", # commit 4
34
+ "ttm_battery_surge", # commit 6
35
+ }
36
+
37
+
38
+ def _fsm_state_keys() -> set[str]:
39
+ """Scrape every state key written by an @action in app/fsm.py.
40
+
41
+ We don't import every action to introspect — Burr's @action wraps
42
+ the function so the `writes` declaration isn't readable on the
43
+ decorated object without instantiating an Application. The cheapest
44
+ reliable read is regex over the module source.
45
+ """
46
+ src = inspect.getsource(fsm)
47
+ keys: set[str] = set()
48
+ # @action(reads=[...], writes=["k1", "k2", ...])
49
+ for m in re.finditer(r"writes\s*=\s*\[([^\]]+)\]", src):
50
+ for tok in re.findall(r'"([^"]+)"', m.group(1)):
51
+ keys.add(tok)
52
+ return keys
53
+
54
+
55
+ def test_data_stones_have_required_attrs():
56
+ for st in DATA_STONES:
57
+ assert isinstance(st.NAME, str) and st.NAME
58
+ assert isinstance(st.TAGLINE, str) and st.TAGLINE
59
+ assert isinstance(st.DESCRIPTION, str) and st.DESCRIPTION
60
+ assert isinstance(st.SOURCES, list) and st.SOURCES
61
+ assert callable(st.collect)
62
+
63
+
64
+ def test_capstone_has_required_attrs():
65
+ assert capstone.NAME == "Capstone"
66
+ assert capstone.TAGLINE
67
+ assert capstone.DESCRIPTION
68
+ # Capstone re-exports the reconciler.
69
+ assert callable(capstone.build_documents)
70
+ assert callable(capstone.run)
71
+ assert isinstance(capstone.EXTRA_SYSTEM_PROMPT, str)
72
+
73
+
74
+ def test_data_stone_sources_are_valid_state_keys():
75
+ fsm_keys = _fsm_state_keys()
76
+ # Sanity: a couple of well-known keys really do appear.
77
+ for required in ("sandy", "dep", "floodnet", "nyc311", "ida_hwm"):
78
+ assert required in fsm_keys, f"FSM scrape missed {required!r}"
79
+ for st in DATA_STONES:
80
+ for key in st.SOURCES:
81
+ if key in FUTURE_STATE_KEYS:
82
+ continue
83
+ assert key in fsm_keys, (
84
+ f"{st.NAME}.SOURCES references {key!r}, which no @action in "
85
+ f"app/fsm.py writes. Either fix the Stone or add the future "
86
+ f"key to FUTURE_STATE_KEYS in this test."
87
+ )
88
+
89
+
90
+ def test_data_stone_sources_are_disjoint():
91
+ """A given state key belongs to exactly one Stone — no double-counting."""
92
+ seen: dict[str, str] = {}
93
+ for st in DATA_STONES:
94
+ for key in st.SOURCES:
95
+ assert key not in seen, (
96
+ f"state key {key!r} listed in both {seen[key]} and {st.NAME}"
97
+ )
98
+ seen[key] = st.NAME
99
+
100
+
101
+ def test_collect_drops_silent_specialists():
102
+ state = {
103
+ "sandy": True,
104
+ "dep": None,
105
+ "ida_hwm": None,
106
+ "prithvi_water": {"some": "data"},
107
+ "microtopo": None,
108
+ # unrelated key, should be ignored entirely
109
+ "paragraph": "irrelevant",
110
+ }
111
+ out = cornerstone.collect(state)
112
+ assert out == {"sandy": True, "prithvi_water": {"some": "data"}}
113
+
114
+
115
+ def test_all_stones_iteration_order():
116
+ """The four data-Stones must appear in canonical order; Capstone last."""
117
+ assert [s.NAME for s in DATA_STONES] == [
118
+ "Cornerstone", "Keystone", "Touchstone", "Lodestone",
119
+ ]
120
+ assert ALL_STONES[-1].NAME == "Capstone"
121
+
122
+
123
+ def test_collect_signatures_are_uniform():
124
+ """Every Stone's collect() takes a single dict argument."""
125
+ for st in (cornerstone, keystone, touchstone, lodestone, capstone):
126
+ sig = inspect.signature(st.collect)
127
+ params = list(sig.parameters.values())
128
+ assert len(params) == 1, f"{st.NAME}.collect arity"