From 8b64c7f8de403fa553eab7d9e6cdf0ac0588f18a Mon Sep 17 00:00:00 2001 From: Kjell Tore Guttormsen Date: Fri, 26 Jun 2026 11:51:25 +0200 Subject: [PATCH] feat(fase3): additive meter= seam on run_project (SC3 detach hook) --- src/portfolio_optimiser/run.py | 7 ++++++- tests/test_vertical_slice_e2e.py | 33 ++++++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/src/portfolio_optimiser/run.py b/src/portfolio_optimiser/run.py index f8e6753..14bfb3b 100644 --- a/src/portfolio_optimiser/run.py +++ b/src/portfolio_optimiser/run.py @@ -124,6 +124,7 @@ async def run_project( top_k: int = 3, enable_layer1_hitl: bool = False, notify: Callable[[Verdict], None] | None = None, + meter: TokenMeter | None = None, ) -> RunResult: """Run the vertical slice for ONE project. ``client_factory`` is the test-injection seam (defaults to the real backend). ``verdict_input`` carries the expert decision/rationale @@ -148,7 +149,11 @@ async def run_project( # The shared meter is driven on the debate's chat calls by the BudgetMiddleware # (the brief's named short-circuit mechanism); the retrieval tool exposes the # citation-bearing data source to the agents (no longer string-stuffed out-of-band). - meter = TokenMeter(Budget(max_tokens=max_tokens, max_rounds=max(max_rounds * 4, 4))) + meter = ( + meter + if meter is not None + else TokenMeter(Budget(max_tokens=max_tokens, max_rounds=max(max_rounds * 4, 4))) + ) factory = client_factory if client_factory is not None else _default_factory(profile) budget_mw = BudgetMiddleware(meter) retrieval_tool = make_retrieval_tool(docs_dir, top_k=top_k) diff --git a/tests/test_vertical_slice_e2e.py b/tests/test_vertical_slice_e2e.py index c903883..63006f2 100644 --- a/tests/test_vertical_slice_e2e.py +++ b/tests/test_vertical_slice_e2e.py @@ -199,6 +199,39 @@ async def test_h_tiny_budget_halts_via_debate_middleware( ) +async def test_i_injected_meter_is_used(docs_dir, make_client_factory) -> None: + """Step 1 (SC3 detach hook): a TokenMeter passed as ``meter=`` IS the meter the run uses — + its pre-charged tokens flow through to ``provenance.token_usage`` (run.py:179). Detaches + cleanly: reverting the run.py:151 injection makes the injected instance be ignored (a fresh + internal meter is built instead), the pre-charge vanishes, and the equality below fails. + Pattern: test_a (run_project call shape).""" + from portfolio_optimiser.budget import Budget, TokenMeter + + factory = make_client_factory(_VALID) + baseline = await run_project( + "FV42-GSV-E1", + "local", + docs_dir=docs_dir, + verdict_input=_VI, + client_factory=factory, + ) + pre_charged = 5000 + injected = TokenMeter(Budget(max_tokens=100_000, max_rounds=max(3 * 4, 4))) + injected.charge(pre_charged) + result = await run_project( + "FV42-GSV-E1", + "local", + docs_dir=docs_dir, + verdict_input=_VI, + client_factory=factory, + meter=injected, + ) + assert baseline.provenance.token_usage > 0 # the run charges a deterministic delta + # The injected instance flows through: end tokens == pre-charge + the same run delta. + assert result.provenance.token_usage == pre_charged + baseline.provenance.token_usage + assert result.provenance.token_usage > baseline.provenance.token_usage + + async def test_f_malformed_contract_raises_before_any_chat(docs_dir, make_client_factory) -> None: calls = {"n": 0} base = make_client_factory(_VALID)