How to grow the model — add a region, a variable, a dimension or a commodity — without touching the engine · mirrors docs/EXTENDING.md
fuse + signals,
and the severity curve resolves automatically by key — the library key is
(crop, peril, metric) = (commodity, hazard-name/peril, signal.name). Everything
that produces a feature (a flood model, a disease detector) lives upstream; ag_risk consumes
its output on a read contract and scores it. Growing the model is adding inputs, never editing the
engine.
read → derive (dose) → WHEN (phenoweight) → accumulate (season-to-date)
→ severity → fuse (within hazard) → weight-free soft-max combine → band + store
| Stage | Owned by | You touch it when… |
|---|---|---|
| read | a source connector (io/read.py registry) | you add a new upstream producer |
| dose | SIGNAL_TO_HEAD (io/connectors/pipeline.py) | a signal needs wiring to its head |
| WHEN + accumulate | upstream only — the weather pipeline's *_onset_cumsum heads | a new dimension needs a head built in the pipeline |
| severity | twx-risk-severity-library (keyed) | you add / anchor a curve |
| fuse | fuse: in config (default max_severity) | never in v1 (config value only) |
| combine | the engine (weight-free soft-max) | never |
Three keyed, reusable inputs feed the science: severity functions (twx-risk-severity-library,
keyed (crop, peril, metric)), WHEN (phenoweight profiles per (crop, region),
which live upstream in the weather pipeline), and the crop calendar (per-pixel planting DOY that
anchors the kernel).
This is the one rule that is easy to miss and breaks the model silently. ag_risk scores compounding risk: a month's score reflects the whole season to date, not that month alone. That only holds if every dimension emits a monotone, season-to-date series — "the consequence so far" — produced by a temporal reducer.
cumsum already baked into
the pipeline's *_onset_cumsum heads. ag_risk reads the head and samples at month-end; it
does not accumulate again.The reducer runs exactly once and must be monotone-to-date. Because it's monotone-to-date, where it runs — at the dimension's own producer or provisionally inside ag_risk — is a deployment choice, not a change of meaning. That is what lets you add a reducer in ag_risk now and migrate it upstream later with no downstream re-validation.
| Dimension type | Reducer | Example |
|---|---|---|
| flux (accumulating stress) | cumsum | heat/EDD, waterlogging surplus (the weather heads) |
| state (a level) | running-min / running-max | soil moisture — worst level reached to date |
| ratio (fraction of demand met) | cumulative ratio | Σ-numerator / Σ-denominator to date (no live KE example — no evaporation in the frozen variable set) |
| exposure (irreversible area) | area-union / running-max | fire scar, flood footprint (no phenoweight) |
source emits the
field, a reducer makes it season-to-date, and a severity entry (a fraction/return_period
exposure measure, or a curve) is keyed in the library.
A new region for an existing commodity is config only. Same engine, same commodity curves; only the region's calendar, timing and geography change. This is the Tanzania / Zambia path.
configs/<region>_<commodity>.yaml. Copy the closest existing
region, set region / bbox / feeds / calendar, and keep
the hazards blocks (the commodity's curves already exist).(commodity, region) — reuse an existing one if the
agro-climate matches (Tanzania can reuse an East-Africa maize profile) or add a new one for a distinct
regime (a Southern-Africa profile for Zambia). This is a weather-pipeline change, applied when the
region's *_phenoweight_onset_cumsum heads are built; ag_risk only needs the heads to exist.calendar.parquet_uri).crop_mask:, else the all-true / cropland stopgap.No new curve, no deriver, no engine change. Then:
ag-risk run score --config configs/<region>_<commodity>.yaml --dry-run # coverage gate ag-risk run score --config configs/<region>_<commodity>.yaml # real run
Use this when an existing hazard should gain a new driver, or a signal needs a curve the library doesn't yet hold.
SIGNAL_TO_HEAD (io/connectors/pipeline.py)
mapping your signal name to the pipeline's onset-cumsum head. There is no in-model
deriver path: if the head doesn't exist, that's a weather-pipeline change (build the phenoweighted
onset-cumsum head there) — do not reintroduce a local dose computation, it would diverge from the
pipeline's phenoweighting and break the shared 0→1 ruler.twx-risk-severity-library, keyed (crop, peril, signal.name),
with its source / citation / verdict provenance.signals list with its
name (== the library metric), source, kind and stage
hint. The curve resolves by key; you never name it.The heaviest journey — a new crop needs its own science, but still no engine change: a crop calendar, a
phenoweight profile, crop-keyed curves (the crop half of the key changes, so maize curves don't
transfer), a crop mask, and a config wiring the hazards to the new signals.
A hazard block declares its signals (and optionally fuse / peril).
Nothing names a severity function — the curve resolves by key; nothing names the dose maths — every scored
signal resolves by name to an upstream onset-cumsum head via SIGNAL_TO_HEAD.
region: kenya
commodity: maize # the `crop` half of the library key
hazards:
drought: # a hazard = a dimension; its name is the `peril` half of the key
fuse: max_severity # optional — signals → ONE hazard severity (default: worst signal)
signals:
- name: dry_spell # == the library metric; resolves (maize, drought, dry_spell)
source: weather
kind: curve # curve | fraction | return_period
stage: pre_flower # hint only — WHEN comes from the phenoweight kernel
source_kind: onset_cumsum_head
cold:
peril: cold_frost
structural_absent: true # reported as its own 0 layer, but EXCLUDED from `combined`
signals:
- {name: frost, source: weather, kind: curve, source_kind: onset_cumsum_head}
water:
peril: waterlogging # maps the product dimension to the severity key
signals:
- {name: precip_excess, source: weather, kind: curve, stage: pre_flower}
signal.name == library metric — the contract that makes the curve resolve, and also the SIGNAL_TO_HEAD key that finds the head.peril: maps a product dimension to its backend key where they differ (cold→cold_frost, water→waterlogging); drought/heat map 1:1.structural_absent: true declares a dimension that physically cannot fire in this region — scored and reported as its own 0 layer, but excluded from the combined power-mean so a permanent zero doesn't dilute the headline. An explicit per-region declaration, never an auto-detected zero.water_deficit from drought (heat-coupled on the KE store); the head and curve stay available for regions where it isn't.ag-risk run score --config configs/kenya_maize.yaml --dry-run # curve-coverage gate; no engine edit
severity_function, no mode, no model method.model method. A detector (flood, disease, anomaly) is a data source — it runs upstream and emits a field; its output becomes a curve/fraction/return_period severity upstream of the engine. The producing model's code never enters ag_risk.source emits the field on the grid (or it already exists in the weather feed).SIGNAL_TO_HEAD entry pointing at the onset-cumsum head. If no such head exists, that's a weather-pipeline change first; there is no in-model deriver path.twx-risk-severity-library keyed (crop, peril, signal.name), with source / verdict provenance.phenoweight profile supplies the WHEN (or reuse one), unless the signal is an observed fraction / return_period.signals: list — name (== library metric), source, kind, stage.ag-risk run score --config <cfg> --dry-run passes (curve-coverage gate). No engine edit anywhere.Source of truth: docs/EXTENDING.md in the model repo
(model-foundry → model_foundry/models/ag_risk). Severity-side steps (adding/anchoring a curve,
new-country calibration) are on the severity-library
explainer; the model overview is on the methods page.