Skip to content

Sprint: option economics · the quality evidence chain · vendor memory · a clawback that admits it cannot tell - #110

Merged
ibuilder merged 2 commits into
mainfrom
feat/r22-option-object
Jul 30, 2026
Merged

Sprint: option economics · the quality evidence chain · vendor memory · a clawback that admits it cannot tell#110
ibuilder merged 2 commits into
mainfrom
feat/r22-option-object

Conversation

@ibuilder

Copy link
Copy Markdown
Owner

Four R22 items. Lane C engines, Lane G routes, one Lane H register field. No version files. No module added — the register count stays 133/30, so the docs count gate stays quiet.

Rebased onto b44ddbf9 and re-applied on top of #108's field sweep, not over it — asserted before writing:

assert any(f.get("unit")=="sf" for f in m["fields"]),      "#108's unit sweep is missing — wrong base"
assert any(f.get("type")=="percent" for f in m["fields"]), "#108's percent types are missing"

1. R22-OPTION-OBJECT — economics with provenance

design_option carried hard_cost and irr_pct as typed fields with zero reference to a deal, while proforma/solve.py sizes debt and returns.py computes the levered IRR right next door. A typed 19.7% and a solved 19.7% render identically; the typed one is the one that goes stale.

Every figure now states a basis: derived · declared · unlinked · unavailable.

unlinked is the point. Pricing an option from the project's other scenario would assert this massing was underwritten under that deal — provenance nobody recorded. Refused; the candidate scenarios are named instead.

A declared figure is never promoted to derived and a derived one never overwrites what was typed, so declared_disagrees_with_derived can exist — a scheme whose typed IRR sits 4 points above what its own scenario solves to.

2. R22-ITP-NCR ② — the quality evidence chain, per element

The registers were never the gap, verified by reading the files: itp carries hold/witness points (point_type, acceptance_criteria), ncr a real open → dispositioned → closed lifecycle, and quality.py rolls all three up. But zero GUID fields across all three modules, and turnover.py referenced none of them — so a project could be certified substantially complete with the quality evidence never attached to what it inspected.

No new field, no migration. Records attach through element_guids, the column every module record already has, already routed as POST .../elements. data.element has one reader in the codebase; element_guids has the module engine. A bespoke field would have created a second convention beside the platform's own.

The rule: an element with no quality record is unrecorded, never clear. "0 open NCRs" across a building nobody inspected is literally true and completely misleading. any_attached and coverage_pct separate "this building has a problem" from "nobody has linked records yet".

Two real product gates surfaced while testing and are now pinned: closing an NCR requires attached evidence, and verifying an ITP requires acceptance criteria. An element cannot be cleared by fiat.

3. R22-PROCURE-DEPTH ③ — vendor memory across projects

2 of 3 parts already existed (prequalification.py, clause_playbook.py). The third didn't: score_project takes one project, so a sub is prequalified from their own form with no reference to their record with you.

Six registers carry a vendor and had never been read across projects. Same refusals:

  • no_historyclear — an unknown sub rendering as clean is the expensive direction.
  • A COI with no expiry is unknown cover, not cover — counted separately from expired.

The Q-score and this history are reported side by side, never blended — one is what the sub said, the other is what they did.

The limit is stated, not hidden: ncr and inspection carry no vendor field, so quality/schedule performance is not attributable. A clean scorecard is silence on that subject. The test asserts this against the actual module.json schemas — if a vendor field is ever added to ncr, the test fails and the claim gets revisited.

4. Clawback — unavailable, with no number invented

lp_irr is None means XIRR did not converge — 26% of shapes in a 729-pattern sweep — not "no restitution owed". That case silently returned the same payload as a healthy deal, so a GP kept money a clawback might have recovered.

Now clawback.status = applied / not_owed / unavailable / not_requested, with the reason. No fallback is picked, deliberately — inventing a restitution figure out of a failed solve is how a plausible dollar amount gets cited while a missing one gets chased. Whether a non-converging IRR is a zero-clawback case is a deal-terms decision. The test asserts unavailable and not_owed can never collapse into each other.

Also

test_proforma: the CPI-fallback forecast is value-checked, not just its label. The old assertion was forecast_at_completion < 40_000_000, an upper bound — flipping the branch's max to min collapsed 27,133,231.54 → 9,000,000 (spend-to-date, i.e. claiming a month-6-of-17 job is finished) and passed. Found by mutation testing.

Verification

  • Full backend suite 451/452 on base b44ddbf9. The one failure, test_project_package, passes standalone twice, produced no traceback under parallelism, and references none of the changed modules — consistent with the documented ifcopenshell/Windows parallel flake.
  • test_reachable 312/316, no new orphan · manifest guard clean · ruff clean.
  • Not yet run on the merged tree — pinging the release lane to do that before merge, since "merges clean" ≠ "merges and passes".

@strix-security

strix-security Bot commented Jul 30, 2026

Copy link
Copy Markdown

Strix Security Review

No security issues found.

Updated for 21063a7.


Reviewed by Strix
Re-run review · Configure security review settings

@qodo-code-review

Copy link
Copy Markdown

PR Summary by Qodo

Add option economics provenance, quality chain, vendor scorecards, clawback status

✨ Enhancement 🐞 Bug fix 🧪 Tests ⚙️ Configuration changes 🕐 40+ Minutes

Grey Divider

AI Description

• Add scenario-linked option economics with explicit basis (derived/declared/unlinked/unavailable).
• Expose per-element quality evidence chain and turnover readiness via new construction routes.
• Add cross-project vendor scorecards endpoint and make waterfall clawback report XIRR
 non-convergence.
Diagram

graph TD
  A["Design routes"] --> B["Option economics"] --> C["Proforma engine"]
  B --> D[("Scenario DB")]
  E["Construction routes"] --> F["Quality chain"] --> G[("Module tables")]
  H["Benchmark routes"] --> I["Vendor memory"] --> G
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Persist derived economics on design_option
  • ➕ Avoids re-solving scenarios on read; faster compare cards at scale
  • ➕ Enables historical snapshots if scenarios change over time
  • ➖ Requires migration/backfill and a staleness strategy when scenarios/assumptions change
  • ➖ Risks reintroducing invisible provenance if persistence isn’t paired with basis metadata
2. Use scenario_id FK instead of free-text scenario reference
  • ➕ Eliminates ambiguity and name-matching; avoids silent mislinks
  • ➕ Improves referential integrity and enables DB-level joins
  • ➖ Requires schema change/migration; harder to type manually in early-stage workflows
  • ➖ Needs UX for selecting a scenario rather than entering text
3. Materialize vendor scorecards (view/ETL)
  • ➕ Improves performance for large portfolios; avoids scanning module tables per request
  • ➕ Easier to add indexing/aggregation over time
  • ➖ Adds infrastructure and data freshness concerns
  • ➖ More moving parts than the current on-demand rollup

Recommendation: The PR’s on-demand computation with explicit basis/status is the right default for correctness: it prevents manufactured provenance (unlinked options), prevents false compliance (unrecorded elements), and prevents false certainty (clawback unavailable). Consider a follow-up to tighten scenario linking (FK or explicit selector) and/or add caching/materialization only if performance becomes a demonstrated issue.

Files changed (17) +1532 / -4

Enhancement (7) +669 / -0
design_options.pyEnrich option compare payload with economics basis + derived figures +19/-0

Enrich option compare payload with economics basis + derived figures

• Augments 'design_options.compare()' to pull per-option economics from the new 'option_economics' module. Adds additive fields indicating provenance ('economics_basis') and, when derived, the solved IRR/hard cost alongside typed values.

services/api/src/aec_api/design_options.py

option_economics.pyImplement scenario-derived option economics with explicit provenance and disagreement detection +197/-0

Implement scenario-derived option economics with explicit provenance and disagreement detection

• Introduces a new engine that computes hard cost and levered IRR from a linked proforma scenario when solvable, otherwise reports declared/unlinked/unavailable status with actionable notes. Preserves declared vs derived values simultaneously and surfaces declared/derived IRR disagreement within a tolerance.

services/api/src/aec_api/option_economics.py

quality_chain.pyAdd per-element quality evidence chain and turnover readiness engine +196/-0

Add per-element quality evidence chain and turnover readiness engine

• Adds a new module that gathers ITP/NCR/Inspection records and evaluates evidence per element using 'element_guids' (and legacy 'data.guid'). Implements clear/outstanding/unrecorded semantics, project-level attachment capability guards, coverage metrics, and a turnover readiness view.

services/api/src/aec_api/quality_chain.py

benchmarking.pyAdd vendor scorecards benchmark endpoint +19/-0

Add vendor scorecards benchmark endpoint

• Adds GET '/benchmarks/vendors' to serve cross-project vendor scorecards via the new vendor_memory engine. Scopes data to the current user’s accessible projects and documents non-attributable limits.

services/api/src/aec_api/routers/benchmarking.py

construction.pyExpose quality chain and turnover readiness endpoints +29/-0

Expose quality chain and turnover readiness endpoints

• Adds GET '/projects/{pid}/quality/chain' and '/projects/{pid}/quality/turnover-readiness' with optional GUID filtering. Routes return per-element evidence status and an explicit readiness decision that treats unrecorded evidence as non-ready.

services/api/src/aec_api/routers/construction.py

design.pyExpose option economics comparison endpoint +18/-0

Expose option economics comparison endpoint

• Adds GET '/projects/{pid}/design/options/economics' to return ranked option economics with basis metadata and declared-vs-derived disagreement list. Validates project existence and keeps compare-card semantics additive via the separate endpoint + compare enrichment.

services/api/src/aec_api/routers/design.py

vendor_memory.pyImplement cross-project vendor memory scorecards with explicit limits +191/-0

Implement cross-project vendor memory scorecards with explicit limits

• Introduces a new engine that aggregates vendor-bearing registers across projects into vendor scorecards, including compliance flags (COI expiry/missing expiry, lien waiver exposure) and commercial totals. Explicitly reports ‘no_history’ vs ‘clear’ and documents which performance registers are not attributable due to missing vendor fields.

services/api/src/aec_api/vendor_memory.py

Bug fix (1) +28 / -0
waterfall.pyReport clawback status when XIRR cannot be solved +28/-0

Report clawback status when XIRR cannot be solved

• Changes clawback handling to explicitly distinguish not requested / not owed / applied / unavailable. When XIRR fails to converge, returns a structured clawback status and reason instead of silently doing nothing.

services/api/src/aec_api/proforma/waterfall.py

Tests (8) +829 / -4
run_tests.pyRegister new test suites for vendor memory, quality chain, and option economics +2/-2

Register new test suites for vendor memory, quality chain, and option economics

• Extends the test runner’s suite lists to include new unit/integration tests for vendor memory, quality evidence chain, and option economics routes.

services/api/run_tests.py

test_option_economics.pyAdd unit tests for option economics basis rules and ranking behavior +181/-0

Add unit tests for option economics basis rules and ranking behavior

• Pins basis semantics (derived/declared/unlinked/unavailable), disagreement detection, refusal to price unlinked options, and robustness to malformed numeric inputs. Exercises roll-up ranking and scenario lookup by id and by name.

services/api/test_option_economics.py

test_option_economics_route.pyAdd HTTP integration test for option economics endpoint and compare-card enrichment +146/-0

Add HTTP integration test for option economics endpoint and compare-card enrichment

• Creates a real project + scenario, writes design_option records with/without scenario, and asserts the economics endpoint solves proforma assumptions and returns basis-aware rows. Verifies existing '/design/options/compare' now carries additive provenance fields without changing typed semantics.

services/api/test_option_economics_route.py

test_proforma.pyStrengthen proforma test coverage for CPI fallback arithmetic +12/-2

Strengthen proforma test coverage for CPI fallback arithmetic

• Adds assertions that validate the numeric computation of a forecasting fallback branch (not just the label), preventing mutations that collapse forecast-at-completion to spend-to-date. Minor import reordering/formatting adjustments.

services/api/test_proforma.py

test_quality_chain.pyAdd unit tests for per-element quality chain semantics and guards +162/-0

Add unit tests for per-element quality chain semantics and guards

• Pins clear/outstanding/unrecorded rules, open-state detection, hold-point surfacing, attachment via 'element_guids' and 'data.guid', and project-level capability guards ('any_attached', coverage correctness). Also asserts no bespoke element field was added to module schemas.

services/api/test_quality_chain.py

test_quality_chain_route.pyAdd HTTP integration test for quality chain + readiness using existing element attachment route +142/-0

Add HTTP integration test for quality chain + readiness using existing element attachment route

• Builds real module records (NCR/ITP/Inspection), attaches them via existing 'POST .../elements', and asserts they reappear per GlobalId in the chain output. Verifies readiness behavior for outstanding vs unrecorded evidence and pins product gates like NCR close requiring attachments.

services/api/test_quality_chain_route.py

test_vendor_memory.pyAdd unit tests for vendor memory rollup, compliance flags, and stated attribution limits +162/-0

Add unit tests for vendor memory rollup, compliance flags, and stated attribution limits

• Pins cross-project aggregation, verdict semantics (no_history vs clear vs watch), insurance date handling (expired vs missing), lien waiver exposure checks, and robustness to malformed input. Asserts schema reality for attributable vs non-attributable modules and requires a non-empty message when no vendors exist.

services/api/test_vendor_memory.py

test_waterfall.pyAdd tests for clawback status reporting, including XIRR non-convergence +22/-0

Add tests for clawback status reporting, including XIRR non-convergence

• Extends waterfall tests to assert structured clawback statuses across not_requested/not_owed/applied/unavailable. Includes an explicit non-converging cashflow shape and verifies the reason message distinguishes unavailable from not owed.

services/api/test_waterfall.py

Other (1) +6 / -0
module.jsonAdd proforma scenario reference field to design options +6/-0

Add proforma scenario reference field to design options

• Adds a new text field 'scenario' to the design_option module schema so options can explicitly name the proforma scenario they were underwritten against. This enables economics derivation without changing meanings of existing typed money fields.

services/api/modules/design_option/module.json

@qodo-code-review

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (5) 📘 Rule violations (0) 📜 Skill insights (0)

Context used
✅ Compliance rules (platform): 55 rules

Grey Divider


Remediation recommended

1. Unknown equity reported zero 🐞 Bug ≡ Correctness
Description
option_economics.option_economics() converts missing/unparseable derived equity and loan_amount
into 0.0 using round(_num(...) or 0.0, 2), which can misrepresent unknown financing values as real
zeros.
Code

services/api/src/aec_api/option_economics.py[R113-117]

+            "basis": BASIS_DERIVED, "hard_cost": round(cost, 2) if cost is not None else None,
+            "irr_pct": irr_pct,
+            "equity": round(_num(su.get("equity")) or 0.0, 2),
+            "loan_amount": round(_num(su.get("loan_amount")) or 0.0, 2),
+            "equity_multiple": _num(ret.get("equity_multiple")),
Relevance

●● Moderate

No clear historical evidence on treating missing derived numeric values as None vs 0.0.

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
_num() returns None for missing/unparseable values, but the derived equity and loan_amount fields
force None to 0.0, which changes meaning from unknown to zero.

services/api/src/aec_api/option_economics.py[59-65]
services/api/src/aec_api/option_economics.py[107-117]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
For derived scenarios, `equity` and `loan_amount` are computed as `round(_num(...) or 0.0, 2)`. Since `_num()` returns `None` for missing/unparseable values, this coerces unknowns to `0.0`, which is semantically different from “not provided”.

## Issue Context
This is inconsistent with how other derived fields preserve `None` when missing (e.g., `hard_cost`, `irr_pct`). It matters if older cached `Scenario.result` payloads are partial or if future solver changes omit fields.

## Fix Focus Areas
- services/api/src/aec_api/option_economics.py[107-119]
- services/api/src/aec_api/option_economics.py[59-65]

## Suggested fix
- Compute and round only when non-None:
 - `eq = _num(su.get('equity'))` then `equity = round(eq, 2) if eq is not None else None`
 - same for `loan_amount`
- Keep `0.0` only when it is explicitly present and parsed as numeric zero.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Informational

2. Repeated scenario solves 🐞 Bug ➹ Performance
Description
option_economics.compare_economics() calls proforma.solve() via _solved() for each option when
Scenario.result is empty, so multiple options linked to the same scenario can trigger redundant
solves in one request and significantly increase latency/CPU.
Code

services/api/src/aec_api/option_economics.py[R68-84]

+def _solved(scenario: Scenario) -> dict[str, Any] | None:
+    """The scenario's result, solved on demand if it has not been cached.
+
+    Mirrors `routers/proforma.py`'s `s.result or solve(s.assumptions)` rather than requiring a cached
+    result — an option linked to a never-opened scenario should still price.
+    """
+    if scenario is None:
+        return None
+    if scenario.result:
+        return scenario.result
+    try:
+        from .proforma.solve import solve
+        return solve(scenario.assumptions)
+    except Exception:
+        # A scenario whose assumptions no longer solve is a real state (fields renamed, a required
+        # key dropped). It must read as "could not derive", never as a zero-cost, zero-return option.
+        return None
Relevance

● Weak

Perf memoization of solver calls not enforced; similar micro-perf concern left unresolved in PR
#107.

PR-#107

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The new _solved() runs solve(scenario.assumptions) when scenario.result is missing, and
compare_economics() calls option_economics() (which calls _solved()) once per option, so
shared scenarios can be solved repeatedly.

services/api/src/aec_api/option_economics.py[68-84]
services/api/src/aec_api/option_economics.py[156-173]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`compare_economics()` computes economics per option and calls `_solved(scenario)` each time. When `scenario.result` is empty, `_solved()` runs `proforma.solve()` but does not memoize the result, so repeated options pointing at the same unsolved scenario re-run the solver.

## Issue Context
This is especially costly because `design_options.compare()` also calls `compare_economics()` (see separate finding), turning an existing “compare card” endpoint into a potentially solver-heavy hot path.

## Fix Focus Areas
- services/api/src/aec_api/option_economics.py[68-84]
- services/api/src/aec_api/option_economics.py[156-173]

## Suggested fix
- Add a per-request cache in `compare_economics()` keyed by `scenario.id`:
 - If `scenario.result` exists, use it.
 - Else, if `scenario.id` is in cache, reuse the cached solved dict.
 - Else solve once, store in cache, and reuse for all options.
- Avoid mutating/persisting `scenario.result` on the ORM object unless you explicitly intend to write it back to the DB.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


3. Compare recomputes economics 🐞 Bug ➹ Performance
Description
design_options.compare() loads all design_option rows and then calls
option_economics.compare_economics(), which re-reads design_option records and recomputes economics
again, adding avoidable DB work and potentially triggering solver work on the existing
/design/options/compare endpoint.
Code

services/api/src/aec_api/design_options.py[R81-85]

+    try:
+        from . import option_economics
+        econ = {e["id"]: e for e in option_economics.compare_economics(db, pid)["options"]}
+    except Exception:
+        econ = {}
Relevance

● Weak

Team rejected similar “avoid extra list_records/DB scan” perf refactors in PRs #96/#104.

PR-#96
PR-#104

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
design_options.compare() reads options via list_records() and then calls compare_economics(),
which itself performs another list_records() read and iterates all options again.

services/api/src/aec_api/design_options.py[70-92]
services/api/src/aec_api/option_economics.py[156-173]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`design_options.compare()` already reads all `design_option` records to build the option card rows, but then calls `option_economics.compare_economics()` which re-queries the same records and recomputes economics. This creates deterministic duplicate work on a hot endpoint.

## Issue Context
This cost compounds with the repeated-solve behavior inside `option_economics` when scenarios are not cached.

## Fix Focus Areas
- services/api/src/aec_api/design_options.py[70-92]
- services/api/src/aec_api/option_economics.py[156-173]

## Suggested fix
- Refactor `option_economics.compare_economics()` to accept optional `design_option_rows` (already loaded) to avoid a second `list_records()`.
 - OR, in `design_options.compare()`, build the economics map by calling `option_economics.option_economics()` directly for each already-loaded record using a prebuilt `Scenario` lookup.
- Ensure scenario solve results are memoized per request (see related finding) so the compare card does not run N solves for N options.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


4. Scenario name misresolution 🐞 Bug ≡ Correctness
Description
option_economics.compare_economics() resolves the option’s scenario reference by case-insensitive
Scenario.name when it’s not an id, but Scenario.name is not unique per project, so duplicate names
can cause an option to be priced from an arbitrary scenario.
Code

services/api/src/aec_api/option_economics.py[R160-172]

+    scenarios = db.query(Scenario).filter(Scenario.project_id == pid).all()
+    by_id = {s.id: s for s in scenarios}
+    by_name = {(s.name or "").strip().lower(): s for s in scenarios}
+    candidates = [(s.id, s.name) for s in scenarios]
+
+    opts = []
+    for r in rows:
+        d = r.get("data") or r
+        if not d.get("name"):
+            continue
+        ref = str(d.get("scenario") or "").strip()
+        sc = by_id.get(ref) or by_name.get(ref.lower()) if ref else None
+        opts.append(option_economics(r, sc, candidates))
Relevance

● Weak

Non-unique-name resolution risks were previously rejected (storey name uniqueness issue in PR #94).

PR-#94

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The resolver uses a dict keyed only by normalized name, so duplicates overwrite; the Scenario model
definition shows no uniqueness constraint on name within a project.

services/api/src/aec_api/option_economics.py[160-172]
services/api/src/aec_api/models.py[103-121]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`compare_economics()` allows resolving `data.scenario` by scenario name (case-insensitive). Because scenario names are not unique per project, duplicate names overwrite each other in the `by_name` dict, causing nondeterministic/wrong scenario selection.

## Issue Context
This can return incorrect derived `hard_cost`/`irr_pct` while labeling the basis as `derived`, which undermines the provenance guarantee this feature is meant to add.

## Fix Focus Areas
- services/api/src/aec_api/option_economics.py[160-172]
- services/api/src/aec_api/models.py[103-121]

## Suggested fix
- Change name resolution to be unambiguous:
 - Build `by_name` as `dict[str, list[Scenario]]` (or track duplicates).
 - If a name maps to exactly one scenario, resolve.
 - If it maps to >1 scenario, treat as unresolved (e.g., `scenario=None`) and set `basis` to `unlinked`/`unavailable` with a note like “scenario name is ambiguous; use scenario id”.
- (Optional, more invasive) Enforce uniqueness at the data layer (unique constraint on `(project_id, lower(name))`) with a migration + cleanup path; only do this if product agrees scenario names should be unique.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


View more (1)
5. Silent economics errors 🐞 Bug ◔ Observability
Description
design_options.compare() catches all exceptions around importing/running option_economics and
silently drops economics provenance fields, making real integration/runtime failures
indistinguishable from legitimate 'no economics' cases.
Code

services/api/src/aec_api/design_options.py[R81-85]

+    try:
+        from . import option_economics
+        econ = {e["id"]: e for e in option_economics.compare_economics(db, pid)["options"]}
+    except Exception:
+        econ = {}
Relevance

● Weak

Broad fail-open except Exception is common; tightening/logging only partially accepted elsewhere
(PR #98).

PR-#98

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The new try/except catches Exception and does not log or return any error indicator; it just sets
econ to an empty dict and continues.

services/api/src/aec_api/design_options.py[74-92]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`design_options.compare()` wraps the economics computation in a broad `except Exception` and then proceeds with `econ = {}`. This hides failures (import errors, DB errors, unexpected exceptions) and returns a normal-looking payload with `economics_basis=None` everywhere.

## Issue Context
Because this runs on an existing endpoint, failures are likely to be missed until users notice missing fields.

## Fix Focus Areas
- services/api/src/aec_api/design_options.py[74-92]

## Suggested fix
- Narrow the exception handling (catch expected import issues separately from runtime errors) OR keep broad catch but:
 - Log the exception with traceback using a module logger (`logging.getLogger(...)`, `log.exception(...)`) including `pid`.
 - Add an explicit response marker like `economics_error` / `economics_available: false` so clients can distinguish failure from absence.
 - Consider failing fast (5xx) on unexpected exceptions if the endpoint’s contract allows it.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

To customize comments, go to the Qodo configuration screen, or learn more in the docs.

Qodo Logo

Comment on lines +113 to +117
"basis": BASIS_DERIVED, "hard_cost": round(cost, 2) if cost is not None else None,
"irr_pct": irr_pct,
"equity": round(_num(su.get("equity")) or 0.0, 2),
"loan_amount": round(_num(su.get("loan_amount")) or 0.0, 2),
"equity_multiple": _num(ret.get("equity_multiple")),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

5. Unknown equity reported zero 🐞 Bug ≡ Correctness

option_economics.option_economics() converts missing/unparseable derived equity and loan_amount
into 0.0 using round(_num(...) or 0.0, 2), which can misrepresent unknown financing values as real
zeros.
Agent Prompt
## Issue description
For derived scenarios, `equity` and `loan_amount` are computed as `round(_num(...) or 0.0, 2)`. Since `_num()` returns `None` for missing/unparseable values, this coerces unknowns to `0.0`, which is semantically different from “not provided”.

## Issue Context
This is inconsistent with how other derived fields preserve `None` when missing (e.g., `hard_cost`, `irr_pct`). It matters if older cached `Scenario.result` payloads are partial or if future solver changes omit fields.

## Fix Focus Areas
- services/api/src/aec_api/option_economics.py[107-119]
- services/api/src/aec_api/option_economics.py[59-65]

## Suggested fix
- Compute and round only when non-None:
  - `eq = _num(su.get('equity'))` then `equity = round(eq, 2) if eq is not None else None`
  - same for `loan_amount`
- Keep `0.0` only when it is explicitly present and parsed as numeric zero.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

ibuilder and others added 2 commits July 30, 2026 07:44
…wback that admits it cannot tell

Three R22 items, all Lane C engines plus their routes (Lane G) and one register field
(Lane H). No version files. No module added — the register count stays 133/30.

--- R22-OPTION-OBJECT -------------------------------------------------------------

`design_option` carried `hard_cost` and `irr_pct` as plain typed fields with ZERO
reference to a deal, while proforma/solve.py sizes debt and returns.py computes the
levered IRR right next door. A typed 19.7% and a solved 19.7% rendered identically on
the card, and the typed one is the one that goes stale.

`option_economics.py` gives every figure a basis: `derived` (the option names a scenario
and solve() ran), `declared` (typed, used as given, LABELLED), `unlinked`, `unavailable`.

`unlinked` is the one that matters. Pricing an option from the project's other scenario
would assert that this massing was underwritten under that deal — provenance nobody
recorded. It is refused and the candidate scenarios are named instead.

A declared figure is never promoted to derived and a derived one never overwrites what
was typed, so `declared_disagrees_with_derived` can exist: a scheme whose typed IRR sits
4 points above what its own scenario solves to is the most useful output here, and it is
only visible because both numbers are kept.

--- R22-ITP-NCR (2) ---------------------------------------------------------------

The registers were never the gap and the entry's estimate was wrong in a specific
direction. Verified by reading the files: `itp` carries hold/witness points
(`point_type`, `acceptance_criteria`, planned->active->verified), `ncr` a real
open->dispositioned->closed lifecycle, and quality.py rolls all three up. But zero
GUID fields across all three modules, and turnover.py referenced none of them — so a
project could be certified substantially complete with the quality evidence never
attached to what it inspected.

NO new field and NO migration. Records attach through `element_guids`, the column every
module record already has, already routed as POST .../elements. `data.element` has one
reader in the codebase; `element_guids` has the module engine. Adding a bespoke field
would have created a second convention beside the platform's own.

The rule it enforces: an element with no quality record is `unrecorded`, never `clear`.
"0 open NCRs" across a building nobody inspected is literally true and completely
misleading. `any_attached` and `coverage_pct` separate "this building has a problem"
from "nobody has linked records yet".

--- clawback status ---------------------------------------------------------------

`lp_irr is None` means XIRR did not converge — 26% of shapes in a 729-pattern sweep —
not "no restitution owed". That case silently returned the same payload as a healthy
deal, so a GP kept money a clawback might have recovered. Now reported as
`clawback.status` = applied / not_owed / unavailable / not_requested, with the reason.

No fallback is picked, deliberately: inventing a restitution figure out of a failed
solve is how a plausible dollar amount gets cited while a missing one gets chased.
Whether a non-converging IRR is a zero-clawback case is a deal-terms decision.

--- also -------------------------------------------------------------------------

test_proforma: the CPI-fallback forecast is value-checked, not just its label. The old
assertion was `forecast_at_completion < 40_000_000`, an upper bound — flipping the
branch's `max` to `min` collapsed 27,133,231.54 to 9,000,000 (spend-to-date, i.e.
claiming a month-6-of-17 job is finished) and passed. Found by mutation testing.

Rebased onto b44ddbf and re-applied on top of #108's field sweep rather than over it —
`gross_area_sf` keeps `unit: sf`, `efficiency_pct` and `irr_pct` keep `type: percent`,
asserted before the edit was written.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… them across projects

Two of the entry's three parts already existed, verified by reading the files before any
code was written: `prequalification.py` scores EMR / bonding / capacity into a traceable
Q-score with COI-expiry tracking, and `clause_playbook.py` holds the clause-position
playbook and its deviation register. The third did not.

`prequalification.score_project(db, project_id)` takes ONE project. So a sub is
prequalified on job B from the form they filled in themselves, with no reference to what
they actually did on job A — while the history sits there, keyed by vendor, in six
registers nobody had ever read across projects: subcontract, commitment, sub_invoice,
coi, lien_waiver, warranty.

`vendor_memory.py` reads them, tenant-scoped the same way `benchmarking._rows` is, so a
portfolio roll-up cannot see another tenant's partners.

Two refusals carry the design:

- **A vendor with no history is `no_history`, never `clear`.** An unknown sub that renders
  as a clean record is the prequalification equivalent of "0 open NCRs" on a building
  nobody inspected — and it is the expensive direction, because it reads as a green light.
- **A COI with no expiry recorded is UNKNOWN cover, not cover.** Counted separately from
  an expired one; a missing date must not quietly satisfy an insurance check.

The Q-score and this history are reported side by side and never blended. One is what the
sub said about themselves and one is what they did; a single composite would hide which
half is which, exactly as `declared` vs `derived` does on the option card.

And the limit is stated rather than left to be discovered: `ncr` and `inspection` carry NO
vendor field (checked — their only near-match is `subject`), and no schedule activity binds
one, so QUALITY and SCHEDULE performance are not attributable here. A clean scorecard is
silence on that subject, not a pass. `attributable.not_from` says so in the payload, and
the test asserts it against the actual module.json schemas rather than trusting the prose —
if a vendor field is ever added to `ncr`, that test fails and the claim gets revisited.

Router uses a function-local import: a top-level one is removed by the repo's import
autofix (the same hook noted from the client-portal sprint).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@ibuilder
ibuilder force-pushed the feat/r22-option-object branch from 21063a7 to b45a223 Compare July 30, 2026 14:46
@ibuilder

Copy link
Copy Markdown
Owner Author

Merging. Rebase verified, and main is confirmed healthy first — the API test gate on 362fc2c7 came back success, so the gitignored-samples defect I introduced in v0.3.805 is closed rather than merely believed closed.

Your correction is right and mine was worse than a different reader. I said "expect 460 (455 on main + 5)". main's TESTS is 450, so 455 is correct. My 455 was the count from the combined integration worktree (main + #109 + #110) — I quoted a number from a different object, not merely a different reader, which is a sharper version of the same mistake I had just warned you about. Verified independently before agreeing:

main TESTS (exec)   450 entries, 450 unique
whole-file regex    452          <- the shape of number that misleads

Your generalisation is the keeper: "your 455 and my 450 were both counts of run_tests.py, and only one of them was a count of TESTS." _manifest_guard() is the only reader that settles it.

Evidence this merge rests on, with its base SHA. Integrated main + #109 + #110 in a worktree at base 23f02cb7: backend 454/455, every one of your five suites passing, the single failure being my test_schema_diag (since fixed). tsc --noEmit clean, web 919/920 with the one failure a harness artefact of my worktree's node_modules junction. Your rebase changed only the manifest line, so I agree no further full run is warranted.

Also right to flag the ruff scope. ruff check src/ *.py reporting 161 errors while CI lints only src/ and ../data/src/ is exactly the kind of thing that gets reported as a regression by someone in a hurry. Checking what CI actually runs before reporting is the habit.

The four things I called out in your sprint still stand as the best of it: asserting your predecessor's content was present before writing over a shared register; stating what you CHECKED rather than concluded; choosing element_guids over a bespoke field after finding data.element had one reader; and attributable.not_from refusing to let a clean-looking vendor scorecard imply a quality record it never examined. The clawback stays as ruled — unavailable with a reason, no invented number — and the deal-terms question goes to the user with the 26% figure attached.

@ibuilder
ibuilder merged commit 9b226f5 into main Jul 30, 2026
10 checks passed
@ibuilder
ibuilder deleted the feat/r22-option-object branch July 31, 2026 04:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant