Phase 17b · The domain model¶
Status: defined 2026-10-02, the reference beside phase-17b-run-model.md
for every issue of Phase 17b that touches a figure, a dataset or a flow
(phase-17b-walk-corrections.md, guardrail 11). The run model
defines how a scenario is run. This document defines what every other surface computes, why it is
scientifically sound, where it stops, and which defects the 2026-10-02 audit found in the data the
platform holds and in the figures it shows. It was verified by a /reassess fast pass of two
refuters (one on scientific and engine grounds, one on business and product grounds) and a
completeness critic (see Reassess record). Nothing here is implemented yet. The
rows that implement it are DT-1 to DT-6, PF-8 to PF-11, AD-11 to AD-16, EV-10 to EV-18, RP-1,
EX-17 and EN-8 in the phase doc.
Sources read: the engine spec climate-lama-engine/docs/CLIMATE_LAMA_ENGINE.md and its
cost_benefit.py, measure.py, CLIMADA.md §6 to §7, the Oasis evaluation on disk (OED location
and account files, PiWind keys), the market scan scripts/market_scan/data.js, the backbone models,
services and ADRs at 02d3f092, the UI at d2b8e2b5 and the prototype snapshot.
1. The data we hold¶
1.1 Hazard datasets: one source, one event axis¶
A hazard dataset is one source (ADR-067). Its events are either return-period-shaped (rp,
exceedance 1/rp, and a marginal weight that is the band width 1/rp_i − 1/rp_(i+1), last band
1/rp_max, core/ingest/base.py:132-143) or scenario × year-shaped (indicator by structure,
ADR-065). The engine sums losses by the marginal weights, which is the exact trapezoidal integral
of the loss-exceedance curve, and recovers the curve by cumulating them (engine §2, §4). A
frequency-less dataset is refused at compute and still bands in Lookup (ADR-069). Computability
per (country, hazard) is modelled | screening | indicator | none from the same classify_eai
the compute gate uses (core/catalog_coverage.py:94-153), and Lookup is not gated by it.
Defects and rules:
frequency_typesays "occurrence" where "marginal" is stored (storm, tropical cyclone and wildfire ingestors set a static label while every path stores and reads band widths). The column stays, because the engine contract requires the tag (spec §4). The return-period commit writes"marginal"whenever_compute_marginal_freqsdrives the weights, the adapter's default becomes"marginal", live snapshots (one event, weight 1) keep"occurrence", and a backfill setsmarginalon every row with a non-emptyreturn_periods(DT-1).- No hazard ingest writes
bbox, so precedence's locality falls back to the region label and its extent term isNone(core/dataset_precedence.py:207-240). The commit step writesbboxfrom the footprint envelope and a migration backfills it fromfootprint(DT-2). Precedence never readsfootprint, so nothing is double-counted. - A single unlabelled field (ADR-068 shape, the EWDS historical FWI) never bands in Lookup
(
core/lookup_service.py:1003-1007, 1248-1272). Lookup treats a one-field unlabelled dataset as its only slice and bands it (DT-3). - Drought is an enum with no pack, curve or scheme. Coverage reports
noneand no surface offers it (hazard grids, Create watch) until a pack exists. - What a hazard upload declares (DT-7, #1667, #1670, 2026-10-07).
POST /v1/hazards/uploadtakessupersedes_dataset_idfor a new version (#1667),event_datefor one observed event sent withsingle_event, andnetcdf_variablefor a NetCDF file in its one declared layout: one variable (defaultintensity) on(return_period, lat, lon)or(lat, lon), on a regular EPSG:4326 grid (#1670). An event outline as GeoJSON goes throughPOST /v1/hazards/footprints, which stores a single-event dataset with the outline asfootprintand no intensity. The hazard read servessupersedes_dataset_id, andoutline_only, derived (true when the dataset stores neithernpz_pathnorcog_path) and never a column. Compute refuses an outline-only dataset.
1.2 Exposure datasets: points with a value, a type and a unit¶
An exposure row is a point with value, value_unit, exposure_type (buildings, population,
infrastructure) and free properties. A dataset carries attribution, identity (ADR-048), bbox
and a derived country (#1166). A streamed source ingest records bbox. An upload does not today
(core/exposure_service.py:1045-1060), so an uploaded dataset has no derived country and lists
under none. EX-18 makes the upload record bbox as the envelope of its points, and fills it in
for the uploads that already exist
(run model § 6). A portfolio is a selection over
datasets (all | bbox | rows, ADR-080). Compared with Oasis OED (OccupancyCode, ConstructionCode, BuildingTIV, ContentsTIV,
LocDed, LocLimit) and CLIMADA Exposures (impf_<HAZ>, deductible, cover, category_id), the
product models exposure at dataset level: one curve and one set of insurance terms per run,
uniform terms before growth, scaled per dataset by the growth factor (#1593).
That is the documented simplification of D18 and D21, stated in every result's provenance, not an
error.
Rules: the CLIMADA HDF5 upload drops deductible, cover and category_id silently
(core/ingest/climada_hdf5.py:120-121, 354-358). The ingest summary lists dropped columns and the
Data drawer shows them (DT-4). The upload template never asks for occupancy, construction or year
built, which no run consumes, until per-asset binding lands (Phase 18).
The exposure upload runs as a job (DT-7, PF-12, #1667, 2026-10-07,
run model § 6, "Uploads run as jobs"). Its form
declares country (ISO3, checked against the country its points derive), reference_year
(stamped into each row's properties.reference_year, never a column), supersedes_dataset_id for
a new version, and portfolio_id, portfolio_name and portfolio_owner when it imports a
portfolio. The exposure dataset read and its list rows serve portfolio_id and
supersedes_dataset_id, so a dataset written by a portfolio import names that portfolio and a new
version names the dataset it replaces.
Boundaries (DT-7, #1670, 2026-10-07): a boundary file is held for review through
POST /v1/admin-boundaries/uploads as a pending_ingests row that names the asking organisation
and person, and nothing loads until a platform admin approves it through the existing approval
route. "Regions", "Regional units" and "Municipalities" load at levels 1, 2 and 3, and another set
of areas loads at level 3 under the source user_upload. GET /v1/admin-boundaries serves level
3, because revision 0116 of #1667 widens ck_admin_level_range to levels 0 to 3.
1.3 Vulnerability curves and score schemes¶
One default curve per (org, hazard type), 22 seeded curves, all for buildings
(core/impact_function_seeder.py:564-844). The run model resolves on (haz_type, exposure_type)
and refuses curve-less types and non-money units (K19). Score schemes are versioned, org-less, ten
half-open bands per (hazard, metric, unit) (ADR-043, ADR-073), banding the intensity at the design
return period. Heatwave and wildfire-FWI are indicator perils: a score, never a money loss.
Rules: the exposure-type pairing becomes an exposure_type column on impact_functions
(NOT NULL DEFAULT 'buildings'), and the one-default partial unique index moves to
(org_id, haz_type, exposure_type) WHERE is_default, so a population curve can be seeded later
without code (DT-5). Heatwave stays score-only everywhere, with no money run and no cost-benefit.
A curve is uploaded through POST /v1/impact-functions with exposure_type (default buildings)
and is_default (default false), and a second default for the same (haz_type, exposure_type)
answers 409 from DT-5's index (DT-7, #1670, 2026-10-07), its code the literal CONFLICT
every other 409 of this API carries, since the registry holds no E_CONFLICT.
1.4 Result cache¶
The cache key already holds the exposure sha, the impact-function version and a non-default
engine (core/result_cache_service.py:166-179). Two gaps serve wrong figures today (EX-17, W0):
deductibleandcoverare not in the key (compute.py:293-294, 653-677), so an insured run and a ground-up run over the same inputs return each other's result as a 200 cache hit. Both join the key, stringified, only when not null, theengine_namepattern.- A portfolio-addressed run is keyed on
portfolio_idalone (result_cache_service.py:180-181) while membership is mutable (api/v1/portfolios.py:252-345) and the only invalidation purges orphans. The key gains a membership digest: SHA-256 of the sorted(exposure_dataset_id, dataset_sha256, selection kind, canonical selection JSON, weight)tuples resolved at request time, so any attachment, selection, weight or re-ingest change mints a new key. The same digest coversexposure_dataset_ids.
2. Lookup¶
As run model §8 and K15: a hazard screening of one point, the right shape for the citizen's address journey (First Street, ThinkHazard). Additions: DT-3 for unlabelled fields, LK-13 for the "How this score is made" block.
3. Portfolio rollup¶
What GET /v1/risk/portfolios/{id} computes today (core/rollup_service.py): up to 100 members
snapped to H3 r8 cells written by runs, each banded per hazard, the member's worst band taken, a
headline = max band across hazards, per hazard the band of the value-weighted mean intensity, and
value_weighted.combined_score = the value-weighted mean of the members' ordinal bands. EAD on
cards comes from saved runs through total_ead.
Model and rules:
- Headline figures. Portfolio grades are a market norm (A to F ratings at portfolio level,
data.js:272, pattern 9) and Moment 2 step 3 is a live walk, so the value-weighted average band stays and is labelled everywhere "value-weighted average band (ordinal, screening)". Beside it the rollup returnsvalue_weighted.band_distribution = [{band, exposed_value, share}]over the ten bands andvalue_weighted.share_high_or_severe, computed over the same banded members and weights. The card's headline is the worst band, then "X % of value in high or severe". This is the backbone half of PF-7's band distribution bar and band donut (PF-9). - Per-hazard figure. The band of the value-weighted mean intensity is a screening figure labelled "typical intensity across the portfolio", with the worst band per hazard beside it (PF-9).
- EAD is per hazard, from portfolio-scoped runs.
total_eadadds a dataset's whole EAD once per member row (db/repositories/portfolio_repository.py:580, pinned bytests/test_api/test_portfolios.py:461-502: EAD 1 000 over a two-row dataset reports 2 000) and takes the latest result whatever its hazard (:110-128). Rule: EAD per (portfolio, haz_type) = the latest impact result whose exposure selector is this portfolio, for that hazard, scenario and horizon. Only a dataset attached with selectionallmay use a dataset-level result, weighted once per dataset, never per row. Abboxorrowsportfolio with no portfolio-scoped result reads "not computed for this portfolio". The card shows one hazard's EAD with its hazard label, never a cross-hazard sum (the engine has no aggregator). The test asserts 1 000 for the two-row case (PF-8, W0). - Removing an asset narrows the selection, never the layer (PF-12, #1688).
POST /v1/portfolios/{id}/members/removerecords each removed id on the attachment that covers it:excluded_row_idson anallorbboxselection, which keeps its kind, or a shorterrow_idson arowsone. No exposure row is deleted. Every membership reader honours the exclusion, and anallattachment with one no longer stands in a dataset-level EAD (the rule above). - One unit or none.
exposed_valueand the value-weighted figures are returned only when every member shares one unit, money or not. A single non-money unit prints as text through PF-1's formatter ("16.8M m3"). Mixed units →exposed_value = null,value_weighted.unit = nullwith reason "mixed units", band figures weighted by member count, the card shows the member count and "mixed units". PF-4 still refuses a money loss for a non-money unit (PF-10). As built (#1579): the gate iscore/value_units.py::shared_value_unitover every loaded member. The rollup'svalue_weightedblock carriesexposed_value,unit,unit_is_money(true for an ISO 4217 code, false form3orpersons, null with a null unit),reason("mixed units"or null) andweighting:member_value_times_membership_weightunder one unit,member_countunder mixed units, where each banded member weighs 1 incombined_scoreand in each hazard'saggregate_value(provenanceaggregation: "member_count_mean"). Each/v1/portfolios/summaryrow carriesunit_count, the distinct units its members hold (0 for an empty portfolio), and answersexposed_value: nullbesidevalue_unit: nullwhenunit_count> 1. Such rows rank last undersort=exposed_value. No FX or unit conversion is made. - Each surface names its unit (PF-3, PF-2, #1578). A
GET /v1/portfolioslist row carriesvalue_unit(an ISO 4217 code,m3orpersons: the covered members' shared unit, else the portfolio's, else null) andunit_is_money(core/value_units.py::is_money_unit, null with a null unit)./v1/portfolios/summaryrows carryunit_is_moneybeside theirvalue_unit./aggregatekeeps its legacy keycurrency, which holds the list row'svalue_unitand is null, never "USD", when the members mix units. Nothing is converted. - The 100-member cap is disclosed: the response already knows
scored, and the card reads "scored on the N largest members" wheneverscored < members(PF-10). - Indicator perils have no run-written cells, so the card reads "n hazards not scored for portfolios" from coverage, the same contract as Lookup's "not covered" (PF-10).
- Scheme per member cell (PF-11, verified by #1581, 2026-10-09). The rollup picks one scheme
per peril, for wildfire the brightness-temperature (K) scheme by the metric-name tie-break
(
_active_schemesincore/rollup_service.py). No FWI value is ever banded on that ladder, because the unit gate sits at write. Every surface writer files a cell metric only throughrollup_metric_key_for_run(core/scoring.py), which returns no key when the dataset'sintensity_unitdiffers from the selected scheme's unit. FWI wildfire and heatwave datasets are indicator datasets that no run computes, so they write no cells (ADR-075), and a portfolio over FWI data reads wildfire unavailable. Per-dataset scheme selection and per-unit hazard rows are not built, since no FWI cell can feed them.tests/test_core/test_rollup_scheme_gate.pypins this at the rollup rung, through the real writer path. Reverse when an FWI or heatwave damage curve makes those datasets computable. - A typical score per indicator hazard (PF-11, #1665, 2026-10-07). Each hazard entry of
GET /v1/risk/portfolios/{id}carriesindicator_score,{band, label, metric_unit, scheme_ref, hazard_dataset_id, members_sampled, in_portfolio_band}within_portfolio_bandalways false. It is the value-weighted mean of the capped members' bands, sampled from the indicator dataset's own raster off the event loop and banded through the scheme of itsintensity_unit(FWI, days), with member count weighting for a mixed-unit portfolio (K25). It stays outside the headline, the band distribution and the share in high or severe, so the synopsis lists it as "indicator, not in the portfolio band". It is null for a run-banded hazard and where no indicator dataset covers the members for the requested scenario and horizon. The stored results readGET /v1/resultsserves the same figure for an exposure selector through the same sampling function (run model § 6, #1664). - What a portfolio card names (PF-7, #1666, 2026-10-07). The rows of
GET /v1/portfoliosandGET /v1/portfolios/{id}carryowner, a free-text team label of at most 120 characters,category(property,hotels,publicorenergy, optional) andgeocoded_precise_share(0 to 1, null with no member), and the list envelope serves the organisation's distinctowners.GET /v1/portfolios/{id}/thumbnailserves{bbox, countries, weight, value_unit, cells: [{lon, lat, value}]}, the members' value binned on a 16 × 16 grid over the portfolio's bbox (member counts withweight: "member_count"for a mixed-unit portfolio), which the card draws as its small map. The spread'sbandis read from the portfolio rollup's member bands (the stored cells at the largest members), never stored per member (decision of 2026-10-15).GET /v1/portfolios/{id}/spread?haz_type=&scenario=&horizon_year=serves{admin_level, weight, value_unit, areas: [{boundary_id, name, exposed_value, member_count, band}]}, the five largest areas, for "Geographic spread". - Where a portfolio came from (PF-12, DT-7, #1667, 2026-10-07). Portfolio rows serve
source(upload,exposure_layersor null),exposure_layers([{dataset_id, name}]) anduploaded_dataset_id, so a card reads "Built from exposure layer:" or links to the file it was uploaded from, and that file's row in Data links back (§1.2).
Showcase: Moment 2 step 3 reads "headline band, per-hazard chips, band distribution of value with the share in high or severe, value-weighted average band (screening)" and step 6 reads "(exposure count, EAD per hazard)" in the PR that lands PF-8 and PF-9.
4. Adaptation measures¶
4.1 The entity and its catalogue¶
A measure (ADR-019) is a stored, attributed transform: a hazard-intensity factor
(hazard_inten_imp → engine haz_inten_a), damage-curve factors (mdd_impact_a/b,
paa_impact_a/b), a frequency cutoff, a cost in a currency, a lifetime, a region label that nothing
reads. The seeded catalogue holds two flood measures with cost = NULL by design: a published unit
cost is country-, currency- and year-specific (core/measure_seeder.py:12-16, 104). That honesty
stays. Additions through AD-4's migration (AD-11, AD-15):
cost_basis(lump_sum | per_building | per_km | per_ha) andunit_count, so total cost = unit cost × count is explicit and resolved backbone-side before_build_measure. The engine takes one scalar cost in the exposure's unit (enginemeasure.py:51, 78-79).cost_unitmust equal the run'svalue_unit, else 422 "measure cost unitdiffers from the exposure unit ". The adapter stops defaulting it to USD (engine cost_benefit.py:136, 149).hazard_inten_shift→ enginehaz_inten_b(hard-coded 0 today,engine_adapter.py:371), labelled "Raise the asset or floor by h" with the copy "depth above the raised floor = max(depth − h, 0)". A levee or wall is never a depth subtraction: when it holds the depth is zero, when overtopped it is the full depth, so barriers use the protection standard below.applies_toas JSONB with kindsscope(the whole run scope, the default and the prototype's "buildings"),admin({admin_boundary_id}) andassets({asset_ids}), consumed by 4.2;effect,cobenefitsand the plan icon from AD-4. One W4 migration carries all of them.rp_protect(AD-2) lands earlier, in W0's migration, with the seeded flood protections set to 100.
4.2 How a measure applies¶
Measure.apply(hazard, impfset) returns a new hazard and curve set and acts on every exposure of
the run (engine §6). Rules:
- Protection standard (AD-2, amended): a return period
RP_protectis translated by selecting the event rows withrp <= RP_protectand removing them, never by a frequency threshold. Marginal band widths are not monotone in return period for irregular lists (RPs 10, 11, 100 give widths 0.0091, 0.0809, 0.0100), so a cutoff on weights can drop the wrong bands. Cards read "prevents floods up to 1-in-100 years". - Applies to a subset (AD-14):
applies_tois the whole run scope (default) or an exposure mask (an admin boundary id or asset ids). The worker runsImpactCalcon the masked and unmasked subsets with and without the measure and combines:eai_expconcatenated in point order,aai_aggsummed,at_eventaligned by event id (events a cutoff removed from the masked half filled with 0) then added, and the loss curve computed from the summedat_event, never as a sum of curves. Exact with and without deductible and cover (insurance applies per point before aggregation, engine §5). The impact run needs no engine change. Cost-benefit does (4.3). - Hazard match (AD-5): a measure whose hazard differs from the run's is 422, the worker never
overwrites
haz_type. - Indicator hazards (AD-16): on heatwave or wildfire-FWI a measure appraises as a score
delta only when it carries a hazard-side transform (
hazard_inten_imp,hazard_inten_shiftor a protection standard at the design return period): the band of the transformed intensity at the design return period versus the baseline band. A curve-side-only measure on such a hazard is refused in the Adapt form with "no loss curve for this hazard, this measure cannot be appraised; record it as a qualitative action". Never a BCR.POST /v1/compute/score-delta(#1627) serves it. The transformed intensity ismax(hazard_inten_imp × value − hazard_inten_shift, 0), and anrp_protectat or above the design return period reads the lowest band. Per measure the answer namesmeasure_id,haz_type,baseline_bandandadapted_band(the scope's "value-weighted average band (ordinal, screening)", K25, to four decimals),baseline_distributionandadapted_distribution(exposed value per band,[{band, exposed_value, share}]from PF-9's one function), andbaseline_share_high_or_severeandadapted_share_high_or_severe. A member weighs its row value. Under mixed units (K27) everyexposed_valueis null,reasonis "mixed units" and the shares are by member count. A scope with no member is 422 "nothing to score". A measure masked to an admin area or to named assets (AD-14) is refused, not applied to the whole scope. A portfolio (its largestrollup_max_portfolio_membersmembers) or a set of at most that many rows answers 200. A larger set answers 202 with ascore_deltajob: the worker runs the same function over every member, stores the answer injobs.result_payload, andGET /v1/jobs/{id}serves it as the job'sresult.POST /v1/compute/cost-benefitgives the curve-side-only refusal for an indicator base result.
4.3 How a plan is appraised¶
Cost-benefit runs over a Today base and a climate-adjusted future hazard (#1482), risk metric
aai_agg, flat discount, benefit as a linear ramp between present and future avoided loss
(engine cost_benefit.py:42, 76-81), BCR and payback (#1544). Rules:
- Lifetime (AD-12).
lifetime_yearsis stored and never read. The engine gainsbenefit_years: int | Noneand zeroes the per-year benefit fori > benefit_years.nstayshorizon_year − present_year, so the ramp slope is untouched. The backbone passes the measure's lifetime and setsfuture_yearto the hazard horizon year when a future cell is given (AD-6), nevernow + N. Payback is the first year the cumulative per-year benefit reaches the cost, from the same series, exposed asper_year_benefiton the result so payback and NPV share one curve. - A plan is one combined run (AD-13). Plan totals today are independent sums
(
api/v1/results.py:611-613), which over-count overlapping measures. The engine gainscombined: booloncalc_cost_benefit: measures apply in list order to one hazard and curve set, one result named "plan" with cost = Σ costs (one cost unit). Order matters once a depth shift exists (shift then factor givesmax(1.0 − 0.6, 0) × 0.5 = 0.2, factor then shift givesmax(0.5 − 0.6, 0) = 0), so the canonical order is hazard-side shifts, then hazard-side factors, then protection standards, then curve-side factors, unless the user reorders the persisted plan (AD-8). The form says "order matters when a measure lowers depth". Per-measure rows stay for ranking. Residual risk = baseline EAD − the combined plan's avoided loss. - Lifetime segments (#1624, decided 2026-10-02). The plan's benefit is summed over one
lifetime segment per distinct lifetime
L1 < … < Lk(a null lifetime counts asn = horizon_year − present_year). Segmentjis one combined run of the measures still alive (lifetime ≥Lj) and addsnpv_of_avoided(…, benefit_years=Lj) − npv_of_avoided(…, benefit_years=L(j−1))of that run's avoided pair; the first segment adds its whole NPV toL1, year 0 included. The plan'sper_year_benefittakes segmentj's entries for yearsL(j−1) < i ≤ Lj. A 50-year and a 5-year measure are two runs, so the short one never cuts the long one's benefit and the plan never ranks below its own 50-year row. No per-measure sums (K29), at mostkcombined runs. - The plan block (#1624). A job of two or more measures stores the combined run as one plan
row and
GET /v1/results/{job_id}/cost-benefitserves it asplan:npv_benefit,npv_cost,benefit_cost_ratio,payback_years,per_year_benefit,avoided_loss_present,avoided_loss_future(baseline risk − the risk with every measure applied),residual_risk_present,residual_risk_future(baseline risk − the plan's avoided loss),haz_type,unitandcost_unit. The top-level totals are the plan block's. A one-measure job and a job stored before the plan row answerplan: null. - The comparison figure (
adapt-4, K29). The plan block also servessingle_measures_avoided_loss, the sum of the per-measure rows'avoided_loss_futurein the plan's unit, for the line "single measures add to, overlap removed". It enters no plan figure. - A saved plan (AD-8, amended 2026-10-05 by the fine-tune, FT-7 and FT-14). A saved plan keeps a name, its scope (the exposure and the clip), one hazard, the measures of that hazard in plan order, the cell and the appraisal settings. Its avoided loss is the figure of the plan as one combined run, in the exposure's unit, never a sum of the per-measure rows, and a hazard with no loss curve shows no money figure. Adapt's landing page (AD-17) lists every saved plan with its name, scope, hazard, number of measures, avoided loss and Open, which sets the scope, the hazard and the measures. In Explore's run form "Use a saved plan" offers the plans of the run's hazard and loads their measures into the run, which keeps its own scope (run model § 3.7).
- The plan entity (#1632, migration 0114).
adaptation_planskeeps the compute request's names:name, the selectorportfolio_idorexposure_dataset_ids,scopeas sent,haz_type, the cellscenario+horizon_year,measure_idsin plan order,custom_order(true once the user reordered the plan, AD-13), the settingsdiscount_rate,value_growth_rateandtime_horizon_years, and the server-written linklast_appraisal_job_idto the plan's last combined appraisal (#1641 sets it; a client never does, and a change to anything butnameclears it). It stores ids and settings, never figures. - The list read (AD-17, FT-14, FT-7).
GET /v1/adaptation-plansserves each plan withappraisal_status(the linked job's status, null with none),avoided_loss(the job'splanblockavoided_loss_future, or a one-measure plan's measure row, null until the job completed) andavoided_loss_unit(that row'sunit), read through the link in two statements for any page;?haz_type=gives "Use a saved plan" the plans of the run's hazard. - Presentation (AD-3, extended): the centre's first card ranks the plan's measures by BCR as the ECA cost curve (x = cumulative avoided loss, bar width = the measure's avoided loss, height = BCR) and shows residual risk, labelled per hazard.
- Engine items (EN-8):
benefit_years,combined, annpv_of_avoided(avoided_present, avoided_future, ...)helper lifted out of the closure atcost_benefit.py:105-119for the masked pieces of 4.2, and §7 of the spec rewritten to the linear ramp the code implements (CLIMATE_LAMA_ENGINE.md:371-389still documents the point-in-time formula).
Market analogues: CLIMADA and the ECA network's cost-benefit, Climate X's defences toggle
(data.js:250), XDI's ranking by avoided value and break-even (:301), FloodAdapt's with and
without comparison (:799). What the product deliberately does not do this phase: per-year
discount schedules, portfolio risk transfer (risk_transf_attach/cover), entity swaps
(exposures_set, hazard_set, imp_fun_map), a return-period risk metric (Phase 18 stub).
5. The live lane and events¶
5.1 The model¶
A watch is one unfolding event over one exposure selector with an AOI. A feed (replay,
FIRMS NRT, USGS ShakeMap) releases observations that ingest as single-event snapshot datasets (one
event, weight 1, frequency_type = "occurrence"). The event loss is Σ value × MDD(i) × PAA(i)
with one default curve, stored apart from annual figures (ADR-078), graded screening, never an
alert, with the life-safety line (ADR-078 item 6), feeds trusted in-process and replays on their
real clock (ADR-081). This is the event-response pattern of the market (Moody's footprints at
data.js:42, CatNet near-real-time overlays at :92, GEM/RISE ShakeMap → exposure → loss) and it
is honest as long as every surface labels it a deterministic ground-up loss of one observed
footprint. The lane is not a forecast: no ensembles, no lead time, no uncertainty (CLIMADA's
Forecast module and PAGER's loss bands are Phase 18 at the earliest).
5.2 Rules¶
- Event to date (EV-10). FIRMS snapshots hold only each overpass's new detections
(
ingest/feeds/firms_nrt.py:23-34), so "latest event loss" is one overpass. Each poll also writes a cumulative snapshot, the cell-wise maximum brightness temperature over the watch's observations to date, which is the quantity CLIMADA's WildFire module (Lüthi 2021) feeds the brightness-temperature curve and is monotone non-decreasing. The headline, the Home card and the rail use the cumulative figure. Per-overpass snapshots remain the replay frames. - Events inside a watch (EV-11). Snapshots carry an
event_key(the USGS event id, the FIRMS watch itself, the replay manifest's event). Timeline, delta and headline operate within one event key. A ShakeMap revision dedupes on (watch, event_key, version), not (watch, valid_at), so revisions are scored (ingest/feeds/usgs_shakemap.py:721-726,db/repositories/live_event_repository.py:567-611). - The selector travels (EV-12). Compute always carries the watch's own selector (the UI sends
only
portfolio_id,EventsMode.tsx:291-296). Deleting a portfolio or exposure dataset that an open watch references is refused with 409 naming the watch, replacing the ON DELETE SET NULL that silently widens a watch to every org exposure (models/live_event.py:44-49, 121-125). - Curve gate (EV-13). Create watch offers a money loss only for hazards whose default curve
consumes the selector's exposure type (coverage computability). Otherwise the watch is "exposure
count only" with
auto_computeoff and the dialog says so (today heatwave and drought are offered with auto-compute on,CreateWatchDialog.tsx:219, 416). - Basis and terms (EV-14). Every surface labels the figure "ground-up loss of one observed
footprint, not annual" (today only the server's grade note,
EventLossHeadline.tsx:108-113). A watch may carry the same uniformdeductibleandcoverthe annual run accepts. With one event of weight 1 the engine's per-point insurance transform yields the insured event loss with no engine change, stored beside the ground-up figure. A watch summary (LiveEventRead, served byGET /v1/live-eventsandGET /v1/live-events/{id}) carrieslatest_insured_event_loss, theinsured_event_lossof the same newest resultlatest_event_lossreads, inlatest_event_loss_unit, and null when that result has none (#1611, 2026-10-07). The terms are editable throughPATCHand apply to computes dispatched after the edit. A stored result keeps the terms it ran with asprovenance.terms({deductible, cover}), solatest_insured_event_lossalways reads with the terms of the result it comes from (#1611, 2026-10-10). - Updates, never alerts, no subscriptions (SH-11, amended). ADR-078 item 6 reads "No push, no
subscription, no email, and the word alert never appears in event-lane copy". The bell carries one
in-app item "Watch update:
scored a new observation" per scored snapshot, with the life-safety line, linking to /explore?mode=events&event=<id>. A loss threshold or "tell me when" rule is a subscription and needs an ADR amending item 6 first: it is a Phase 18 stub item, and the prototype's "Alert me when…" and "+12 %" are design-only. Market-scan pattern 23 (subscription alerts) is blocked by that item until amended. The item'swatch_updatepayload carries the announced result'sevent_loss,insured_event_loss(null when the watch has no terms) andunit(null for a mixed portfolio), as stored, and nothing annual is derived from them (#1617, 2026-10-07). As built (#1617, 2026-10-10): the item is the notification typewatch_update(ADR-037), written for a feed's own compute of a frame snapshot and never for acumulativeone or a person's Compute, to the watch's creator only. Beside the three keys its payload holdslive_event_id,job_id,result_idandtitle. - What a poll looked at (EV-1, #1614, 2026-10-07). A feed's
last_poll_summarynameswindow_start(ISO 8601 UTC, or null), the start of the poll's discovery window: theupdated_afterthe USGS ShakeMap adapter sends, the start of theday_rangedays a FIRMS poll asks for, and null for a replay. The watch's row "In the window" and the outcome of Poll now read "since" from it (ui#771). Its other five keys are polled_at,observations(the observations the poll dispatched as snapshots),events_in_window(the USGS discovery count, null for FIRMS and a replay, which list no events),criterion({"min_magnitude": …},{"confidence_min": …}or{}) andrefused(the refusal text, or null). A failed poll keeps the previous summary and stampslast_error, and the summary is null until a feed's first completed poll. - Units and legend (EV-16).
latest_event_losscarriesvalue_unit; the footprint raster has a legend with the snapshot'sintensity_unit(K, g). The watch summary serves it aslatest_event_loss_unit: the one unit shared by the rows the headline result scored — its exposure dataset'svalue_unit, or for a portfolio the unit of the members inside the scored snapshot, not of every member — and null when they mix, when the unit is blank and when the watch has no result. The result's ownunitis the same reading (#1608, 2026-10-10). - The headline's reads (EV-10, EV-17, #1608, 2026-10-10).
latest_event_lossis theevent_lossof the newest result on acumulativesnapshot, and of the newest result when the watch holds none (a ShakeMap watch, a pre-17b watch). Every row ofGET /v1/live-events/{id}/resultscarriescumulativefrom its snapshot. The summary also serveslatest_severity_level, the levelprovenance.severity_levelsholds for theevent_keyof the watch's newest snapshot, and null when the feed publishes none, when none is recorded and when the watch has no snapshot. - Admin aggregation (EV-8, now
both). "Most exposed municipalities" needs per-admin-unit sums of the event result throughsum_points_by_admin_unit, cached on the result. The field (#1612, 2026-10-10) isadmin_units: [{admin_boundary_id, name, admin_level, event_loss, asset_count}] | nullonGET /v1/live-events/{id}/results/{job}: one row per GADM unit, at eachresult_cell_admin_levelslevel, that holds at least one asset, summed over every asset of the result and never over the page. The rows read in the result'sunit. An asset outside every loaded boundary counts in no unit.compute_event_impactsums once, when it scores the result, and stores one JSON array atresults/{job_id}/admin_units.jsonbeside the loss vector; the read only reads it. The write is best-effort and a failed sum does not fail the result. A result with no stored object (scored before the change, or its sum could not be stored) servesadmin_units: null, "not available", and[]means no asset stood in a loaded unit. - Intensity per asset (EV-3, #1612, 2026-10-07). Every asset row of
GET /v1/live-events/{id}/results/{job}carriesintensity, the footprint's intensity at the asset's assigned centroid in the snapshot'sintensity_unit, stored as anintensity_expvector besideloss_exp. A result scored before the change servesintensity: null, never 0. - A replay watch is created with its series (EV-20, #1807, 2026-10-16). The create dialog
offers the series the deployment holds from
GET /v1/live-events/replay-series→[{series_dir, name, frames}](directories holding amanifest.jsonup to two levels under an allowed series root, at most 200; a missing root is skipped, so no data volume is200 []), and creates the watch with its feed in one call:POST /v1/live-eventstakes an optionalfeed(the feeds route's body). Watch and feed are written in one savepoint, so a refused feed answers its own code (E_VALIDATIONwithdetails.problems,E_LIVE_FEED_KIND_UNKNOWN) and leaves no feedless watch behind.POST /v1/live-events/{id}/feedsstays;series_diris still checked lexically. - Portfolios in the footprint (SH-16, FT-23, #1687, 2026-10-16). Data's event drawer lists
every portfolio of the organisation with members inside a snapshot's footprint from
GET /v1/live-events/{id}/snapshots/{snapshot_id}/portfolios, answering{snapshot_id, valid_at, items: [{portfolio_id, name, asset_count, value, value_unit}]}.asset_countcounts the portfolio's members on a cell whose intensity is above zero (a member inside the envelope on a zero cell is not counted),valueis Σweight×valueover them invalue_unit, and both are null when those members mix units (K27). Items with a value come first byvaluedescending, then the others byasset_countdescending; a portfolio without a counted member is absent. No loss is computed. The count runs at read time in the caller's organisation and is cached athazards/{snapshot_id}/portfolios/{org_id}/{fingerprint}.json, the fingerprint a digest of everyportfoliosandportfolio_datasetsrow of the organisation, so a new or deleted portfolio, an attached dataset and a narrowed selection each read under a new key. A watch not visible to the caller, or a snapshot that is not the watch's, answers 404E_LIVE_EVENT_NOT_FOUND. - Post-event report (EV-17). Offered on close, carries the footprint map and the cumulative
loss, its checklist says "Event loss" (
core/reports/checklist.py:399-419). - Pagination and states (EV-18). The assets drawer shows
total_assets, the timeline loads every snapshot of the event (500 cap), Compute hides on a closed watch.
6. Reports¶
A report document references results by id and never copies figures (ADR-085). Eight templates
(impact, orsa, portfolio, place, event, esrs_e1, eu_taxonomy_cra, ifrs_s2), every framework item a
paraphrase with verified = False (core/reports/frameworks.py:15-28), a checklist of
ok | partial | missing, no compliance verdict. Guardrail 7 holds. Rules (RP-1): ORSA is marked
available while all ten of its items are to_verify (core/reports/composer.py:123-125 vs
checklist.py:494-514). It becomes wording_to_verify with a test that a template whose items are
all to_verify can never be available, and the Studio's template card shows the "Wording to
verify" badge whenever availability == "wording_to_verify" (rendered reports already print the
banner and the "(to verify)" suffix). The prototype's "Engine agreement" section is the ORSA item
"Model uncertainty described", satisfied by an analysis widget referencing an engine comparison.
A report reads stored results and never makes a figure up (RP-2, #1638, 2026-10-07). A document
names what it reads by its scope and its run, not only by a result id. ReportScope gains
admin_boundary_id, the municipality a place report is about, one unit and never a run's clip
list admin_boundary_ids. A result widget takes either result_id or a run address
{haz_type, scenario, horizon_year}, read against the document's scope through the stored results
read of #1664 (run model § 6), with the views
by_hazard, horizons, top_areas and map beside summary, curve and top_exposures. The
scope's exposure is its portfolio_id, or for a place the default exposure layer of the place's
country (default_exposure_dataset, #1660) clipped to admin_boundary_id. With no stored result
the widget reads "Not computed yet." and shows no figure and no result id.
Every report is printed for its organization and its reader (SH-24, #1689). The cover carries the
organization's logo (POST /v1/orgs/{id}/branding/logo, an SVG or PNG at least 256 px wide, stored
at storage_keys.org_logo_key), its display name and a band in its primary colour; the
organization's branding.report_footer (200 characters at most) runs at the foot of every page;
and money, counts and dates follow the formats preference of the user who asked for the report
(currency_display, money, number, area, date, the snapshot's option values), so a report
reads as the screen does for the same user.
7. Honesty rules added to run model § 11¶
- "Event loss: ground-up loss of one observed footprint, not annual." Shown wherever an event loss and an expected annual impact can appear on one screen (Home, History, Reports).
- "Mixed units:
members, no single exposed value." A portfolio's exposed value is shown only in its one unit. - Screening-grade figures (coastal flood, earthquake, event loss) carry the grade badge on cards, not only in drawers.
- "n hazards not covered" (Lookup) and "n hazards not scored for portfolios" (Portfolios, Home) come from coverage on every surface that scores.
8. Docs drift¶
docs/CLIMATE_LAMA.md §5 lists n_centroids (:2121), centroid_idx and centroid_dataset_id
(:2170-2171), none of which exist, says events map by insertion order (:2139) while event_index
exists (models/hazard.py:544-545), and lists an FWI curve (:2180) that was never seeded. DT-6
rewrites §5 from the models.
9. Out of scope, and Phase 18¶
Per-asset occupancy, construction and TIV splits (per-asset binding, D21), per-year discount schedules, portfolio risk transfer, measure entity swaps, a return-period risk metric, population and built-volume curves, probabilistic or ensemble footprints, uncertainty on the event loss, a flood feed, loss-threshold notifications (ADR-078 item 6 amendment), drought data, a multi-hazard aggregate.
Reassess record¶
2026-10-02, fast: two refuters (science and engine on Fable, business and product) and a completeness critic over claims D1 to D10.
- Confirmed: the two-shape hazard model and marginal weights, exposure at dataset level, the cumulative FIRMS footprint as a cell-wise maximum, insured event loss through the existing insurance transform, the lane's model.
- Corrected and adopted:
frequency_typederived from storage rather than dropped. The rollup scheme resolved per member cell. Theexposure_typecolumn with its index change. The value-weighted score kept and labelled beside a band distribution rather than removed. EAD per hazard from book-scoped runs. One unit or none.benefit_yearsin the engine rather than a truncated ramp. A combined cost-benefit mode with a canonical order. Exactat_eventalignment for masked measures. "Raise the asset" semantics for the depth shift. Score deltas only for hazard-side transforms. The bell as "Watch update" with no threshold rule under ADR-078 item 6. ORSA aswording_to_verify. - Refuted as duplicates: the protection standard (AD-2) and the hazard match (AD-5).
- Missed and folded: AD-2 selects events by
rp <= RP_protect. BCR requirescost_unit == value_unit. The cache key must holddeductible,coverand a membership digest. Moment 2 steps 3 and 6 change wording with PF-8 and PF-9.
2026-10-05, amended from the fine-tune record (phase-17b-finetune.md).
The quoted UI copy of § 3 and § 7 says "portfolio" where it said "book" and names the saved-work
list History (K49, K43), and § 4.3 says what a saved plan keeps and where it is listed (AD-8 as
amended, AD-17). Where older prose in this document says "book" it means a portfolio. § 1.2 says
that an exposure upload records no bbox today and that EX-18 adds it. What an upload creates is
in the phase doc's rows PF-12 and DT-7, and what a saved run, comparison or event replay carries
is in run model § 6.
2026-10-07, amended from the delta split of the fine-tune: § 1 names what an upload declares and
the upload routes (#1667, #1670), § 3 names indicator_score (#1665), the card fields and reads of
PF-7 (#1666) and where a portfolio came from (#1667) and says "portfolio-scoped" where it said
"book-scoped", § 5.2 names latest_insured_event_loss, the watch_update payload keys,
window_start and the asset row's intensity (#1611, #1617, #1614, #1612), and § 6 names the place
report scope and the run address of a result widget (#1638).