assessment-model/CONTEXT.md
Khalim Conn-Kowlessar 639cee6c4c feat(scenarios): portfolio Scenarios tab — gallery, detail, creation journey
Users could only create scenarios buried inside the CSV-upload modal, at
trigger time. This adds a first-class Scenarios tab: a gallery of the
portfolio's scenarios, a read-only detail page, and a 3-step creation
journey (goal → measures → review) that saves app-authored scenario rows
per ADR-0003.

Domain rules (TDD'd in src/lib/scenarios/model.test.ts, 19 tests):
- goal semantics: "Increasing EPC" requires a target band; all other
  goals are maximise-within-budget with goal_value forced null
- exclusions-only measure model: normalised (dedupe+sort), unknown keys
  rejected, all-excluded rejected, empty set valid; serialised to the
  backend's brace text format (parses the legacy space-padded variant)
- insert mapping: multi_plan=true, is_default=false, budget per property
- exact-config duplicate detection ignoring name (review-step warning)
- status derived from plan existence — no stored status column

Queries follow the ADR's discipline: gallery status = one grouped
COUNT(DISTINCT property_id) per portfolio; detail = one scoped count
(index-only, powers the blocked-delete message); delete = single atomic
DELETE guarded on NOT EXISTS(plans). Rename is the only other mutation —
configuration is immutable.

Verified live on portfolio 814: create (400 on missing band, 201 on CO₂
draft), derived Awaiting/Modelled in the gallery, rename, delete of the
draft (200) and blocked delete of the modelled scenario (409, "338
properties…" matching the portfolio's true count).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 08:34:47 +00:00

14 KiB
Raw Blame History

Context

This document captures the domain language used in this project. Terms here are the canonical ones — when more than one word exists for a concept, we pick one and treat the others as aliases to avoid.

This file grows as terms are resolved during design conversations. Concepts that haven't been examined yet are not listed.

Language

Bulk upload

BulkUpload: A user-supplied spreadsheet of addresses for a Portfolio, transformed and matched to UPRNs before being inserted as Properties. Has an explicit lifecycle from upload through finalisation. Avoid: import, batch, file upload, ingest

ColumnMapping: The user's declaration of which spreadsheet column means what (e.g. column "Property Address" means address_1). Stored as JSON on the BulkUpload row. Avoid: schema, header map, field mapping

UPRN: Unique Property Reference Number — the UK national identifier for an address. Address matching attaches a UPRN to each row where possible.

Address matching: The pipeline stage that splits the source file by postcode, looks up UPRNs, and produces matched-address output. Triggered via FastAPI. Avoid: postcode lookup, address resolution, address lookup

Combiner: The pipeline stage that aggregates the per-postcode address-matching outputs into a single combined CSV in S3, ready for review. Avoid: aggregator, merger

Finalise: The terminal action that reads the combiner output, inserts rows as Properties on the Portfolio, and decides whether the BulkUpload needs further review. Avoid: import, commit, ingest

Landlord overrides

Landlord: The housing association supplying a Portfolio's BulkUploads. A Landlord knows facts about their properties that EPC data doesn't (e.g. that a cavity has been filled), and those facts take precedence when computing an assessment. Avoid: customer, client, owner, organisation (Organisation is a separate, broader entity)

Landlord override: A landlord-supplied fact about a property that takes precedence over EPC-derived defaults when computing an assessment. The end-to-end Landlord override journey has two layers — a VocabularyMapping layer (this glossary entry below) and a per-Property fact layer (the Property override, below). Avoid: customer data, manual override, landlord data

Property override: The per-Property fact layer — one resolved fact per (Property, Building part, component), where component is one of wall_type/roof_type/property_type/built_form_type. Holds a snapshot of the resolved enum value (a denormalised copy of the VocabularyMapping outcome at finalise time, so two Properties sharing a description can later diverge), plus the original spreadsheet text it resolved from. Materialised by the finaliser for UPRN-matched Properties only (v2); the resolved value is never UNKNOWN — the Verify step forces every UNKNOWN to be mapped before Finalise, and an unresolved description fails the run. See ADR-0005 (table) and ADR-0006 (population). Avoid: per-property mapping, property fact, override row

Source row id: A synthetic UUID minted per source-file row at start-address-matching and written into both the address CSV and the classifier CSV. It is the stable join key that lets the finaliser tie a row's identity (combiner output → property_id) to that row's raw descriptions (classifier CSV), since neither file preserves row order and Internal Reference is absent from the classifier CSV. See ADR-0006. Avoid: row index, internal reference (a separate, optional landlord field)

VocabularyMapping: The translation from a Landlord's free-text description in a BulkUpload column (e.g. "cavity: filledcavity") to a canonical domain enum value (e.g. WallType.CAVITY). Produced by a ColumnClassifier (today an LLM, tomorrow possibly a lookup table or rules engine) in the Model service. Stored per-Portfolio, one row per (category, description). A row carries provenance (classifier or user) so user overrides survive re-classification. Avoid: column mapping (that's a separate concept — see ColumnMapping above), classification, dictionary

Building parts

Building part: One physically distinct part of a dwelling described by a single entry within a multi-valued cell. A dwelling is one Main building plus zero or more Extensions. Per-part descriptions appear as comma-separated entries in physical-element columns (e.g. Walls, Roofs); whole-dwelling columns (e.g. Property Type) carry a single entry and are not split per part. Avoid: annexe, unit, section, dwelling part

Main building: The principal building part of a dwelling — exactly one per address. The others are Extensions.

Extension: A building part that is not the Main building, numbered Extension 1 … Extension N-1 for an N-entry address. Avoid: annexe, addition, outbuilding

Multi-entry: The property of a BulkUpload row whose physical-element cells hold more than one comma-separated entry, one per Building part. Always intra-cell in our data — never multiple rows sharing one address/UPRN. Within a row, the multi-valued columns agree on entry-count, so position i is the same Building part across every multi-valued column. Avoid: multi-row, multi-record, duplicate address

Building-part ordering (a.k.a. ordering): The user's declaration, captured once per file, of which list-position maps to which Building part — because the entry order is a consistent per-file mistake ("A, B" could be [Main, Extension 1] or [Extension 1, Main]). Stored per entry-count as a permutation. See ADR-0004. Avoid: sort order, sequence, column mapping

Scenarios

Scenario: A named modelling configuration for a Portfolio — the question a modelling run answers (e.g. "what does it take to reach EPC C, excluding solid floor insulation?"). Captures the target, the measure constraints and the housing type (Social/Private — a genuine modelling input because it drives funding treatment, not portfolio metadata). The configuration is immutable once created — a change of mind is a new Scenario — but the name is a label, renameable at any time, and a Scenario that has no Plans yet may be deleted (an unasked question can be withdrawn; a modelled one cannot). Plans are generated against a Scenario by the modelling engine; impact/results live on the Plans (and Reporting), never on the Scenario itself. Avoid: plan (a Plan is one property's modelled answer within a Scenario), simulation, run

Goal: The optimisation objective of a Scenario. Increasing EPC targets a band (its goal value is the band letter, required). Every other goal (Reducing CO₂ emissions, Energy Savings, Valuation Improvement) means "improve that dimension as much as possible" — it carries no goal value; the only brake is an optional Budget, without which the engine recommends the maximum interventions. Avoid: target (reserve for the EPC band specifically), objective

Budget: An optional per-Scenario spending cap, denominated per property (£) — never a whole-portfolio envelope. Absent budget = no cap. Avoid: portfolio budget (a separate concept on the Portfolio row), envelope, cost limit

Measure exclusion: A Scenario constraint that takes a measure off the table for the optimiser. The only measure-constraint concept on a Scenario — there is no stored inclusion list; "only Solar PV + ASHP" is expressed by excluding everything else. An empty exclusion set is valid and means every measure is available. Avoid: inclusions, allowed measures (as a stored concept — UI may present "allowed/excluded" toggles, but what's captured is the exclusion set)

Lifecycle

A BulkUpload moves through these statuses:

ready_for_processing
  → mapping_complete         (user submits ColumnMapping; Next.js writes)
    → processing             (Address matching triggered; Next.js writes)
      → combining            (Combiner stage running; FastAPI writes directly)
        → awaiting_review    (Combiner output in S3; FastAPI writes directly)
          → finalising       (Finalise dispatched; Next.js writes via compare-and-swap)
            → complete       (Finaliser succeeded; FastAPI/Lambda writes directly)
            → failed         (Finaliser failed; FastAPI/Lambda writes directly)

complete and failed are terminal. finalising is the in-flight state of the async finaliser (mirrors combining); the UI renders it as "Uploading to ARA". See ADR-0005.

Re-mapping (PATCHing columnMapping) is legal only in ready_for_processing and mapping_complete. Any later state rejects with 409.

Two writers: Next.js owns transitions out of mapping_complete, into processing, and the awaiting_review → finalising compare-and-swap at Finalise dispatch. FastAPI/Lambda owns combining, awaiting_review, and the terminal finalising → complete/failed — writing them direct to the DB during the combiner and finaliser runs. The BulkUpload aggregate observes both. See ADR-0005.

At awaiting_review, Finalise is gated (not a new status — a precondition on the action): when classifier columns were mapped the user must acknowledge the classification-verification step, and when the file is Multi-entry they must confirm the Building-part ordering. See ADR-0004.

See ADR-0001 for the deliberate "not yet" decisions baked into this lifecycle.

Relationships

  • A Portfolio has many BulkUploads.
  • A BulkUpload produces zero or more Properties when finalised.
  • A BulkUpload has at most one Task (the orchestration handle for the FastAPI pipeline run); a Task has many SubTasks (one per pipeline stage: address matching, combiner).
  • A Portfolio has many VocabularyMappings — one row per (category, description) it has ever encountered across all its BulkUploads. See ADR-0002.
  • A Recommendation belongs to exactly one Plan. Denormalised onto recommendation.plan_id; the plan_recommendations join table is being retired.
  • A Recommendation has at most one Material. Denormalised onto recommendation.material_id (+ material_quantity, material_quantity_unit, material_depth). Historically (pre-~2023) a recommendation could carry multiple materials; ~128 such legacy rows were reconciled to one each on 2026-06-07. The cardinality guard in the backfill enforces this going forward.

Baseline performance

Lodged performance: The SAP score, EPC band, CO₂ emissions, and primary energy intensity as submitted to the government EPC register. Ground truth from the register; never modified. Avoid: original performance, registered performance

Effective performance: The SAP score (and associated metrics) that the modelling engine actually uses as its baseline. Usually equals Lodged performance, but differs when a Landlord override or data-quality issue makes the lodged certificate unreliable — triggering a Rebaseline. Avoid: current performance, adjusted performance

Rebaseline: The act of substituting a corrected set of performance metrics in place of the Lodged values. Recorded on property_baseline_performance with a rebaseline_reason enum value: none, pre_sap10, physical_state_changed, or both. Avoid: override, adjustment, correction

EPC provenance (epc_property.source): Whether a property's EPC picture is a real certificate or a gap-fill. lodged = a real public/landlord EPC exists; predicted = no certificate, so the EPC was estimated from nearby properties (EPC Prediction gap-fill). Independent of Rebaseline: a lodged property may still be rebaselined, and a predicted property still carries Lodged-performance figures (mirrored estimates), so the presence of lodged_* columns does not imply a real certificate — only source = lodged does. Avoid: estimated EPC (reserve "estimated" for the UI signal), source EPC

Provenance signal (UI): What the user is told about an EPC's trustworthiness, derived from EPC provenance and Rebaseline together: Estimated (source = predicted — dominant; "estimated based on nearby homes") Re-modelled (source = lodged AND rebaseline_reason != none — effective diverged from the lodged certificate under SAP 10) none (source = lodged AND rebaseline_reason = none — effective equals lodged). Estimated always wins when both could apply. Avoid: estimation notification, banner (those are component names)

Example dialogue

Dev: "If the Combiner finishes but the user hasn't clicked Finalise, what does the user see?" Domain expert: "The BulkUpload sits in awaiting_review. The frontend polls and shows a 'review and confirm' button. Nothing's been written to Properties yet."

Dev: "And if Finalise runs and 30% of rows have no UPRN?" Domain expert: "Those still get imported as Properties — just without a UPRN — and the BulkUpload moves to complete. Manual cleanup happens later in the property table."

(Planned change — v3 / ADR-0006: no-UPRN rows will move to a separate staging table to be re-matched, so property holds only matched rows. v2 does not change this yet — and v2 writes Property overrides only for the UPRN-matched rows.)

Flagged ambiguities

  • The UI surfaces labelled "Current EPC" and "Current Efficiency State" (property table, building-passport card) show Effective performance, not Lodged — despite the glossary advising against "current performance" for Effective. The label is a product choice ("current" = the property's true present-day modelled state); the underlying datum is still Effective performance. The "Lodged EPC" column/badge is the only surface showing Lodged performance, and only when a real certificate exists (source = lodged).
  • "Upload" is used in the codebase to mean both the file-on-S3 and the BulkUpload row. We standardise on BulkUpload for the row; the file is just "the source file."
  • "Onboarding" appears in some route paths (bulk_onboarding_inputs/...) but isn't part of this glossary — we use BulkUpload end-to-end.