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
incompletefor any row withf > 0or nof. The only complete variant total is synthetic: every rowf = 0plus grid energy with a usableEF_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.comparisonchangesdocs/api-reference/openapi.json(CIopenapi-drift,ci.yml:61-78),frontend/src/api/schema.gen.ts(CIgit diff --exit-code,ci.yml:48), and the synced mockfrontend/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):
- It is pure and reads only
VariantResult.keyand.summary. It never mutates its input and never calls an engine. project_compute.compute_projectcalls it after the variant list is built, and_comparison_pendingis deleted.- This is not
services.comparative_assertion: the ISO 14044 §6.2 documentation stays Phase 2 (ROADMAP:106).module-map.mdgets acomparisonrow (statusimplemented, neverparity-verified, because there is no reference to be on par with), anddata-flow.mdgets thevariants → compare() → comparisonedge.
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
Falsethrough 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-reviewis 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 withn = 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}ondeltaanddelta_pct, plusp_earth_lower(the share of iterations withdelta < 0). - Decision cost: none on #18, which it implements as written. Its cost is schedule: it pulls
the Phase-2
services.uncertaintyforward, and it delivers nothing until AR-13 is sourced.
4.3 Sub-variant (b′): pedigree-analytical band · BLOCKED on AR-13 · not recommended (strictly dominated)¶
- 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-reviewissue. 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.deltaanddelta_pctare emitted withband = nulland 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-reviewissue (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/exceptprojects_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¶
- 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 onlycomparison, at the cost of two fields that can drift. - Delta sign:
earth − conventional(negative = earth better) is proposed (§1.3). Confirm before the delta PR. It does not affect this PR. - The forced
EnvelopeViewline (§6) slightly contradicts "frontend out of scope". The orchestrator should confirm that a one-line contract adaptation is acceptable in an API-only PR. - 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.