portfolio-optimiser/CLAUDE.md
Kjell Tore Guttormsen 688ee24973 feat(sim): the demo script is keyed on the project the prompt names, so a new project is data
The scripted proposer answered one hard-coded pair of proposals. A second project meant a second
hand-written selector, written under demo-week time pressure -- the risk the week plan names
explicitly (§4, risk 2). It is now a registry: `ScriptedCandidate` entries selected by
`scripted_proposer`, plus `project_id` as an argument to `simulate_learning_loop`.

The open decision was WHAT identifies the candidate in the prompt blob; the plan flagged it as
unverified, so it was measured. Two prompt shapes reach the selector: the debate prompt carries the
whole bundle context, the generation prompt carries `Project: {id} - {name}` plus -- as its context
-- the debate output, which is the selector's own earlier reply. So the cost code and the measure
name are present in the generation prompt only because the script put them there; keying on them
would key the script on its own output. The project id is the one identifier both shapes carry and
the framework stamps.

Validation, never repair: no match, or more than one, raises `ScriptedCandidateError`. A default
reply would answer an unregistered project with another project's numbers, which on screen is
indistinguishable from a correct run; an ambiguous blob is a data problem that must surface at the
rehearsal rather than be decided by registry order.

Load-bearing MEASURED against the whole suite, five mutations all red plus a green control: detach
the project keying - one global flip key - fall back on an unknown project - first-match on an
ambiguous prompt - detach the `project_id` argument. The flip-key test was rewritten mid-measurement
because its first form asserted on the FIRST registry entry, where "the matched candidate's key" and
"candidates[0]'s key" coincide -- it could not separate the two implementations, and proved nothing.

766 passed / 4 skipped. Simulation still exits 0, still prints eight labelled steps, still
byte-identical across two runs.

[skip-docs] README is deliberately untouched: O4 defers the README rewrite to 14-15 August, after
the demo has produced the evidence for the level-2 claim. CLAUDE.md carries the invariant.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XoHJCKBTjFKcjsfEQyGbzh
2026-08-06 16:43:29 +02:00

30 KiB

portfolio-optimiser

Kontekst

Generisk, åpent Python-rammeverk på Microsoft Agent Framework (MAF) som finner kostnadsbesparelser INNI hvert prosjekt i en portefølje av uavhengige prosjekter. Multi-agent samarbeid genererer kandidat-tiltak; en obligatorisk deterministisk validator avgjør verdiene; fageksperter vurderer via HITL, og systemet lærer av dommene. Publiseres på Forgejo for alle som har MAF.

Bakgrunn og beslutninger: research (§15 implementeringsregister), plan. Løpende state: STATE.md (local-only).

Stack

Python ≥3.10. MAF (agent-framework-core 1.9.0). Pakkehåndtering: uv. To backend-profiler: Azure/Foundry (full) + lokal (fallback).

Konvensjoner

  • Type hints overalt (mypy der mulig). Pydantic for validering/IR.
  • ruff for lint+format. pytest for test.
  • Modell-valg som konfig (modell-map rolle→Foundry-deployment), ikke spredt i kode.
  • Metode kodifiseres som Agent Skill (agentskills.io: SKILL.md + scripts/ + references/).
  • Datatilgang: in-process FunctionTool er default-sømmen i kjørestien. MCP er wiret som opt-in i kjørestien (mcp_tools.py + --mcp-config, Trekk B 2026-08-05): konkrete eksterne servere blir verktøy agentene kan kalle UNDER debatten. Uten konfig gjøres null nettverkskall og verktøylista er uendret. Tre regler er load-bearing: allowlist er påkrevd (tom liste ville latt motparten bestemme hva agentene får kalle), hver server og hvert tillatte verktøy navngis i kunngjøringen før første kall (også uten --mandate — ingen udeklarert egress), og --live-dry-run åpner ingenting. Egen søm fra ingest_mcp.py (kildedokumenter FØR kjøring, null-argument-tools) — samme protokoll, ulik jobb. build_mcp_server (datasource.py) er fortsatt kun demo. Data-source-konfig JSON-Schema-validert, fail-fast.
  • shared/ er en git subtree av portfolio-optimiser-commons (source of truth, R1 realisert 2026-07-03; publisert i open/ 2026-08-04 — commons-remoten peker fortsatt på ktg/ og virker uendret). Synk er pull-only: endringer committes i commons og hentes med git subtree pull --prefix=shared commons main --squash. ALDRI git subtree push fra konsument — re-split lekker hele konsument-historikken inn i commons (observert + opprydd 2026-07-03). Se shared/README.md.

Kommandoer

  • Sync: uv sync
  • Test: uv run pytest
  • Lint: uv run ruff check . + uv run ruff format .
  • Type: uv run mypy src

Arbeidsflyt (invarianter)

  • Rent teknisk rammeverk: deployer eier DPIA/ROS/behandlingsformål. Bygg IKKE compliance-funksjoner — kun tekniske forutsetninger (lokal-only, provenance, ingen stille egress) + disclaimer.
  • 90%-prinsipp: bygg den generiske kjernen + tydelige extension points; jakt IKKE de siste 10 %.
  • Deterministisk validator er obligatorisk og blokkerende — aldri valgfri plugin.
  • Framework-nøytral kontekst-søm: OKF-bundle-navigasjon (okf.py) og den delte shared/-kjernen er ren stdlib — null agent_framework/mcp-import, så samme bundles konsumeres uendret av begge stacker (D7-portabel). Håndhevet av tests/test_okf.py::test_okf_is_maf_free; importér aldri MAF inn i kontekst-laget.
  • OKF-navigert bundle-kontekst (ikke stuffing): på bundle-stien bygges agent-lese-konteksten ved å NAVIGERE bundelen (okf.bundle_context: index + frontmatter + cross-links, progressiv disclosure) — aldri keyword-chunk-stuffing (målbilde §2/§4). type: verdict-laget ekskluderes fra denne konteksten: tidligere dommer når hypotese-prompten KUN via den gatede ExpeL-folden. Load-bearing: test_bundle_context_excludes_verdict_layer + empty-store-kontrollen i test_step1_expel_loadbearing.py (realiseringssignalet lekker aldri inn via kontekst).
  • Navigasjons-kontrakten er hierarkisk, og escape — ikke dybde — er forbudt (method-spec §3 Steg 1): navigate_bundle følger cross-links REKURSIVT, dybde-først i først-sett-rekkefølge; ledende / betyr bundle-rot (aldri filsystem-absolutt), alt annet er relativt til den LENKENDE filas katalog; dedup skjer på resolvert sti (så ./a.md == a.md, og sykler termineres). safe_resolve er den ENESTE inn-/ut-av-bundle-testen (fail-closed) — den erstattet den pensjonerte «separator = utenfor bundelen»-heuristikken, som forvekslet dybde med escape. Manglende index.md er feil KUN i bundle-rota (navigasjon følger lenker, aldri katalog-enumerering). Rendering er FLAT uansett dybde; nestede index.md er navigasjon, ikke innhold. Gaten er commons-eide nav-goldens (shared/examples/nav-golden-*/expected-read-context.md, byte-nivå fasit): test_nav_golden_hierarchy_* (positiv) + test_nav_golden_escape_* (negativ — en gate som bare kan bli grønn beviser ingenting).
  • Kuraterte skrivere kan ikke forfalske ingest-stempelet (ingest-spec §3): write_concept_file er repoets ene authoring-primitiv som materialiserer en konseptfil fra CALLER-oppgitt frontmatter, og avviser derfor det KOMPLETTE eierskaps-stempelet (generated: true + ingest_manifest) med IngestStampError — mens hver halvdel alene er lovlig (kuratert innhold kan bære ett provenance-felt). Validering, ALDRI reparasjon: ingenting skrives. Uten dette kunne en kuratert fil bli stille slettet av en senere re-materialisering, som fjerner nøyaktig det som bærer stempelet.
  • IngestError må overleve anyio-task-gruppene (kø-(x), 2026-08-03): stdio_client og ClientSession er hver sin task group, og anyio pakker ALT som forlater en av dem i en BaseExceptionGroup. Derfor nådde stdio_call_tools egne feil (mcp_tool_error, mcp_non_text_content) kalleren som en exception group — aldri som IngestError, som er typen hele Door A fanger og switcher på via code. _unwrap_ingest_error pakker ut og re-raiser den eide feilen; alt annet re-raises URØRT (innsnevring, aldri blanket re-raise). Duck-typet på .exceptions, fordi except*/ExceptionGroup er 3.11+ og repoet er >=3.10. Ingen canned-tool-test kunne fanget dette — de går aldri inn i en task group; defekten dukket opp første gang koden faktisk ble kjørt. En MCP-server på ingest-stien må eksponere en null-argument-tool (URL-en bærer begge koordinatene, tool kalles med {}), så datasource.build_mcp_server kan IKKE serve den — retrieve_cost_docs(query) har et påkrevd argument og returnerer et error-result. De to er separate sømmer med vilje. Load-bearing MÅLT (tests/test_ingest_golden_mcp.py), fem mutasjoner alle røde: detach unwrappingen · detach initialize() · gjør feilkoden generisk · detach isError-grenen · endre ett byte av bodyen.
  • MCP-timeouten må komponeres MED anyios eget cancel scope, ikke asyncio.wait_for utenfra (kø-(z), 2026-08-03): STATE-premisset ("en utypet TimeoutError re-raises urørt") var FEIL — målt mot en EKTE hengende server ga asyncio.wait_for(run(), timeout=...) aldri en TimeoutError i det hele tatt; den kansellerer run() UTENFRA strukturen anyio selv eier (stdio_client/ClientSession), og de to kansellerings-mekanismene komponerer ikke — målt utfall var en anyio.BrokenResourceError inni en BaseExceptionGroup (en bakgrunns-reader-task mistet skrive-enden midt i nedrigging). Fiksen er anyio.fail_after(timeout_seconds) NESTET INNI begge task-gruppene, der anyio rigger ned sin egen struktur rent og raiser en ren TimeoutError. Innsnevringen dekker IKKE bare typen: builtin TimeoutError er også socket.timeout (≥3.10) og asyncio.TimeoutError (≥3.11), så et ubetinget except TimeoutError ville mislabelt en HVILKEN SOM HELST TimeoutError som mcp_timeout — reviewet FØR commit (advisor) fant nøyaktig dette. Retteslen er anyio.CancelScope.cancelled_caught: kun scopet som faktisk traff SIN EGEN deadline tjener mcp_timeout-koden, ellers re-raises urørt (speiler _unwrap_ingest_errors eierskaps-regel). Ingen levende utløser finnes i dag for en "fremmed" TimeoutError på denne stien — MÅLT: MCPs eget per-request read-timeout (ClientSession.send_request) konverterer sin anyio.fail_after til McpError FØR den når oss, og en tool som raiser TimeoutError server-side blir et ordinært isError-resultat (samme som enhver annen tool-exception) — begge verifisert empirisk, ikke antatt. Diskriminatoren er likevel pinned med en syntetisk test (raiser fra StdioServerParameters-konstruksjon, inni fail_after-scopet men FØR noen task group), fordi defektklassen ellers ikke har en nåbar sti å bevise den mot. Load-bearing MÅLT (tests/test_ingest_golden_mcp.py) mot HELE 625-suiten, fire mutasjoner alle røde: detach hele oversettelsen · revert til asyncio.wait_for · relabel koden · detach cancelled_caught-gaten (behold kun scope-presence). anyio promotert fra transitiv (via mcp) til deklarert direkte dep (pyproject.toml) — modulen importerer den nå direkte.
  • Stoppkriterier + budsjett-tak påkrevd ved oppstart (fail-fast, aldri ubegrenset loop).
  • Group Chat maker-checker som debatt-default (IKKE Magentic, som er eksperimentell).
  • To falsifiserere, samme kandidat (Steg 3/4, målbilde §2/§6): den deterministiske validatoren gater tallene (blokkerende), checkeren gater resonnementet. Checkeren avslutter turen med en VERDICT: APPROVE / VERDICT: REJECT — <grunn>-linje; et eksplisitt avslag blokkerer et ellers validert forslag (run_project overflater begge debatt-deltakere via output_from=agents og overstyrer utfallet til en checker-kilde-Rejection). Gaten er opt-in-reject (fail-open ved manglende markør), og provenance.validator_decision forblir ærlig — den speiler KUN validatoren, aldri checkeren (de to falsifisererne blandes aldri). Load-bearing: tests/test_checker_gate_loadbearing.py blir rød ved BEGGE detach-punkt (revert output_from, eller fjern override). Checkeren «må faktisk gate, ELLER vi slutter å kalle det maker-checker».
  • Informert forbedring, bundet (Steg 5, målbilde §5/§7): generate_via_llms ytre max_attempts-løkke er ikke lenger blind — validatorens forrige Rejection.reason mates inn i neste forsøks prompt (_build_messages(prior_rejection=...)), så proposeren korrigerer i stedet for å gjenta. Kun den mest-nylige falsifiseringen (last, ikke akkumulert), kun grunnen (aldri forrige proposal-JSON), under EKSISTERENDE tak (meter.tick_round + max_attempts — ingen ny løkke; «forbedre til god nok» uten tak er forbudt). Eneste per-forsøk-falsifiserer her er validatoren; å seede generering med checker-kritikken er run-nivå og separat scoped (IKKE bygget her) — så koden påstår ikke mer enn den gjør. Load-bearing: tests/test_step5_refine_loadbearing.py blir rød når reason-injeksjonen detaches (utfallet flipper aldri + reason-verbatim-asserten faller); kontrollen beviser at løkka forblir bundet.
  • Falsifiserings-historikken FORLATER generate-løkka som typet returverdi (Steg 5, del 2): generate_via_llm returnerer GenerationResult(outcome, refinements) — ikke lenger bare ValidatedProposal | Rejection. Før dette forbrukte løkka hver Rejection internt (last) og DROPPET den, så Steg 5 var det ene av åtte steg uten observerbart utfall. Returverdi, ikke out-parameter/callback: en returnert verdi kan ikke bli stille tapt av en kaller som glemmer å sende en samler, og mypy tvinger hvert kallsted til å ta stilling. refinements bærer KUN avvisninger som faktisk ble matet tilbake i et senere forsøks prompt — ved uttømt budsjett ER den siste avvisningen outcome, den informerte ingenting, og å telle den med ville vært dobbeltføring (en «samle alt»-implementasjon består den positive testen og faller på kontrollen). Taket er URØRT: max_attempts + meter.tick_round står, og last driver fortsatt prompten alene (prompt-veksten er uendret). run.py akkumulerer på tvers av _evaluate-kallene, så _evaluate_mandate er urørt; RunResult.refinements er defaultet (coverage-presedensen), og med mandat er den KONKATENERT på tvers av tiltak, ikke nøklet per tiltak (uttalt ærlighets-grense). scripted_factory tar nå str | reply_selector per rolle, så simuleringens proposer korrigerer seg innholds-nøklet uten en andre scriptet kropp. Load-bearing MÅLT (tests/test_step5_history_loadbearing.py), fire mutasjoner: detach returneringen · samle-alt · detach run-wiringen · reverter simuleringens proposer til konstant svar.
  • Lang/async fil-løkke (Steg 7, målbilde §3/§7): run_project(verdict_dir=...) er den lange tilbakemeldings-tidsskalaen — en ekspert/persona dropper en verdict-fil (vanlig JSON, RAW-laget per §10 R2) i en inbox-mappe ETTER en kjøring, og en separat, senere kjøring load_verdicts_from_dirstore.add merger den inn FØR Steg-1-folden (ingen endring i folden), så dommen når neste hypotese. Rolledeling (§3, ufravikelig): systemet LESER mappa; eksperten/personaen SKRIVER den — run_project persisterer ALDRI sin egen fangede dom tilbake (det er outbox/Steg 8). Merge, aldri erstatt (run_portfolio-tråding intakt); tolerant last (manglende mappe / fremmede / halvskrevne filer hoppes over, ikke raises — RAW-lag, kontrast okf.load_ir_projections fail-fast); id leses verbatim, re-mintes aldri. write_verdict er den offentlige authoring-primitiven (persona/test + framtidig Steg 8), men wires IKKE inn i run_project. Load-bearing: tests/test_step7_async_loop_loadbearing.py — en dom droppet etter Run A MÅ nå Run B's prompt (Run B bruker FERSK store → overføringen er fil-løkka, ikke in-memory-carryover); tom-inbox-kontroll beviser kausalitet. Markør = realiseringsverdi som finnes ingen steder i bundelen (ikke frøets 0.82).
  • Gated wiki-promotering (Steg 8, målbilde §3/§6/§7): når en ekspert/persona GODKJENNER et utfall, løfter verdicts.promote_verdict det fra RAW output-laget inn i kontekst-laget (OKF-bundelen) som en type: verdict-konseptfil, navigerbar av neste kjørings seed_store_from_bundle. Gaten er fail-closed: en ikke-godkjent dom (decision ∉ {approved, approved_with_adjustment}) raiser PromotionRefused og skriver/linker INGENTING — kun menneske/persona-godkjent kunnskap når wikien, aldri rå agent-output (selv-forurensning). Provenance-stemplet (hvem/eksperiment/når; timestamp er påkrevd keyword, ingen wall-clock-default → deterministisk). OKF-skriveren bor i okf.py og er ren stdlib (D7-portabel, MAF-fri — håndhevet av test_okf_is_maf_free); navigasjon følger KUN index-cross-links, så promote_verdict linker filen i index.md via en NØYTRAL label (ellers lekker signalet inn i index_summarybundle_context utenom gaten). R4 = valgfri+gated: promote_verdict er en offentlig opt-in-primitiv, wires IKKE inn i run_project (speiler write_verdict — systemet leser; gaten/personaen promoterer). Ærlighets-grenser: promotert fil er MINIMAL (læringssignal kun som description/body-prosa, reproduserer ikke seedens strukturerte realization_rate o.l.); id = læringsnøkkel, så to godkjenninger om samme kandidat deler filnavn (last-write-wins, som write_verdict) — wikien vokser én kuratert fil per distinkt kandidat, ikke per dom-hendelse. Load-bearing-trio (tests/test_step8_promotion_loadbearing.py): gaten avviser ikke-godkjent dom (RØD uten gate); godkjent dom er navigerbar (RØD når link_in_index detaches); promotert signal holdes ute av bundle_context (RØD når en beskrivende index-label lekker det inn). Index-RMW er ikke-atomisk (enprosess-MVP).
  • En dom nøkles på SIN kandidat, ikke bundelens ene IR-projeksjon (S3.2): seed_store_from_bundle leser hver type: verdict-fils EGNE strukturelle felt fra frontmatter (affected_codes / measure_type / claimed_saving_nok); mangler de, faller nøklingen tilbake til bundle_candidate_features — så hver pre-S3.2-seed står uendret (fallbacken er BÆRENDE: fjernes den, brekker step1-suiten ved collection). Uten dette kollapset en bundle med dommer om flere kandidater dem på ÉN nøkkel, og en dom om kandidat B scoret perfekt strukturell match mot kandidat As query. ALLE TRE felt eller ingen: en delvis erklæring raiser VerdictFrontmatterError i stedet for å slås sammen med bundle-kandidaten — sammenslåingen ville myntet en nøkkel som tilhører INGEN av kandidatene. Validering, ALDRI reparasjon (speiler write_concept_file); den tolerante hopp-over-regelen hører til RAW-innboks-laget. claimed_saving_nok parses med json.loads — SAMME literal-regel IR-projeksjonen gikk gjennom — og skrives tilbake som str() av råverdien, fordi _mint_id hasher den (3000030000.0; en normaliserende skriver ville splittet én kandidats signal på to id-er). promote_verdict skriver de tre feltene, så en promotert dom ikke utgir seg for målbundelens kandidat; nøkkelen er signal-fri, så Steg-8s no-leak-egenskap står. Semantikken er bestemt HER — commons' seeding-regel (method-spec §3 Steg 1) hadde ikke kommet; D7-speiling forblir ÅPEN. Load-bearing MÅLT (tests/test_step32_multicandidate_loadbearing.py + test_step8_promotion_loadbearing.py), fem mutasjoner alle røde: detach per-dom-nøklingen · detach feltene promote_verdict skriver · gjør en delvis/uparsebar nøkkel tolerant · normaliser magnituden ved skriving · fjern fallbacken (kontroll).
  • Den deterministiske gaten er FORANKRET i prosjektets faktiske kostbaseline (S4.0, F3/F8): før S4.0 resonnerte HVER stage kun om tall forslaget selv oppga, så en internt konsistent hallusinasjon klarerte hele gaten. validate_proposal(..., baseline=...) avstemmer nå hvert affected_item mot CostBaseline (ir.py) i en stage 0 — FØR løseren (billigst, og den eneste som skiller en oppdiktet linje fra en ekte; å bruke en CBC-solve på tall som ikke tilhører prosjektet er arbeid på et krav som uansett ikke kan valideres). To uavhengige avvisninger: ukjent kostkode, og ekte kode med quantity/unit_cost utenfor tolerance (default 5 %, konfig) relativt til BASELINE-verdien. Validering, ALDRI reparasjon — forslaget avvises, aldri stilltiende korrigert til baselinen. Argumentet er VALGFRITT (None = pre-S4.0-oppførsel), men begge run-stier SETTER det: road-stien fra project.cost_items (alltid — estimatet ER prosjektet), bundle-stien KUN når bundelen shipper cost-baseline.json (load_optional_cost_baseline) — en pre-amendment-bundle er legitimt uforankret, og det er dét som holder commons-goldenene byte-identiske. Toleransen stopper ved fravær: en baseline som FINNES men er malformed raiser på BEGGE loaderne (å lese korrupt som «ingen baseline» ville gitt en uforankret gate i forkledning — samme resonnement som read_spend). F8: metode-cap-en slås opp i METHOD_CAPS-registeret (måletype→brøk, injiserbart), ikke mot energy_efficiency-literalen — en andre metode er nå data, ikke en redigering av validatoren. Format- og toleranse-semantikken er bestemt LOKALT (commons-amendmentet D-A pkt. 2 kom aldri, som i S3.2); D7-speiling ÅPEN. Load-bearing MÅLT (tests/test_s40_cost_baseline_loadbearing.py), seks mutasjoner alle røde: detach avstemmings-stagen · detach magnitude-toleransen · detach road-wiringen · detach bundle-wiringen · ignorer det injiserte cap-registeret · gjør den valgfrie loaderen tolerant.
  • Globalt token-tak håndheves FØR kall, aldri bare etterpå (S3.4, F10): PortfolioBudget + PortfolioMeter er ÉN ledger over hele porteføljepasset (og — seedet av read_spend — på tvers av pass), mens per-run Budget/TokenMeter er uendret. Taket har tre tenner, med hver sin jobb: (1) oppstartsnekt — en rest som ikke kan finansiere én kjøring raiser BudgetRefused FØR noe lastes (et pass som har råd til null prosjekter er en caller-feil, ikke et resultat); (2) wave-assembly — et prosjekt som ikke kan finansieres blir ALDRI STARTET, og passet stopper strukturert (budget_stop + stopped_early, fullførte runs bevart). Aldri-startet er poenget: et ufinansiert prosjekt som bare avbrytes har allerede kostet kall. Fordi hele bølgen sjekkes mot SAMME før-bølge-rest, reserverer admission hver members krav — ellers overforplikter en bølge av k taket med inntil k kjøringer; (3) pre-call-guard i BudgetMiddleware — et kall resten ikke kan betale for NEKTES i stedet for å gjøres (post-charge-sjekken består: ekte usage kjennes først etterpå, så guarden stopper NESTE kall, aldri det som er i lufta). budget_stop er et EGET felt, aldri stop_reason: et mål-stopp er suksess, dette er ressurs-utmattelse — å slå dem sammen ville gjort «vi stoppet» uleselig. record/check er SPLITTET i PortfolioMeter fordi tokens leverandøren allerede har fakturert må nå ledgeren selv når samme charge bryter run-taket. Spend-fila er vår EGEN regnskapstilstand: read_spend raiser på korrupt innhold (kontrast det tolerante RAW-inbox-laget — å lese korrupt som null ville gitt tilbake et allerede brukt budsjett), og write_spend tar et PÅKREVD stamp uten wall-clock-default (byte-determinisme, speiler promote_verdict). portfolio_meter og meter_factory er gjensidig utelukkende — en factory-meter er ubundet, så begge sammen ville gitt et pass som SER capped ut uten å være det. Load-bearing MÅLT (tests/test_portfolio_budget_loadbearing.py + tests/test_budget.py), seks mutasjoner alle røde: detach wave-sjekken · detach pre-call-guarden · detach bølge-reservasjonen · sjekk run-taket før global kreditering · detach oppstartsnekten · gjør read_spend tolerant.
  • BudgetExceededs tre felt beskriver ÉN og samme ledger (kø-(y)): kind/limit/observed er ett strukturert stopp-event, og observed var det udefenderte tredje feltet — MÅLT: fire av fem raise-steder (TokenMeter.charge, tick_round, og BEGGE armene i exhausted()) kunne rapportere hvilken som helst verdi uten at 621 tester merket det; bare PortfolioMeter.check var dekket. Fellen som skjulte det: spikes/_harness.py:41 har sin EGEN kopi av BudgetExceeded/TokenMeter, så spikes/test_harness.pys observed-assert dekker IKKE den shippede modulen — produksjonens tick_round hadde null direkte test. exhausted() er det ENESTE stedet som VELGER ledger (S3.4-guarden), så en refusal som sier portfolio_tokens mens den rapporterer runets eget forbruk ville villedet enhver leser. Testene bygges med observed != limit: ved nøyaktig-uttømt sammenfaller de to, og en test skrevet der kan ikke skille dem — den ville passert på en implementasjon som ekkoet taket tilbake som forbruket. Ingen defekt funnet i verdiene selv (kontrast (x)/(p)): trippelen var koherent alle fem steder, gapet var rent dekning. Load-bearing MÅLT (tests/test_budget.py), ni mutasjoner alle røde: fem observed-mutasjoner (inkl. kontrollen) + fire ekko-mutasjoner.
  • Pengetall kvantiseres i ÉN orden, fra ÉN kilde (kø-(p)): ledger.to_ore er rammeverkets ENE NOK→øre-konvertering (Decimal, ROUND_HALF_UP), og den brukes per pengebeløp — deretter summeres HELTALL. run.py importerer den; aldri en egen kopi (to kopier av en penge-konvertering drifter, og en driftet kopi setter de to sidene av en mål-sammenligning på hver sin skala — S4.0s REPLIES-presedens). Før dette summerte run.pys mål-baseline Project.total_cost-FLOATS og kvantiserte totalen ÉN gang, mens SavingsLedger summerte per-kandidat-heltall — og de to ordenene møttes i nøyaktig ETT punkt: _goal_limit_if_reached, der et prosent-mål avgjør om et porteføljepass stopper tidlig. Målt divergens: tre linjer à 60000.005 NOK er 18000003 øre kvantisert først, men 18000001 summert først (float-drift til 180000.01499999998) — nok til å vippe et mål. Kvantiser-først valgt fordi hver CostItem ER et beløp (S4.0 gjorde per-linje quantity/unit_cost til validatorens grunnsannhet), og fordi heltallsaddisjon er assosiativ → rekkefølge-uavhengig under D-D-bølgemodellen, som float-folden ikke er. To kallsteder, ikke ett: portefølje- og per-prosjekt-baselinen er separate, og en fiks på bare den ene OVERLEVDE hele suiten (målt). Load-bearing MÅLT (tests/test_money_quantization_loadbearing.py), fem mutasjoner alle røde: detach portefølje-baselinen · detach per-prosjekt-baselinen · gjeninnfør en privat kopi i run.py · endre avrundingsmodus · la realize gå utenom to_ore. Ærlighets-grense: sum_claimed_saving_nok (run.py:_aggregate) er BEVISST urørt — et float-NOK-rapportfelt som aldri kvantiseres og aldri sammenlignes mot ledgeren, altså utenfor ordens-defekten.
  • Kostnadsdisiplin: utvikle primært på lokal profil (gratis); Foundry/Azure (privat tenant finnes) kun til målrettet, minimal verifisering; billigste modeller + små syntetiske data + harde token-tak. Ingen tunge test-kjøringer.
  • Offline simulering = primært metode-bevis (kostnadsdrevet, erstatter §11.8): operatøren kjører IKKE MAF mot ekte modell (verken Azure/Foundry eller Ollama — API for begge repoene er for kostbart privat). portfolio_optimiser.simulation driver run_project med en SKRIPTET syntetisk chat-klient (ScriptedChatClientOpenAIChatCompletionClient — IKKE bare BaseChatClient, ellers no-op-er BudgetMiddleware) over to kjøringer adskilt av en promotering, og viser at læringssløyfa lukkes: Run A's godkjente persona-dom (markør fraværende fra bundelen) → promote_verdict → re-seed → Run B's hypotese-prompt bærer markøren (tom-wiki-kontroll på Run A beviser kausalitet). Ærlighet (§1, ufravikelig): beviser plumbing + deterministisk ryggrad + at dataflyten lukkes — IKKE at en levende LLM ville produsert forslaget/dommen (skriptede stand-ins). Den genuine modell-atferd-sammenligningen lever på Claude-SDK-siden (minimal API-kjøring). Skriptet klient = MAF-side stillas, IKKE delt (shared/ forblir framework-nøytralt). Kjøres uv run python -m portfolio_optimiser.simulation. Load-bearing: tests/test_simulation_loadbearing.py blir RØD når promoteringen detaches.
  • Det skriptede manuset nøkles på PROSJEKT-ID-en, og det er MÅLT: scripted_proposer(candidates) bygger simuleringens proposer fra et ScriptedCandidate-register, så et nytt prosjekt er en data-oppføring (demo-uke-plan §4 risiko 2) — ikke et andre håndskrevet manus. Hvorfor ikke kostkode/tiltaksnavn: to prompt-former når selectoren — debatt-prompten bærer hele bundle-konteksten, mens genererings-prompten (generate._build_messages) bærer Project: {id} - {name} pluss debatt-outputen som kontekst, altså selectorens EGET tidligere svar. Kostkode og tiltaksnavn står derfor i genererings-prompten kun fordi manuset selv la dem der; å nøkle på dem ville nøklet manuset på sin egen output. Prosjekt-ID-en er den ene identifikatoren BEGGE former bærer og som RAMMEVERKET stempler. Validering, ALDRI reparasjon: null treff — eller mer enn ett — raiser ScriptedCandidateError; et default-svar ville besvart et uregistrert prosjekt med et ANNET prosjekts tall, som på skjermen er umulig å skille fra en riktig kjøring, og en tvetydig blob er et DATA-problem som skal falle på generalprøven, ikke avgjøres av register-rekkefølgen. flip_key MÅ være fraværende fra bundelen (ellers bærer forsøk 1s prompt den allerede). simulate_learning_loop tar project_id ved siden av bundle_dir. Load-bearing MÅLT (tests/test_content_keyed_script_loadbearing.py), fem mutasjoner alle røde + grønn kontroll: detach nøklingen · én global flip-key · fallback ved ukjent prosjekt · første-treff ved tvetydighet · detach project_id-argumentet. Flip-key-testen ble skrevet om under målingen — første form asserterte på FØRSTE register-oppføring, der «den matchede kandidatens nøkkel» og «candidates[0]s nøkkel» sammenfaller; den kunne ikke skille de to implementasjonene.
  • Delt ekspert-persona som Agent Skill (§8, framework-nøytral): ekspert-reviewer-personaen bor i shared/skills/expert-reviewer/ (SKILL.md + references/example-verdict.json) og er den ENE delte artefakten begge stacker instansierer reviewer-en fra. shared/ forblir REN DATA — MAF-siden leser den via portfolio_optimiser.persona.load_persona_example (call-time, fail-fast), Claude-SDK- søskenet med sin egen loader mot samme JSON. Dette AV-STUBBER simuleringen: persona-dommen (decision
    • rationale + sporet markør) hentes nå fra artefaktet ved call-time, ikke en inline-literal — så personaen er genuint konsumert og kan ikke råtne stille. Decision er binær (approved/rejectedFeedbackContract run-stien tar; approved_with_adjustment avvises der, bor kun i bundle-seedens frontmatter + promoterings-gaten); realiseringskorreksjonen lever i rationale-prosaen, ikke et tredje enum. SKILL.md-prosaen nevner ALDRI en konkret framework (maf-guarden er import-formet). Load-bearing- trio (tests/test_persona_skill_loadbearing.py): struktur+framework-nøytralitet (RØD på framework- import), eksempelet er gyldig pipeline-input inkl. FeedbackContract (RØD på skjema-/kontrakt-drift, på en throwaway-kopi — aldri den git-tracked fixturen), og sim-ens markør følger artefakt-fila (RØD i det øyeblikk personaen re-inlines).
  • STATE.md er local-only (gitignored). Voyage session-state er efemert; STATE.md er kanonisk kontinuitet.
  • Prosess: Voyage-plugin (/trekbrief → /trekplan → /trekexecute → /trekreview) per større fase.

Communication patterns

When linking to local files in responses, use named markdown links — [Human-friendly name](file:///absolute/path), never bare file:// URLs or autolinks <file://...>, always absolute paths (never ~/ or relative), one bullet per file when there are several. (Bare file:// URLs render only the first as clickable across multiple lines; named links stay independently clickable.)