Stones C1: add taxonomy modules without changing behaviour
Browse filesFirst 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 +41 -0
- app/stones/capstone.py +47 -0
- app/stones/cornerstone.py +38 -0
- app/stones/keystone.py +35 -0
- app/stones/lodestone.py +34 -0
- app/stones/touchstone.py +35 -0
- tests/test_stones.py +128 -0
|
@@ -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 |
+
]
|
|
@@ -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
|
|
@@ -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}
|
|
@@ -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}
|
|
@@ -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}
|
|
@@ -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}
|
|
@@ -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"
|