Skip to content

Comparison — design spec (Phase-1 step 5)

Status: mechanism implemented (services/comparison.py, to the pending boundary). The delta and band are not built. Governing decisions: #18 (comparative assertions: functional-unit lock-in, "Monte Carlo uncertainty band on every comparison", ISO 14044 §6.2 path), amended #29 (Phase-1 comparison = the same engine run on two provided material sets; same-geometry auto-generation deferred with Option C, decision #33), and the #59 module-state contract (every state explicit, nothing omitted, nothing a fake 0). Model: a read-only compare() over the two existing VariantResults. It evaluates five gates and reports every failed one. No delta arithmetic ships. Scope of the implementation PR: the mechanism, the typed Comparison object and the pending/BLOCKED boundary. No delta number is wired. complete cannot be constructed in this PR. AR-83 band method: open. §4 sets out the options, and the final pick is Marco's.


0. What the code does today (read pass, cited, origin/main = 5df5030)

# Premise Verified against Holds
1 Earth and conventional variants are both computed ComputeRequest.variants takes 1–2 VariantInputs keyed earth / conventional, and earth is required (schemas/compute.py:249-265). Each runs through the same _compute_variant (services/project_compute.py:184-243). Per variant: summary.modules[] (A1-A3, A4, A5, B4, B6, each with status, total, incomplete_reason) and summary.total, which is null unless A1-A3, B4 and B6 are all complete (:208-225). A two-variant request round-trips (tests/unit/test_project_compute.py:248-258). API: yes. UI: no. toComputeRequest emits only the earth variant and attest_same_building: false (frontend/src/wizard/draft.ts:236-251).
2 No comparison computation exists No delta arithmetic anywhere. But a stub exists: ResultEnvelope.comparison: None (always null) and comparison_pending_reason: list[str] (schemas/compute.py:407-410). It is filled by _comparison_pending() (project_compute.py:246-252): "one variant submitted…", or "comparison engine not built yet (AR-79, AR-83)" plus "same-building not attested". EnvelopeView renders one <Variant> per variant, then a "No earth-vs-conventional comparison: …" note while comparison == null (frontend/src/components/results/EnvelopeView.tsx:186-205). Partly. This spec replaces the stub.
3 #18 wording PROJECT.md:79: "Functional-unit lock-in mandatory (same GIA, same RSP, same climate, same programme); Monte Carlo uncertainty band on every comparison; ISO 14044 §6.2 compliance path documented." Amended #29 (PROJECT.md:356-361): delta only when both totals are non-None "and, per decision #18, only with an uncertainty band". Lock-in is asserted on the inputs. Yes
4 Register state AR-79 (action-register.md:104): Open, Chiwara team, conventional/BTC file not received. AR-83 (:147): Open, Marco, three options (a) accept pending, (b) minimal services.uncertainty in Phase 1 gated on AR-13, (c) decision-review on #18 for a labelled unbanded delta. AR-13 (:99): Open, UNASSIGNED. Yes
5 Pedigree axes usable for a band Captured, not usable. All five axes, scored 1–5 per seed EPD (PedigreeScore, schemas/proxy_provenance.py:37-43), are carried on each A1-A3 row (schemas/a1_a3.py:119). But services/pedigree.py:16-20 raises NotImplementedError, and ProxyProvenance.gsd_squared (proxy_provenance.py:68-83) is a placeholder that always returns 1.0, because the Ciroth basic-uncertainty table (AR-13) has not been sourced. Further gaps: inline-literal rows have pedigree=None; B6's EF_grid is user-supplied with no pedigree; B4's RSL uncertainty is not captured by any axis; axis 1 is scored inconsistently (AR-69). services/uncertainty.py is a one-line Phase-2 TODO. No. See §4.

Two further facts shape the scope:

  • No real variant total completes today. Every RSL is BLOCKED (AR-12), so B4 is incomplete for any row with f > 0 or no f. The only complete variant total is synthetic: every row f = 0 plus grid energy with a usable EF_grid (test_t9_…, test_project_compute.py:286). So the delta is gated on AR-79 + AR-12 (+ AR-13 on any banded path), not on AR-83 alone.
  • The API contract moves. Retyping ResultEnvelope.comparison changes docs/api-reference/openapi.json (CI openapi-drift, ci.yml:61-78), frontend/src/api/schema.gen.ts (CI git diff --exit-code, ci.yml:48), and the synced mock frontend/src/mocks/fixtures/projects_compute_sample.ts (mocks:check, ci.yml:51). None of these is a golden or a fixture, but each is a byte change. They are the expected collateral (§6).

1. Comparison model

1.1 Gates

compare() evaluates all gates. It does not short-circuit, so the response names everything that is missing at once, the same way _validate_materials collects every error.

Gate Condition Source of truth Fails today because
G1 conventional_submitted a conventional variant is in the request request.variants AR-79: no conventional file
G2 same_building_attested attest_same_building is True request.attest_same_building the wizard always sends false
G3 earth_total_complete earth.summary.total is not None VariantResult.summary B4 (AR-12), B6 (AR-65 EF_grid / not provided)
G4 conventional_total_complete conventional.summary.total is not None VariantResult.summary G1; otherwise the same causes as G3
G5 band_present an uncertainty band for the delta exists none in this PR, so always False AR-83 open; every banded arm BLOCKED on AR-13

status = complete ⇔ G1 ∧ G2 ∧ G3 ∧ G4 ∧ G5. In this PR G5 is constant False, so complete is unreachable by construction. §2 also makes it unconstructable by type.

Functional-unit lock-in (#18). Both variants share one ProjectCreate (ComputeRequest.project), so GIA, RSP, climate and programme are equal by construction. No runtime equality assert is added. If per-variant project context is ever introduced, compare() must gain a lock-in assert that raises, as in design §5.3 of docs/architecture/estimator-catalog-comparator.md. G2 covers the part the schema cannot see: that the two material sets describe the same building (amended #29). The catalog-version condition of original #29 (§5.4 cond. 2) does not apply in Phase 1, because there is no catalog (decision #33).

1.2 Reasons

reasons: list[str], deterministic, in gate order, one string per failed gate. When G1 fails, G4 adds no separate reason, because G1 subsumes it. Module names come from summary.modules where in_whole_life and status != complete. They are referenced, not repeated: the full per-module reasons already live in variants[i].summary.incomplete_reason.

Gate Reason string (exact text pinned by test)
G1 conventional: no conventional variant submitted (AR-79: Chiwara conventional/BTC file not yet received)
G2 same building and functional unit not attested (amended #29: lock-in is asserted on the inputs)
G3 earth: whole-life total incomplete — {modules} not complete (see variants[earth].summary.incomplete_reason)
G4 conventional: whole-life total incomplete — {modules} not complete (see variants[conventional].summary.incomplete_reason)
G5 no uncertainty band: decision #18 requires a Monte Carlo band on every comparison; no band is computable until the Ciroth basic-uncertainty table is sourced (AR-13); band method pending AR-83

{modules} is a comma-joined list in module order, e.g. B4, B6.

1.3 Delta (specified, not built)

When the delta lands (after AR-83 is decided and its dependencies are met), per #18 and design §5.4 it is computed per counted module and for the whole-life total, GWP only in Phase 1:

delta_m      = total_m(earth) − total_m(conventional)          [kg CO2-eq]
delta_pct_m  = delta_m / total_m(conventional)                  [signed; null if conv total = 0]

Negative means earth emits less. This sign convention is proposed, and UI spec §2.4.3 renders it signed with formatSignedPercent (open point §8.2). The browser never computes the ratio.


2. Schema (implementation PR)

In backend/app/schemas/compute.py:

class ComparisonStatus(StrEnum):
    PENDING = "pending"  # at least one gate failed; reasons non-empty
    COMPLETE = "complete"  # all gates hold; delta and band set — unreachable in this PR


class ComparisonGates(BaseModel):
    conventional_submitted: bool
    same_building_attested: bool
    earth_total_complete: bool
    conventional_total_complete: bool
    band_present: bool
    model_config = {"frozen": True}


class Comparison(BaseModel):
    """Earth vs conventional (decision #18, amended #29). Read-only over ``variants``."""

    status: ComparisonStatus
    gates: ComparisonGates
    reasons: list[str]
    delta: None = Field(None, description="Reserved. Null until AR-83 + AR-79 + AR-13.")
    delta_pct: None = Field(None, description="Reserved. UI spec §2.4.3.")
    band: None = Field(None, description="Reserved. Decision #18 band; AR-83, AR-13.")
    model_config = {"frozen": True}

    # validator: status is COMPLETE  ⇔  all gates true  ⇔  reasons == []
    #            status is COMPLETE  ⇒  delta, delta_pct, band not None

Because delta, delta_pct and band are typed None, the second validator makes status=complete raise at construction. The delta PR widens those three types. That is the one place the boundary moves, and the diff will show it.

ResultEnvelope.comparison: Comparison (non-null). comparison_pending_reason is removed, so the reasons have one source of truth (open point §8.1).

3. Service (implementation PR)

New backend/app/services/comparison.py (AGPL header):

def compare(variants: Sequence[VariantResult], *, attested: bool) -> Comparison: ...
  • It is pure and reads only VariantResult.key and .summary. It never mutates its input and never calls an engine.
  • project_compute.compute_project calls it after the variant list is built, and _comparison_pending is deleted.
  • This is not services.comparative_assertion: the ISO 14044 §6.2 documentation stays Phase 2 (ROADMAP:106). module-map.md gets a comparison row (status implemented, never parity-verified, because there is no reference to be on par with), and data-flow.md gets the variants → compare() → comparison edge.

4. AR-83: band method options

AR-83 is the register's three options as written, plus one sub-variant. The final pick is Marco's.

4.0 The common blocker: AR-13

services/pedigree.py raises NotImplementedError, and ProxyProvenance.gsd_squared is a placeholder that returns 1.0 for every record. Captured Weidema scores do not turn into a variance until the Ciroth et al. 2016 basic-uncertainty table (cross-checked against Brightway2) is sourced. So no band of any kind is computable today. Every banded arm below is BLOCKED on AR-13.

Beyond AR-13, every banded arm also needs, before its band covers the whole-life total:

  • a pedigree score for each inline-literal row (gravel, water, the unresolved literals), which today carries pedigree=None;
  • an uncertainty for the user-supplied EF_grid (B6), which today has no pedigree field;
  • an RSL uncertainty for B4, which depends on AR-12 RSLs existing at all;
  • consistent axis-1 scoring (AR-69).

These are proposed as one new register row (§7, AR-98). They matter only on the banded arms.

4.1 Option (a): accept pending for Phase 1 · orchestrator recommendation

  • Code shape: exactly the implementation PR in §2–§3. G5 stays constant False through Phase
  • The delta and band land together in Phase 2 with services.uncertainty (Monte Carlo, as #18 is written).
  • Data needs (for Phase 2): AR-13, the AR-98 coverage items, AR-79, AR-12.
  • What the user sees in Phase 1: both variant envelopes side by side, and a comparison reading pending with its reasons. No % anywhere.
  • Decision cost: none. #18 is untouched and no decision-review is needed.
  • Rationale (orchestrator): the delta is gated on AR-79 (conventional file) regardless of the band method, and on AR-12 (RSLs) for any real, non-synthetic variant. Amending #18 buys no real comparison while that data is absent. Option (a) ships the whole mechanism and moves no decision.

4.2 Option (b): minimal services.uncertainty (Monte Carlo) in Phase 1 · BLOCKED on AR-13

  • Code shape: implement services.pedigree.compute_gsd_squared (Ciroth: GSD² = exp(√(Σ ln(Uᵢ)² + ln(U_basic)²)), already in the docstring), then a Monte Carlo over lognormal factors with n = MethodologySettings.uncertainty_n (default 10,000, schemas/project.py:116) and a fixed seed for reproducibility. The two variants must be sampled pairwise: a factor used by both variants (cement, sand, rebar) takes the same draw in both runs in each iteration. Independent sampling would overstate the delta band.
  • Data needs: AR-13, AR-98 coverage, AR-79, AR-12.
  • What the band looks like: band = {method: "monte_carlo", n, seed, p2_5, p50, p97_5} on delta and delta_pct, plus p_earth_lower (the share of iterations with delta < 0).
  • Decision cost: none on #18, which it implements as written. Its cost is schedule: it pulls the Phase-2 services.uncertainty forward, and it delivers nothing until AR-13 is sourced.
  • Code shape: the same GSD² inputs as (b). The band is propagated in closed form instead of sampled. The delta is linear in the factors (Σ (q_e,i − q_c,i)·F_i), so its mean and variance are exact from lognormal moments, and shared factors net out without a correlation term. Percentiles are then approximated, for example as a normal on the moments, which is a known bias for skewed lognormal sums.
  • Data needs: identical to (b): AR-13, AR-98, AR-79, AR-12.
  • What the band looks like: band = {method: "analytical_pedigree", mean, sd, lo, hi} with an approximation note.
  • Decision cost: it amends locked #18 ("Monte Carlo … on every comparison"), so it needs a decision-review issue. It is blocked on the same AR-13 as (b), so it delivers no earlier.
  • Verdict: (b′) has the same data as (b), the same date as (b), adds a locked-decision amendment and gives a less exact band. (b) dominates it strictly. It is recorded so that the option is visibly considered and closed.

4.4 Option (c): decision-review on #18 for a labelled unbanded Phase-1 delta

  • Code shape: add a third status complete_unbanded. delta and delta_pct are emitted with band = null and a mandatory label, for example "indicative — no uncertainty band; not a comparative assertion under ISO 14044 §6.2 (decision #18 amended, see #NN)". G5 is relaxed only for that status.
  • Data needs: AR-79 and AR-12 only. It is the only path to a Phase-1 delta without AR-13.
  • What the user sees: a signed % with a visible unbanded warning.
  • Decision cost: it amends locked #18 through a decision-review issue (CLAUDE.md: never silently). It weakens the ISO 14044 §6.2 comparative rigour that #18 exists to protect. An unbanded earth-vs-conventional % is exactly the figure an ISO 14071 reviewer would challenge, and it risks being quoted outside the tool as a comparative assertion. It still delivers nothing until AR-79 and AR-12 clear.

4.5 Summary

Option Amends #18 Blocked on Earliest real delta Band Status
(a) pending No AR-79, AR-12, AR-13 (Phase 2) Phase 2 Monte Carlo Recommended by orchestrator
(b) MC in Phase 1 No AR-13, AR-98, AR-79, AR-12 when AR-13 lands Monte Carlo, paired Open
(b′) analytical Yes same as (b) same as (b) moments-based approximation Not recommended (dominated)
(c) unbanded Yes AR-79, AR-12 when AR-79 lands none Open, needs decision-review

Under every option this implementation PR is identical. It ships G1–G5 with G5 constant False. AR-83 decides only what later widens G5.


5. Module-state contract (#59, symmetric)

Earth total Conv. variant Conv. total Attested Band status reasons (gates failed)
incomplete absent — any no pending G1, (G2), G3, G5
complete absent — any no pending G1, (G2), G5
incomplete present incomplete yes no pending G3, G4, G5
complete present incomplete yes no pending G4, G5
incomplete present complete yes no pending G3, G5
complete present complete no no pending G2, G5
complete present complete yes no pending G5 only (#18)
complete present complete yes yes complete — (unreachable and unconstructable in this PR)

The contract is symmetric: earth and conventional are evaluated by the same rule, and neither side's incompleteness is hidden by the other's.


6. KS parity invariant and collateral

compare() is additive and read-only over existing outputs. It cannot change a variant number.

Byte-identical (proved with git diff --exit-code origin/main -- …):

  • backend/tests/regression/ (all KS / KDS parity tests);
  • backend/tests/fixtures/;
  • backend/tests/golden/ except projects_compute_sample.json, i.e. a1_a3_keur_songho.json, a4_keur_songho.json, a5_keur_songho.json, lci_materials.json, projects_compute_request.json, quantities_keur_songho.json.

Scoped regeneration: projects_compute_sample.json only (UPDATE_GOLDEN=1 pytest backend/tests/golden, named in the PR). It is earth-only, so it moves from

"comparison": null,
"comparison_pending_reason": ["one variant submitted: no conventional set provided"],

to

"comparison": {
  "band": null, "delta": null, "delta_pct": null,
  "gates": {"band_present": false, "conventional_submitted": false,
            "conventional_total_complete": false, "earth_total_complete": false,
            "same_building_attested": false},
  "reasons": ["conventional: … (AR-79 …)", "same building … not attested …",
              "earth: whole-life total incomplete — B4, B6 not complete …",
              "no uncertainty band: … (AR-13) … AR-83"],
  "status": "pending"
},

with comparison_pending_reason removed (§8.1). input_hash does not move, because the request is unchanged.

Proof that only the comparison keys moved:

F=backend/tests/golden/projects_compute_sample.json
K='del(.comparison, .comparison_pending_reason)'
diff <(git show origin/main:$F | jq -S "$K") <(jq -S "$K" $F) && echo "only comparison moved"
diff <(git show origin/main:$F | jq -S .variants) <(jq -S .variants $F) && echo "variants identical"

Expected generated collateral (byte changes, not goldens, each CI-checked): docs/api-reference/openapi.json (scripts/dump_openapi.py), frontend/src/api/schema.gen.ts (npm run gen:api), frontend/src/mocks/fixtures/projects_compute_sample.ts (npm run mocks:sync).

Frontend: one forced line (flag, §8.3). The feature is API-only, and the conventional-set wizard entry is step 2 (AR-99). But EnvelopeView.tsx:197 keys its pending note on comparison == null and reads comparison_pending_reason. Once the type changes, that note would either fail type-check (field removed) or silently stop rendering (never null). The minimum change is the condition envelope.comparison.status === "pending" and the text envelope.comparison.reasons. It is a contract adaptation that keeps today's behaviour, not a feature.


7. Governance

Item Action in the implementation PR
AR-13 (Ciroth table → GSD²) Is now on the comparison critical path for every banded option, and is UNASSIGNED. Update the row's Blocks to add "comparison band (AR-83 options b, b′)". Flag for Marco to route, probably as a later sourcing sub-chat rather than now.
AR-83 Final pick is pending Marco. Record the orchestrator recommendation (a), the (b′) dominance finding, and a link to this spec. The row stays Open.
AR-79 No change. Add "G1 of compare()" to Blocks.
AR-12 Add "G3/G4 of compare(): no real variant total completes while every RSL is BLOCKED" to Blocks.
AR-99 (new; AR-97 is the ownership/authorship row) FRONTEND · Claude Code · Conventional-set entry in the wizard (step 2): a second material list, the attest_same_building control, and the comparison panel reading comparison.status/reasons. Blocks: any user-reachable comparison. Gated on AR-79. Link: this spec §6.
AR-98 (new) METHODOLOGY · UNASSIGNED — Marco to route · Band coverage gaps: inline-literal rows have no pedigree; user-supplied EF_grid has no uncertainty field; B4 RSL uncertainty uncaptured; AR-69 axis-1 inconsistency. Blocks: AR-83 options (b)/(b′) whole-life band. Link: this spec §4.0.
Docs module-map.md (new comparison service row plus graph edge), data-flow.md, ROADMAP step 5 tick (mechanism only) and Session Log, docs/methodology/module-coverage.md if it lists the comparison.

8. Open points

  1. Remove comparison_pending_reason, or keep it as a mirror? The spec removes it: one source of truth, and the frontend line changes anyway (§6). Keeping it as a deprecated mirror would make the golden diff touch only comparison, at the cost of two fields that can drift.
  2. Delta sign: earth − conventional (negative = earth better) is proposed (§1.3). Confirm before the delta PR. It does not affect this PR.
  3. The forced EnvelopeView line (§6) slightly contradicts "frontend out of scope". The orchestrator should confirm that a one-line contract adaptation is acceptable in an API-only PR.
  4. AR-83 pick: Marco.

9. Test plan (implementation PR)

New backend/tests/unit/test_comparison.py. test_project_compute.py::test_comparison_is_null_with_reasons is rewritten to assert the object. "Complete variant" below means the synthetic test_t9 recipe: every row f = 0 with a TEST ONLY basis, plus grid energy with _EF_DELIVERED. No real variant total completes today, because every RSL is BLOCKED (AR-12).

# Case Asserts
C1 Earth only, default (incomplete) pending; gates conventional_submitted=False, earth_total_complete=False, band_present=False; reasons = [G1, G2, G3 naming B4, B6, G5]; no G4 string
C2 Earth only, synthetic complete pending; reasons = [G1, G2, G5]; earth_total_complete=True
C3 Both incomplete, attested pending; reasons = [G3, G4, G5]; G3/G4 name each side's incomplete modules
C4 Earth complete, conventional incomplete, attested pending; reasons = [G4, G5]
C5 Earth incomplete, conventional complete, attested pending; reasons = [G3, G5] (symmetry)
C6 Both complete, attested, no band still pending per #18; reasons == [G5] exactly; all gates true except band_present
C7 Both complete, not attested reasons = [G2, G5]
C8 Reason text each G1–G5 string pinned verbatim, including the AR ids (AR-79, AR-13, AR-83)
C9 complete is unconstructable Comparison(status="complete", …) raises ValidationError (delta/band typed None); pending with empty reasons also raises
C10 Read-only compare() leaves its input VariantResults equal (frozen models; deep-equal before/after), and variants in the envelope equals [_compute_variant(...)] computed without compare()
C11 Hash unaffected input_hash is equal with and without the change (the request is unchanged)
G Golden projects_compute_sample.json regenerated once; the §6 jq proof is pasted in the PR body

Gate before merge: pytest, ruff check ., ruff format --check ., mypy backend/app, the frontend gen:api / mocks:check / type-check, openapi-drift, and the three git diff --exit-code proofs in §6.