Commit graph

37 commits

Author SHA1 Message Date
3abc61bac3 feat(run): a CLI door onto the offline whole-loop run (--scripted-replies)
An adopter without an API budget had two half-doors and no whole one.
`--live-dry-run` takes their own bundle but stops before the first model call
(`run_project` returns a DryRunReport), while `portfolio_optimiser.simulation`
runs the complete loop but only over ITS bundle with ITS scripted answers.
The seam for the missing third case -- the whole loop over your OWN data,
offline -- already existed as `run_project(client_factory=...)` and had zero
CLI exposure. This is the door onto that one seam, not a second implementation
of it (`scripted_factory` is imported lazily; `simulation` imports `run`, so a
module-level import would be circular).

The honesty banner is part of the feature, not decoration (maalbilde §1): a
scripted run that reads like a model run is worse than having no offline mode,
so every scripted invocation prints what is real (context navigation, debate
plumbing, deterministic validator, verdict) and what is not (the answers).

The two offline modes are mutually exclusive rather than one silently winning,
`--report` mode refuses the new flag by allowlist, and a replies file that
cannot serve the run is refused at the door rather than surfacing as a KeyError
mid-run.

Load-bearing MEASURED against the whole suite (645 -> 652), six mutations all
red: detach the wiring · detach the banner · detach the dry-run exclusivity ·
drop the flag from the --report allowlist · make the loader tolerant · control
(print the banner unconditionally).

The --report blade was measured GREEN first: with a non-existent ledger path
the load failure refused before the gate and masked it entirely. Rewritten
against a valid saved ledger, so rc 1 can only come from mode-exclusivity.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWsexbQjPo9rsV3aUE54ZS
2026-08-05 08:55:37 +02:00
756e8f8259 fix(money): quantize NOK to øre in one order, from one source (kø-p)
Two quantization orders existed and met at exactly one comparison.
SavingsLedger quantizes every realized candidate to integer øre and sums the
ints; run.py's goal baselines summed Project.total_cost FLOATS across items and
projects and quantized the total once. _goal_limit_if_reached compared the
former against a threshold derived from the latter — so whether a portfolio pass
stops early was decided by two differently-computed sides.

Measured divergence: three 60000.005 NOK lines are 18000003 øre quantized first
but 18000001 summed first (the float sum drifts to 180000.01499999998).

Decision: quantize per cost line, then sum integers. Each CostItem IS a money
amount — S4.0 made per-line quantity/unit_cost the validator's ground truth — and
integer addition is associative, keeping totals order-independent under the D-D
wave model, which the float fold is not.

ledger.to_ore is now the framework's one NOK->øre conversion; run.py imports it
rather than keeping a private copy (the S4.0 REPLIES precedent).

Measuring the mutations found two further gaps, both now closed: the per-project
baseline is a SECOND call site whose mutation survived the whole suite, and
realize bypassing to_ore with a raw float*100 was caught by nothing.

Load-bearing MEASURED (tests/test_money_quantization_loadbearing.py), five
mutations all red: detach the portfolio baseline · detach the per-project
baseline · reintroduce a private copy in run.py · change the rounding mode · let
realize bypass to_ore. 615 -> 621 tests.

Honesty boundary: sum_claimed_saving_nok (run.py:_aggregate) is deliberately
untouched — a float NOK reporting field that is never quantized and never
compared against the ledger, hence outside the ordering defect.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WiY53sm8JFqk7NN75g5wRS
2026-08-03 20:08:59 +02:00
126807aee7 feat(validator): anchor the deterministic gate to the project's real cost baseline (S4.0)
Every stage of validate_proposal reasoned only about numbers the proposal itself
supplied, so an internally-consistent hallucination cleared the whole gate (F3).
A new stage 0 reconciles each affected_item against the project's CostBaseline
before the CBC solve: an unknown cost code is rejected, and a real code carrying
a quantity/unit_cost outside the configured tolerance (5% default, relative to
the baseline value) is rejected. Validation, never repair.

The baseline argument is OPTIONAL (None = pre-S4.0 behaviour), but both run
paths set it: the road path projects project.cost_items, the bundle path loads
cost-baseline.json when the bundle ships one. Bundles written before the
amendment stay un-anchored, so the commons-owned goldens run byte-identically;
a baseline that exists but is malformed still raises on both loaders.

F8: the method-specific cap now comes from the METHOD_CAPS registry (measure
type -> fraction, injectable) instead of an energy_efficiency string comparison.

The baseline format and tolerance semantics were decided locally — the commons
amendment (D-A pt. 2) never arrived, exactly as in S3.2. D7 mirroring stays open.

Three portfolio fixtures quoted cost codes belonging to OTHER projects; the new
gate caught them. They now quote each project's own lines, and the two copied
REPLIES tables import the single source instead of drifting from it.

Load-bearing measured (tests/test_s40_cost_baseline_loadbearing.py), six
mutations all red: detach the reconciliation stage; detach the magnitude
tolerance; detach the road wiring; detach the bundle wiring; ignore the injected
cap registry; make the optional loader tolerant of malformed content. Control:
with the road wiring detached the repaired portfolio fixtures still pass, so
they are not masking the seam. 597 -> 612 tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JdwK7bQ4BZkWH4t8MRDKb4
2026-08-03 17:19:31 +02:00
873f5fa272 test(portfolio): gate the wave handler's catch width and the failed-project ledger (v/t/s)
Three items on one seam — what a FAILED project does to the wave loop — plus the
snapshot copy they sit next to.

(v) The catch is BaseException, not Exception, and that width was ungated. The
existing collect-and-continue test raises RuntimeError, so it stays green when
the handler is narrowed: measured, the whole of test_portfolio_concurrent_
loadbearing.py (13 tests) passes under the narrowing. asyncio.CancelledError is
the one realistic vector that separates the two — probed first, gather(
return_exceptions=True) COLLECTS it, while KeyboardInterrupt propagates
regardless and could never be helped by a wider catch. Narrowed, a cancelled
member is cast into runs as a fake RunResult and the pass dies in _aggregate,
pointing away from its cause. RED measured.

(t) sum_token_usage excludes a failed project's spend, and that is the honest
answer, not a bug: a run that died before producing a stamp has no provenance,
and inventing one is the fabrication RunFailure exists to avoid. What needed
gating is that those tokens still reach the ledger the global cap is enforced
against — otherwise a repeatedly-failing project burns budget while the meter
reads clean. Pins meter.spent as the pass's real cost, sum_token_usage as the
completed-run subtotal, and their difference as exactly the failed spend. RED
measured against the likely "fix" (sourcing sum_token_usage from the meter),
which is wrong because a seeded meter also carries EARLIER passes' spend; 21
existing budget/portfolio tests stay green under it.

(s) _wave_snapshot uses dataclasses.replace, so a field added later is carried
without touching the function. Not cosmetic: measured, dropping retriever by
hand-enumerating left all 585 tests green — the Step-2 coverage its docstring
credited no longer existed, so the S3.1 retriever seam could be downgraded
mid-pass in silence. Now gated by a property test derived from
dataclasses.fields (not a field count, the shape rejected earlier). The explicit
verdicts copy is retained and separately gated: replace(store) alone shares the
caller's list and takes the byte-identical determinism test RED.

strict=True on the zip is documented as deliberately untested — measured green
when dropped, since gather is built from exactly snapshots, so a test could only
go red by manufacturing a mismatch and would exercise zip rather than this pass.

The new double is registered in the S2.5 consolidation guard's delegating-
overrides list rather than the guard being weakened; it already delegates via
super()._inner_get_response, which test_delegating_overrides_call_super now
enforces on it.

583 -> 586 tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MbgTCEZma764i1rHTrzceU
2026-08-03 16:09:12 +02:00
a831aa1e3b feat(budget): enforce a global portfolio token cap before the call, not after it (S3.4/F10)
PortfolioBudget + PortfolioMeter carry ONE token ledger over a whole portfolio
pass -- and, seeded from a persisted spend file, across passes -- while the
per-run Budget/TokenMeter pair is untouched. Three enforcement points, each
doing a different job:

- startup: a remainder that cannot fund one run raises BudgetRefused before
  anything loads (a pass that can afford zero projects is a caller mistake,
  not a result);
- wave assembly: an unfundable project is NEVER STARTED and the pass stops
  structurally (budget_stop + stopped_early, completed runs preserved).
  Because every member of a wave is funded against the SAME pre-wave
  remainder, admission RESERVES each member's requirement -- otherwise a wave
  of k over-commits the cap by up to k runs;
- pre-call: BudgetMiddleware refuses a call the remainder cannot pay for
  instead of making it. The post-charge check stays: real usage is only
  knowable after the response, so the guard stops the NEXT call, never the
  one in flight.

budget_stop is its own field rather than a widened stop_reason -- a goal-stop
is success, this is resource exhaustion, and fusing them would make "we
stopped" unreadable. PortfolioMeter splits record/check so tokens the provider
already billed reach the ledger even when the same charge breaks the run's own
cap. read_spend raises on corrupt content (our own accounting state, unlike
the tolerant RAW inbox layer); write_spend takes a REQUIRED stamp with no
wall-clock default, mirroring promote_verdict.

Load-bearing MEASURED, not asserted -- 6 mutations, all red: detach the wave
check; detach the pre-call guard; detach the wave reservation; check the run
cap before crediting the global ledger; detach the startup refusal; make
read_spend tolerant. Files restored from shasum-verified copies after each.

Two findings worth keeping: the pre-call guard MASKS a detached wave check if
the test asserts on overspend (spend stays under the cap either way), so the
load-bearing assertion had to become failures == () plus never-started; and
the token arithmetic is probed (32 tokens/run at tokens=8), not guessed.

537 -> 553 tests, ruff + mypy green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015EaxFnaDAbMQkmTeX4u7sd
2026-07-31 21:34:48 +02:00
16e1734264 docs(s33): document the wave snapshot's deliberate intra-wave semantics, pinned by test
Closes OQ1 (SC8). At k>1 every project in a wave reads the WAVE-START store,
so a verdict captured by project A does not reach project B's hypothesis
prompt inside the same wave — sequentially it would. Deliberate: the snapshot
is what removes the append race, and restoring intra-wave visibility would
restore the completion-order dependence the barrier exists to eliminate.
Learning flows ACROSS wave boundaries, not within them; concurrency trades
learning granularity for wall-clock.

The shipped fixture cannot show this (the fold is bundle_dir-gated and no
reference project sets bundle_dir), which is a property of the fixture and not
of the design — so it is pinned on the road-k + bundle-k+1 pair via the
existing load_reference_projects monkeypatch seam, no new production seam.
k=1 (two waves) carries the sentinel; k=2 (one wave) does not; store content
stays identical across k. Each half is the other's control.

Detach measured: remove _wave_snapshot -> RED on three tests including this one.

Also corrects two stale docstrings that outlived Session 1's finding: the
module header and the Step-2 test still named a sorted() in _merge_wave as the
detach point. There is no sorted() there, and a project_id sort would BREAK
the k=1-identity contract rather than protect it. A docstring naming a detach
point that does not exist is the green-but-dead defect this file exists to
prevent, so both now name the snapshot and mark the plan's claim as measured
wrong.

_wave_snapshot gains the honesty line about copying exactly two fields, with
the reason no field-count guard was added.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015vYbqW4MppACRvPhEMDpvF
2026-07-31 17:40:05 +02:00
796f8d3af0 feat(s33): wave-boundary goal-stop semantics + single-loop and one-writer guards
Documents the wave-model reading of the existing Step-8 goal semantics (a
reading, not a redesign — Session 1 already moved the checks to wave assembly
because the executor required it) and pins it with three tests.

Goal checks run at WAVE ASSEMBLY, per member: a HARD per-project goal removes
the pid before the wave starts, so an excluded project is never STARTED and
leaves no verdict behind; a HARD portfolio goal stops the pass with the
assembled wave still crossing its barrier; SOFT flags and continues.

Detach points measured:
  per-project goal keyed per WAVE, not per member -> RED (membership diverges
    at k=3, unaffected at k=1)
  import threading under src/                     -> RED (AST guard)
  promote_verdict called on the run path          -> RED (tripwire)

Corrects one claim the plan and my first docstring both implied: checking the
goal MID-WAVE does NOT make membership completion-order dependent.
_goal_limit_if_reached reads only the ledger, contract and baseline, all
invariant during a pass, so a later check reaches the same decision.
Determinism of membership is the ONE-WRITER rule's (C3, now a tripwire test),
not the check's placement; placement buys the never-started property. The
docstrings say this rather than claiming a detach point that does not exist.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015vYbqW4MppACRvPhEMDpvF
2026-07-31 17:33:17 +02:00
dd15e33556 feat(s33): collect-and-continue error policy via RunFailure slots
One project's exception no longer cancels its siblings: the wave's gather
runs with return_exceptions=True and each raised project becomes a frozen
RunFailure(project_id, error, error_type) in the defaulted
PortfolioResult.failures, while every completed project keeps its full
RunResult. RunFailure is a distinct type rather than an error field on
RunResult (six required non-defaulted fields -> dummies would be fabricated
provenance); the deviation from the spec's wording is stated in both
docstrings. TaskGroup is rejected: it cancels siblings on first exception.

snapshots reaches _merge_wave unfiltered, and results are paired back to pids
by POSITION (gather resolves in argument order) so a mid-wave failure cannot
disturb store order or misattribute the failure.

Detach points measured, not asserted:
  drop return_exceptions=True      -> RED (both new tests)
  reorder snapshots before merge   -> RED (+ Session 1's determinism test)
  pair results by sorted(), not position -> RED (failure misattributed)
  filter failed members before merge -> GREEN, measured

The last one corrects the plan: its carried-forward requirement implied
filtering before the merge was the hazard. Filtering preserves relative order,
which is all _merge_wave consumes, so the variant is undetectable AND harmless.
The docstring now names the reorder as the detach point and records the
filter asymmetry, rather than claiming a detach point that does not exist.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015vYbqW4MppACRvPhEMDpvF
2026-07-31 17:26:09 +02:00
756b1d5b5c feat(s33): wave executor with per-project snapshot and deterministic merge barrier
Replaces run_portfolio's sequential loop with a wave loop over _waves(ids, k):
each wave takes a per-project snapshot of the shared store, runs the wave under
one asyncio.gather in a single event loop, then crosses a merge barrier that
folds each project's NEW verdicts back in wave-submission order. Step 2's
contract goes GREEN; runs stays in project_ids order because gather resolves in
argument order, not completion order.

TWO PLAN CORRECTIONS, both found by the RED-first test rather than by reading:

1. The plan specified sorting the merged verdicts on `project_id`. Measured, that
   produces a deterministic order which is the WRONG one: lexicographic gives
   BRU/FV42/RV13 while the sequential pass gives FV42/RV13/BRU. It satisfies
   "deterministic" while breaking "identical to concurrency=1" — and the second is
   the actual contract. The merge preserves submission order instead.

2. The plan named the barrier's sort as the load-bearing seam. It is not — with
   per-project snapshots the wave list is never reordered by completion, so a
   sorted() there would re-sort an already-ordered list and read as a guard while
   guarding nothing. The SNAPSHOT is the half that carries the load. Rather than
   ship a decorative sort, both halves were measured (scratchpad-restore, never
   git checkout):

     detach _wave_snapshot  -> RED (store lands in completion order)
     detach merge ordering  -> RED (reversed wave order diverges)

   Both restored byte-identical (sha 42b01d46).

The snapshot carries `retriever` across deliberately: dropping it would silently
downgrade a caller-owned store's S3.1 semantic-retrieval opt-in mid-pass.

Also strengthens the scripted-client consolidation guard, which the probe broke by
being a legitimate third _inner_get_response def-site. It pinned a literal count
of 2 — the wrong shape: it failed on any new legitimate subclass while still
passing if someone pasted a duplicated body into an already-listed file. It now
pins the property (registered sites, scripted-lineage overrides must delegate via
super(), foreign-lineage doubles must genuinely be foreign). Verified load-bearing:
removing both delegation sites turns it RED.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LQapztREtC2mkr5oU811pr
2026-07-31 15:56:13 +02:00
c15165ca77 feat(s33): concurrency parameter + wave partitioning, k<1 fail-fast
Add keyword-only `concurrency: int = 1` to `run_portfolio`, a module-level
`_waves` partitioner, and a fail-fast on k < 1 that precedes any project load.

The execution loop is deliberately NOT changed here. At the default k=1 every
wave holds a single project, so the wave partition reproduces the existing
`for pid in ids` order by construction — the parameter is inert until Step 3
wires the executor. The 5 tests pin exactly that: order preservation across
every k, nothing dropped or duplicated, and the k<1 raise.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LQapztREtC2mkr5oU811pr
2026-07-31 15:39:47 +02:00
9e149c6847 docs(s31): close the review's honesty gap — narrow semantic claims to the shipped mechanism 2026-07-25 13:00:13 +02:00
b9dd91cdbe fix(s31): close 1 review BLOCKER — EmbedderConfig registry + --embedder-config, never an import path 2026-07-25 12:50:19 +02:00
8e9f6603d7 fix(s31): close 1 review BLOCKER — refuse --semantic-retrieval when it cannot take effect 2026-07-25 12:39:42 +02:00
fc69285f2c fix(s31): close 1 review BLOCKER — per-call retriever + run_portfolio forwards semantic_retrieval 2026-07-25 12:37:15 +02:00
63734f5bfa feat(s31): --semantic-retrieval opt-in threaded through run_project/run_portfolio 2026-07-25 06:29:39 +02:00
19000d89f6 feat(s54): --report/--json CLI mode in run.py over value_report 2026-07-24 01:35:35 +02:00
1b990f0887 feat(s53): CLI mode-exclusivity refusals (portfolio vs single-project partition)
After parse_args, validate mode consistency with structured refusals (rc 1, not argparse.error):
--portfolio + any single-project-only flag (--docs-dir/--bundle-dir/--verdict-dir/--outbox-dir/
--run-id/--live-dry-run) is refused naming the offending flag; --goals/--ledger outside --portfolio
is refused. --decision/--rationale are EXCLUDED (non-None defaults make explicit-vs-default
indistinguishable — Pass-2 #2; inert in portfolio mode, README says so). --dimension-config is
valid in both modes. Validation precedes the portfolio dispatch, so refusals fire before any load.
RED-first: (a)/(a')/(c) failed offline (rc 0 fall-through) before the check, green after; (b)/(b')
single-project guard + legacy backward-compat pin were already green post-Step-3. All refusal-arm
RED fall-throughs held OFFLINE (met-goal / --live-dry-run) — no socket, per brief NFR. 15 passed,
ruff + mypy clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KNNiJRk1sSwxgVLS5AobT1
2026-07-23 21:44:23 +02:00
905b2f9a43 feat(s53): --portfolio mode + --goals/--ledger wiring, GoalReached observable in CLI output
main() gains a --portfolio mode flag dispatching to the EXISTING run_portfolio (not modified):
loads --goals/--ledger/--dimension-config via the fail-fast loaders, passes project_ids=(pid,)
or None (all reference projects), and prints a deterministic goal-stop line
'goal reached: scope=... project=... observed_ore=... limit_ore=... stopped_early=...' from the
returned GoalReached/PortfolioResult. Positional project_id relaxed to nargs='?' and --docs-dir to
optional, with a compensating single-project-mode guard (no pid/no --docs-dir -> rc 1 refusal) so
the legacy contract still fails loudly. Dispatch + guard sit BEFORE the live_dry_run branch
(Pass-2 #3 — appended after, they'd be dead code). Loader failures use the same structured refusal
('portfolio run refused: ...'). Load-bearing pair (RED-first): portfolio-hard goal already met
breaks offline before any client (scope=portfolio, stopped_early=True); per-project-hard control
skips the only pid (scope=project, stopped_early=False) — the two arms force the printed fields to
derive from run_portfolio's real values. Marker 13731 øre. 9 passed, ruff + mypy clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KNNiJRk1sSwxgVLS5AobT1
2026-07-23 21:40:35 +02:00
663639d376 feat(s53): --dimension-config/--outbox-dir/--run-id CLI flags + full-run structured refusal
main() single-project path now parses --dimension-config (fail-fast via load_dimension),
--outbox-dir, and --run-id (deliberate 7th companion flag: determinism invariant forbids a
wall-clock run_id default). All three threaded into BOTH run_project call sites. Full-run
branch wrapped in a structured-refusal (catch ValueError/FileNotFoundError/ValidationError ->
'run refused: {exc}' on stderr, rc 1, no traceback); the EXISTING dry-run handler widened to
the same tuple (pydantic ValidationError is not a ValueError subclass; load_dimension's
FileNotFoundError would otherwise traceback — Pass-2 #1). --outbox-dir carries a loud help=
note it must differ from --verdict-dir (self-contamination footgun; documented, not enforced).
RED-first: 4 CLI tests failed on unrecognized args, green after. 7 passed (incl. live-dry-run
regression). ruff + mypy clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KNNiJRk1sSwxgVLS5AobT1
2026-07-23 21:36:21 +02:00
ce5b1151c8 fix(s42): close review WARN — hermetic dry-run env + scoped azure hint
Post-hoc /trekreview of S4.2 surfaced two confirmed findings; both closed via TDD.

S42-001 (MAJOR): the new --live-dry-run CLI tests read PORTFOLIO_MODEL_MAP /
PORTFOLIO_FOUNDRY_PROJECT_ENDPOINT via resolve_model/AzureFoundryBackend but did
not isolate them, so both arms inverted their rc in a Foundry-configured env.
Add an autouse fixture that delenvs both, mirroring test_backends/test_preflight.

S42-002 (MINOR): the --live-dry-run except ValueError attached the azure-preflight
remediation to every offline-path ValueError (unknown project_id, empty docs_dir,
bundle mismatch). Scope the hint to args.profile == "azure"; structured refusal +
rc 1 preserved for all. New test proves a LOCAL unknown-project refusal carries no
azure hint.

Gate: pytest 358 passed / 4 skipped, ruff check + format clean, mypy 24 files.
2026-07-15 18:30:30 +02:00
d7313593bc feat(s42): --live-dry-run CLI flag + dry-run summary 2026-07-15 18:08:28 +02:00
0e986fe6c4 feat(s42): live_dry_run cut in run_project + DryRunReport 2026-07-15 18:05:16 +02:00
a706184bdd feat(fase2a): wire run_project(outbox_dir, run_id) → outbox-skriving, load-bearing (S2.1) 2026-07-15 07:22:41 +02:00
9311813080 feat(fase2a): percent-mål mot baseline 0 reiser ValueError (S2.0) 2026-07-15 07:14:29 +02:00
8ec71c9814 feat(fase2a): thread bundle_dir/verdict_dir i run_portfolio — kryssprosjekt-læring load-bearing (S2.0) 2026-07-15 07:12:50 +02:00
16b6d80b82 feat(fase1): hard/soft goal-stop in run_portfolio on accumulated ledger (F1) 2026-07-07 08:11:43 +02:00
d2029964cc feat(fase1): dimension in run_project — context scope + candidate constraint (F1) 2026-07-07 07:50:26 +02:00
e2861cac0c feat(fase5): add the long/async verdict file inbox (Steg 7 resumable feedback)
The short loop captured the expert verdict inline into an in-memory store, so a
verdict arriving days/weeks later in a separate run could not influence any future
hypothesis (målbilde §5 row 7). Steg 7 adds the long timescale: run_project gains an
opt-in verdict_dir async inbox that load_verdicts_from_dir -> store.add MERGES into the
store BEFORE the Step-1 ExpeL fold, so a verdict dropped after an earlier run reaches a
separate, later run's hypothesis — fully resumable across runs separated in time.

- verdicts.py: verdict_to_dict / verdict_from_dict (id read verbatim, never re-minted),
  write_verdict (public authoring primitive, NOT wired into run_project — system reads
  the folder, expert/persona writes it, §3 role split), tolerant load_verdicts_from_dir
  (missing/foreign/half-written files skipped, not raised — RAW layer per §10 R2),
  VerdictStore.from_dir.
- run.py: verdict_dir kwarg; ingest-merge block after load_contracts (merge not replace
  keeps run_portfolio's cross-project threading; store.add idempotent on content-hash id;
  no change to the fold). CLI --bundle-dir/--verdict-dir thread the long loop to the
  console entry. No auto-persist of the run's own captured verdict (outbox/Steg 8).
- Load-bearing PAIR (test_step7_async_loop_loadbearing.py): a verdict dropped after run A
  must reach run B's prompt (run B uses a FRESH store -> the transfer is the file loop,
  not in-memory carryover); empty-inbox control proves causality. Marker = a realization
  value absent from the bundle (not the seed's 0.82). Proven RED on ingest detach.

Suite 138 -> 140 passed, 4 skipped; mypy + ruff check clean. Målbilde treated as frozen
(no §3/§5/§7 edit). Step 8 (gated wiki promotion) remains.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MHR8iKxJRxDiDfNw8HZmWE
2026-06-30 09:54:23 +02:00
4ec778c855 feat(fase3): make the maker-checker checker actually gate the reasoning
Closes gap #3 (maalbilde §5): the GroupChat checker critiqued into the void —
output_from=[proposer] surfaced only the proposer, so an explicit checker
rejection was ignored and the deterministic validator was the sole gate. Two
falsifiers now act on the same candidate: the validator gates the NUMBERS
(blocking, unchanged), the checker gates the REASONING (maalbilde §2/§6).

- workflow.py: output_from=agents surfaces both participants; the checker
  instruction ends with a VERDICT: APPROVE / VERDICT: REJECT - <reason> line.
- run.py: _authored_texts() reads author_name through out.messages (MAF 1.9.0
  puts it there, not on the AgentResponse); _debate_text() now selects the
  PROPOSER-authored output (fixes a latent texts[-1] regression that would feed
  the checker's verdict to generation at even round counts); _checker_verdict()
  parses the gate decision. An explicit REJECT overrides an otherwise-validated
  outcome to a checker-sourced Rejection. Opt-in-reject (fail-open on a missing
  marker). RunResult gains checker_verdict; provenance.validator_decision is
  stamped from the validator outcome BEFORE the override, so it never conflates
  the two falsifiers (provenance honesty).

Load-bearing (maalbilde §7): tests/test_checker_gate_loadbearing.py is a PAIR —
an explicit checker REJECT on a VALIDATOR-VALID proposal yields a Rejection whose
reason carries the checker's reason while validator_decision stays "validated";
the causality control (checker APPROVE, same proposer) validates normally. Proven
RED on BOTH detach points (revert output_from, or drop the override).

Suite 134->136 passed, 4 skipped; mypy + ruff check clean. Pre-existing
ruff-format drift (backends/budget/verdicts/test_contracts) left untouched for a
surgical diff.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MHR8iKxJRxDiDfNw8HZmWE
2026-06-30 07:24:30 +02:00
8814a698c2 feat(fase2b): OKF-navigated bundle context replaces chunk-stuffing
Closes the honest Fase 2a limitation: docs_dir==bundle_dir let keyword
chunk-stuffing leak the verdict's realization rate ("0.82") into the debate /
generation prompt regardless of the ExpeL fold (it surfaced from both
verdict-led-fro.md AND golden.json). The realization signal now reaches the
hypothesis prompt ONLY via the gated ExpeL fold.

- okf.py: bundle_context() + Bundle.context_files render the navigated bundle
  (index + frontmatter + cross-links) as the agent read-context, EXCLUDING
  type: verdict (maalbilde §2/§4). Pure stdlib, still MAF-free.
- datasource.py: bundle_citations() derives first-class citations from the
  navigated non-verdict files.
- run_project: on the bundle path context + citations + debate tools come from
  navigation (tools=[]; navigation replaces query-time RAG); the road path keeps
  chunk-stuffing unchanged.

Load-bearing (maalbilde §7): the marker is upgraded from the minted verdict id
to the realization signal itself. The empty-store control now asserts "0.82"
reaches NO prompt — RED against the pre-2b chunk-stuffing path, green after
navigation (TDD red->green). New okf-level test_bundle_context_excludes_verdict_layer
guards the seam directly.

Suite 133->134 passed, 4 skipped; mypy + ruff check clean. Reverted unrelated
ruff-format drift (backends/budget/verdicts/test_contracts) to keep the diff
surgical.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MHR8iKxJRxDiDfNw8HZmWE
2026-06-30 06:42:19 +02:00
d6d83d42b5 feat(fase2): wire Step-1 ExpeL retrieval into the hypothesis prompt
Closes maalbilde §5 gap #1 (the one missing "feedback-into-prompt" dataflow)
for the OKF-bundle path. Before, ExpeL was computed AFTER generation into a
discarded SessionContext, so a prior verdict could not influence any hypothesis
(context_providers=0).

- New okf.py: framework-neutral OKF bundle navigation (index + frontmatter +
  cross-links), pure stdlib, no agent_framework/mcp (D7-portable), enforced by
  test_okf_is_maf_free.
- verdicts.py: seed_store_from_bundle + bundle_candidate_features build the
  ExpeL substrate + the pre-hypothesis query key from a bundle.
- run_project(bundle_dir=...): folds the candidate's prior verdicts into the
  generation context BEFORE generate_via_llm; the road path is unchanged.

Load-bearing (maalbilde §7): test_step1_expel_loadbearing proves a prior verdict
reaches the hypothesis prompt and goes RED when the fold is detached (shown via
TDD red->green). The marker is the minted verdict id (content hash) because
docs_dir==bundle_dir lets keyword chunk-stuffing leak the realization rate;
clean layer separation is Fase 2b.

Suite 121->133 passed; mypy + ruff check clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MHR8iKxJRxDiDfNw8HZmWE
2026-06-29 10:56:48 +02:00
e0f93dfa7b fix(fase3): stamp real proposer model into provenance, kill fake-model leak (F1)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019any9zfGNNwWJPX5Zq2QRz
2026-06-26 15:03:45 +02:00
52f6f65b7d feat(fase3): run_portfolio sequential orchestrator + PortfolioResult aggregate 2026-06-26 12:02:08 +02:00
8b64c7f8de feat(fase3): additive meter= seam on run_project (SC3 detach hook) 2026-06-26 11:51:25 +02:00
1694141bff feat(fase2): feed debate converged output into candidate generation 2026-06-26 00:40:44 +02:00
7573c4439f feat(fase2): wire BudgetMiddleware + retrieval tool onto the debate in run_project 2026-06-26 00:37:45 +02:00
7491367fb6 feat(fase2): vertical-slice orchestrator + two-layer HITL wiring 2026-06-24 13:54:54 +02:00