portfolio-optimiser/.claude/projects/2026-06-26-fase3-portfolio-fanout/plan.md
Kjell Tore Guttormsen 497399e0aa docs(fase3): /trekplan — implementation plan (8 steps; plan-critic APPROVE_WITH_NOTES + scope-guardian ALIGNED) [skip-docs]
8-step TDD plan composing run_project into a sequential run_portfolio orchestrator.
3 authorized one-time core seams (meter=, config docs_dir + verdict_input); load-bearing
SC3 meter-detach guard + SC4 overlap-ranking; SC7 resolve_model teeth. Both majors from
plan-critic (SC4 fixture magnitudes; Step 3/4 coupling) folded in via the REPLIES table.
gemini Pass 2 unavailable (Gemini-API deprecation, operator-side tooling).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019any9zfGNNwWJPX5Zq2QRz
2026-06-26 11:41:58 +02:00

704 lines
47 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Fase 3 — Sequential fan-out orchestrator over N portfolio projects
> **Plan quality: B+** (88/100) — APPROVE_WITH_NOTES
>
> Generated by trekplan v5.6.1 on 2026-06-26 — `plan_version: 1.7`
> Profile: premium · phase_signal plan = effort:high, model:opus
> Brief: `.claude/projects/2026-06-26-fase3-portfolio-fanout/brief.md`
## Context
The framework today proves the method on ONE project (`run_project`, Fase 2 vertical
slice). The core promise — a *generic* portfolio optimiser — is unproven until the
framework runs over N independent projects with **zero core-code change per project**.
Fase 3 generalises: a sequential `run_portfolio` orchestrator fans out over a configured
portfolio, **isolates each project's execution state** (budget meter, debate, retrieval
context) so there is no state-bleed, while threading **one shared `VerdictStore`** so the
ExpeL learning loop composes across the portfolio (a verdict on project A informs a similar
proposal on project C). Proving a brand-new synthetic project drops in **via config only**
is the concrete falsification of the genericity claim (D4/D5) and unlocks Fase 4
(open-source) + D7 (Claude-SDK sibling). Motivation traces to the brief's Intent verbatim.
## Architecture Diagram
```mermaid
graph TD
subgraph "Fase 3 — fan-out (NEW + changed)"
CFG["reference_projects.json<br/>+ docs_dir + verdict_input (NEW fields)"] --> LD["load_reference_projects<br/>(extended loader)"]
LD --> RP["run_portfolio (NEW)<br/>sequential for-loop"]
RP -->|"per project, await"| RJ["run_project (REUSE as-is<br/>+ additive meter= seam)"]
RP -->|"ONE instance, threaded"| VS["VerdictStore<br/>(SHARED — ExpeL accumulates)"]
RJ -->|"fresh per call"| TM["TokenMeter<br/>(ISOLATED per project)"]
RJ -->|"retrieve before add"| VS
RP --> PR["PortfolioResult (NEW)<br/>runs + store + thin aggregate"]
end
```
## Codebase Analysis
- **Tech stack:** Python ≥3.10, Microsoft Agent Framework (`agent-framework-core` 1.9.0),
Pydantic IR, PuLP/CBC validator, `uv`, `pytest` (`asyncio_mode=auto`), `ruff`, `mypy`.
- **Key patterns:** layered/hexagonal composition root; every seam explicit
(`client_factory`, `store`, `ChatBackend` protocol). Result types are `@dataclass(frozen=True)`;
the one mutable accumulator (`VerdictStore`) is a non-frozen `@dataclass` by design.
`from __future__ import annotations` + full type hints + keyword-only (`*,`) options + absolute
imports in every module. Tests mirror src module names; each opens with a docstring + `Pattern:`
pointer; lettered SC names (`test_a_…`); guard-tests grep src for anti-patterns
(`test_budget.py:88-99`). JSON config carries a `_note` provenance/disclaimer first key.
- **The seam to compose — `run_project`** (`src/portfolio_optimiser/run.py:114-127`): signature
`run_project(project_id, profile=LOCAL, *, docs_dir, verdict_input, store=None,
client_factory=None, max_rounds=3, max_tokens=100_000, top_k=3, enable_layer1_hitl=False,
notify=None) -> RunResult`. All execution state is built **fresh per call** — `TokenMeter`
(`run.py:151`), `fresh_workflow` (`run.py:155`), retrieval context (`run.py:141`) — so
sequential reuse is **inherently isolated**. The ONE shared seam is `store=`: retrieve
(`run.py:189`) happens **before** `store.add` (`run.py:193`), so project k+1 sees project k's
verdict. `RunResult` (`run.py:57-68`, frozen): `outcome` (`ValidatedProposal | Rejection`,
both expose `.proposal`), `provenance` (`token_usage = meter.tokens`, `run.py:179`), `verdict`,
`retrieved`, `store`, `debate_output`.
- **Learning store** (`verdicts.py`): `VerdictStore.add` is **idempotent on a content-hash id**
minted over `(affected_codes, measure_type, claimed_saving_nok)` — `description` excluded
(`_mint_id`, `verdicts.py:78-90`). `retrieve` ranks by structural `similarity`
(codes-Jaccard 0.60 + measure-match 0.25 + magnitude-bucket 0.15) with **no similarity floor**
(`verdicts.py:110-119`). `_features_of` (`run.py:98-104`) maps proposal → features.
- **Project loader** (`reference_domain.py:53-75`): reads bundled `data/reference_projects.json`
via `importlib.resources.files`. `Project` (`reference_domain.py:38-51`, frozen) has
`id/name/description/currency/cost_items` — **no `docs_dir`, no `verdict_input`**. `docs_dir`
is supplied per-call to `run_project` today (CLI `--docs-dir`); there is **no project→docs
mapping** anywhere. This is the gap Step 2 closes config-side.
- **Reusable code:** `_project_by_id` (`run.py:91`), `load_reference_projects`,
`SyntheticUsageChatClient` + `make_client_factory`/`fresh_store`/`docs_dir` fixtures
(`tests/conftest.py:27-114`), the two-call accumulation precedent
(`test_vertical_slice_e2e.py:69-90`), the decoy-vs-true-match precedent (`test_verdicts.py`),
the load-bearing fence (`test_h`, `test_vertical_slice_e2e.py:172-199`), the gated-live skip
(`test_foundry_profile_live.py:19-24`), the src-grep guard (`test_budget.py:88-99`).
- **No external tech.** MAF concurrency was spiked in Fase 1 and deliberately out of scope
(sequential chosen). No research-scout needed.
- **Baseline (verified this session):** `uv run pytest -q` → **103 passed, 3 skipped** (the 3
skips are env-gated live arms). `mypy src` + `ruff check .` clean.
## Implementation Plan
Steps are TDD (test-first; the project has tests). Steps 1–2 are the authorized **one-time**
core changes (the marginal per-project cost SC1 measures *afterwards*). Steps 3+ are additive.
Forbidden across every step: `src/portfolio_optimiser/validator.py` and `ir.py` (the
D7-portable pure core — never imported/edited by the orchestrator).
### Step 1: Add additive `meter=` injection seam to run_project
- **Files:** `src/portfolio_optimiser/run.py`, `tests/test_vertical_slice_e2e.py`
- **Changes:** Add a keyword-only param `meter: TokenMeter | None = None` to `run_project`
(after `notify`, `run.py:126`). Change the meter construction at `run.py:151` from
`meter = TokenMeter(Budget(...))` to `meter = meter if meter is not None else
TokenMeter(Budget(max_tokens=max_tokens, max_rounds=max(max_rounds * 4, 4)))`. Behaviour is
**preserved** when `meter is None` (the default) — every existing call is unaffected. This is
the injection point SC3 needs to encode the isolation detach as an automatic guard (there is
no meter seam today; without it the detach degrades to a manual reviewer-revert, the Fase 2
failure mode the brief forbids). **Why a seam and not Step 5's seamless per-project-baseline
fallback:** the baseline assertion proves isolation *holds now*, but provides no automatic
guard that reddens if a future edit makes `run_portfolio` share a meter. SC3 explicitly prefers
the encoded detach ("inject en delt meter i orkestratoren, assert at isolasjons-testen da blir
rød") — that requires an injection point. The param is additive, optional, and
behaviour-preserving (`None` = today's code), consistent with the brief's authorization of
one-time core changes for the Fase 3 build (cf. `docs_dir`).
- **Reuses:** `TokenMeter`/`Budget` (`budget.py:40,57`), already imported at `run.py:35`.
- **Test first:**
- File: `tests/test_vertical_slice_e2e.py` (existing) — add `test_i_injected_meter_is_used`
- Verifies: a `TokenMeter` pre-charged to a known value and passed as `meter=` is the meter
used by the run — assert `result.provenance.token_usage == pre_charged + run_delta` (i.e.
strictly greater than the same run with a fresh meter), proving the injected instance flows
to `provenance.token_usage` (`run.py:179`). Detaches cleanly: revert the `run.py:151` change
and the injected meter is ignored → assertion fails.
- Pattern: `tests/test_vertical_slice_e2e.py:28-41` (`test_a`, run_project call shape)
- **Verify:** Run `test_i_injected_meter_is_used` BEFORE the `run.py:151` change → expected:
**red** (injected meter ignored). After the change: `uv run pytest tests/test_vertical_slice_e2e.py -q`
→ expected: all pass incl. the new test (Iron-Law red→green gate).
- **On failure:** revert — `git checkout -- src/portfolio_optimiser/run.py tests/test_vertical_slice_e2e.py`
- **Checkpoint:** `git commit -m "feat(fase3): additive meter= seam on run_project (SC3 detach hook)"`
- **Manifest:**
```yaml
manifest:
expected_paths:
- src/portfolio_optimiser/run.py
- tests/test_vertical_slice_e2e.py
min_file_count: 2
commit_message_pattern: "^feat\\(fase3\\): additive meter= seam on run_project \\(SC3 detach hook\\)$"
bash_syntax_check: []
forbidden_paths:
- src/portfolio_optimiser/validator.py
- src/portfolio_optimiser/ir.py
must_contain:
- path: src/portfolio_optimiser/run.py
pattern: "meter: TokenMeter \\| None = None"
- path: tests/test_vertical_slice_e2e.py
pattern: "test_i_injected_meter_is_used"
```
### Step 2: Add config-driven docs_dir + verdict_input to the project loader
- **Files:** `src/portfolio_optimiser/reference_domain.py`,
`src/portfolio_optimiser/data/reference_projects.json`,
`src/portfolio_optimiser/data/docs/FV42-GSV-E1/notes.txt` (new),
`src/portfolio_optimiser/data/docs/RV13-RAS-TP/notes.txt` (new),
`src/portfolio_optimiser/data/docs/BRU-LAKS-REHAB/notes.txt` (new),
`tests/test_reference_domain.py`
- **Changes:**
1. JSON: add to **each** of the 3 existing projects a `"docs_dir"` (relative, e.g.
`"docs/FV42-GSV-E1"`) and a `"verdict_input"` object `{"decision","rationale"}`. Mark the
`verdict_input` values SYNTHETIC/AI-authored in the file-level `_note` (operator is not a
domain expert — `[[user-not-domain-expert]]`; production replaces the reference domain with
a real data source + real HITL — see Step 8 doc).
2. `Project` dataclass (`reference_domain.py:38-51`): add fields `docs_dir: str` and
`verdict_input: dict[str, str]`.
3. Loader (`reference_domain.py:53-75`): read `p["docs_dir"]` and resolve it to an absolute
filesystem path against the package data root —
`str(files("portfolio_optimiser").joinpath("data", p["docs_dir"]))` — and read
`p["verdict_input"]` into the dataclass. Missing keys raise `KeyError` (fail-fast, matches
existing loader contract).
4. Create one small synthetic `notes.txt` per project (AI-authored, flagged) whose text
contains the **cost-saving query terms** the orchestrator searches for
(`retrieve_chunks("cost saving measure", …)`, `run.py:141`) — e.g. a line naming a
cost-saving measure on a cost code — so `retrieve_chunks` returns ≥1 chunk and
`run_project` does not raise `ValueError` (`run.py:143-144`). Mirror the existing
`docs_dir` fixture content style (`conftest.py:110-113`).
- **Reuses:** `importlib.resources.files` pattern already at `reference_domain.py:55`; the JSON
`_note` provenance discipline (`reference_projects.json:2`, `model_map.json:2`).
- **Test first:**
- File: `tests/test_reference_domain.py` (existing)
- Verifies: every loaded `Project` has a non-empty `docs_dir` that points to an existing
directory, and a `verdict_input` dict containing exactly `decision` + `rationale` keys.
- Pattern: `tests/test_reference_domain.py:8-38` (loader shape assertions)
- **Verify:** Run the extended loader assertions BEFORE the `reference_domain.py` change →
expected: **red** (`Project` has no `docs_dir`/`verdict_input`). After: `uv run pytest
tests/test_reference_domain.py -q` → expected: all pass (Iron-Law red→green gate).
- **On failure:** revert — `git checkout -- src/portfolio_optimiser/reference_domain.py src/portfolio_optimiser/data/reference_projects.json tests/test_reference_domain.py` and `git clean -fd src/portfolio_optimiser/data/docs/`
- **Checkpoint:** `git commit -m "feat(fase3): config-driven docs_dir + verdict_input on Project loader"`
- **Manifest:**
```yaml
manifest:
expected_paths:
- src/portfolio_optimiser/reference_domain.py
- src/portfolio_optimiser/data/reference_projects.json
- src/portfolio_optimiser/data/docs/FV42-GSV-E1/notes.txt
- src/portfolio_optimiser/data/docs/RV13-RAS-TP/notes.txt
- src/portfolio_optimiser/data/docs/BRU-LAKS-REHAB/notes.txt
- tests/test_reference_domain.py
min_file_count: 6
commit_message_pattern: "^feat\\(fase3\\): config-driven docs_dir \\+ verdict_input on Project loader$"
bash_syntax_check: []
forbidden_paths:
- src/portfolio_optimiser/validator.py
- src/portfolio_optimiser/ir.py
- src/portfolio_optimiser/run.py
must_contain:
- path: src/portfolio_optimiser/reference_domain.py
pattern: "docs_dir: str"
- path: src/portfolio_optimiser/reference_domain.py
pattern: "verdict_input: dict\\[str, str\\]"
- path: src/portfolio_optimiser/data/reference_projects.json
pattern: "verdict_input"
```
### Step 3: Create PortfolioResult + run_portfolio orchestrator (SC2 fan-out)
- **Files:** `src/portfolio_optimiser/run.py`, `src/portfolio_optimiser/__init__.py`,
`tests/conftest.py`, `tests/test_portfolio.py` (new)
- **Changes:**
1. `run.py`: add `@dataclass(frozen=True) class PortfolioResult` mirroring `RunResult` style
(`run.py:57-68`): fields `runs: tuple[RunResult, ...]`, `store: VerdictStore`,
`validated_count: int`, `rejected_count: int`, `sum_claimed_saving_nok: float`,
`sum_token_usage: int`. Class docstring describing each field.
2. `run.py`: add `async def run_portfolio(project_ids: Sequence[str] | None = None,
profile: Profile | str = Profile.LOCAL, *, store: VerdictStore | None = None,
client_factory=None, max_rounds=3, max_tokens=100_000, top_k=3,
meter_factory: Callable[[], TokenMeter] | None = None) -> PortfolioResult`. Body: build
`{p.id: p for p in load_reference_projects()}`; resolve `ids` (None → all loaded); create
ONE `store` if not given; sequential `for pid in ids: await run_project(pid, profile,
docs_dir=project.docs_dir, verdict_input=project.verdict_input, store=store,
client_factory=client_factory, max_rounds=..., max_tokens=..., top_k=...,
meter=meter_factory() if meter_factory else None)`; collect `RunResult`s; return
`_aggregate(runs, store)`. Unknown id → `ValueError` (reuse `_project_by_id` semantics).
Import `Sequence` from `collections.abc`.
3. `run.py`: add private `_aggregate(runs, store) -> PortfolioResult` —
`validated = [r for r in runs if isinstance(r.outcome, ValidatedProposal)]`;
`sum_claimed = sum(r.outcome.proposal.claimed_saving_nok for r in validated)`;
`sum_tokens = sum(r.provenance.token_usage for r in runs)`.
4. `__init__.py`: export `PortfolioResult`, `run_portfolio` (add to imports + `__all__`).
5. `tests/conftest.py`: add `make_portfolio_client_factory(replies: dict[str, str])` fixture →
a project-aware `SyntheticUsageChatClient` subclass that selects its reply by scanning the
incoming prompt for a known `project_id` substring (the prompt embeds `project.id` at
`run.py:162` and `generate.py:48`), falling back to a default. Keeps `run_portfolio`'s
single `client_factory` production-shaped while letting tests vary the proposal per project.
6. **Define the shared `REPLIES` fixture constant (used by Steps 3, 4, 7)** in
`tests/test_portfolio.py`. The synthetic reply IS the proposal — `generate._parse_ir`
(`generate.py:60-64`) builds `affected_items` (each with its own `quantity`/`unit_cost`)
straight from this JSON, and the validator's P90 = `0.30 × Σ(quantity·unit_cost)`
**only when `assumptions` is empty** (degenerate Monte Carlo, `validator.py:108-113`). All
three replies therefore OMIT `assumptions` and carry explicit magnitudes so each
`claimed_saving_nok ≤ P90`. Exact constants (verified):
| project_id | affected_items (code, qty, unit_cost) | Σ item totals | P90 (0.30·Σ) | claimed | validates? |
|---|---|---|---|---|---|
| FV42-GSV-E1 | (05.2, 4300, 215), (03.1, 1800, 310) | 1,482,500 | 444,750 | 200,000 | yes |
| RV13-RAS-TP (decoy) | (88.2, 180, 4200) | 756,000 | 226,800 | 130,000 | yes |
| BRU-LAKS-REHAB | (05.2, 4300, 215), (07.4, 2400, 690) | 2,580,500 | 774,150 | 210,000 | yes |
`measure` strings are **byte-identical** "Reduce scope" for FV42 + BRU (measure-match is
exact string equality, `verdicts.py:68`) and "Material substitution" for the decoy. This
makes `validated_count==3`, `rejected_count==0`, `sum_claimed_saving_nok==540000`
derivable IN this step (no forward dependency on Step 4), and sets up the Step-4 overlap
(FV42 and BRU share code 05.2 + measure + magnitude bucket → similarity 0.60).
- **Reuses:** `run_project` (`run.py:114`, composed as-is), `RunResult`/`ValidatedProposal`/
`Rejection`, `load_reference_projects`, `SyntheticUsageChatClient` (`conftest.py:27`), the
`__init__.py:3,7` export pattern.
- **Test first:**
- File: `tests/test_portfolio.py` (new) — `test_a_fanout_returns_one_runresult_per_project`
- Verifies: `run_portfolio(["FV42-GSV-E1","RV13-RAS-TP","BRU-LAKS-REHAB"], "local",
store=fresh_store, client_factory=make_portfolio_client_factory(REPLIES))` → `len(runs)==3`,
each `isinstance(r, RunResult)`; aggregate pinned to the Step-3 fixture constants:
`validated_count==3`, `rejected_count==0`, `sum_claimed_saving_nok==540000`; and
`sum_token_usage` equals the **explicit element-wise sum**
`runs[0]+runs[1]+runs[2]` token_usage (a real wiring check, not the field's own definition)
with each run's `token_usage > 0`. Add `test_a2_unknown_project_id_raises`:
`with pytest.raises(ValueError): await run_portfolio(["NOPE-DOES-NOT-EXIST"], ...)` (the
unknown-id error path, `run.py` `_project_by_id` semantics).
- Pattern: `tests/test_vertical_slice_e2e.py:28-41` + module docstring/`Pattern:` convention
- **Verify:** Run the new tests BEFORE the `run.py` change → expected: **red** (ImportError /
`run_portfolio` undefined) — the Iron-Law failing-test-first gate. Then after the change:
`uv run pytest tests/test_portfolio.py -q` → expected: pass
- **On failure:** revert — `git checkout -- src/portfolio_optimiser/run.py src/portfolio_optimiser/__init__.py tests/conftest.py` and `git clean -fd tests/test_portfolio.py`
- **Checkpoint:** `git commit -m "feat(fase3): run_portfolio sequential orchestrator + PortfolioResult aggregate"`
- **Manifest:**
```yaml
manifest:
expected_paths:
- src/portfolio_optimiser/run.py
- src/portfolio_optimiser/__init__.py
- tests/conftest.py
- tests/test_portfolio.py
min_file_count: 4
commit_message_pattern: "^feat\\(fase3\\): run_portfolio sequential orchestrator \\+ PortfolioResult aggregate$"
bash_syntax_check: []
forbidden_paths:
- src/portfolio_optimiser/validator.py
- src/portfolio_optimiser/ir.py
must_contain:
- path: src/portfolio_optimiser/run.py
pattern: "async def run_portfolio"
- path: src/portfolio_optimiser/run.py
pattern: "class PortfolioResult"
- path: src/portfolio_optimiser/__init__.py
pattern: "run_portfolio"
- path: tests/test_portfolio.py
pattern: "test_a_fanout_returns_one_runresult_per_project"
```
### Step 4: Shared VerdictStore accumulates + load-bearing retrieval (SC4)
- **Files:** `tests/test_portfolio.py`
- **Changes:** Add `test_b_shared_store_accumulates_and_surfaces_prior_verdict`. Fan out over
`["FV42-GSV-E1","RV13-RAS-TP","BRU-LAKS-REHAB"]` reusing the **Step-3 `REPLIES`** (already
specified with explicit magnitudes + empty `assumptions` — no re-specification here). The three
proposals are deliberately distinct-but-overlapping and all validate, minting **3 distinct ids**.
similarity(BRU, FV42) = 0.60, decomposed as: codes-Jaccard({05.2,07.4}, {03.1,05.2}) = 1/3 →
`0.60 × 0.333 = 0.20`, measure-match ("Reduce scope" == "Reduce scope", exact equality) →
`0.25`, magnitude-bucket (both in `[1e5,5e5)`) → `0.15`; **total 0.60**. similarity(BRU, decoy) =
codes 0 + measure 0 + magnitude 0.15 = **0.15**. So BRU's retrieval ranks FV42's verdict above
the decoy.
- **Reuses:** the two-call precedent (`test_vertical_slice_e2e.py:69-90`); the decoy/true-match
ranking idea (`test_verdicts.py`); `_mint_id`/`similarity` semantics (`verdicts.py:65-90`).
- **Test first:**
- File: `tests/test_portfolio.py`
- Verifies (load-bearing, not mere non-emptiness): after fan-out
`len(result.store.verdicts) == 3`; the 3 verdict ids are pairwise distinct;
`result.runs[2].retrieved[0].id == result.runs[0].verdict.id` (BRU surfaces FV42, the
structural match, ranked above the decoy). If retrieval ranking breaks or proposals collide
to one id, this fails — it asserts the specific match, not store-non-empty.
- Pattern: `tests/test_verdicts.py` (true-match-beats-decoy assertion style)
- **Verify:** `uv run pytest tests/test_portfolio.py::test_b_shared_store_accumulates_and_surfaces_prior_verdict -q` → expected: pass
- **On failure:** revert — `git checkout -- tests/test_portfolio.py`
- **Checkpoint:** `git commit -m "test(fase3): SC4 shared-store accumulation + load-bearing cross-project retrieval"`
- **Manifest:**
```yaml
manifest:
expected_paths:
- tests/test_portfolio.py
min_file_count: 1
commit_message_pattern: "^test\\(fase3\\): SC4 shared-store accumulation \\+ load-bearing cross-project retrieval$"
bash_syntax_check: []
forbidden_paths:
- src/portfolio_optimiser/validator.py
- src/portfolio_optimiser/verdicts.py
must_contain:
- path: tests/test_portfolio.py
pattern: "test_b_shared_store_accumulates_and_surfaces_prior_verdict"
```
### Step 5: Load-bearing state-isolation guard (SC3, meter detach)
- **Files:** `tests/test_portfolio.py`
- **Changes:** Add `test_c_execution_state_isolation_is_load_bearing`, a cap-independent
automatic detach guard built on the Step-1 `meter=` seam + Step-3 `meter_factory`. The factory
`f` here emits a **uniform but valid JSON `SavingsProposal`** (reuse `REPLIES["FV42-GSV-E1"]`,
the same string for both projects) — NOT a bare default like `"ok"`: `generate_via_llm` loops
re-fetching until `_parse_ir` succeeds, charging the meter each pass (`generate.py:106-115`), so
an unparseable reply would run to `BudgetExceeded` instead of completing. Observable is
per-project `RunResult.provenance.token_usage` (`= meter.tokens`, `run.py:180`):
1. **Baseline:** `standalone = await run_project("RV13-RAS-TP", ..., client_factory=f)`.
2. **Isolated (default):** `iso = await run_portfolio(["FV42-GSV-E1","RV13-RAS-TP"], ...,
client_factory=f)` (uniform reply) → assert `iso.runs[1].provenance.token_usage ==
standalone.provenance.token_usage` — project 1's usage is its OWN only, independent of
project 0. This `==` is the load-bearing assertion: if `run_portfolio` shared a meter by
default, runs[1] would be cumulative and this breaks.
3. **Shared (injected):** `shared = TokenMeter(Budget(...))`; `sh = await run_portfolio([...],
..., client_factory=f, meter_factory=lambda: shared)` → assert
`sh.runs[1].provenance.token_usage > sh.runs[0].provenance.token_usage` — sharing
accumulates, proving the detach is real and the `==` arm would redden under sharing.
Both arms run every CI → the detach is encoded automatically (no manual revert).
- **Reuses:** the load-bearing fence technique (`test_h`, `test_vertical_slice_e2e.py:172-199`);
`TokenMeter`/`Budget`.
- **Test first:**
- File: `tests/test_portfolio.py`
- Verifies: the three assertions above (baseline equality under isolation; strict growth under
sharing).
- Pattern: `tests/test_vertical_slice_e2e.py:172-199` (load-bearing detach)
- **Verify:** `uv run pytest tests/test_portfolio.py::test_c_execution_state_isolation_is_load_bearing -q` → expected: pass
- **On failure:** retry — if the isolated `==` does not hold because the two projects issue
unequal chat-call counts, switch the isolation observable to a per-project standalone baseline
for BOTH projects (`iso.runs[i] == standalone_i`); if still flaky, fall back to the
cap-based variant (size `max_tokens` so 1 project < cap < 2 shared → `pytest.raises(BudgetExceeded)`
only under sharing). Then revert on persistent failure: `git checkout -- tests/test_portfolio.py`
- **Checkpoint:** `git commit -m "test(fase3): SC3 load-bearing meter-isolation detach guard"`
- **Manifest:**
```yaml
manifest:
expected_paths:
- tests/test_portfolio.py
min_file_count: 1
commit_message_pattern: "^test\\(fase3\\): SC3 load-bearing meter-isolation detach guard$"
bash_syntax_check: []
forbidden_paths:
- src/portfolio_optimiser/validator.py
must_contain:
- path: tests/test_portfolio.py
pattern: "test_c_execution_state_isolation_is_load_bearing"
```
### Step 6: Both profiles exercised offline + gated Azure arm (SC7)
- **Files:** `tests/test_portfolio.py`, `tests/test_portfolio_live.py` (new)
- **Changes:**
1. `test_portfolio.py`: add `test_d_both_profiles_run_offline`, parametrized
`@pytest.mark.parametrize("profile", ["local", "azure"])`, running `run_portfolio` with the
synthetic `client_factory` and asserting a `PortfolioResult` with `len(runs)==N` of
`RunResult` for each profile (the contract path is actually executed offline, not just the
backend instantiated). **Plus a load-bearing teeth assertion** that the profile reaches a
profile-dependent seam offline: `resolve_model(profile, "proposer")` resolves to that
profile's configured model from the bundled `model_map.json` and `resolve_model("local",
"proposer") != resolve_model("azure", "proposer")` (deleting the azure model-map entry
reddens it). **Honest limitation documented:** under an injected `client_factory`, `profile`
is otherwise inert inside `run_project` (`run.py:152,173` — `model="fake-model"`, the F9
standing item); the orchestrator end-to-end run proves the path executes under both, and the
`resolve_model` check is the only profile-dependent seam provable without egress.
2. `test_portfolio_live.py` (new): a `@pytest.mark.skipif`-gated real-Azure portfolio run
mirroring `test_foundry_profile_live.py:14-24` — stays skipped offline (SC9 unchanged).
- **Reuses:** `resolve_model` (`backends.py:54`), the gated-live skip pattern
(`test_foundry_profile_live.py:19-24`).
- **Test first:**
- File: `tests/test_portfolio.py` + `tests/test_portfolio_live.py`
- Verifies: both profiles produce a `PortfolioResult` offline; `resolve_model` distinguishes
the profiles; the live arm is collected-but-skipped.
- Pattern: `tests/test_foundry_profile_live.py:14-24`
- **Verify:** `uv run pytest tests/test_portfolio.py -k both_profiles tests/test_portfolio_live.py -q` → expected: offline params pass, live arm skipped
- **On failure:** revert — `git checkout -- tests/test_portfolio.py` and `git clean -fd tests/test_portfolio_live.py`
- **Checkpoint:** `git commit -m "test(fase3): SC7 both profiles offline + gated Azure portfolio arm"`
- **Manifest:**
```yaml
manifest:
expected_paths:
- tests/test_portfolio.py
- tests/test_portfolio_live.py
min_file_count: 2
commit_message_pattern: "^test\\(fase3\\): SC7 both profiles offline \\+ gated Azure portfolio arm$"
bash_syntax_check: []
forbidden_paths:
- src/portfolio_optimiser/validator.py
must_contain:
- path: tests/test_portfolio.py
pattern: "both_profiles"
- path: tests/test_portfolio_live.py
pattern: "skipif"
```
### Step 7: New project via config only + no-code-mapping guard (SC1)
- **Files:** `src/portfolio_optimiser/data/reference_projects.json`,
`src/portfolio_optimiser/data/docs/SKOLE-VVS-OPPGR/notes.txt` (new),
`tests/test_portfolio.py`
- **Changes:**
1. Append a 4th synthetic project (id e.g. `SKOLE-VVS-OPPGR`) to `reference_projects.json`
with the full key set **including** `docs_dir` + `verdict_input` — AND its bundled docs
dir. All domain data (codes/units/prices, the verdict decision/rationale) is AI-authored
and flagged SYNTHETIC/ "ikke verifisert" in the `_note` discipline
(`[[user-not-domain-expert]]`). **No `src/portfolio_optimiser/*.py` change** — this is the
SC1 demonstration that adding a project is config + docs only.
2. `test_portfolio.py`: `test_e_new_project_flows_through_via_config_only` — run
`run_portfolio(["SKOLE-VVS-OPPGR"], ...)` and assert it produces a `RunResult` whose
`outcome.proposal.project_id == "SKOLE-VVS-OPPGR"`, proving config-only onboarding.
3. `test_portfolio.py`: `test_f_no_hardcoded_project_ids_in_src` — a src-grep guard asserting
**no** reference-project id literal (`FV42-GSV-E1|RV13-RAS-TP|BRU-LAKS-REHAB|SKOLE-VVS-OPPGR`)
appears anywhere under `src/portfolio_optimiser/` (ids live only in the data JSON). If a
future project is added via a code-side id→path/verdict mapping, this guard reddens —
load-bearing for SC1's "config-only" claim.
- **Reuses:** the `_note` provenance discipline; the src-grep guard idiom (`test_budget.py:88-99`).
- **Test first:**
- File: `tests/test_portfolio.py`
- Verifies: the new project runs end-to-end via config; no hardcoded ids in src.
- Pattern: `tests/test_budget.py:88-99` (src anti-pattern grep guard)
- **Verify:** `uv run pytest tests/test_portfolio.py -k "new_project or no_hardcoded" -q` → expected: pass; then `git diff --name-only HEAD~1` shows only `*.json`, `data/docs/**`, `tests/**` (no `src/portfolio_optimiser/*.py`)
- **On failure:** revert — `git checkout -- src/portfolio_optimiser/data/reference_projects.json tests/test_portfolio.py` and `git clean -fd src/portfolio_optimiser/data/docs/SKOLE-VVS-OPPGR`
- **Checkpoint:** `git commit -m "test(fase3): SC1 new project via config-only + no-hardcoded-id src guard"`
- **Manifest:**
```yaml
manifest:
expected_paths:
- src/portfolio_optimiser/data/reference_projects.json
- src/portfolio_optimiser/data/docs/SKOLE-VVS-OPPGR/notes.txt
- tests/test_portfolio.py
min_file_count: 3
commit_message_pattern: "^test\\(fase3\\): SC1 new project via config-only \\+ no-hardcoded-id src guard$"
bash_syntax_check: []
forbidden_paths:
- src/portfolio_optimiser/run.py
- src/portfolio_optimiser/reference_domain.py
- src/portfolio_optimiser/validator.py
- src/portfolio_optimiser/ir.py
must_contain:
- path: tests/test_portfolio.py
pattern: "test_f_no_hardcoded_project_ids_in_src"
- path: src/portfolio_optimiser/data/reference_projects.json
pattern: "SKOLE-VVS-OPPGR"
```
### Step 8: Extension documentation (SC6)
- **Files:** `docs/extending.md` (new), `tests/test_portfolio.py`
- **Changes:**
1. `docs/extending.md` — sections "Legg til eget prosjekt", "Legg til egen datakilde",
"Legg til egen modell-map", each pointing at the concrete config seams:
`reference_projects.json` (the per-project `docs_dir` + `verdict_input` fields, the `_note`
SYNTHETIC discipline), the bundled `data/docs/<id>/` docs source, and `model_map.json`
(role→deployment). State plainly that the bundled reference domain + its `verdict_input`
values are SYNTHETIC fixtures and that a real deployer replaces the data source and supplies
Layer-2 verdicts via real HITL (not static config) — keeps the architecture honest.
2. `test_portfolio.py`: `test_g_extension_doc_exists_and_references_seams` — assert
`docs/extending.md` exists and contains `reference_projects.json`, `docs_dir`,
`verdict_input`, and `model_map.json`.
- **Reuses:** the file-existence + content guard idiom.
- **Test first:**
- File: `tests/test_portfolio.py`
- Verifies: doc exists and names the config seams.
- Pattern: `tests/test_budget.py:88-99` (file-content assertion)
- **Verify:** `uv run pytest tests/test_portfolio.py -k extension_doc -q` → expected: pass
- **On failure:** revert — `git checkout -- tests/test_portfolio.py` and `git clean -fd docs/extending.md`
- **Checkpoint:** `git commit -m "docs(fase3): extension-point guide (add project / data source / model-map) + SC6 test"`
- **Manifest:**
```yaml
manifest:
expected_paths:
- docs/extending.md
- tests/test_portfolio.py
min_file_count: 2
commit_message_pattern: "^docs\\(fase3\\): extension-point guide \\(add project / data source / model-map\\) \\+ SC6 test$"
bash_syntax_check: []
forbidden_paths:
- src/portfolio_optimiser/validator.py
must_contain:
- path: docs/extending.md
pattern: "reference_projects.json"
- path: tests/test_portfolio.py
pattern: "test_g_extension_doc_exists_and_references_seams"
```
## Alternatives Considered
| Approach | Pros | Cons | Why rejected |
|----------|------|------|--------------|
| Per-project `client_factory` arg on `run_portfolio` (test seam) | Explicit; no prompt-scanning | Adds test-only API surface; production always uses one factory; diverges from `run_project`'s single-factory seam | Project-aware synthetic client keeps `run_portfolio` production-shaped (one `client_factory`), pushing per-project reply logic into the test double only |
| Per-project isolated `VerdictStore` | Simpler isolation story | Kills cross-project ExpeL learning — the whole point of Fase 3 | Explicit Non-Goal; the brief deliberately shares one store |
| `asyncio.gather` concurrent fan-out | Faster | Races the shared store's retrieve-before-add ordering; interleaves debates; needs the meter/store made concurrency-safe | Explicit Non-Goal (sequential only; concurrency is a documented extension point, 90%-principle) |
| Hardcoded `{project_id: docs_dir}` map in orchestrator code | No JSON change | Adding project N+1 edits `src/*.py` → **breaks SC1** | Config-driven `docs_dir` on the loader is the authorized one-time change that makes N+1 config-only |
| Make `profile` load-bearing by changing `run.py:173` (`model="fake-model"` → `resolve_model`) | Would make SC7 fully load-bearing end-to-end | Touches a Fase 2 seam (F9) — scope creep beyond the brief | Out of scope; SC7 teeth come from a direct `resolve_model` assertion instead, F9 left as the standing item |
## Test Strategy
- **Framework:** `pytest` + `pytest-asyncio` (`asyncio_mode=auto` — bare `async def test_*`).
- **Existing patterns:** synthetic offline client (`conftest.py:27`), shared-store two-call
accumulation (`test_vertical_slice_e2e.py:69-90`), load-bearing fence (`test_h`), gated-live
skip (`test_foundry_profile_live.py`), src-grep guard (`test_budget.py:88-99`),
decoy-vs-match ranking (`test_verdicts.py`).
- **New tests in this plan:** 1 in `test_vertical_slice_e2e.py` (meter seam) + ~7 in
`tests/test_portfolio.py` (SC2,4,3,7,1×2,6) + 1 in `test_portfolio_live.py` (gated) + loader
assertions in `test_reference_domain.py`.
### Tests to write
| Type | File | Verifies | Model test |
|------|------|----------|------------|
| Unit | `tests/test_vertical_slice_e2e.py` | injected `meter=` is used (Step 1) | `test_a` (`:28`) |
| Loader | `tests/test_reference_domain.py` | `docs_dir`+`verdict_input` resolved per project | `test_reference_domain.py:8` |
| Integration | `tests/test_portfolio.py` | SC2 fan-out + aggregate sums | `test_vertical_slice_e2e.py:28` |
| Integration | `tests/test_portfolio.py` | SC4 accumulation + load-bearing retrieval | `test_verdicts.py` |
| Load-bearing | `tests/test_portfolio.py` | SC3 meter-isolation detach (auto guard) | `test_h` (`:172`) |
| Parametrized | `tests/test_portfolio.py` | SC7 both profiles offline + `resolve_model` teeth | `test_foundry_profile_live.py` |
| Integration+Guard | `tests/test_portfolio.py` | SC1 config-only project + no-hardcoded-id src grep | `test_budget.py:88` |
| Guard | `tests/test_portfolio.py` | SC6 extension doc exists + references seams | `test_budget.py:88` |
| Gated live | `tests/test_portfolio_live.py` | real-Azure portfolio arm (skipped offline) | `test_foundry_profile_live.py:19` |
## Risks and Mitigations
| Priority | Risk | Location | Impact | Mitigation |
|----------|------|----------|--------|------------|
| Critical | SC4 collapses to `len==1` if projects mint identical ids | `verdicts.py:121-124,78-90` | green-but-dead | Project-aware factory yields 3 structurally **distinct** proposals; assert 3 distinct ids, not just `len==3` (Step 4) |
| Critical | SC4 "surfaces prior" passes trivially — `retrieve` has no similarity floor | `verdicts.py:110-119` | zero-overlap projects pass | Assert `runs[2].retrieved[0].id == runs[0].verdict.id` (specific match beats the RV13 decoy), not non-emptiness (Step 4) |
| High | SC3 has no meter-injection point today | `run.py:151` | detach degrades to manual revert | Add additive `meter=` seam (Step 1) + `meter_factory` (Step 3); both detach arms run every CI |
| High | SC3 `==` arm flaky if the two projects issue unequal chat-call counts | `run.py:151-169` | false red/green | Cap-independent baseline: `iso.runs[1] == standalone(project_1)`; documented fallbacks in Step 5 On-failure (per-project baseline, or cap-based `BudgetExceeded`) |
| High | SC7 profile inert under injected client (`model="fake-model"`) | `run.py:152,173` (F9) | parametrize proves nothing | Add direct `resolve_model(profile,…)` teeth + document the limitation honestly; do not silently claim full profile coverage (Step 6) |
| High | `verdict_input` hidden non-config dependency breaks SC1 | `run.py:119` | adding N+1 edits core | Make `verdict_input` a per-project config field resolved by the loader (Step 2), same home as `docs_dir` |
| Medium | Bundled-docs path resolution via `importlib.resources` is source/editable-install dependent | `reference_domain.py:55` | a future zip-wheel install can't `open()` a Traversable dir | Acceptable for the dev+test context (90%); note as Assumption; if packaged, switch to `importlib.resources.as_file` |
| Medium | Partial-failure mid-fan-out aborts the sequential loop with a half-accumulated store | `run.py:130-131,193` | ambiguous portfolio result | v1 policy = **fail-fast** (a raising project aborts; consistent with the offline synthetic suite where all validate); collect-and-continue is a documented future extension |
| Low | Magnitude-bucket boundary flips drop the magnitude similarity component | `verdicts.py:29,53-57` | fixture overlap not as intended | All three SC4 savings (130k/200k/210k) sit inside bucket `[1e5,5e5)` interior — verified numerically (Step 4) |
## Assumptions
| # | Assumption | Why unverifiable | Impact if wrong |
|---|-----------|-----------------|-----------------|
| 1 | The two/three offline projects issue **equal** synthetic chat-call counts, so the SC3 `==` isolation arm holds | Depends on `fresh_workflow` round dynamics under the synthetic client; not statically determinable | SC3 isolation arm needs the documented fallback (per-project baseline or cap-based variant) — Step 5 already carries it |
| 2 | `importlib.resources.files(...).joinpath("data", docs_dir)` yields an `os.PathLike` real path under the `uv` editable/source install | `files()` returns a `Traversable`; concrete behavior differs for zip-imported packages | A zip-wheel install would need `as_file`; harmless in the current dev/test context |
| 3 | Putting synthetic `verdict_input` (decision/rationale) in `reference_projects.json` is the right config home (vs a separate portfolio-config) | Brief leaves it open | If a separate portfolio-config is later preferred, the loader field moves; SC1 still holds as long as it stays config-driven |
| 4 | Static config `verdict_input` faithfully stands in for Layer-2 HITL in the offline synthetic framework | It is a fixture convenience, not the production HITL path | Documented in Step 8 doc as synthetic — production supplies verdicts via real HITL, not config |
## Verification
*Per-step manifests are checked automatically by trekexecute. These are the end-to-end gates.*
- [ ] `uv run pytest -q` → expected: `(103 + new) passed, 3 skipped` — the 3 existing live
skips preserved, zero new failures (SC5; baseline re-read at execute-start, not hardcoded).
- [ ] `uv run mypy src` → expected: clean (SC5).
- [ ] `uv run ruff check .` → expected: clean; `uv run ruff format .` → no diff (SC5).
- [ ] `uv run pytest tests/test_portfolio.py -q` → all SC2/SC3/SC4/SC7/SC1/SC6 tests pass.
- [ ] SC1 git-diff proof — for the Step-7 commit:
`git show --name-only --format= HEAD` (the SC1 commit) lists ONLY `*.json`,
`data/docs/**`, and `tests/**` — **no `src/portfolio_optimiser/*.py`**.
- [ ] SC3 detach is genuinely load-bearing — temporarily make `run_portfolio` share one meter
across projects; `test_c_execution_state_isolation_is_load_bearing` MUST go red; revert.
- [ ] SC4 is genuinely load-bearing — temporarily give two projects identical proposals; the
distinct-id assertion MUST go red; revert.
## Estimated Scope
- **Files to modify:** 7 — `run.py`, `reference_domain.py`, `reference_projects.json`,
`__init__.py`, `conftest.py`, `test_reference_domain.py`, `test_vertical_slice_e2e.py`.
- **Files to create:** ~9 — `tests/test_portfolio.py`, `tests/test_portfolio_live.py`,
`docs/extending.md`, and 4 bundled `data/docs/<id>/notes.txt` (+ the SC1 new project's docs).
- **Complexity:** medium — additive orchestration over a proven seam; the difficulty is in the
load-bearing test design (SC3/SC4), which the exploration de-risked with concrete fixtures.
## Execution Strategy
8 steps. Dependencies make Steps 1–3 a foundation wave; Steps 4–8 are additive and largely
independent (they extend `tests/test_portfolio.py` + sibling files). A single sequential
`/trekexecute` session is appropriate (one author, clean tree); the grouping below is the
dependency map, not a mandate to parallelise.
### Session 1: Core seams
- **Steps:** 1, 2
- **Wave:** 1
- **Depends on:** none
- **Scope fence:**
- Touch: `run.py` (meter seam only), `reference_domain.py`, `reference_projects.json`,
`data/docs/**`, `test_vertical_slice_e2e.py`, `test_reference_domain.py`
- Never touch: `validator.py`, `ir.py`, `verdicts.py`
### Session 2: Orchestrator
- **Steps:** 3
- **Wave:** 2
- **Depends on:** Session 1 (loader provides `docs_dir`/`verdict_input`)
- **Scope fence:**
- Touch: `run.py` (PortfolioResult + run_portfolio), `__init__.py`, `conftest.py`, `test_portfolio.py`
- Never touch: `validator.py`, `ir.py`
### Session 3: Success-criteria tests + docs
- **Steps:** 4, 5, 6, 7, 8
- **Wave:** 3
- **Depends on:** Session 2 (`run_portfolio` exists)
- **Scope fence:**
- Touch: `tests/test_portfolio.py`, `tests/test_portfolio_live.py`, `docs/extending.md`,
`reference_projects.json` (SC1 new project), `data/docs/SKOLE-VVS-OPPGR/**`
- Never touch: `src/portfolio_optimiser/*.py` (SC1 demands config-only) except none
### Execution Order
- **Wave 1:** Session 1
- **Wave 2:** Session 2 (after Wave 1)
- **Wave 3:** Session 3 (after Wave 2)
### Grouping rules applied
- Steps sharing files → same session (Steps 4–8 all extend `test_portfolio.py`).
- Core changes isolated to Waves 1–2 so Wave 3 can assert config-only (SC1).
- Sessions ordered strictly by dependency.
## Plan Quality Score
| Dimension | Weight | Score | Notes |
|-----------|--------|-------|-------|
| Structural integrity | 0.15 | 92 | Steps dependency-ordered; foundation seams before consumers |
| Step quality | 0.20 | 90 | Each step TDD, single focused change, concrete fixtures + commands |
| Coverage completeness | 0.20 | 92 | All 7 SCs mapped to steps; brief Open Questions (docs_dir, verdict_input, SC4 fixture, aggregate) resolved |
| Specification quality | 0.15 | 88 | Exact file:line, computed P90/similarity math, no placeholders |
| Risk & pre-mortem | 0.15 | 90 | The 5 green-but-dead traps surfaced by exploration each have a mitigation + a documented fallback |
| Headless readiness | 0.10 | 85 | On-failure + Checkpoint per step; SC3 carries an explicit fallback path |
| Manifest quality | 0.05 | 80 | All steps have checkable manifests with forbidden_paths fencing the pure core |
| **Weighted total** | **1.00** | **88** | **Grade: B+** |
**Adversarial review:**
- **Plan critic:** APPROVE_WITH_NOTES — 0 blockers, 2 major, 9 minor (score 81/B). Both majors
(SC4 fixture magnitudes unspecified + Step 3↔4 forward coupling) addressed by moving the
concrete `REPLIES` table into Step 3. Minors folded in (red-phase gates, JSON uniform reply,
error-path test, similarity wording, notes.txt relevance, file count, tautology).
- **Scope guardian:** ALIGNED — 0 creep, 0 gaps. All 7 SCs covered; the 3 flagged items
(`meter=`, `resolve_model` teeth, config `verdict_input`) each judged in-scope; 4 over-reaches
correctly rejected in Alternatives.
## Revisions
*Added by adversarial review (Phase 9).*
| # | Finding | Severity | Resolution |
|---|---------|----------|------------|
| 1 | SC4/SC2 proposal magnitudes (quantity·unit_cost → P90) never specified; identity assumes empty `assumptions` | major | Step 3 now carries the explicit `REPLIES` table (item magnitudes, Σ totals, P90, claimed) + states empty `assumptions`; all three validate by construction |
| 2 | Step 3 `540000`/`validated_count==3` depended on a fixture deferred to Step 4 (forward coupling) | major | `REPLIES` constant defined in Step 3; Step 4 now reuses it (no re-specification) |
| 3 | Step 5 "uniform reply" could be a bare non-JSON string → runs to `BudgetExceeded` | minor | Step 5 now reuses `REPLIES["FV42-GSV-E1"]` (valid JSON) for `f` |
| 4 | Step 3 `sum_token_usage` assertion was tautological (field's own definition) | minor | Re-stated as an explicit element-wise sum wiring check + each run `>0` |
| 5 | `meter=`/`meter_factory` production surface not justified vs the seamless fallback | minor | Step 1 now states why the encoded detach (SC3-preferred) needs the injection point |
| 6 | Steps 1–3 lacked an explicit TDD red-phase gate (Iron Law) | minor | Steps 1–3 Verify now require the test to be **red before** the production change |
| 7 | `notes.txt` relevance to `retrieve_chunks("cost saving measure")` not tied | minor | Step 2 now requires the notes text to contain the cost-saving query terms |
| 8 | `run_portfolio` unknown-id / error paths untested | minor | Step 3 adds `test_a2_unknown_project_id_raises` (`pytest.raises(ValueError)`) |
| 9 | similarity(BRU,FV42) parenthetical overstated the codes contribution; measure equality is exact | minor | Step 4 now shows the full 0.20+0.25+0.15 decomposition + byte-identical measure requirement |
| 10 | Estimated Scope "6 files to modify" but listed 7 | minor | Corrected to 7 |
| 11 | SC7 azure offline arm near-inert under injected client | minor (acknowledged) | Already documented honestly in Step 6 + Risks; `resolve_model` teeth retained; no change |
## Adversarial Pass 2 (gemini-bridge, v5.1.1 high-effort)
**Status: attempted, unavailable — no independent findings.** The high-effort plan phase
(`phase_signal: plan effort=high`) prescribes an additional independent gemini-bridge review of
the post-revision plan. The pass was launched but the Gemini Deep Research API returned a
**deterministic** failure: its legacy Interactions API schema was deprecated (May 2026) and the
local `gemini-mcp` client SDK predates the replacement. This is an operator-side tooling issue,
not a plan defect, and not retryable as-is.
**Decision:** proceed without the third cross-check. The plan already cleared two independent
adversarial reviewers (plan-critic APPROVE_WITH_NOTES, scope-guardian ALIGNED) with all
substantive findings consumed; the gemini pass was additive triangulation, not a gate. *Operator
action if desired:* upgrade `gemini-mcp` per the May-2026 breaking-change notice, then this pass
can be re-run for an independent second opinion before `/trekexecute`.