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
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 (
mypyder mulig). Pydantic for validering/IR. rufffor lint+format.pytestfor 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
FunctionTooler 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 fraingest_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 avportfolio-optimiser-commons(source of truth, R1 realisert 2026-07-03; publisert iopen/2026-08-04 —commons-remoten peker fortsatt påktg/og virker uendret). Synk er pull-only: endringer committes i commons og hentes medgit subtree pull --prefix=shared commons main --squash. ALDRIgit subtree pushfra konsument — re-split lekker hele konsument-historikken inn i commons (observert + opprydd 2026-07-03). Seshared/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 delteshared/-kjernen er ren stdlib — nullagent_framework/mcp-import, så samme bundles konsumeres uendret av begge stacker (D7-portabel). Håndhevet avtests/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 itest_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_bundlefø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_resolveer den ENESTE inn-/ut-av-bundle-testen (fail-closed) — den erstattet den pensjonerte «separator = utenfor bundelen»-heuristikken, som forvekslet dybde med escape. Manglendeindex.mder feil KUN i bundle-rota (navigasjon følger lenker, aldri katalog-enumerering). Rendering er FLAT uansett dybde; nestedeindex.mder 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_fileer repoets ene authoring-primitiv som materialiserer en konseptfil fra CALLER-oppgitt frontmatter, og avviser derfor det KOMPLETTE eierskaps-stempelet (generated: true+ingest_manifest) medIngestStampError— 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. IngestErrormå overleve anyio-task-gruppene (kø-(x), 2026-08-03):stdio_clientogClientSessioner hver sin task group, og anyio pakker ALT som forlater en av dem i enBaseExceptionGroup. Derfor nåddestdio_call_tools egne feil (mcp_tool_error,mcp_non_text_content) kalleren som en exception group — aldri somIngestError, som er typen hele Door A fanger og switcher på viacode._unwrap_ingest_errorpakker ut og re-raiser den eide feilen; alt annet re-raises URØRT (innsnevring, aldri blanket re-raise). Duck-typet på.exceptions, fordiexcept*/ExceptionGrouper 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_serverkan 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 · detachinitialize()· gjør feilkoden generisk · detachisError-grenen · endre ett byte av bodyen.- MCP-timeouten må komponeres MED anyios eget cancel scope, ikke
asyncio.wait_forutenfra (kø-(z), 2026-08-03): STATE-premisset ("en utypetTimeoutErrorre-raises urørt") var FEIL — målt mot en EKTE hengende server gaasyncio.wait_for(run(), timeout=...)aldri enTimeoutErrori det hele tatt; den kansellererrun()UTENFRA strukturen anyio selv eier (stdio_client/ClientSession), og de to kansellerings-mekanismene komponerer ikke — målt utfall var enanyio.BrokenResourceErrorinni enBaseExceptionGroup(en bakgrunns-reader-task mistet skrive-enden midt i nedrigging). Fiksen eranyio.fail_after(timeout_seconds)NESTET INNI begge task-gruppene, der anyio rigger ned sin egen struktur rent og raiser en renTimeoutError. Innsnevringen dekker IKKE bare typen: builtinTimeoutErrorer ogsåsocket.timeout(≥3.10) ogasyncio.TimeoutError(≥3.11), så et ubetingetexcept TimeoutErrorville mislabelt en HVILKEN SOM HELSTTimeoutErrorsommcp_timeout— reviewet FØR commit (advisor) fant nøyaktig dette. Retteslen eranyio.CancelScope.cancelled_caught: kun scopet som faktisk traff SIN EGEN deadline tjenermcp_timeout-koden, ellers re-raises urørt (speiler_unwrap_ingest_errors eierskaps-regel). Ingen levende utløser finnes i dag for en "fremmed"TimeoutErrorpå denne stien — MÅLT: MCPs eget per-request read-timeout (ClientSession.send_request) konverterer sinanyio.fail_aftertilMcpErrorFØR den når oss, og en tool som raiserTimeoutErrorserver-side blir et ordinærtisError-resultat (samme som enhver annen tool-exception) — begge verifisert empirisk, ikke antatt. Diskriminatoren er likevel pinned med en syntetisk test (raiser fraStdioServerParameters-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 tilasyncio.wait_for· relabel koden · detachcancelled_caught-gaten (behold kun scope-presence).anyiopromotert fra transitiv (viamcp) 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_projectoverflater begge debatt-deltakere viaoutput_from=agentsog overstyrer utfallet til en checker-kilde-Rejection). Gaten er opt-in-reject (fail-open ved manglende markør), ogprovenance.validator_decisionforblir ærlig — den speiler KUN validatoren, aldri checkeren (de to falsifisererne blandes aldri). Load-bearing:tests/test_checker_gate_loadbearing.pyblir rød ved BEGGE detach-punkt (revertoutput_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 ytremax_attempts-løkke er ikke lenger blind — validatorens forrigeRejection.reasonmates 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.pyblir 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_llmreturnererGenerationResult(outcome, refinements)— ikke lenger bareValidatedProposal | Rejection. Før dette forbrukte løkka hverRejectioninternt (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.refinementsbærer KUN avvisninger som faktisk ble matet tilbake i et senere forsøks prompt — ved uttømt budsjett ER den siste avvisningenoutcome, 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_roundstår, oglastdriver fortsatt prompten alene (prompt-veksten er uendret).run.pyakkumulerer på tvers av_evaluate-kallene, så_evaluate_mandateer urørt;RunResult.refinementser defaultet (coverage-presedensen), og med mandat er den KONKATENERT på tvers av tiltak, ikke nøklet per tiltak (uttalt ærlighets-grense).scripted_factorytar nåstr | reply_selectorper 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øringload_verdicts_from_dir→store.addmerger 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_projectpersisterer 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, kontrastokf.load_ir_projections fail-fast);idleses verbatim, re-mintes aldri.write_verdicter den offentlige authoring-primitiven (persona/test + framtidig Steg 8), men wires IKKE inn irun_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_verdictdet fra RAW output-laget inn i kontekst-laget (OKF-bundelen) som entype: verdict-konseptfil, navigerbar av neste kjøringsseed_store_from_bundle. Gaten er fail-closed: en ikke-godkjent dom (decision ∉ {approved, approved_with_adjustment}) raiserPromotionRefusedog skriver/linker INGENTING — kun menneske/persona-godkjent kunnskap når wikien, aldri rå agent-output (selv-forurensning). Provenance-stemplet (hvem/eksperiment/når;timestamper påkrevd keyword, ingen wall-clock-default → deterministisk). OKF-skriveren bor iokf.pyog er ren stdlib (D7-portabel, MAF-fri — håndhevet avtest_okf_is_maf_free); navigasjon følger KUN index-cross-links, såpromote_verdictlinker filen iindex.mdvia en NØYTRAL label (ellers lekker signalet inn iindex_summary→bundle_contextutenom gaten). R4 = valgfri+gated:promote_verdicter en offentlig opt-in-primitiv, wires IKKE inn irun_project(speilerwrite_verdict— systemet leser; gaten/personaen promoterer). Ærlighets-grenser: promotert fil er MINIMAL (læringssignal kun somdescription/body-prosa, reproduserer ikke seedens strukturerterealization_rateo.l.); id = læringsnøkkel, så to godkjenninger om samme kandidat deler filnavn (last-write-wins, somwrite_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årlink_in_indexdetaches); promotert signal holdes ute avbundle_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_bundleleser hvertype: verdict-fils EGNE strukturelle felt fra frontmatter (affected_codes/measure_type/claimed_saving_nok); mangler de, faller nøklingen tilbake tilbundle_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 raiserVerdictFrontmatterErrori stedet for å slås sammen med bundle-kandidaten — sammenslåingen ville myntet en nøkkel som tilhører INGEN av kandidatene. Validering, ALDRI reparasjon (speilerwrite_concept_file); den tolerante hopp-over-regelen hører til RAW-innboks-laget.claimed_saving_nokparses medjson.loads— SAMME literal-regel IR-projeksjonen gikk gjennom — og skrives tilbake somstr()av råverdien, fordi_mint_idhasher den (30000≠30000.0; en normaliserende skriver ville splittet én kandidats signal på to id-er).promote_verdictskriver 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 feltenepromote_verdictskriver · 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å hvertaffected_itemmotCostBaseline(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 medquantity/unit_costutenfortolerance(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 fraproject.cost_items(alltid — estimatet ER prosjektet), bundle-stien KUN når bundelen shippercost-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 somread_spend). F8: metode-cap-en slås opp iMETHOD_CAPS-registeret (måletype→brøk, injiserbart), ikke motenergy_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+PortfolioMeterer ÉN ledger over hele porteføljepasset (og — seedet avread_spend— på tvers av pass), mens per-runBudget/TokenMeterer uendret. Taket har tre tenner, med hver sin jobb: (1) oppstartsnekt — en rest som ikke kan finansiere én kjøring raiserBudgetRefusedFØ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 iBudgetMiddleware— 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_stoper et EGET felt, aldristop_reason: et mål-stopp er suksess, dette er ressurs-utmattelse — å slå dem sammen ville gjort «vi stoppet» uleselig.record/checker SPLITTET iPortfolioMeterfordi 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_spendraiser på korrupt innhold (kontrast det tolerante RAW-inbox-laget — å lese korrupt som null ville gitt tilbake et allerede brukt budsjett), ogwrite_spendtar et PÅKREVDstamputen wall-clock-default (byte-determinisme, speilerpromote_verdict).portfolio_meterogmeter_factoryer 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ørread_spendtolerant. BudgetExceededs tre felt beskriver ÉN og samme ledger (kø-(y)):kind/limit/observeder ett strukturert stopp-event, ogobservedvar det udefenderte tredje feltet — MÅLT: fire av fem raise-steder (TokenMeter.charge,tick_round, og BEGGE armene iexhausted()) kunne rapportere hvilken som helst verdi uten at 621 tester merket det; barePortfolioMeter.checkvar dekket. Fellen som skjulte det:spikes/_harness.py:41har sin EGEN kopi avBudgetExceeded/TokenMeter, såspikes/test_harness.pysobserved-assert dekker IKKE den shippede modulen — produksjonenstick_roundhadde null direkte test.exhausted()er det ENESTE stedet som VELGER ledger (S3.4-guarden), så en refusal som sierportfolio_tokensmens den rapporterer runets eget forbruk ville villedet enhver leser. Testene bygges medobserved != 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: femobserved-mutasjoner (inkl. kontrollen) + fire ekko-mutasjoner.- Pengetall kvantiseres i ÉN orden, fra ÉN kilde (kø-(p)):
ledger.to_oreer rammeverkets ENE NOK→øre-konvertering (Decimal, ROUND_HALF_UP), og den brukes per pengebeløp — deretter summeres HELTALL.run.pyimporterer 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.0sREPLIES-presedens). Før dette summerterun.pys mål-baselineProject.total_cost-FLOATS og kvantiserte totalen ÉN gang, mensSavingsLedgersummerte 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.005NOK er18000003øre kvantisert først, men18000001summert først (float-drift til180000.01499999998) — nok til å vippe et mål. Kvantiser-først valgt fordi hverCostItemER et beløp (S4.0 gjorde per-linjequantity/unit_costtil 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 irun.py· endre avrundingsmodus · larealizegå utenomto_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.simulationdriverrun_projectmed en SKRIPTET syntetisk chat-klient (ScriptedChatClientpåOpenAIChatCompletionClient— IKKE bareBaseChatClient, ellers no-op-erBudgetMiddleware) 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øresuv run python -m portfolio_optimiser.simulation. Load-bearing:tests/test_simulation_loadbearing.pyblir 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 etScriptedCandidate-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ærerProject: {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 — raiserScriptedCandidateError; 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_keyMÅ være fraværende fra bundelen (ellers bærer forsøk 1s prompt den allerede).simulate_learning_looptarproject_idved siden avbundle_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 · detachproject_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 viaportfolio_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/rejected—FeedbackContractrun-stien tar;approved_with_adjustmentavvises 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).
- 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 (
- 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.)