Krav 3, and the operator chose the run path explicitly: the external service must be reachable WHILE the run works, not only when documents are ingested. Until now the run path had one in-process tool against a local folder — and on the bundle path the agents had no tools at all. MAF already ships the client (MCPStdioTool / MCPStreamableHTTPTool, verified in the pinned 1.9.0 with allowed_tools and request_timeout), so `mcp_tools.py` owns only what MAF cannot decide for us: which servers a run may contact, which of their tools it may call, how long it waits, and where the credential comes from. This is a DIFFERENT seam from ingest_mcp.py on purpose — that one pulls source documents before a run and speaks to null-argument tools. Same protocol, different job. Every refusal is a live hazard, not tidiness. An empty allowlist would let the far end decide what the agents may call, so naming the tools is mandatory. A non-positive timeout is an unbounded wait against a third party. An unknown field is refused rather than ignored, which is also what keeps a literal secret from being parked in the config — there is no field for one, only the NAME of an env var. A named-but-unset credential refuses instead of calling anonymously, because an anonymous call can succeed with the wrong scope. Egress is declared, always. Every server and permitted tool is named in the run announcement before the first call — including when no --mandate is given, which was a real hole: the announcement only printed with a commission, so configuring servers without one would have contacted third parties with nothing printed at all. --live-dry-run still opens nothing, because the tools are entered after the dry-run cut: the promise to stop before the first call now covers egress too. Threaded through BOTH modes. A flag accepted in one mode and silently dropped in the other is the defect class this CLI refuses by name. Load-bearing MEASURED against the whole 744-test suite, four mutations all red: build the tools but never hand them to the agents (2) · never enter the AsyncExitStack, so they are constructed and useless (1) · never declare the egress (2) · drop the allowlist on the built client (1). Two live docs claimed MCP was unwired in the run path; both corrected rather than left to rot. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ULCqjLF61rehj5cZmdUoR3
272 lines
27 KiB
Markdown
272 lines
27 KiB
Markdown
# 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](docs/research/2026-06-23-prior-art-platform.md) (§15 implementeringsregister), [plan](docs/plan/2026-06-23-incremental-plan.md). 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`](https://git.fromaitochitta.com/open/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_tool`s 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_error`s 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_llm`s 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.
|
|
- **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_dir`
|
|
→ `store.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_projection`s 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_summary`→`bundle_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 (`30000` ≠ `30000.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.
|
|
- **`BudgetExceeded`s 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.py`s `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.py`s 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
|
|
(`ScriptedChatClient` på `OpenAIChatCompletionClient` — 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.
|
|
- **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`/`rejected`
|
|
— `FeedbackContract` 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.)
|