Compare commits

...

115 commits

Author SHA1 Message Date
3bdc5c1b7c docs: MAF-gjeldens omfang malt, ikke bygget (F3/F15/F16/U16-17-19, ORDRE 20260825T214801Z)
F15 re-verifisert EKSTERNT i dag (29.08): core siste er 1.16.0 (28.08), ikke 1.15.0 -
repoet er sju minor bak, ikke seks. Scratch-venv-diff (1.9.0 vs 1.16.0, slettet etter
bruk) bekrefter de to konkrete private-API-premissene testen navngir holder strukturelt
stabile. Fresh U1-U19-telling flytter U12 fra nei til ja siden c08ae91 (checkpoint landet
26.08, etter reviewen) - 6/5/8 av 19, ikke reviewens 5/5/9. U17 og U19 lukkes i dette
dokumentet med begrunnelse (ordrens eget unntak for smaa U-rader); U16 er et ekte,
udesignet gap og forblir aapent med et konkret revisit-vilkaar. Ingen kode, pin eller
kjorende sti er rort.
2026-08-29 09:30:01 +02:00
56f4f6d084 feat(hitl): ekspertdommen kan ikke oppstaa av stillhet (F2, ORDRE 20260825T214801Z)
run_project KREVDE verdict_input og kjorte capture_verdict ubetinget; CLI-en
defaultet det til {"approved", "reviewed by expert"} og hosting listet det som
PAAKREVD. Netto: hver flaggloes kjoering myntet en ekspertgodkjenning ingen ga,
den gikk inn i den delte storen, og run_portfolio bar den inn i neste prosjekts
hypotese-prompt som en prior expert verdict -- paa flaten som ble overlevert
14.08. Non-goal 3, brutt i en soem.

RunResult.verdict er naa Verdict | None, og None er hva stillhet produserer:
ingenting myntes, ingenting lagres, ingenting varsles. Prinsippet sto allerede i
repoet -- RunFailure sin docstring: aa fylle et felt med en dummy legger
FABRIKKERT proveniens inn i aggregatet.

Traceability koster ingenting: RunResult.verdict_key (property, derivert fra
kandidaten) er verdicts.verdict_key sitt alt dokumenterte formaal -- identisk
med verdict.id naar en dom BLE gitt, og fortsatt meningsfull naar ingen ble det.
Det er den outboxen og den hostede responsen stempler.

Halv dom NEKTES paa begge doerer (FeedbackContract er eneste sted formen
valideres; CLI-en nekter ved navn FOER enhver mode-dispatch). Validering, aldri
reparasjon. De to mode-partisjonene fikk --decision/--rationale inn: kommentarene
sa ordrett at en aerlig nekt var uimplementerbar fordi de non-None
argparse-defaultene gjorde en eksplisitt verdi uskillbar fra defaulten -- med
defaultene borte er den implementerbar.

Hosting er WIDENING, ikke bryting: verdict_input flyttet fra _REQUIRED_FIELDS
til _OPTIONAL_FIELDS. Ingen ekstern kaller brekker.

AERLIGHETS-GRENSE: referanse-fixturens SYNTETISKE verdict_input-rader staar
uroert -- de er merket SYNTETISK paa fire steder og er reviewens F5 (maaling av
misjonspaastanden), ikke F2. Project.verdict_input er naa valgfri.

Load-bearing MAALT (tests/test_ungiven_verdict_loadbearing.py, 15 armer), aatte
mutasjoner alle roede mot HELE suiten + gronn kontroll 1080/5 og golden
demo-transcript.stdout BYTE-UENDRET (ea8c534773acdbe41ae68f2c55724d69aaf8be4f).
En mutasjon falsifiserte testen foerst (vakuoes-gate-klassen, ellevte gang):
--report-armen brukte et bart --report, som nekter rc 1 uansett fordi --ledger
mangler.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 01:22:07 +02:00
a1f8522bdf feat(explore): katalogkallet koster O(baser), ikke O(korpus) (ORDRE 20260825T213645Z)
list_bundles returnerte hele rot-indeksens body for HVER konfigurert base samtidig,
pluss ett JSON-objekt per ufulgt kryss-lenke. Begge vokser med korpuset, saa prisen
paa aa finne ut HVILKE baser som finnes ble satt av hvor mye de INNEHOLDER - progressiv
disclosure snudd paa hodet.

Maalt med o200k_base, instrumentet foerst validert mot commons' egne fasittall:
  tre flate Vegnormal-baser   112 116 -> 362 tokens   (-99,7 %)
  171 grenbaser               124 942 -> 21 448       (-82,8 %)
Grenformen (vegnormal-okf 8145c23) lukket bundle-siden og gjorde katalogsiden verre,
noeyaktig som det repoet forutsa.

Et premiss ble felt FOER noe ble bygget paa det: "indeksbodyen forteller hva basen
handler om" er usant for maskin-importerte baser - grenbasenes index.md er en ren
lenkeliste uten frontmatter og prosa, saa feltet var dyrt OG innholdsloest der.

Fast vindu (200 tegn), aldri en andel av basen. Avkorting annonseres som FELT
(index_truncated), og en base som passer blir ikke merket avkortet. En ufulgt lenke
overlever som ANTALL; per-lenke-detaljen blir liggende der den er handlingsbar.
Hele indeksen er fortsatt ett read_file(id, "index.md") unna.

Taket (500 tegn/base) bor i TESTEN, ikke i explore.py.

Load-bearing MAALT: tests/test_catalogue_cost_loadbearing.py, 7 armer, ni mutasjoner
alle roede mot HELE suiten + groenn kontroll 1066/5 og golden demo-transcript.stdout
byte-uendret (ea8c534773acdbe41ae68f2c55724d69aaf8be4f).

Ogsaa: MINOR-1 i syretest-rapporten rettet - flatheten er Doer C sin
(llm-ingestion-okf importer.py, §6-index-blokka), ikke vegnormals emitterform.
Verifisert mot kilden, ikke mot meldingen.

Maaling: docs/2026-08-26-katalogkostnaden.md
2026-08-26 14:45:16 +02:00
c08ae91809 feat(explore): plan-reviewen kan besvares over DAGER (U12 + asynkron U13, rad 3)
F4 gjorde "be om svar, BRUKE svarene" naabar, men bare SYNKRONT: terminal_plan_reviewer
blokkerer loekka paa et menneske ved en terminal, saa svaret maa komme mens prosessen lever.
Maalbilde §3s tidsskala er den andre - eksperten svarer dager senere, i en prosess som aldri
saa kjoeringen.

--checkpoint-dir PARKERER reviewen (FileCheckpointStorage + {run_id}-plan-review.json) og
avslutter; --resume <run_id> leser svaret fra --review-inbox i en fersk interpreter. Det
eneste som krysser prosessgrensen er disk.

MAALT FELLE (Verifiseringsloven ansikt 4): list_checkpoints (_checkpoint.py:386-388) svelger
en blokkert deserialisering til en logger.warning og returnerer TOM liste. Uten BEGGE
MagenticPlanReviewRequest/Response i allowed_checkpoint_types feiler en resume som et FRAVAER,
ikke som en feil. _ALLOWED_CHECKPOINT_TYPES har derfor EN kopi, checkpoint_storage er eneste
konstruksjonssted, og en tom listing ved park raiser CheckpointUnreadable i stedet for aa
skrive et spoersmaal ingen kan besvare.

Budsjettet og revisjons-capen spenner over suspensjonen (meter.charge(parked.tokens_spent) +
trace.ledger.extend), ellers faar hver park et helt budsjett paa nytt. Fail-closed paa
ekspertens egen fil: request_id-mismatch, ord utenfor vokabularet og revise uten innhold
refuseres alle ved navn. hitl.pending_plan_reviews er registeret over hvem som venter.

Load-bearing MAALT: 17 tester, TRETTEN mutasjoner alle roede mot HELE suiten, groenn kontroll
1059 passed / 5 skipped, golden demo-transcript.stdout byte-uendret
(ea8c534773acdbe41ae68f2c55724d69aaf8be4f).

EN MUTASJON FALSIFISERTE SUITEN (vakuoes-gate-klassen, tiende gang): detach av
trace.plan_reviews.extend(parked.plan_reviews) lot HELE suiten staa groenn - capen leser
parked.plan_reviews DIREKTE, saa den binder uansett, og de to foerste legene er identiske
under begge implementasjoner. Gaten maatte bli det TREDJE leget, der artefaktet ellers taper
dag 1s revisjon og to ulike planer deler indeks 1. Ny test skrevet mot mutasjonen foerst.

Aerlighets-grenser: hostet flate NEKTER fortsatt (synkron review ville blokkert baade
requesten og event-loekka som svarer /readiness); en park midt i loepet etter en stall har
ingen naabar sti under det skriptede manuset, saa carry-overen som betjener den drives gjennom
en CRAFTED parkert tilstand.

Ordre 20260825T114645Z-6622513622-from-portfolio-optimiser.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 12:26:04 +02:00
6020f4453d docs(explore): PlanReviewInputError inn i unnslippings-listen (F4-oppfoelging) [skip-docs]
Kommentaren over explore-kallet i run.py teller opp hva som fortsatt KAN forlate sloeyfa etter at
konfig-nektene er hoistet. Etter F4 er det tre ting, ikke to - en ubesvart plan-review er den
tredje. Repoet gater prosa-paastander flater gjoer om seg selv; en liste som ikke lenger er komplett
er den samme klassen, bare i kommentarform.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LtMDsh2zfp4Bmw8KGJ4aLD
2026-08-26 00:36:45 +02:00
84e8de8679 feat(explore): --plan-review gjoer "be om svar, bruke svarene" naabar fra CLI (F4, ORDRE 20260825T133139Z)
F4 fra misjonsreviewen: begge operatorflatene nektet enable_plan_review, og eneste doer var
explore(..., plan_reviewer=...) i bibliotek-APIet. Maalbildets HITL-loop var dermed unaabar for
enhver som ikke importerte pakka. Reviewens tre fil:linje-paastander ble verifisert mot kilden foer
bygging og stemte.

Flate: CLI. `--plan-review` bygger en terminal_plan_reviewer() og gir den til den UENDREDE sloeyfa.
Operatoren vises planen og svarer "approve" eller "revise <hva>"; en revisjon gaar tilbake til
manageren, som replanlegger og spoer IGJEN om den NYE planen.

Gaten er den ANDRE halvdelen av setningen. En doer som printer planen, leser linja og kaster den
bestaar "operatoren ble spurt" og feiler maalbildet -- repoets vakuoes-gate-klasse. T1 er derfor
test_explore_loadbearing sin T15 loeftet til CLI-niva og er ROED mot en alltid-godkjenn-reviewer.
Vitnet er {run_id}-exploration.json (skrevet fra en finally), ikke skrapet stdout.

Fail-closed paa operatorens egen input: alt utenfor vokabularet spoerres paa nytt, og EOF raiser
PlanReviewInputError -- stillhet er aldri en signatur.

Fire nekter ved navn, hvorav to lukket et stille dropp ingen test dekket: report_forbidden (report-
modus returnerer FOER hver utforsknings-nekt) og portefoelje-partisjonen. De to konfig-avhengige
nektene deler tokenet enable_plan_review og har derfor ulik saertekst; den eksisterende testen
asserterte paa det delte tokenet og er rettet (oekt-57-mutasjonen, niende gang).

Hosting nekter fortsatt -- reviewen er synkron og ville blokkert bade HTTP-requesten og event-loekka
som svarer /readiness -- men meldingen navngir na CLI-doeren i stedet for aa paasta at biblioteket er
den eneste.

Mid-loep-spoersmaal er IKKE bygget, og fravaeret er MAALT: _magentic.py har noeyaktig ETT
ctx.request_info (:1044, plan review) i hele modulen. Reviewens "kun plan-review FOER loepet" er
derimot upresist -- samme forespoersel fyrer ogsaa ved re-plan etter en stall.

Load-bearing MAALT (tests/test_plan_review_cli_door_loadbearing.py, 12 tester), elleve mutasjoner
alle roede mot HELE suiten + groenn kontroll 1040/5 og golden demo-transcript.stdout BYTE-UENDRET
(ea8c534773acdbe41ae68f2c55724d69aaf8be4f).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LtMDsh2zfp4Bmw8KGJ4aLD
2026-08-26 00:27:36 +02:00
444fea7e94 fix(explore): lukk KeyError:'navigator' i --explore --scripted-replies
_SCRIPTED_ROLES = ("proposer", "checker") var det ENESTE rollesettet
_load_scripted_replies validerte OG returnerte. explore()s tre ekstra
roller (manager/navigator/hypothesiser) ble filtrert bort selv når de
fantes i --scripted-replies-JSON-en, og manglet en av dem krasjet CLI-en
med en rå KeyError midt i eksplorasjonssløyfa i stedet for en ren
"run refused:"-linje (MAJOR-2, docs/2026-08-25-syretest-vei-ab.md).

_EXPLORATION_SCRIPTED_ROLES legges nå til kravet KUN når --explore er
satt, slik at en manglende rolle nektes VED NAVN ved døren, før
explore() kalles. En rein debattkjøring skal ikke måtte svare for
roller den aldri bruker.

Målt (ikke bare antatt): med alle fem roller scriptet fullfører CLI-en
uten krasj, men sløyfa er fortsatt delvis vakuøs (1 runde, 0 approaches)
som syretesten forutså — en konstant streng per rolle kan ikke svare
korrekt på magentic-managerens stadiespesifikke former.

1028 passed / 5 skipped (+3 nye tester, RØD→GRØNN). Golden
demo-transcript byte-uendret.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YSHNrYvKxDR5uct1QoVZng
2026-08-25 15:42:15 +02:00
932ece345b fix(docs): rett README-statusen — to usanne paastander paa offentlig flate (ORDRE 20260825T122454Z)
"no live-model run yet" var falskt siden 14.08 (docs/2026-08-14-fase1b-forste-levende-kjoring.md):
en live koersel mot gpt-4.1-mini konkluderte "rejected" — korrekt utfall, men modellen fant opp
en kostkode. "sibling built in parallel" stemte ikke: po-claude er PARKERT (STATE.md). Begge
punkt verifisert mot primaerkilde foer retting (Verifiseringsloven ansikt 3).

Statusavsnittet er ogsaa gjort mer noekternt: ingen validerte forslag mot levende modell enna,
ingen ekte ekspertdommer i treet (kun AI-forfattede froe, merket som saadan).

Utvidet test_public_surface_claims_loadbearing.py med to nye rode->groenne gater (Iron Law):
begge paastandene kan ikke gjeninnfores usett. Fant og fikset en linjebrudd-felle i egen test
underveis (blockquote-wrap gjorde "no live-model run yet" usynlig for et raatt substring-soek).

Ingen kodeendring i src/. Ingen push til open (Azure-gatet, egen ordre). 1025 passed / 5 skipped.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RF8QXXBWJ917Uj9zMf18nz
2026-08-25 15:07:08 +02:00
98cbb80a45 docs: syretesten vei A/B — tre Vegnormal-baser MAALT gjennom po (ORDRE 20260825T111038Z)
MAAL, IKKE BYGG: ingen fil under src/ er endret. 1021 passed / 5 skipped (166 s),
golden demo-transcript byte-uendret (ea8c534773acdbe41ae68f2c55724d69aaf8be4f).

Alle seks maalepunkter dekket, hvert tall fra en kommando kjoert i oekten.
Instrumentet er validert mot commons' tre fasittall (3861/12595/10406) foer
Vegnormal-tallene ble konsumert.

Kjernetall: 446/1017/270 konsepter; 93 422 / 250 785 / 85 937 o200k_base-tokens
(sum 430 144); list_bundles() = 112 116 tokens i ETT verktoeykall; 0 av 3 baser
har validator-input.json eller cost-baseline.json; 0 nestede index.md av 1733.

Funn: BLOCKER-1 kontekstkostnaden gjoer live utforskning ugjennomfoerbar som
korpuset staar · MAJOR-1 gjentatt --bundle-dir forkastes STILLE, exit 0
(run.py:1581-1583 + :2070) · MAJOR-2 --explore --scripted-replies krasjer med
KeyError: 'navigator' (run.py:1531) · MAJOR-3 multi-base = N PROSJEKTER, ikke
1 prosjekt x N referansebaser (okf.py:433 via run.py:1491) · MINOR-1 flat
importform · NICE-1 ukjent base nektes ved navn.

Punkt 5 delvis vakuoest, som ordren forutsaa: sloeyfa FULLFOERER offline mot tre
baser og produserer et rutet mandat (bundle_id, stop=None), men 0 verktoeykall og
0 quick_validate - navigatoren aapnet aldri en base. Plumbing bevist, verdi ikke.
Syretesten trenger en levende modell.

Eksponerings-grensen holdt: ingen bundle kopiert, ingen kravtekst gjengitt,
rapporten baerer kun tall/stier/kommandoer/egne observasjoner. Ingen Azure-
handling, ingen live modellkall, ingen push til open.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q4zGuDG2X7FQHMWBTDPLxF
2026-08-25 14:35:15 +02:00
d01a157986 docs: Fable 5 misjons-review — nærmer systemet seg faktisk målet? (ORDRE 20260825T104711Z)
Uavhengig adversarial review av hele repoet mot README-løftet, non-goals,
status-advarselen, operatørens målbilde (23.08) og §15.1 U1–U19. Konklusjon:
DELVIS — mekanismen er komplett og målt grønn (1021/5, ruff, mypy, golden
ea8c534), misjonsbeviset mangler (null validerte forslag mot levende modell,
null ekte ekspertdommer, ingen måling av utforskningens verdi), og
MAF-dekningen er 5 fullt / 5 delvis / 9 nei av 19 på en versjon seks
minor-releaser bak. Ingen kodeendringer.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014hWpJms7fLyC1BtwAmffFg
2026-08-25 14:12:23 +02:00
785261f229 docs: multi-base som invariant + den ende-til-ende-sloeyfa som beviser den (ORDRE 20260825T080753Z)
CLAUDE.md-invarianten «Multi-base er en PARTISJON, aldri en videre
run_project-signatur» baerer hele designet: den strukturelle grunnen til at
run_project ikke KAN ta flere bundle_dir (fire enkeltverdier avledet fra DEN
basen), de tre soemmene, hvorfor dispatchen ikke tar project_id, de tolv maalte
mutasjonene, og de fire uttalte aerlighets-grensene - inkludert at CLI-en er
BEVISST uroert (§ C.8 ber om ETT nytt kallsted i run.py, levert i 57; et
repeterbart --bundle-dir er en NY operatoerflate og en egen beslutning).

README faar multi-base-doeren beskrevet der --explore alt er beskrevet, med den
samme nekten uttalt for en leser som ikke leser CLAUDE.md: en hypotese som ikke
navngir noen base blir NEKTET naar flere er konfigurert, aldri rutet til en
gjetning.

T22 er ende-til-ende-vitnet, og det eneste stedet de tre soemmene moetes:
prompt + TO baser -> hypotesiseren former to retninger og navngir hver sin base
-> route_by_bundle partisjonerer -> hver base sin run_project svarer for SIN
hypotese og ingen andres. Assertet paa COVERAGE-radene, ikke paa kall-argumenter,
fordi det er rapporten en fagperson faktisk leser: en approach som havnet i feil
base ville fortsatt sett evaluert ut.

1021 passed / 5 skipped; golden demo-transcript.stdout byte-uendret
(ea8c534773acdbe41ae68f2c55724d69aaf8be4f); mypy/ruff rene.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L3YHobQC3WzYVoxSgZus4d
2026-08-25 13:41:31 +02:00
18af86e422 feat(explore): U4+U13 del 3 - multi-base er en PARTISJON, ikke en videre signatur (ORDRE 20260825T080753Z) [skip-docs]
Ordrens premiss («endrer run_project sin signatur») er FALSIFISERT foer bygging,
og ordren ba selv om nettopp den sjekken. run_project kan ikke ta mer enn en
bundle_dir: paa bundle-stien avleder den FIRE enkeltverdier fra DEN basen -
prosjektet (_project_from_bundle fail-faster naar basens egen project_id ikke er
den forespurte), validatorens stage-0-baseline, agentenes lesekontekst og
ExpeL-noekkelen - og returnerer ETT stemplet RunResult. En andre katalog paa den
signaturen ville tvunget et stille velg-en for alle fire. Planens egen setning
sier det samme lest naert: «pipelinen kjoeres per bundle som i dag
(run_portfolio-formen)» = N kall, ikke ett kall med N.

Levert form, tre soemmer:
- mandate.Approach.bundle_id (default "" - hvert mandat skrevet foer i dag er
  fortsatt gyldig og dispatchbart uendret)
- mandate.route_by_bundle - ren partisjon, fail-fast paa et mandat som ikke kan
  utfoeres som skrevet (load_mandate-regelen). En base som ingen approach navngir
  kjoeres ikke; med NOEYAKTIG en base absorberer den alt uten navn, som ikke er en
  gjetning men det eneste mulige svaret.
- run.run_mandate_across_bundles - dispatchen. INGEN project_id-parameter:
  hver base sitt prosjekt leses fra DEN basens egen IR-projeksjon, altsaa den
  verdien _project_from_bundle allerede fail-faster mot. En delt VerdictStore
  traades paa tvers (kryss-base-laering, run_portfolio-formen), og med
  portfolio_meter gjelder de to S3.4-tennene som HAR mening her: oppstartsnekt
  (BudgetRefused) og aldri-startet + budget_stop + not_evaluated-rader.

INGEN eksisterende kaller endrer signatur - CLI, hosting og simulation sender
fortsatt en base hver, og kan fortsatt gjoere det.

explore(): hver myntet approach baerer bundle_id; markoeren kan navngi basen;
en umerket markoer med flere baser NEKTES (HypothesisParseError), med en base
resolveres den. Froe-approaches VALIDERES men skrives ALDRI om (§ C.6 doer 1 er
en bevaringsregel) - og valideringen skjer FOER foerste modellkall, samme
oekt-57-hoist-grunn: ved unntaket alene ser en nekt etter forbruket identisk ut.
explore()-docstringens gamle aerlighets-grense («venter paa at run_project tar
mer enn en bundle_dir») er RETTET - den ville vaert usann fra i dag.

Load-bearing MAALT, 12 mutasjoner alle roede mot HELE suiten, groenn kontroll
1020/5 (fra 999/5 + 21 nye tester); golden demo-transcript.stdout BYTE-UENDRET
(ea8c534773acdbe41ae68f2c55724d69aaf8be4f): detach myntet bundle_id (2) ·
stille gjennomfall ved >1 base (1) · ukjent id resolvert etter rekkefoelge (1) ·
froe-sjekk etter forbruket (2) · ruteren gjetter foerste base (1) · uroutbar
approach droppet (1) · dispatchen kollapser til en base (5) · project_id fra
foerste base (2) · detach aldri-startet-tannen (1) · unreached urapportert (1) ·
fersk store per base (1) · detach oppstartsnekten (2).

EN MUTASJON FALSIFISERTE TESTEN FOERST (repoets vakuoes-gate-klasse, aattende
gang): store-testen sammenlignet med ==, og VerdictStore er en pydantic-modell
med VERDI-likhet - tre ulike tomme stores er alle like, saa «fersk store per
base» lot hele suiten staa groenn. Delt INSTANS er paastanden, saa testen
asserterer naa paa `is`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L3YHobQC3WzYVoxSgZus4d
2026-08-25 13:35:39 +02:00
3cbea91a72 fix(explore): utforskningen skal ikke brenne budsjettet paa en argv som ikke kan fullfoere (ORDRE 20260823T204216Z)
Tre review-funn fra oerkt 57, alle handtert.

1. DEFEKT, funnet i review og REPRODUSERT foer fiks: --explore --outbox-dir uten
   --run-id kjoerte HELE utforskningen og ble deretter nektet av run_project, som
   eier outbox-kontrakten og nekter paa sin FOERSTE setning - tidlig nok for enhver
   sti som fantes foer U4, men utforskningen kjoerer FORAN det kallet. Artefakt-
   skrivingen hoppet ogsaa over (den krever begge), saa ikke engang regnskapet over
   hva som ble brukt overlevde. Fiksen er en HOIST i utforskningsblokka, ikke en
   andre kopi av regelen - samme hoist main() alt gjoer for de paakrevde argumentene,
   av samme grunn. Testen asserterer at INGEN modellkall skjedde, ikke bare at rc er
   1: ved exit-koden ser en nekt etter forbruket identisk ut.

2. VAKUOES ASSERT FJERNET (repoets egen klasse, snudd innover): scenarioets
   label_in_bundle ble beregnet av den SAMME variabelen vakten raiser paa, saa feltet
   kunne strukturelt kun vaere False og assertet kunne ikke feile mot noen
   implementasjon - mens docstringen kalte det en kausalitetskontroll «akkurat som
   marker_in_run_a_prompt». Feltet og assertet er borte; vakten ER kontrollen, og en
   alltid-sann gjentakelse av den ville bare gjort den ekte lettere aa avfeie.

3. MAALINGS-PAASTANDEN PRESISERT: M10 (fjern --explore fra portefoelje-partisjonen)
   ble re-maalt mot HELE suiten etter test-fiksen - foer sto den kun maalt med -k.
   Invarianten sa «seksten mutasjoner mot HELE suiten + groenn kontroll 998/5» og
   slo dermed sammen tre ulike kontroller; den oppgir naa alle fire (990/5, 996/5,
   998/5, 999/5). En paastand om egen maaling som er upresis om sin egen nevner er
   premiss-vs-faktum-regelen vendt innover.

Pluss en uttalt aerlighets-grense i invarianten: den HOSTEDE flaten gir ingen innsyn
i hva som formet mandatet (ingen outbox, intet utforskningsfelt i _response_payload).
Bevisst scope-grense, men uttalt, fordi flatens hele argument er at svaret er
etterproevbart.

Sytten mutasjoner totalt, alle roede mot HELE suiten. 999 passed / 5 skipped.
Golden-transkriptet byte-uendret (ea8c534773acdbe41ae68f2c55724d69aaf8be4f).
mypy + ruff rene.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YRZhBJcxqTcqWyMW6hBttx
2026-08-25 09:58:28 +02:00
2d44c5e224 docs: U4-kallstedene som invariant + --explore paa den publiserte flaten (ORDRE 20260823T204216Z)
CLAUDE.md: ny invariant «Utforskningens KALLSTEDER» - det kaller-eide sporet og
hvorfor formen er tvunget av en maaling og ikke valgt, tredelingen av hostings
whitelist og den NEGATIVE halvdelen Fase 4e-beviset trengte, de to nektene som baerer
en beslutning (to kilder til ett mandat; enable_plan_review nektet FOER explore()
fordi ExplorationError er en RuntimeError), demo-scenarioets vakuitets-vakt, seksten
maalte mutasjoner, og den ene som falsifiserte testen foerst. Linja «--explore i
run.py ... er IKKE bygget (oekt 57)» er fjernet - den er ikke sann lenger.

README: --explore/--explore-config i single-project-partisjonen, med bundene som maa
oppgis og hvorfor ingen av dem har en default, nekten mot --mandate og doera som
faktisk betjener fagpersonens egne hypoteser, samt {run_id}-exploration.json og det
hostede explore_prompt.

998 passed / 5 skipped. Golden-transkriptet byte-uendret.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YRZhBJcxqTcqWyMW6hBttx
2026-08-25 09:43:46 +02:00
118eabf9db feat(explore): U4 kallsted 3 - demo-scenarioet, naabart ved NAVN og bare der (ORDRE 20260823T204216Z) [skip-docs]
Kallsted (3), siste av oerkt 57s fire.

simulate_exploration er et TREDJE scenario ved siden av simulate_learning_loop, og
bevisst ikke en del av det: demoens stdout og stderr er begge byte-pinnede fasiter,
og laeringsgjennomgangens paastand (en dom krysser to kjoeringer) er en ANNEN
paastand enn denne (en prompt + en kunnskapsbase blir et mandat pipelinen
evaluerer). Aa slaa dem sammen ville flyttet et pinnet transkript av en grunn som
ikke har noe med det transkriptet pinner aa gjoere. main() kaller det ikke - og at
golden-transkriptet er byte-uendret ETTER at scenarioet ble lagt til er selve
maalingen av det (ea8c534773acdbe41ae68f2c55724d69aaf8be4f).

VAKUITETS-VAKTEN er den baerende delen: en label kunnskapsbasen ALLEREDE oppgir
ville naadd hypotese-prompten som ordinaer navigert kontekst enten utforskningen
kjoerte eller ei, saa scenarioets egen assert ville holdt mot en implementasjon som
aldri wiret mandatet. Refusert, ikke demonstrert - noeyaktig samme vakt
simulate_learning_loop raiser paa naar de to markoerene faller sammen.

Manager-manuset noekles paa PROMPT-STADIET, ikke paa prosjekt-ID-en, og det er ikke
et unntak fra scripted_proposer-regelen: manageren faar FEM ulike spoersmaal (fakta,
plan, progress ledger, replan, sluttsvar), og hvilket det er er det eneste et svar
KAN noekles paa - prosjekt-ID-en er konstant over alle fem og ville valgt ingenting.

Aerlighets-grense, samme som resten av demoen (maalbilde §1): hvert svar er skriptet,
saa det som vises er at roerleggingen lukkes - at en formet retning faktisk blir en
Approach proposeren blir spurt om - aldri at en levende modell ville formet en god en.

Load-bearing MAALT (tests/test_explore_callsites_loadbearing.py, 2 nye tester), to
mutasjoner begge roede mot HELE suiten + groenn kontroll 998/5: detach mandate= fra
scenarioets run_project (1 roed) - detach vakuitets-vakten (1 roed).

mypy + ruff rene.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YRZhBJcxqTcqWyMW6hBttx
2026-08-25 09:38:30 +02:00
234c8d138a feat(explore): U4 kallsted 2 - hostet explore_prompt, og whitelisten blir en TREDELING (ORDRE 20260823T204216Z) [skip-docs]
Kallsted (2) av oerkt 57s fire.

explore_prompt + explore_contract whitelistes paa den hostede flaten. De er ikke
run_project-parametre - utforskningen kjoerer FOERST og gir run_project et Mandate -
saa de KONSUMERES i stedet for aa videresendes, og whitelisten er dermed en
TREDELING (_REQUIRED / _OPTIONAL / _CONSUMED). Ett sted (_run_kwargs) avgjoer hvilke
felt som naar signaturen: et konsumert felt som blir liggende i kwargs er et argument
run_project ikke har, som containeren svarer 500 paa for det som egentlig er en
wiring-feil.

FASE 4e-REGELEN UTVIDET MED EN NEGATIV HALVDEL, og det var paakrevd: 4e-testen
sender HVERT whitelistet felt gjennom den EKTE run_project og asserterer dekning mot
_ALLOWED_FIELDS - en assert et konsumert felt ALDRI kan oppfylle. Den positive
halvdelen dekker naa _REQUIRED|_OPTIONAL (hvert felt maalt mot
inspect.signature(run_project)), og den negative at _CONSUMED er DISJUNKT fra samme
signatur. Uten den ville et felt som glir fra konsumert til videresendt vaere nettopp
driften 4e finnes for.

Fire nekter, alle ved navn og alle paa KALLERENS kanal (400):
explore_contract uten explore_prompt - explore_prompt uten explore_contract -
explore_prompt uten bundle_dir - enable_plan_review=true.

Den siste er nektet HER og ikke i explore(), som ogsaa nekter den: ExplorationError
er en RuntimeError, saa aa overlate den til sloeyfa ville svart en kallers
konfigurasjonsfeil paa KRASJ-kanalen (500) - samme sammenblanding BudgetExceeded
fikk sin egen 429 for aa avslutte. U13-doera er dessuten SYNKRON: den blokkerer
sloeyfa paa et menneske, og en HTTP-forespoersel har ingen - invocationen ville
hengt i stedet for aa svare. TracingConfigError er derimot en ValueError, saa 400-
armen dekket den alt (verifisert, ikke antatt).

Load-bearing MAALT (tests/test_explore_callsites_loadbearing.py, 6 nye tester), fire
mutasjoner alle roede mot HELE suiten + groenn kontroll 996/5: videresend de
konsumerte feltene igjen (2 roede) - detach mandate=-wiringen (1 roed) - detach
enable_plan_review-nekten (1) - detach bundle_dir-kravet (1).

Golden-transkriptet byte-uendret (ea8c534773acdbe41ae68f2c55724d69aaf8be4f).
mypy + ruff rene.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YRZhBJcxqTcqWyMW6hBttx
2026-08-25 09:25:01 +02:00
8ba824c96f feat(explore): U4 kallsted 1 - --explore former mandatet, og sporet overlever taket (ORDRE 20260823T204216Z) [skip-docs]
Kallsted (1) + artefaktet (4) av oerkt 57s fire.

--explore "<prompt>" + --explore-config FILE i run.py: utforskningen kjoerer FOER
pipelinen og mandatet den former gaar rett inn i run_project(mandate=...). Flagget
er opt-in, og hver ting det ikke kan aere NEKTES ved navn:

- --explore-config uten --explore (--embedder-config-presedensen, ordrett)
- --explore uten --explore-config: CLI-en oppfinner ALDRI grenser, fordi
  MagenticBuilders egen fallback er "ubegrenset"
- --explore + --mandate: TO KILDER TIL ETT MANDAT. Nektet, aldri slaatt sammen -
  explore() tar objective fra prompten og hardkoder allow_own_proposals=True, saa
  en sammenslaaing ville stille overskrevet tre felt operatoeren skrev selv.
  Nekten NAVNGIR biblioteksdoera (seed_approaches), fordi C.6 doer 1 er et ekte
  behov denne flaten ikke betjener
- --explore + --live-dry-run (motstrid), --explore uten --bundle-dir (leser
  ingenting), --portfolio --explore (partisjonen)
- enable_plan_review=true nektes HER, ikke i explore(): ExplorationError er en
  RuntimeError og ligger UTENFOR main()s (ValueError, FileNotFoundError,
  ValidationError)-tuppel, saa den ville forlatt som traceback i stedet for rc 1

ExplorationTrace er en KALLER-EID akkumulator (funn-1-sinken, ett lag opp):
explore() raiser BudgetExceeded paa rundetaket og tokentaket fyrer fra middleware
midt i loepet - paa begge stier finnes ingen ExplorationResult, og C.2 krever at
artefaktet er lesbart uansett hvilken vakt som fyrte. ExplorationResult.ledger_log
BYGGES FRA akkumulatoren, aldri ved siden av (kø-(p)).

{run_id}-exploration.json skrives fra en finally (write_parse_failures-presedensen)
med rundene, plan-reviewene og quick_validate-dommene - de siste bor bevisst ikke i
ExplorationResult. `completed` er et eget felt: en stop: null som betyr BAADE
"avsluttet normalt" og "vi fikk aldri vite" er stillheten cost_baseline_anchored
ble paakrevd for aa lukke.

Load-bearing MAALT (tests/test_explore_callsites_loadbearing.py, 15 tester), ti
mutasjoner alle roede mot HELE suiten + groenn kontroll 990/5: detach sink-appenden
(1 roed) - andre liste for rundene (4 roede) - detach --mandate-nekten (1) - detach
--explore-config-nekten (1) - skriv artefaktet kun naar kjoeringen fullfoerte (1) -
detach mandate= inn i run_project (1) - slipp enable_plan_review gjennom (1) -
detach --bundle-dir-kravet (1) - detach --live-dry-run-nekten (1) - fjern --explore
fra portefoelje-partisjonen (1).

EN MUTASJON FALSIFISERTE TESTEN FOERST (repoets vakuoes-gate-klasse, syvende gang):
portefoelje-testen asserterte kun at meldingen nevnte --explore, og var groenn UTEN
partisjonen - kjoeringen falt da gjennom til "--explore requires --bundle-dir", som
nevner --explore ogsaa. To nekter som deler en delstreng; testen navngir naa
--portfolio.

Golden-transkriptet byte-uendret (ea8c534773acdbe41ae68f2c55724d69aaf8be4f).
mypy + ruff rene.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YRZhBJcxqTcqWyMW6hBttx
2026-08-25 09:08:49 +02:00
ddc33eed8b docs: fagpersonens bidrag - spørsmål/sjekkliste til fagperson (ORDRE 20260824T092912Z) [skip-docs]
Leveranse for sidespor-ordren: konkret presentasjon om hva en fagperson
bidrar med før/under en kjøring (rolle, sjekkliste, feiltyper, dom som
produkt). Kun ny fil - ingen andre docs endret, ingen kodeendring.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WmF3iYHFYvQbD8F7AGxYqg
2026-08-24 15:24:51 +02:00
f0c54cc8dc feat(explore): U4+U13 synkron - utforskningssloeyfa som mandat-former (ORDRE 20260823T185602Z) [skip-docs]
Magentic legges OVER den normative sloeyfa, aldri inni Steg 3: prompt +
kunnskapsbaser -> Mandate -> run_project(mandate=...) UENDRET. Manageren velger
VEI; det som forlater friheten er et Mandate, aldri et forslag. explore() skriver
ingenting - niva 3 (skriverettigheter) tilhoerer pipelinen alene.

Levert i denne oekten (kjernen; kallstedene staar til oekt 57):
- ExplorationContract: seks paakrevde felt uten default. max_reset_count=0 nektes
  paa en MAALING - reset_count >= max_reset_count mot en teller som starter paa 0
  terminerer kjoeringen FOER foerste runde med null ledger-events, altsaa en
  utforskning som utforsket ingenting, forkledd som en stall som aldri skjedde.
- explore() + fresh_exploration_workflow(): fersk builder per utforskning,
  BudgetMiddleware paa HVER agent inkl. manageren, synkron plan review via
  request_info, og max_plan_revisions som binder den ubundne revise-loekka.
- Tre kanaler: tokens OG runder raiser BudgetExceeded (rundene oversatt av vaart
  lag som kind="exploration_rounds", fordi orkestreringen maalt ikke raiser ved
  sitt eget rundetak); alt semantisk er en VERDI i stop.
- quick_validate (niva 1, raadgivende) + navigator-verktoey over safe_resolve.
- U14s tre utsatte events landet som span-events paa EN exploration-span.

Load-bearing MAALT mot HELE suiten, groenn kontroll 975/5, golden ea8c534
uendret: tolv mutasjoner alle roede. TO av dem falsifiserte testen foerst -
skrivefrihets-testen naadde aldri en verktoeykropp (ScriptedChatClient emitterer
ingen verktoeykall), og stdout-testens capsys er blind for ConsoleSpanExporter,
hvis out-default bindes ved modulimport. Begge er rettet; stdout-armen er naa en
subprosess, som er P4-presedensen.

[skip-docs] fordi flaten ikke er naabar for en bruker enna: --explore, det
whitelistede hosting-feltet og sim-scenarioet bygges i oekt 57, og en
README-oppfoering naa ville vaert en paastand om en inngang som ikke finnes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 22:39:10 +02:00
4a19d39e63 feat(tracing): U14 - sporing er opt-in, og "av" betyr at MAF aldri kalles (ORDRE 20260823T165757Z)
PORTFOLIO_OTEL er eneste bryter, lest paa truthiness. Uten den kalles
configure_otel_providers ikke i det hele tatt: spans lages fortsatt
(ENABLE_INSTRUMENTATION defaulter True) og kastes, saa ingenting KAN forlate
prosessen. `console` skriver spans til stderr - demoens stdout er byte-identisk
med fasiten, maalt. `otlp` eksporterer over nett, og kun mot et endepunkt
operatoeren selv har navngitt.

To regler er MAALT, ikke valgt (observability.py:849 bygger exporter-lista i
fast rekkefoelge):

- enable_console_exporters sendes EKSPLISITT False i begge moduser. Overlatt til
  miljoeet faller den tilbake paa ENABLE_CONSOLE_EXPORTERS, hvis
  ConsoleSpanExporter skriver til STDOUT - nettopp det S6 maalte som oedeleggende
  for goldenen.
- `console` NEKTER naar en OTEL_EXPORTER_OTLP_*_ENDPOINT finnes: env-avledede
  exportere bygges UBETINGET og FOER vaare, saa ordet "console" ville vaert en
  usann paastand om hvor kjoeringens innhold tok veien. Validering, ALDRI
  reparasjon - vi fjerner ikke operatoerens variabel bak ryggen paa dem.

Tre kallsteder (run.main, simulation.main, hosting.main): demoen er et skriptet
bevis, ikke produktet, og en soem bare demoen naar ville latt de to inngangene en
virksomhet faktisk kjoerer vaere usporbare. tracing_notice er ENESTE renderer og
returnerer None naar sporing er av - omisjon, aldri tom rad.

IKKE bygget, med grunn: PLAN_CREATED/REPLANNED/PROGRESS_LEDGER_UPDATED hoerer til
sloeyfa U4 bygger; en emitter uten kallsted er en form gjettet i stedet for maalt.
OTLP-exporter-PAKKENE er bevisst ikke deklarert (egress + grpc/protobuf-vekt i et
publisert wheel); uttalt i README/DEPLOY/env.template.

Ny dep: opentelemetry-sdk>=1.42,<2 (operatoerbeslutning 2, 23.08). EN pakke, ikke
to - ConsoleSpanExporter bor inne i sdk-en. opentelemetry-api fulgte med
1.42.1 -> 1.44.0, maalt uskadelig.

Load-bearing MAALT (tests/test_tracing_loadbearing.py), ni mutasjoner alle roede
mot HELE suiten + groenn kontroll 943/5. Tre av de roede bor i tester som fantes
fra foer (golden-transkriptets fire-linjers stderr + portefoelje-CLI-ens stille
pass), altsaa er omisjons-regelen gatet av uavhengige vitner.

Golden ea8c534... uendret. mypy src + ruff rene.

Ordren tar ogsaa de fire operatoerbeslutningene inn i planens paragraf F.
Laasen paa orchestrations 1.0.1 er ENDELIG (operatoerbekreftelse 23.08), ikke
midlertidig: spike-ordrens "revert hvis E7 staar" er overstyrt av den senere
beslutningen, som betinget paa groenn suite - ikke paa E7.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 20:51:46 +02:00
9f0843ab6d test(spikes): S0-S6 maalt for Magentic-utforskningssloeyfa (ORDRE 20260823T162224Z) [skip-docs]
Maaling foer bygging. Alle aatte antakelsene i planens § F flyttet fra «umaalt» til
et maalt utfall; ingenting bygget, ingenting i src/.

S0 (versjon): 1.0.1 loeser paa core 1.9.0. Diffen mot 1.0.0 er upstream #4371 -
`StandardMagenticManager.__init__` mistet sin persistente `AgentSession`, og hvert
manager-kall mynter naa en engangs-sesjon. **E2 OG E4 er dermed BORTE (4/4 -> 0/5).**
E1 (single-use) og E7 (orphan `_agent_thread`, :1369) staar. Ordrens «felles hvis»
(E7 staar -> revert) hviler paa at 1.0.1 ikke kjoeper noe; den kjoepte noe stoerre
enn det som ble haapet, saa laasen staar paa 1.0.1 I PAAVENTE AV OPERATOEREN.

S1 (B7): E1-E4 + E7 i repoets form. Versjons-sensitiviteten testes mot en STRUKTURELL
sonde (holder manageren en persistent sesjon?), aldri en versjonsstreng - den sier
AARSAKEN og overlever en versjon planen ikke har sett.
S2 (budsjett): A1+A2 GROENNE. `BudgetMiddleware` fyrer paa manager-stien
(`meter.tokens == 8`), og `BudgetExceeded` forlater `workflow.run` som repoets EGEN
type med `kind`/`limit`/`observed` intakt - ikke pakket i en ExceptionGroup.
S3 (plan review): rundturen virker; en revise koster 2 manager-kall, 0 ledger-kall,
0 runder, og SPOER PAA NYTT -> `max_plan_revisions` maa inn i kontrakten.
S3b: doer 3 staar. To rundturer per menneskesvar; `from_strings` gjenopptar IKKE
manageren, kun `approve` gjoer det. Pris: `AgentApprovalExecutor` er ikke re-eksportert.
S4 (resume i NY prosess): GROENN. Pris: `FileCheckpointStorage` nekter aa deserialisere
plan-review-typene uten `allowed_checkpoint_types` - uten det feiler resume som et
FRAVAER (tom listing), ikke som en feil.
S5: median `validate_proposal` 13,6 ms - fritt kallbart i loekka.
S6 (scratch-venv, ingenting lagt til pyproject): 2 `workflow.run`-spans, men
`enable_console_exporters` skriver til STDOUT og ville oedelagt golden-transkriptet;
`ConsoleSpanExporter(out=sys.stderr)` gir spanene paa stderr OG byte-identisk stdout.

Klienten er repoets `ScriptedChatClient` og budsjett-typene er PRODUKSJONENS - en bar
`BaseChatClient` no-op-er middleware, og `spikes/_harness.py`s egen kopi er nettopp
grunnen til at koe-(y) fantes.

Load-bearing MAALT mot HELE suiten, groenn kontroll 920/5: konstant persistent-sesjon
(2 roede) · aldri fest middleware paa manageren (3 roede, detach-armen groenn) · detach
markoer-registreringen (1 roed) · flipp `_route`-rekkefoelgen (4 roede) · resume uten
`checkpoint_id` (1 roed) · builder uten `with_checkpointing` (1 roed) · tom
`_ALLOWED_CHECKPOINT_TYPES` (1 roed). Og EN falsifisert: resume uten
`checkpoint_storage=` gir 0 roede - planens E-tabell navngir feil detach-punkt, og
det er skrevet inn i § F i stedet for aa staa som en gate som ikke kan bli roed.

Planens V1-sti ble portabel i 2eb4622 (pakke-gaten var roed paa HEAD siden 2e33905).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UTEa7uw2JMxgijx8k8XgxG
2026-08-23 19:36:00 +02:00
2eb4622f44 docs(plan): portabel sti i V1-skriptet - pakke-gaten var roed paa HEAD [skip-docs]
Oekt 53s plan-commit (2e33905) bar `cd /Users/ktg/repos/portfolio-optimiser` i
V1-maalingsskriptet. `test_package_leaks_no_secret_content` leser `git archive HEAD`
og ble derfor roed i det oeyeblikket planen ble committet - suiten var ikke maalt
etter den commiten (STATEs «904 passed» er fra oekt 52). Ikke relatert til S0s
orchestrations-oppgradering: funnet er ordrett plan-dokumentets stistreng.

`git rev-parse --show-toplevel` holder skriptet kjoerbart og fjerner hjemstien.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UTEa7uw2JMxgijx8k8XgxG
2026-08-23 19:00:25 +02:00
2e33905ffe docs(plan): MAF-utnyttelsesrevisjon U1-U15 + Magentic-utforskningssloeyfe (ORDRE 20260823T125528Z) [skip-docs]
Planarbeid, ingen kode. Maalt: 7/9 av .claudes null-paastander holder (U5/U11 presisert),
Magentic er ikke kode-markert eksperimentell og Learn-siden baerer ingen Python-advarsel,
men "untested outside Magentic-One" staar. Installert 1.0.0 maalt: single-use workflow,
builder/manager-instans bloer (4/4), plan review virker uten revise-cap, max_round gir
kanonisk streng, ResetSignal nullstiller ikke deltakere, opentelemetry-sdk mangler.
Design: Magentic OVER sloeyfa som mandat-former -> run_project(mandate=) uendret.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013waQJ7HtoNDWnjKeBqw4Vk
2026-08-23 18:19:30 +02:00
ed696d2650 fix(okf): fail-closed ingest-stempel-sjekk paa YAML-1.1-sannhetsformer [skip-docs]
_carries_complete_ingest_stamp sammenlignet generated kun mot literalen "true" og
feilet AAPENT: en pinnet ingest-writer som skrev "yes"/"on" ville sluppet det
komplette eierskaps-stempelet forbi write_concept_file uten en eneste lokal diff.
_YAML_TRUE_LITERALS ({"true","yes","on"}, case-insensitivt) er naa ENESTE vokabular,
malt mot PyYAML sin safe_load-resolver; "1"/bare "y"/"n" er bevisst utelatt siden en
YAML-leser aldri leser dem som bool. Halv-stempel forblir lovlig.

RED-foerst (tests/test_ingest_stamp_fail_closed_loadbearing.py), fire mutasjoner
alle roede mot hele suiten (904 passed/5 skipped): revert til literalen "true"
(2 roede) - over-widen til aa inkludere 1/y (1 roed) - and->or paa halv-stempel
(4 roede) - detach gaten helt (4 roede).

Ordre: 20260821T152153Z-432035430-from-portfolio-optimiser

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhxZ7XNjAFHFenWX5j9pev
2026-08-21 19:20:51 +02:00
56c48f6f65 feat(visibility): en lenke som ikke ble fulgt sier det - spor + betinget linje (ORDRE 20260821T142704Z)
okf._walk toleret en ulesbar/utenfor-basen lenke uten aa etterlate spor (okf.py:182
"continue  # broken link"), og navigate_bundle returnerte kun filene den FANT. En base
der halve innholdet aldri ble lest var derfor umulig aa skille fra en base der de
dokumentene aldri ble skrevet - og toerrkjoeringen sa ingenting.

Toleransen er UROERT: OKF SPEC §4 krever at navigasjonen ikke kaster, og den kaster
fortsatt ikke. Dette er synlighet, ikke en ny nekt.

To tenner (samme form som ordre 20260821T092039Z, synlig uforankring):

1. okf.SkippedLink + Bundle.skipped - strukturert spor, aldri en streng: hvilken fil
   lenken sto i, lenketeksten ORDRETT (operatoeren redigerer den teksten, ikke den
   resolverte stien), og hvilken av de TO grunnene som gjaldt - outside-bundle (escape,
   ofte bevisst) eller missing (inne i basen, ingen lesbar fil, nesten alltid en
   skrivefeil). Dedup-grenen (canonical in seen) registreres ALDRI: den er korrekt
   navigasjon og det som terminerer sykler.
2. run.skipped_links_notice - EN renderer, tar den alt opploeste tuppelen, returnerer
   None naar ingenting ble hoppet over. Printes paa BEGGE flater: --live-dry-run og
   den fulle enkeltkjoeringen (en kjoering som PRODUSERTE et forslag fra en halvlest
   base er der tausheten kostet mest).

Defaulten er MOTSATT forrige ordres, og forskjellen er innsikten: cost_baseline_anchored
er paakrevd fordi begge defaults lyver, mens en TOM tuppel her er et aerlig positivt
utsagn ("hver lenke ble fulgt") - external_calls-presedensen. Vei-stien navigerer ingen
base, saa tom er bokstavelig sant der ogsaa.

Sporet bor paa RunResult.skipped_links (RUN-nivaa: navigasjonen skjer EN gang per
kjoering, foer noe forslag finnes), aldri paa ProvenanceStamp, som beskriver gaten som
doemte EN kandidat. Ingenting av dette naar bundle_context - derfor er de commons-eide
nav-goldenene byte-uendret, og Bundle( har fortsatt EN konstruksjons-sted (maalt).

Load-bearing MAALT (tests/test_navigation_visibility_loadbearing.py), aatte mutasjoner
alle roede mot HELE suiten + groenn kontroll 897 passed / 5 skipped:
  detach missing-registreringen (6 roede) · detach outside-bundle (2) · kollaps de to
  grunnene til en (2) · registrer dedup-grenen (1) · renderer returnerer alltid linja
  (3, inkl. kontrollene - omisjonen er selv gatet) · detach dry-run-printen (1) ·
  detach full-run-printen (1) · konstant tom trace ut av run_project (4).

Docs rettet der de paasto det motsatte: kunnskapsbase-for-en-kjoring.md §5.7 + §6,
presentasjon-bygge-kunnskapsbase.html (steg 8, steg 9, fallgruve 3, avslutningen),
README-ens navigasjonsavsnitt, og CLAUDE.md-ens navigasjons-kontrakt-invariant.
2026-08-21 17:18:18 +02:00
b6397229ca docs: innholdet i gjoer-dette-guiden (forrige commit bar kun omdoepingen) [skip-docs]
Forrige commit (3340fa1) staget ingenting: 'git add' fikk to stier, og den ene var
filen slik den het FOER omdoepingen. En ikke-eksisterende sti faar git add til aa feile
og stage INGENTING - stderr var omdirigert, saa feilen var usynlig, og commiten gikk
igjennom med 0 insertions. Dette er innholdet den skulle baaret.

15 slides, ni nummererte steg, null veglys-referanser, null filnavn eller kommandoer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DA6HAF8HFQxGYC2h6ypRQe
2026-08-21 12:38:58 +02:00
3340fa19da docs: presentasjonen skrevet om til en gjoer-dette-guide (9 steg) [skip-docs]
Operatoerretning: dropp veglys-eksemplet, gjoer den saa enkel og konkret som mulig, med
fokus paa hva som maa GJOERES for aa faa opp en kunnskapsbase for et optimaliseringsprosjekt.

Omskrevet fra konseptuell gjennomgang til handlingsrettet guide. Filen er doept om
(veglys -> bygge) fordi eksemplet ikke lenger baerer den.

Formen: ni nummererte steg i fire faser (ramme 1-3, innhold 4-6, bind sammen 7-8, kjoer 9).
Hvert steg sier hvem som gjoer det, hva som leveres, og har en 'Ferdig naar'-boks. I tillegg:
rollefordelingen (fagperson eier innhold, teknisk person eier form - og utleder ALDRI et tall),
en sjekkliste hvem-leverer-hva, og de fem dyreste fallgruvene.

Steg 2 (kostnadstallene) er markert gjennomgaaende som det som stopper prosjekter - det er
ogsaa den ene fallgruven som er usynlig i resultatet.

Bro-kolonnen fra forrige versjon er BORTE: tiltakstyper er naa generiske (utskifting til nyere
teknologi, behovsstyring, tilstandsbasert vedlikehold, levetidsforlengelse) med beskjed om at
fagmiljoeet eier listen for sin type. Ingen ufagverifisert domenepaastand staar igjen.

Verifisert: 15 slides, null veglys-referanser, null filnavn eller kommandoer i teksten.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DA6HAF8HFQxGYC2h6ypRQe
2026-08-21 12:38:25 +02:00
143c5865b9 docs: presentasjonen faar spoersmaalet om eksterne systemer (14 spoersmaal) [skip-docs]
Ny slide som skiller de TO soemmene, fordi de gir ulik integrasjon:
- FOER kjoeringen: kilden hentes og blir INNHOLD i basen, med opphav og dato, lesbart og
  korrigerbart foer kjoeringen starter. Selve kjoeringen gjoer da null nettverkskall.
- UNDER kjoeringen: tjenesten blir et VERKTOEY agenten kan kalle mens forslaget formes.
  Krever uttrykkelig liste over tillatte kall, at alt som kan kontaktes navngis foer
  foerste kall, og at toerrkjoeringen ikke aapner noe.

Utgangspunktet er ingen integrasjon - uten konfigurasjon gjoeres null nettverkskall.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DA6HAF8HFQxGYC2h6ypRQe
2026-08-21 12:23:55 +02:00
270ec87345 docs: presentasjon av kunnskapsbase-prosessen (norsk, selvstendig HTML) [skip-docs]
15 slides som forklarer prosessen fra bestilling til kjoereklar kunnskapsbase, med
veglysportefoeljen som gjennomgaaende eksempel. Bygget paa
docs/kunnskapsbase-for-en-kjoring.md og shared/examples/veglys-fv-soer/.

Selvstendig: inline SVG-illustrasjoner, ingen eksterne avhengigheter, ingen filnavn eller
kommandoer i teksten - den forklarer prosessen, ikke repoet.

To slides gaar ut over kildedokumentet, etter operatoerforespoersel:
- «Hva er gjort foer?» - skillet mellom gjennomfoerte tiltak (hoerer i anleggsbeskrivelsen
  og kostnadsgrunnlaget) og tidligere vurderinger (hoerer i erfaringslaget). Foelger av
  designet: et allerede gjennomfoert tiltak som ikke staar i basen blir foreslaatt paa nytt,
  og besparelsen dobbelttelles usynlig.
- Typiske tiltakstyper per prosjekttype. Veg- og tunnel-kolonnen speiler tiltakene som
  faktisk er bygget som eksempelbaser (LED-utskifting + adaptiv styring; trinnstyring av
  innkjoeringssone + portalskjerming). BRO-KOLONNEN ER IKKE FAGVERIFISERT - ingen bro-base
  finnes; den er en illustrasjon av formen, og det staar i bildeteksten.

Spoersmaalslista utvidet fra 11 til 13 tilsvarende.

Filnavnet er bevisst forskjellig fra docs/presentasjon-portfolio-optimiser.html, som eies
av en annen sesjon og er uroert.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DA6HAF8HFQxGYC2h6ypRQe
2026-08-21 12:22:14 +02:00
156312c32e feat(visibility): en uforankret kjoering sier det - felt + linje (ORDRE 20260821T092039Z)
Maalt oekt 48: en bundle-kjoering uten cost-baseline.json gikk rc 0 uten et ord, og
`grep baseline provenance.py outbox.py` ga null treff - hverken stdout, stempelet eller
utboksen bar at validatorens steg 0 ble hoppet over.

To tenner, begge smaa, og begge fra kjoeringens ENE oppslag av baselinen (koe-(p)):

1. ProvenanceStamp.cost_baseline_anchored - PAAKREVD bool uten default. Begge defaults
   lyver: True lar en glemsom konstruktoer paastaa en ankring som ikke skjedde, False
   underrapporterer en ekte. Naar utboksen gratis (write_proposal dumper hele stempelet).
   DryRunReport baerer det samme - en dry-run stopper foer noe stempel finnes.
2. run.cost_baseline_notice(anchored) - ENESTE renderer, tar den alt opploeste booleanen,
   returnerer None naar kjoeringen ER forankret (omisjon, aldri en tom rad). Printes paa
   tre flater: --live-dry-run, full enkeltkjoering, og per prosjekt i portefoeljemodus.

IKKE foldet inn i mandate.announce, og det er en MAALING: den fyrer kun med --mandate, saa
nettopp de bare bundle-dry-runsene defekten ble maalt paa ville fortsatt sagt ingenting -
og den renderes foer run_project, altsaa foer noen har opploest baselinen.

Ankeringen forblir VALGFRI (en pre-amendment-base kjoerer uendret) - dette er synlighet,
ikke en ny nekt. Golden-transkriptet er byte-uendret: demoen kjoerer en base som HAR fila.

Load-bearing MAALT (tests/test_baseline_visibility_loadbearing.py, 11 tester), seks
mutasjoner alle roede mot HELE suiten + groenn kontroll 885/5: konstant stamp-wiring
(3 roede) - konstant dry-run-wiring (1) - detach dry-run-printen (1) - renderer returnerer
alltid linja (2, inkl. den forankrede kontrollen) - detach full-run-printen (1) - detach
portefoelje-printen (1). Portefoelje-armen er DEFENSIV og uttalt (ingen referanse-prosjekt
setter bundle_dir; budget_stop-presedensen, crafted PortfolioResult).

Det paakrevde feltet tvang fem eksisterende test-konstruktoerer til aa ta stilling.

Dokumentene som beskrev den gamle stillheten er rettet: kunnskapsbase-for-en-kjoring.md
S4.1 (tabellraden re-maalt live), S6 og S7; README «How it is set up»; CLAUDE.md S4.0-raden.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DA6HAF8HFQxGYC2h6ypRQe
2026-08-21 12:21:48 +02:00
0bc8dae5c0 docs: llms.txt + sikkerhetskontakt til security@ (ORDRE 20260821T041218Z)
D2b: llms.txt i rota - H1, blockquote, EN startkommando byte-identisk med
README-ens forste kodeblokk (verifisert med diff), Docs- og Optional-seksjoner
med relative fillenker. Loser README-ens tre konkurrerende startverb ved a
velge den dokumenterte veien. Minimal med vilje: llms.txt er en konvensjon,
ikke en ratifisert standard. Begrunnes IKKE med crawlere - leseren er en
KI-agent som allerede star i repoet.

D6: SECURITY.md sin kontaktadresse hello@ -> security@fromaitochitta.com.
Kun sikkerhetskontakten; hello@ i CODE_OF_CONDUCT.md star urort (riktig for
alt annet). shared/SECURITY.md er urort - shared/ er pull-only subtree fra
commons og eies der.

I samme okt (operator-ja 21.08): docs/extending.md sa "no bundled example
ships a cost-baseline.json (checked)". Usant siden 09.08 - MALT: veglys-fv-soer
og tunnel-hauglia shipper begge fila. Setningen peker na pa den shippede fila
som formreferanse og gjengir ir.CostBaseline korrekt (project_id + items-map).

llms.txt ligger utenfor _LIVE_DOCS-gaten (den skanner README.md + docs/**/*.md),
verifisert i testfila - ingen ny doc-klassifisering kreves. Handover-gatens
_REQUIRED_MEMBERS er en tilstedevaerelses-sjekk, ikke en uttommende liste.
874 passed / 5 skipped, uendret.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DA6HAF8HFQxGYC2h6ypRQe
2026-08-21 11:29:54 +02:00
9d149b3503 docs: kunnskapsbase for ÉN konkret kjøring — kategorier, innholdstyper, veiprosjekt-eksempel (ORDRE 20260821T083046Z)
Nytt dokument docs/kunnskapsbase-for-en-kjoring.md (norsk, for fagperson + teknisk person):
fire kategorier etter hva de FØLGER (prosjekt / fagområde / organisasjon / bestilling), elleve
avgjørelsesspørsmål, innholdstype-tabell (eier, leveringsform, rolle i loopen, hva skjer hvis
den mangler), og veglys-eksempelet ende til ende mot den innsjekkede basen.

Alt er målt, ikke antatt: type-vokabularet (6 typer i drift; de 4 ikke-reserverte er KONVENSJON,
nevnt i ingen spec), metode-filene er tre ulike filer (40/81/98 linjer), og fire live-dry-runs på
kopier av veglys-basen — uten validator-input.json rc 1, uten cost-baseline.json rc 0 UTEN MELDING,
korrupt baseline rc 1. Ingen artefakt (provenance/outbox) bærer forankret/uforankret; foreslått
som kodeendring i dokumentets §7, ikke bygget (Iron Law).

Oppskriften (knowledge-base-recipe.md) beholder prosess/roller og lenker hit; README Docs-lista
får én rad; dokumentet er klassifisert i _LIVE_DOCS (gaten er fail-closed på uklassifiserte dok).
extending.md er utdatert på ett punkt (sier ingen eksempelbase shipper cost-baseline.json) —
flagget i §6, ikke rettet her. shared/ urørt.

874 passed / 5 skipped.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XJhpbb4acREp56CaQ5bwXV
2026-08-21 11:10:59 +02:00
4cf8c4f6ba feat(gate): pakke-gaten leser INNHOLDET, ikke bare filnavn + vurdering av Azure-omdøping
ORDRE 20260818T103716Z-251212929. To deler.

DEL 2 - innholds-gapet (TDD, red-first MÅLT):
Hver eksisterende gate i test_handover_package_loadbearing.py leser arkiv-MEDLEMSNAVN.
Ingen leste hva medlemmene SIER - som er nøyaktig hvorfor ressursgruppe, ressurs,
prosjekt og vertsnavn nådde en ekstern organisasjon i 14:24-bygget uten at én av 869
tester merket det.

RED FIRST, mot ekte data: gate-kroppen kjørt mot den LEVERTE zip-en gir 7 funn
(vertsnavnet + seks /Users-stier), mot `git archive 77076b9` gir den 8. Den finner
altså det som faktisk lakk, før den brukes til å påstå at HEAD er ren.

Gaten er en NEKTELSE, aldri et filter: den fjerner ingenting fra arkivet, den sier at
treet ikke er leveringsklart. Pakka forblir `git archive HEAD` - kø-(p) intakt.
Mønstrene bor i ÉN liste, hver rad med sin egen kjent-positive prøve, og hver prøve er
BYGGET VED KONKATENERING så fila ikke matcher seg selv (verifisert: 0 funn i egen kilde
- ellers hadde eneste fiks vært et hull i gaten der en hemmelighet kan gjemme seg).
Aksept-lista er selv gatet: en oppføring som ikke lenger nås er drift og felles.

MÅLT, seks mutasjoner - fem røde, én uten diskriminerende kraft:
- detach scanneren               -> 1 rød (leaked-kontrollen)
- aksept-lista sluker ekte vert  -> 2 røde
- ødelegg vertsnavn-regexen      -> 2 røde
- foreldet aksept-oppføring      -> 1 rød (minimalitets-kontrollen)
- koordinat tilbake i HEAD       -> 1 rød, gaten ALENE
- fjern nevner-asserten          -> 0 røde (kontroll, ikke søm - uttalt)

Nevner: 325 medlemmer lest, 1 hoppet over (sqlite-binær). 873 passed / 5 skipped.

ÆRLIGHETS-GRENSE, uttalt i koden: gaten fanger STRUKTUR. Vertsnavnet har en form;
ressursgruppe og prosjekt er fri tekst uten form, og ble i august bare oppdaget fordi
de sto i samme tabell som verten. Å lukke det gapet krever en navneliste - den andre
kopien av eksponeringsregelen, som er dét pakkas `git archive HEAD`-form finnes for å
forby.

DEL 1 - vurdering av omdøping (ingenting rørt i Azure):
docs/2026-08-18-vurdering-azure-omdoeping.md. Anbefaling: IKKE døp om. Lekkasjen ga
MÅLRETTING, ikke tilgang, og målrettingen kan ikke trekkes tilbake - navnene ligger i
publisert historikk og i en zip hos en tredjepart. Omdøping finnes dessuten ikke som
operasjon: et custom subdomain KAN IKKE endres (Learn), så det er riving + gjenoppbygging
i sju steg. Det ene tiltaket som faktisk fjerner en autorisasjonsvei Entra ikke dekker er
`disableLocalAuth` + nøkkelregenerering. Beslutningen er operatørens; valgene står med
konsekvenser, ikke som konklusjon.

PREMISS KORRIGERT (Verifiseringsloven ansikt 3): ordren sa koordinatene sto i repoet og
at tre /Users/ktg-stier lå på open/main. Målt på 6d2837f: 0 og 0 - 241b50d og 6d2837f
lukket begge. Premisset var sant da ordren ble skrevet (10:37Z) og sluttet å være det
13:03/13:20. Ingen begrunnet aksept-oppføring var derfor nødvendig for sti-klassen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01964PUr46mfnxWtMw23AnVD
2026-08-18 13:42:02 +02:00
6d2837fdca docs: fjern brukernavn, hjemmekatalog-stier og privat namespace fra publisert flate
Operatørdirektiv 2026-08-18: alt av privat natur skal bort. Dette er klasse 1
fra økt 43-gjennomgangen, utvidet etter en bredere sveip enn de seks linjene
som var kjent.

FJERNET (11 linjer i 7 filer):
- /Users/ktg-stier i tre docs -> relative stier (../portfolio-optimiser-claude,
  tests/golden/...). Røpet brukernavn OG maskinens repo-layout. De relative
  formene er dessuten mer brukbare for en ekstern leser.
- `Hi there, ktg!` i demo-runbookens SSH-probe -> `<user>!`.
- `ktg-plugin-marketplace` -> `plugin-marketplace` i to plandokumenter.
- `commons`-remotens `ktg/`-referanse i CLAUDE.md -> "den private namespacen".
- git.fromaitochitta.com/ktg/portfolio-optimiser-claude ->
  .../open/portfolio-optimiser-claude. Den var både privat OG utdatert:
  søskenet ligger i open/ (verifisert med ls-remote, exit 0).

BEHOLDT, MED GRUNN:
- LICENSE:3 `Copyright (c) 2026 Kjell Tore Guttormsen` - MIT krever at
  copyright-erklæringen står. Å fjerne rettighetshaveren ville brutt lisensen,
  ikke beskyttet noe: dette er forfatterens egen, tilsiktede attribusjon på et
  åpent prosjekt.
- hello@fromaitochitta.com (SECURITY/CODE_OF_CONDUCT) og
  git.fromaitochitta.com/open/... - organisasjonens publiserte kontaktpunkt og
  publiseringsvert. Å fjerne verten ville brukket install-instruksjonene.
- Statens vegvesen-sitatene under shared/examples/ - offentlige håndbøker
  (V124, N500) sitert som kildemateriale, ikke en opplysning om noen.

IKKE RØRT, RAPPORTERT VIDERE: tre linjer under shared/ (.gitignore:7 "KTG
global", 2026-07-26-...:608 `human:ktg`, 2026-07-31-...:92
`~/repos/ktg-plugin-marketplace/catalog`). shared/ er en pull-only subtree av
commons; å redigere den her ville brutt subtree-kontrakten og blitt overskrevet
ved neste pull. Meldt til commons, som er source of truth.

MÅLT: ingen test, gate eller kode pinner de redigerte strengene (0 treff i
tests/+src/+scripts/; kjent-positiv kontroll fant DEPLOY.md pinnet i to tester).
Regresjonsgaten: 869 passed / 5 skipped, likt referansen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WVTZgMF94VZtNa7nGVtupF
2026-08-18 13:20:23 +02:00
241b50d6c4 docs(1b): koordinatene byttet mot plassholdere - vei A, redigert framover
Operatørbeslutning 2026-08-18: vei A. Ressurs-, prosjekt- og vertsnavnene i
måleprotokollen fra første levende kjøring byttes mot repoets eksisterende
plassholder-form (<resource-group> / <resource> / <project>, samme som
DEPLOY.md og auth-oppskriften). Vei B (omskrive historikken) er avvist:
koordinatene nådde alt en tredjepart via 14:24-bygget, så B ville kostet en
force-push for en gevinst vi ikke kan bekrefte.

MÅLT, ikke antatt:
- 5 linjer bar koordinatene (10-12, 14, 172), ikke de 10 STATE påsto. Linje
  164-166 bærer kun `gpt-4-1-mini` - offentlig Azure-nomenklatur, står.
- Ingen gate pinner strengene: 0 treff i tests/ + src/ + scripts/, kjent-positiv
  kontroll samme kommandoform ga 5. En prosa-redaksjon kan ikke brekke en guard.
- Klasse 2 etter redaksjonen: 0 treff over hele treet (nevner 327 sporede filer),
  tre spørringsklasser hver med kjent-positiv kontroll som fyrte. De gjenværende
  AI-Services-vertene er testdummies (x./platform./injected), og GUID-en
  53ca6127-db72-4b80-b1b0-d745d6d5456d er Azures OFFENTLIGE built-in
  role definition id for Foundry User (verifisert mot Microsoft Learn), lik i
  hver tenant - ikke en koordinat.
- Regresjonsgaten: 869 passed / 5 skipped, likt referansen.

Dokumentets verdi står: dette er en måleprotokoll, og poenget er hva som ble
målt - ikke hvilken ressursgruppe det skjedde i. En linje under tabellen sier
høyt at navnene er plassholdert, så ingen leser tror <resource> var det
literale navnet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WVTZgMF94VZtNa7nGVtupF
2026-08-18 13:03:11 +02:00
77076b922c docs(deck): tredje deck bærer samme MÅLTE 1b-utfall som de to andre
ORDRE 20260818T071858Z punkt 2. `presentasjon-ledelse.html:337` var den
tredje tracked deck-fila, og INGEN av de 13 upubliserte commitene rørte
den — den lå altså allerede publisert på open/ med en setning c2d1504
rettet i de to andre.

Rettet til samme målte utfall som c2d1504, ikke sterkere:
- «dette er utviklet uten budsjett» er NÅ USANT — en betalt kjøring
  skjedde 14.08. Fjernet.
- «Selve rammeverket er bygget for å kjøre mot ekte modeller, og gjør
  det» var en bygget-for-påstand; erstattet av det som faktisk ble målt:
  én kjøring 14.08, modellen svarte i bestilt form, den fant opp en
  kostnadslinje som ikke finnes i kunnskapsbasen, regneporten avviste.
- At et forslag fra en levende modell kommer GJENNOM porten STÅR
  fortsatt som ikke vist — det er fortsatt usant, og skal være det.

Beholdt fordi det fortsatt er sant (ordren kalte det usant; målingen sier
noe annet, og målingen vinner): «Agentenes svar i demoen er skriptet —
det er ingen levende språkmodell i rommet». Demoen ER fortsatt skriptet.
c2d1504 SKOPET den samme påstanden i fagdecket i stedet for å slette den;
her var den allerede skopet av «i demoen».

Fellene c2d1504 dokumenterte, unngått og verifisert:
- «Tre forbehold» (linje 245 + 333) og «disse tre» (334) er håndskrevne
  tellinger. Endringen skjer INNI kort 1 — fortsatt 3 `.fb`-kort.
- Decket er pinnet til 13.08.2026 / v1.0.0 / 810 tester, som er den
  versjonen TALLENE DER kommer fra. URØRT (6 forekomster står). Den nye
  påstanden bærer sin egen dato i setningen i stedet.
- 1 linje inn, 1 linje ut — ingen ny høyde, så ingen nettleser-måling
  skyldes.

Regresjonsgate: `uv run pytest` → 869 passed, 5 skipped. Identisk med
referansen målt 14.08 på bb4807a. Ingen test gater deck-HTML; verifisering
av selve rettelsen er lesing.

IKKE pushet til open/ — se ordrens punkt 1: gjennomgangen fant et
publiseringsfunn som må til operatøren først.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0129nxK8u4SLT66nCbazcrWR
2026-08-18 09:37:14 +02:00
f4c98101eb docs(readme): add table of contents (ORDRE 32, B-axis)
11 H2 sections over 542 lines had no navigation aid. Anchors derived
and verified against Forgejo/Gitea's actual CleanValue() slug function
(modules/markup/common/footnote.go) — letters/numbers keep, space to
hyphen, everything else (apostrophe, colon, comma, em-dash, &) dropped
with no substitute, confirmed against a known-positive broken-anchor
control before commit.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019bbNJW4eVi2Pc4tg9AbZDA
2026-08-16 16:19:57 +02:00
c2d150495c docs(deck): begge deck bærer det MÅLTE 1b-utfallet — ærlighet, ikke seier
Den publiserte slide 12 sa ordrett «aldri kjørt mot en levende språkmodell».
Det er usant siden 14.08 (økt 40, betalt kjøring). Rettet i to deck.

Sant nå, og målt: rammeverket HAR kjørt mot en levende modell; hvert
genererings-svar kom tilbake i den bestilte formen; kjøringen KONKLUDERTE.
Fortsatt usant, og BLIR STÅENDE som usant: at et forslag fra en levende
modell har passert regneporten. Den ene kjøringen ble avvist, og
avvisnings-grunnen står ordrett i decket.

Funnet fra §6 er med, fordi det er verdt mer enn den grønne testen:
modellen fant opp en kostkode som ikke finnes i kunnskapsbasen, og porten
stoppet den på BELØPET (30 %-cap-en), ikke på at koden var oppdiktet —
bygg-energi-mikro shipper ingen cost-baseline.json, så S4.0-forankringen
er inaktiv der.

Tre selvrefererende feller unngått:
- kort-decket sa «kjøringstallene i denne presentasjonen er byte-låst
  fasit». Å legge live-tall på siden gjorde den påstanden usann om seg
  selv (Fase 3-klassen) — fotnoten skiller nå demo-tall fra live-tall.
- fagdeckets «Tre forbehold» + «disse tre» er håndskrevne tellinger. Et
  FJERDE kort ville brutt begge (P4s «én av de TO»-klasse) — endringen
  skjer INNI de tre, og statusen ligger i rad-lista som ikke teller.
- fagdeckets «810 tester» er bevisst pinnet til den versjonen tallene
  DER kommer fra, og er urørt. kort-deckets 846/4 delte dato-anker med
  den nye påstanden og MÅTTE flyttes: `uv run pytest` → 869 passed,
  5 skipped (målt før OG etter endringen).

MÅLT I NETTLESER over lokal HTTP, begge temaer via data-theme:
kort slide 12 = 873 px (under 900-taket), fagdeck slide 44/45 = 675/725 px.
Kontrollen økt 27 ba om: 12 av 12 og 44 av 51 DISTINKTE høyder, null
nuller — et sveip som ga like verdier ville bevist ingenting. Slide 21 i
fagdecket måler 901 px, men er URØRT av denne endringen (diffen ligger
i 1057/1081/1083) — pre-eksisterende, ikke innført her.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EkL7xFeBgYMwDiT8gGgdLm
2026-08-14 20:28:35 +02:00
bb4807aff7 fix(1b): regelen gjelder HVER betalt arm - fan-out-testen var fortsatt ugatet [skip-docs]
Forrige commit skrev en CLAUDE.md-invariant om at en betalt test far sin egen
opt-in, men gatet bare den NYE testen. test_portfolio_live.py passerer ingen
client_factory og er derfor selv en betalt kjoring - den fyrte pa
to-variabel-paret fra et bart `uv run pytest`. Invarianten var altsa halvt
usann den dagen den ble skrevet, som er nyaktig Fase 3-klassen (en pastand
flaten gjor om seg selv uten dekning).

MALT etter fiksen, med begge Foundry-vars satt og UTEN PORTFOLIO_LIVE_FULL_RUN:
  test_foundry_profile_live   PASSED   (det billige trinnet, med vilje pa to vars)
  test_portfolio_live         SKIPPED  (var betalt og ugatet for dette)
  test_full_run_live          SKIPPED

Ovrige oppfolgingspunkter fra review:
- assert_full_run_contract far ekte type hints (repoets "type hints overalt";
  mypy src ser ikke tests/, sa den ville rotnet ubemerket).
- Maleprotokollen SS6 far oppsettet STATE peker pa - model-map-formen og de fire
  export-linjene. Pekeren lovet noe seksjonen ikke hadde.

869 passed / 5 skipped, uendret. ruff+format+mypy rene.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GF7va4cpRiuf79kTzAi3vW
2026-08-14 20:12:47 +02:00
2d1264088e feat(1b): skjemaet ER akseptert av det levende endepunktet - malt, ikke resonnert [skip-docs]
Fase 1b siste trinn: forste bundne levende kjoring over HELE run_project-stien
mot Foundry (gpt-4-1-mini). Okt 37s uttalte aerlighets-grense er lukket ved
maling: ingen -parse-failures.json i outboksen, altsa parset hvert eneste
genererings-svar. assumptions-normaliseringen virket ende-til-ende.

Utfall: rejected pa P90 (claimed 34500 > feasible 11488), checker approve.
Kjoringen KONKLUDERTE - per pre-registreringen det bestatte utfallet. De to
falsifisererne skilte lag for forste gang mot en levende modell.

FUNN storre enn den gronne testen: modellen fant opp kostkoden
EL-LIGHTING-OP-HR (null treff i kunnskapsbasen). Avvisningen var riktig men
skjedde pa 30%-cap-en, ikke stage 0 - bundelen shipper ingen cost-baseline.json,
sa S4.0-forankringen var inaktiv. Ko-fort, ikke rettet her.

Gate-designet er ovis beslutning, tatt for koding:
- TREDJE distinkt opt-in PORTFOLIO_LIVE_FULL_RUN (truthiness, 4b-invarianten).
  Begge eksisterende live-tester gater pa SAMME to Foundry-variabler, sa
  gjenbruk ville latt den billige proben fyre den dyre kjoringen - stigen i
  maleprotokollen ville kollapset til ett trinn. MALT: den dyre SKIPPET med
  begge Foundry-variablene satt.
- Asserten i EN kopi (conftest.assert_full_run_contract, ko-(p)), smal med
  vilje: fravaer av parse-failures-artefaktet + at validatoren avgjorde. En
  rejected BESTAR - pastanden er schema-aksept, ikke modell-dommekraft.
- Iron Law uten a betale to ganger: diskrimineringen bevist OFFLINE av
  test_live_full_run_contract.py. To mutasjoner, hver sin signatur: detach
  artefakt-sjekken (T1 rod ALENE) - raise ubetinget (T2 rod ALENE).

STATE-premiss korrigert: "test_foundry_profile_live dekker KUN klient-nivaet"
var upresist - test_portfolio_live.py dekket allerede fan-outen, men dens
len(runs)==1 kan ikke skille validert fra avvist og bar derfor ikke pastanden.

869 passed / 5 skipped (fra 867/4), ruff+format+mypy rene.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GF7va4cpRiuf79kTzAi3vW
2026-08-14 20:02:26 +02:00
986fc19350 feat(1b): et tak som fyrer er ikke en krasj — BudgetExceeded får sin egen kanal [skip-docs]
Prosjektets første levende kjøring døde på `rounds limit=12 observed=13`, og den
hostede flaten svarte `500 {error_type, error}` — nøyaktig det samme den sier når
modell-endepunktet faller. Nå: 429 med trippelen som STRUKTUR.

Beslutningen er S3.4-invarianten anvendt på transporten: `budget_stop` ble holdt
utenfor `stop_reason` fordi de to stoppene betyr motsatte ting, og å svare
ressurs-utmattelse på krasj-kanalen gjør «det gikk ikke» uleselig på samme måte.

IKKE 200, og det er dét som skiller den fra `Rejection`: en `Rejection` er en
kjøring som KONKLUDERTE og hører i payloaden, mens et uttømt budsjett produserte
ingen proposal — en 2xx ville latt en automatisk kaller bokføre «analysert» for en
kjøring som analyserte ingenting. 429 fordi betingelsen oppstår av en TILDELING
(`max_rounds`/`max_tokens` er whitelistede request-felt), aldri av en serverfeil.

`kind`/`limit`/`observed` legges ut som felt, aldri `str(exc)` (kø-(y));
`error_type` holdes ute — den nøkkelen tilhører feilkanalen. `budget_exhausted` er
ikke foldet inn i `outcome_type` og kunne ikke vært det: `outbox.outcome_payload`
er den ene kopien av den forgreningen og tar `ValidatedProposal | Rejection`.
Ærlighets-grense: ingen `Retry-After` — å vente endrer ingenting.

Iron Law: begge nye tester RØDE før armen fantes. Fem mutasjoner mot HELE suiten,
alle røde med hver sin signatur, grønn kontroll 867/4: detach armen (2 røde) ·
flat streng i stedet for struktur (1 rød — struktur-testen alene) · ekko `limit`
som `observed` (1 rød) · utvid armen til `Exception` (6 røde) · stemple
`error_type` på budsjett-kroppen (1 rød).

500-armens vitne ble BYTTET, ikke slettet: den eksisterende testen brukte
`BudgetExceeded` som sin 500-prøve, så en ny arm alene ville etterlatt
krasj-kanalen uten vitne. Den bærer nå en ekte ikke-budsjett-`RuntimeError`, og er
dét som holder den nye armen smal.

Kjørt, ikke bare testet: `python main.py` startet, `/readiness` 200, ukjent felt →
400 med navnet, 404, SIGTERM → exit 0.

865 → 867 passed / 4 skipped; ruff + format + mypy rene. DEPLOY.md §6 dokumenterer
429 for mottakeren.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W1bsX79aDS7fJ5udWGWAEN
2026-08-14 17:06:11 +02:00
605c02aa62 feat(5): overleveringspakka er KUN kjørbar Python — container-innpakningen fjernet
Operatørdirektiv 14.08 etter ekstern test: mottakeren skal få kjørbar Python, ikke en
Docker-innpakking. `Dockerfile` og `azure.yaml` er SLETTET fra treet.

Sømmen er valgt av den eksisterende invarianten, ikke av smak: pakka ER `git archive HEAD`,
så å ekskludere filene fra arkivet ville krevd en kurerings-mekanisme — den andre kopien av
«hva mottakeren får», fri til å drifte fra HEAD (kø-(p)). Fjerning holder arkivet ukurert og
gjør fraværet til en egenskap ved HEAD, som er det eneste en gate kan måle.

De to gatene som pinnet flaten er håndtert bevisst:
- 4e-rå-tekst-gaten (`--platform linux/amd64` + ÉN kopi av startkommandoen) er SLETTET, med
  et notat der den sto. En gate som pinner en fjernet flate kan bare bli grønn.
- handover-gatens `_REQUIRED_MEMBERS` er ikke bare fratatt de to navnene, men erstattet av en
  POSITIV fraværs-assert pluss en dokument-gate. Å kun slutte å KREVE dem ville gitt en gate
  som ikke kan skille «fjernet» fra «shippes fortsatt».

Startkommandoen har nå ÉN kopi igjen: DEPLOY.md-ens `python main.py`, som navngir inngangen
subprosess-testen faktisk kjører.
2026-08-14 14:09:35 +02:00
642ce8ae9a feat(1b): proposeren får en grammatikk — strict structured output [skip-docs]
Fase 1b, funn 1b. Den første levende kjøringen brant tolv runder på svar som
ikke lot seg parse til IR-formen; e371890 gjorde teksten synlig, dette fjerner
årsaken. generate_via_llm sender nå
options={"response_format": proposal_response_format()} på hvert
genererings-kall.

Formen er MÅLT, ikke valgt. ChatOptions.response_format tar
type[BaseModel] | Mapping, og begge profiler ærer den: LOCAL sender en Mapping
ordrett til Chat Completions, AZURE (FoundryChatClient -> RawFoundryChatClient
-> RawOpenAIChatClient) konverterer samme envelope til Responses-APIets
text.format. Klassen — det korteste svaret — er avvist på bevis: gitt en klasse
konverterer klienten med type_to_response_format_param, som emitterer
minimum/exclusiveMinimum/minItems/prefixItems og et assumptions-node hvis
additionalProperties er et skjema. Azures publiserte subset utelukker alle fire.

assumptions kan ikke bare droppes, og det er også en måling: validator
._monte_carlo faller tilbake på item.unit_cost for hver kode uten bånd, så uten
bånd er alle 512 samples identiske og P10 == P50 == P90. Den stokastiske
falsifisereren ville gått inert mens den fortsatt rapporterte persentiler.
Wire-en bærer derfor et array av navngitte entries som _parse_ir folder tilbake
til IR-ens map — additivt, aldri erstatning. Skjemaet deriveres fra
SavingsProposal; sanitiseren er fail-closed (StructuredOutputUnsupported).

Load-bearing målt mot hele suiten, seks mutasjoner alle røde, grønn kontroll
864/4: detach wiringen (1) · detach sanitiseren (3) · dropp assumptions fra
skjemaet (1) · fail-closed -> stille reparasjon (1) · detach normaliseringen
(3) · erstatning i stedet for tillegg (2, inkl. golden-transkriptet).

T3 ble skrevet vakuøs først og felt av sin egen mutasjon: den påsto å bli rød
når assumptions forsvant fra skjemaet, men den scriptede klienten ignorerer
skjemaet. Testen fikk en direkte assert på skjemaet.

Ærlighets-grense: ingen betalt kjøring gjort. Testene beviser konformitet med
det dokumenterte subsettet, ikke aksept fra det levende endepunktet.

859 -> 864 passed / 4 skipped. ruff + format + mypy rene.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013EQNU4tfAhsBvdefT1jUhk
2026-08-14 14:00:32 +02:00
d371475ec9 feat(deck): arkitekturen som kretsløp — ny slide 6 i kort-presentasjonen
Decket beskrev arkitekturen i prosa og tegnet den stykkevis (slide 8 som
lagdeling, slide 9 som åtte steg), men aldri som ÉN krets. Ny slide 6 åpner
«Slik virker det» med hele maskinen på ett bilde: kunnskapsbasen → KI-debatten
(foreslår/utfordrer) → regnekoden → fagpersonen → porten → tilbake i basen.

Håndskrevet tema-bevisst SVG etter slide 7-mønsteret (økt 29): null eksterne
avhengigheter, alle farger fra CSS-variablene, ingen fargeliteraler. Ingen nye
påstander — hver boks og hver pil står allerede som prosa et annet sted i
decket. Retur-pilene er `svg-line-hi` og forover-pilene `svg-line`, samme
konvensjon som åtte-stegs-sliden, så «tilbake» leses likt på tvers av decket.

MÅLT i nettleser over lokal HTTP (127.0.0.1, ikke file://), .inner + padding —
aldri section (100vh gir samme tall for hver slide): ny slide 852 px, taket er
900, og høyeste er fortsatt slide 7 med 864. Identisk i begge temaer via
data-theme. Første plassering av «avvist — nytt forsøk» lå UNDER regnekode-
boksens kant (fanget ved zoom, ikke ved full skjermdump) og er flyttet.

«Ærlig status» → «Status» (kicker + data-part): overskriften annonserte sin
egen ærlighet, og alle de andre delene bærer ett nøytralt substantiv
(Problemet, Grepet, Menneskene, Verdien). Kortformen føyer seg inn i mønsteret.

Følgeendringer, grepet FØR commit: «Elleve sider» → «Tolv sider», README
«11 slides» → «12», seksjonskommentarene renummerert. Telleren i baren er
avledet av slides.length og viser 6/12 og 12/12 uten endring.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Cgm9YYHEyTFEz6pc6JgmH
2026-08-14 11:59:22 +02:00
e3718908d0 feat(1b): fang den råe modell-svarteksten ved parse-feil
Fase 1b funn 1 lukket. generate._fetch_parsed kastet hvert uparsebart modellsvar i
except: continue, så prosjektets første levende kjøring brant tolv runder på formatfeil
og etterlot null tegn av det modellen faktisk sa. Enhver videre betalt kjøring ville
vært gjetning.

HVOR teksten overflates er avgjort av en måling, ikke av symmetri med Steg 5:
meter.tick_round() raiser BudgetExceeded INNE i _fetch_parsed, og uten mandat fanger
ingen den, så på nøyaktig den stien fangsten finnes for returnerer generate_via_llm
ingenting. Et felt på GenerationResult ville vært blindt for den, og et outbox-artefakt
skrevet etter kjøringen likeså. Sinken speiler meter: en kaller-eid akkumulator hvis
innhold kalleren holder uansett hvordan løkka endte. Artefaktet skrives fra en finally,
ikke except BudgetExceeded, og kun når noe faktisk feilet.

Iron Law: testfila rød ved collection FØR modulen fantes. Seks mutasjoner mot HELE
suiten, alle røde, hver med sin egen signatur; grønn kontroll 859 passed / 4 skipped
(fra 854). Den skarpeste er trunkering som BEHOLDER sentinelen: da faller kun
verbatim-asserten, som er det som beviser at den ene testen bærer den egenskapen.

Samme økt: mutasjonsmålingen økt 34 utsatte for de to Fase 5-gatene er kjørt. Fire
preflight-mutasjoner mot hele suiten, alle røde på riktig test og ingen annen (detach
fallbacken 2 røde, snu presedensen, presence i stedet for truthiness, avslaget navngir
kun vårt navn). To handover-mutasjoner kjørt MÅLRETTET mot egen testfil under tidspress,
ikke mot hele suiten — uttalt, ikke skjult (drop uv.lock, bygg fra arbeidstreet i stedet
for tracked files). De to DEPLOY.md-mutasjonene gjenstår: git archive leser HEAD, ikke
arbeidstreet, så de krever en midlertidig commit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WLQd8ojQ9xwxhB8vsETYBs
2026-08-14 11:41:04 +02:00
5bd8e1caa1 docs(1b): første levende kjøring — måleprotokoll, ikke konklusjon
Foundry-miljøet opprettet og målt. Stigen fra fase 1a fulgt: token → preflight fra
utpakket pakke → gatet triviell probe (prosjektets FØRSTE levende modellkall, 1 passed,
5,02 s) → full run_project.

Trinn 4 døde med BudgetExceeded rounds limit=12 observed=13. Diagnosen er utledet av
KODE, ikke av flere betalte kjøringer: _fetch_parsed tikker en runde per forsøk og
retryer ved parse-feil, så tolv oppbrukte runder betyr at svarene i hovedsak ikke lot
seg parse til IR-formen. Den råe svarteksten finnes ikke i noen artefakt i dag — å
skaffe den er en søm, altså Iron-Law-arbeid, ikke en omkjøring med høyere tak.

Uttalt bieffekt: BudgetExceeded forlot kjøringen som traceback, ikke strukturert utfall.
På den hostede flaten ville det blitt HTTP 500 for en normal, forventet tilstand.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SeW1LhH5TtXxKZPe9JkqL1
2026-08-14 10:57:06 +02:00
88c223276c fix(5): preflight kjenner samme endepunkt-variabler som kjørestien [skip-docs]
Målt fra den utpakkede overleveringspakka: med KUN plattformens injiserte
FOUNDRY_PROJECT_ENDPOINT — altså nøyaktig situasjonen i en hostet Foundry-container —
avslo preflight en konfigurasjon backends.py ville godtatt. Gaten og kjørestien kjente
ulike navn; det er repoets egen «checker og kjøresti validerer ulikt»-klasse, og for
mottakeren av pakka er det et falskt avslag på riktig oppsett.

_ENDPOINT_ENVS IMPORTERES nå fra backends i stedet for å gjentas, så de to kan ikke
drifte fra hverandre igjen. Presedens over VERDIER, ikke deklarasjoner: et eksportert-men-
tomt eget navn faller igjennom i stedet for å skygge et ekte injisert inn i en fail-fast.
Avslaget navngir BEGGE variablene.

Iron Law: 3 røde diskriminatorer + 1 grønn kontroll FØR fiksen. 854 passed / 4 skipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SeW1LhH5TtXxKZPe9JkqL1
2026-08-14 10:48:45 +02:00
a3300ab0f6 feat(5): overleveringspakke for eksterne — git archive HEAD + DEPLOY.md
Én zip en mottakende organisasjon deployer uten å klone repoet eller ha konto her.
Arkivet er git archive HEAD (tracked files only), som er SAMME tre den målte
docker-build-konteksten bruker — og grunnen til at STATE.md/*.local.md/.env ikke kan
komme inn: de er gitignorert, ikke filtrert bort av et filter vi må vedlikeholde.

DEPLOY.md svarer mottakerens tre første spørsmål: hvem gjør hva (plattform-operatør,
bestiller, fagperson), prosessen ende-til-ende, og hvorfor det ikke finnes et
chat-grensesnitt. Den navngir også deploy-kravet 4e målte men aldri skrev ned:
pakket model_map.json bærer REPLACE-WITH-*, så uten PORTFOLIO_MODEL_MAP starter
containeren, svarer på /readiness og feiler hver invocation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SeW1LhH5TtXxKZPe9JkqL1
2026-08-14 10:43:28 +02:00
817bcf130f docs(2): slide 6 teller BASER, ikke dokumenter — flere kunnskapsbaser per prosjekt
Slide 6 sa «Grunnlaget er en kunnskapsbase» og talte fem DOKUMENTER. Poenget er
at ett prosjekt trenger FLERE baser samtidig, med ulike eiere og ulik takt.

MÅLT FØRST (scratchpad, `okf.navigate_bundle` mot en tre-nivå testbundle):
flere baser som under-kataloger med hver sin `index.md` er ALLEREDE støttet —
alle fire dokumenter fra begge baser nådde lesekonteksten, tre nivåer dypt, og
rot-relative lenker (`/index.md`) krysser mellom basene. Utfall (a) i STATE.

GRENSEN er også målt, og den står nå i prosaen i stedet for å bli lovet vekk:
en base UTENFOR prosjektets inngang nås ikke — verken via relativ escape
(`../../delt/...`) eller symlink; `safe_resolve` er fail-closed, begge skippes
stille. Derfor «prosjektets inngang avgjør hvilke baser som er med; maskinen
når ingenting utenfor den».

ÆRLIGHET om eksempelet: `shared/examples/bygg-energi-mikro` er FLAT (index + 4
dokumenter + 1 verdict). Decket sier det høyt — eksempelet er én base med fem
dokumenter; flere baser er formatet, ikke eksempelet.

Figuren tegnet om til to nivåer: inngangen ØVER, fem baser i rad, og dokument-
ark UNDER hver base, så nivået «base» blir synlig over nivået «dokument».

Verifisert i nettleser over lokal HTTP, lyst og mørkt tema: 11 slides, UTF-8
uten mojibake, slide 6 = 864 px på `.inner` + padding (tak 900, forrige høyeste
780). Ingen slide lagt til eller fjernet — «Elleve sider» og README-ens
«11 slides» står uendret og riktig. `uv run pytest`: 846 passed, 4 skipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RyJCQ65nChDtY5qe2v3dg1
2026-08-14 09:36:44 +02:00
46b3e0e97d docs(2): slide 11 ut av kort-decket — elleve sider, to følgeendringer
Operatørbeslutning 14.08, gitt etter publisering: «Kom i gang: tre kommandoer»
fjernes fra docs/kort-presentasjon.html. Slidens innhold sto allerede i
README-ens install-blokk, som er den ene kopien.

To følgeendringer var obligatoriske, ellers ville flaten sagt noe usant om seg
selv (A5-klassen):

1. Slide 1s foot-note lovet «tre kommandoer (side 11)» — en henvisning til en
   side som ikke lenger finnes. Skrevet om, ikke utvidet: setningen sier
   fortsatt at kjøringen kan gjentas uten kostnad, men peker ikke lenger på et
   sted i decket. En README-peker et annet sted i decket er en beslutning som
   ikke er tatt her.
2. «Tolv sider» → «Elleve sider» i samme foot-note, og README-linja
   «12 slides» → «11» (fortsatt innenfor planens 10–12).

Målt i nettleser over lokal HTTP (file:// avvises av utvidelsen): 11 slides,
teller 1/11, UTF-8 uten mojibake, null gjenværende treff på «Tolv sider» /
«side 11» / «Kom i gang». Høyeste slide 808 px (< 900-taket), identisk i lyst
og mørkt tema — sveipet måler .inner + padding, ikke seksjonen: første forsøk
ga 1003 px for alle elleve, altså viewport-høyden, og et instrument som gir
samme tall for hver slide kan ikke skille en høy fra en lav.

Sidefunn, uttalt og ikke handlet på: `.cmd`-reglene i CSS-en har ingen bruker
igjen. Inert, og å rydde dem er en egen beslutning.

uv run pytest: 846 passed / 4 skipped (uendret). repo-standard: OK, 20 sjekker,
null ERROR/WARN — men gaten var grønn før endringen også, så den er ikke
verifikasjon for denne.
2026-08-14 09:07:16 +02:00
4da00cefb0 docs(2): kort visuell presentasjon — 12 slides, norsk, verdiskaping — som følger repoet
Ny fil (aldri en beskjæring av fagdecket): selvbærende HTML, håndskrevne
tema-bevisste SVG-er, meta charset, null eksterne avhengigheter. Hvert tall
produsert av en kommando mot fasiten tests/golden/demo-transcript.stdout.
Slide 12 er ærlig status: aldri kjørt mot levende modell ER innholdet.
README lenker decket med én engelsk linje.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011FmTp2wg9GxRuENkHECpdE
2026-08-14 08:10:42 +02:00
9148df0113 docs(3): to nye invarianter — flatens egne påstander, og GOVERNANCE som lenke
Fører fase 3s sømmer inn i invariant-registeret, etter repoets egen konvensjon om
at hver load-bearing søm har en blokk som sier hva den er, hvorfor formen ble
valgt, og hvilke mutasjoner som ble målt.

1. Påstander flaten gjør om SEG SELV gates som rå tekst, LINJEFORANKRET. To
   påstander bodde i prosa der ingen test kunne se dem. Gaten leser klassene
   backends.py konstruerer fra selve tilordningslinja, ikke fra modulen — fordi
   kommentarene NAVNGIR DefaultAzureCredential fire ganger for å begrunne
   fraværet, og en fil-bred substring-gate ville vært rød på nøyaktig den prosaen
   den beskytter. Hver positiv assert er paret med en kontroll på at det søkes
   etter noe som finnes. Blokka bærer også hvorfor repo-standard-gaten IKKE kan
   verifisere denne fasen: den var OK/20 sjekker før arbeidet startet, og
   RELEASE-STALE er strukturelt blind for repo med null utgivelser.

2. GOVERNANCE er en LENKE, aldri en kopi (org-ops D11). Skrevet ned fordi planen
   sa det motsatte, og fordi den neste som leser «GOVERNANCE.md mangler» i en
   gate-rapport skal finne begrunnelsen her i stedet for å skrive kopi nr. 12.

Ingen kodeendring; STATE.md (local-only) bærer øktas fulle logg.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011ckyg3Pc6k7FRuR6fDGQLJ
2026-08-14 07:14:19 +02:00
d73c98f22d fix(3): den fremmedes første kjøring skriver ingen SyntaxWarning [skip-docs]
Funnet av ren-klon-verifikasjonen, og KUN der: to modul-docstrings bærer ``\```
(en escapet backtick i prosa om at rå ``|`` og ``\`` overlever uendret gjennom
http-sømmen). Python advarer om ugyldig escape-sekvens ved KOMPILERING, så i
arbeidstreet er advarselen borte etter første kjøring — .pyc-cachen gjør den
usynlig for oss og synlig for alle andre. Nøyaktig den klassen denne fasen
finnes for: en defekt som bare viser seg på fremmed maskin.

Fikset ved å gjøre de to docstringene rå (r"""). Innholdet er BYTE-UENDRET —
prosaen som beskriver escapingen skal fortsatt lese som den gjør; det er
tolkningen av den som var feil, ikke teksten.

Målt: 0 SyntaxWarning igjen ved kompilering av alle .py utenom .venv (var 2).
846 passed / 4 skipped, ruff + format rene.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011ckyg3Pc6k7FRuR6fDGQLJ
2026-08-14 07:04:08 +02:00
28a420ab97 feat(3): flaten sier sant om seg selv, og to gater måler at den fortsetter å gjøre det
Fase 3 (AAA+ på publisert flate). Tre av planens premisser falt på måling og er
rettet FØR handling, ikke etterpå:

* GOVERNANCE-raden hadde feil tiltak. Planen sa «skriv den»; org-ops D11 sier én
  kanonisk fil som hvert repo LENKER, og filen er nå publisert (målt: HTTP 200 på
  open/repo-standard). Å skrive vår egen ville gjort oss til kopi nr. 12 av en
  fil D11-bølgen holder på å rydde vekk. README lenker den, i samme form som
  repo-mailbox bruker, og bus-faktor 1 står uttalt i den kanoniske teksten.
* Release-objektet for v1.0.0 FINNES allerede på open/ (id 155, CHANGELOG-kropp,
  siden rendrer) — det som mangler er vedlegg, ikke objektet.
* WARN RELEASE-STALE fyrer ikke, og kan ikke: regelen sammenligner utgivelse mot
  tagg og er strukturelt blind for repo med null utgivelser (org-ops hovedbok
  #18). Gaten var OK/20 sjekker FØR arbeidet startet, så den kan ikke tjene som
  verifikasjon for denne fasen. Bevisene er Forgejo-APIet, filinnholdet og
  ren-klon-kjøringen.

A5-defekten rettet: env.template:21 sa at credential resolves via
DefaultAzureCredential. Den har aldri gjort det — backends.py:149 konstruerer
ManagedIdentityCredential eller AzureCliCredential, og Learns MAF-veiledning
navngir den spesifikke credentialen NETTOPP for å unngå probing. En operatør som
kopierte templaten ble fortalt at feil identitet ville bli brukt.

To load-bearing gater (Iron Law: begge røde før fiksen, 2 failed / 7 passed):

1. env.template navngir de credentials backends.py faktisk konstruerer, og ingen
   linje utgir DefaultAzureCredential for å være mekanismen. LINJEFORANKRET, ikke
   delstreng: backends.py NAVNGIR klassen fire ganger i kommentarene som
   begrunner hvorfor den ikke brukes, så en fil-bred substring-gate ville vært
   rød på nøyaktig den prosaen den beskytter (repoets 08-09-klasse, fjerde gang).
2. README-ens wheel-filnavn bærer versjonen bygget stempler på fila. Uten den
   ville en versjonsbump stille etterlatt en publisert install-kommando som peker
   på en fil som ikke finnes.

Hver positiv assert er paret med en KONTROLL på at det søkes etter noe som
finnes — en ekstraktor som stille finner null lager en gate som bare kan bli
grønn.

MUTASJONER MÅLT MOT HELE SUITEN, begge røde på riktig test og på INGEN annen:
gjeninnfør den usanne credential-påstanden (2 røde, 844 grønne) · la
wheel-filnavnet drifte til 1.0.0 (1 rød, 845 grønne). Restaurert fra scratchpad
+ shasum -c mellom hver. Bumpen selv var den andre mutasjonen: pyproject 1.0.0 →
1.1.0 gjorde README-gaten rød alene, før README ble rettet.

SECURITY.md: varslingsfrist (minst én minor-release og aldri under 30 dager
mellom kunngjøring og fjerning, med sikkerhetskritisk fjerning som uttalt
unntak). Støttetabellen er bevisst VERSJONSFRI — et release-nummer skrevet der
ville drevet ved neste tagg, altså samme defektklasse som gate 2 fanger.

CLAUDE.md beholdt på flaten med en engelsk innramming øverst (operatørvalg): den
sier hva fila er for en fremmed. Innholdet er repoets sterkeste bevis på at hver
beslutning er målt; å fjerne det ville fjernet bevis, ikke friksjon.

Versjon 1.1.0 — synket i pyproject, __init__, test_smoke og README-kommandoen.
1.0.0-treet kan ikke produsere en kjørbar wheel (force-include kom etter taggen,
målt: git show v1.0.0:pyproject.toml har den ikke), så en wheel hengt på den
utgivelsen ville vært nøyaktig den usanne påstanden denne fasen finnes for å
fjerne. Operatøren valgte bumpen framfor et vedlegg som ikke virker.

846 passed / 4 skipped (fra 837). ruff + format + mypy rene.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011ckyg3Pc6k7FRuR6fDGQLJ
2026-08-14 06:57:25 +02:00
9a869baf8a docs: wheel-konsumenten får de to git-kravene i install-instruksen
Wheel-beslutningen tatt på den ene armen som ligger i DETTE repoet: dokumentér
kravene. Å publisere de to pakkene er de to ANDRE repoenes beslutning
(open/llm-ingestion-okf, open/llm-ingestion-pipeline-security) og forblir åpen.

Premisset målt på nytt før teksten ble skrevet, ikke arvet: wheelen bygget her og
Requires-Dist lest ut — llm-ingestion-okf og llm-ingestion-guard står som BARE
NAVN, som rapportens §7.2 forutsa. Install-kommandoen er kopiert ordrett fra den
målte i §7.2 (65 pakker, exit 0), ikke formulert på nytt.

Rettet samtidig en påstand som var blitt ufullstendig av 4a: «an installed
distribution works without a checkout» gjaldt DATAEN (shared/ som pakkede data),
ikke avhengighetene. Presisert til «finds its knowledge without a checkout».

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018GfbDLY7YVLKVqpUHnbwVW
2026-08-14 00:07:17 +02:00
18dbd7d4d6 test(4e): whitelisten mot EKTE run_project + rå-tekst-gate på deploy-artefaktene
De to gjenstående 4e-radene, begge målt mot hele suiten (837 passed / 4 skipped).

(1) /invocations svarer gyldig mot en SKRIPTET backend gjennom EKTE run_project.
Alle 4d-testene ga invoke en stand-in som sluker **kwargs, så whitelisten kunne
navngi et felt run_project ikke tar — eller sende samme argument to ganger — uten
at én test merket det, mens en levende container svarte 500. Sømmen er
run._default_factory, ikke payloaden: client_factory nektes av whitelisten med
vilje, så factory-defaulten er eneste injeksjonspunkt flaten etterlater.
Payloaden sender HVERT whitelistet felt, med en dekningsassert mot
_ALLOWED_FIELDS. Profilen er LOCAL fordi AZURE-armen slår opp et
Foundry-deployment-navn i modell-mappet FØR noen klient bygges (målt).

(2) Rå-tekst-gate: Dockerfile + azure.yaml kjøres av ingen test (docker build og
azd deploy er operatør-gatet). Gaten pinner --platform linux/amd64 (målt påkrevd)
og ÉN kopi av startkommandoen (imagets CMD; azure.yaml har ingen startupCommand).
Nøkkel-sjekkene er linjeforankret, ikke delstreng — azure.yaml sin egen kommentar
navngir begge nøklene for å begrunne fraværet.

Fem mutasjoner, alle røde på riktig test og på INGEN annen (836 øvrige grønne
hver gang): send project_id to ganger · whitelist et felt run_project ikke tar ·
fjern bundle_dir fra whitelisten · fjern --platform linux/amd64 · gi azure.yaml en
startupCommand-nøkkel. Kontroll: pristine tre 837/4.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018GfbDLY7YVLKVqpUHnbwVW
2026-08-14 00:03:20 +02:00
426ccb0ad6 feat(4d): hostet inngang — main.py wrapper rundt run_project på én asyncio-løkke
To målinger avgjorde formen FØR koden: (1) hosting-pakkas InvocationsHostServer
finnes kun i bygg som krever agent-framework-core>=1.13.0 (treet låser 1.9.0;
eneste 1.9-kompatible bygg er en forlatt alfa som importerer mcp udeklarert),
(2) et gjenbrukt bygget workflow er single-use på 1.9.0 (kall-serie [2,0,0] —
rundetaket persisterer; ferskt objekt per kall er ren kontroll). Derfor spikens
§5-fallback: hosting.py serverer kontrakten (8088/PORT, /readiness,
/invocations, SIGTERM→0) selv, stdlib asyncio på ÉN løkke — aldri as_agent()
(gatene ligger utenfor grafen), aldri tråder (NG1-guarden fanget første utkast
med ThreadingHTTPServer; asyncio-formen består den by construction).

Payload whitelistes på run_projects signatur — ukjente felt nektes ved navn
(400), aldri stille droppet; profile defaulter til azure kun her. ValueError →
400, alt annet → 500 {error_type, error}; Rejection er vellykket kjøring → 200.
outbox.outcome_payload ekstrahert som den ENE kopien av validated/rejected-
forgreningen (kø-(p)-regelen). azure.yaml validert GRØNN mot begge autoritative
skjemaer (jsonschema, hentet ferskt); ingen env:, ingen startupCommand (imagets
CMD er den ene kopien). Dockerfile: 3.12-slim-bookworm + git + uv==0.9.8 +
uv sync --frozen --no-dev; git archive <indeks-tre> | docker build
--platform linux/amd64 grønn på nøyaktig de stagede bytene.

Iron Law fulgt: testfila rød ved collection FØR modulen fantes. 835 passed /
4 skipped (fra 821), ruff+format+mypy rene. Seks mutasjoner mot HELE suiten,
alle røde på riktig test: detach felt-mappingen · dropp ukjente felt stille ·
flipp 400/500 · detach azure-defaulten · detach SIGTERM-handleren · detach
main.py-shimen (de to siste kun fanget av subprosess-testen, P4-presedensen).
Deploy IKKE utført — azd-steget er operatørens.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PEiiSGRShizKc771ZBa1iq
2026-08-13 23:31:02 +02:00
63eec917d2 feat(4b): AZURE-profilen leser miljøet sitt, ikke operatørens laptop
Endepunktet løses som første ikke-tomme av vårt eget
PORTFOLIO_FOUNDRY_PROJECT_ENDPOINT og Foundrys injiserte
FOUNDRY_PROJECT_ENDPOINT — vårt vinner, fallbacken lar samme image kjøre
hostet uten ekstra wiring. Presedensen gjelder verdier, ikke deklarasjoner.
Credential velges av samme miljø: AzureCliCredential lokalt,
ManagedIdentityCredential når FOUNDRY_HOSTING_ENVIRONMENT er satt, fordi
containeren ikke har noen Azure CLI. Ikke DefaultAzureCredential — Learns
MAF-veiledning navngir den spesifikke credentialen for å unngå probing.

Load-bearing målt mot hele suiten, fire mutasjoner alle røde + grønn
kontroll: detach credential-valget · presence i stedet for truthiness ·
detach fallbacken · snu presedensen. Fail-fast-testen var vakuøs først —
vårt variabelnavn inneholder det injiserte som delstreng.

De fire åpne azure.yaml-valgene lukket mot de to JSON-skjemaene og ført i
docs/2026-08-13-fase4-azure-yaml-valg.md. Ingen azure.yaml skrevet (4d).

821 passed / 4 skipped. Ruff + format + mypy rene.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jk8tauXXAojNKC7Tzq7ziF
2026-08-13 22:27:21 +02:00
447c5a9d15 docs(4·): containeren er bygget — og wheelen er ikke installerbar alene
Fullfører spikens siste måling etter at docker-tillatelsen kom på plass.

7.1 — `docker build --platform linux/amd64` grønn på python:3.12-slim-bookworm.
`uv sync --frozen` resolverte inne i containeren, inkludert begge git-pinnene,
og siste lag ga `import OK`. Det lukker gapet uv-resolusjonen ikke kunne lukke:
at avhengighetene LØSER for linux beviser ikke at koden KJØRER der.

7.2 — Andre måling bygde wheelen, SLETTET kilden, og installerte kun wheelen.
Den feilet: `llm-ingestion-guard was not found in the package registry`.
Wheelens metadata bærer de to avhengighetene som BARE NAVN — [tool.uv.sources]
er uv-konfig og reiser ikke med wheelen, og navnene finnes ikke på PyPI. En
nedlaster som får et wheel (f.eks. fra release-objektet fase 3 skal lage)
treffer denne veggen. Med direct references ved siden av: 65 pakker, exit 0.

Dette er den skarpeste friksjonskanten spiken fant, og ingen hadde spurt om den.

7.3 — Samme bygg beviste fase 4a i container: shared_root() peker på
site-packages/portfolio_optimiser/_shared, 80 filer, persona-skillen lesbar —
uten arbeidstre, siden /build var slettet før installasjonen. Invarianten er
dermed målt i situasjonen den ble bygget for, ikke bare i enhetstest.

Byggekonteksten er `git archive HEAD` (311 sporede filer) — det en fremmed
faktisk laster ned, ikke arbeidstreet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jk8tauXXAojNKC7Tzq7ziF
2026-08-13 21:57:40 +02:00
976fcfcf38 docs(4·): tre av fase 4-premissene felte på måling
Research-spiken som åpner fase 4. Fire spørsmål, besvart med kjørte kommandoer
og siterte Learn-kilder via et agent-team på fire spor.

Tre premisser i planen viste seg gale:

1. «azd-utvidelsen heter enten microsoft.foundry eller azure.ai.agents — én er
   utdatert». Begge er gjeldende. Målt mot azds offisielle register: den ene er
   en meta-pakke uten egne kommandoer, den andre eier `azd ai agent`. Det som
   faktisk henger etter i doc-en er azd-minsteversjonen (1.25.2 vs >=1.27.1).

2. «Foundry injiserer FOUNDRY_PROJECT_ENDPOINT, AZURE_AI_MODEL_DEPLOYMENT_NAME
   og APPLICATIONINSIGHTS_CONNECTION_STRING». To av tre. Modelldeployment-navnet
   deklarerer vi selv. Kilden til antakelsen var MAF-hostingsiden, som motsier
   både plattformdoksettet og Microsofts eget kjørende sample.

3. «docker build og azd ai agent run mot localhost:8088 er testbare UTEN Azure».
   Halvveis: azd ai agent run krever azd auth login og provisjonerte ressurser,
   og modellkallene går fortsatt til Foundry. --local sparer deploy, ikke Azure.
   Kostnadsrelevant.

Q4 avgjorde 4c-omfanget: Workflow.as_agent() KJØRER på vårt objekt (målt), men
eksponerer bare debatten — validator, forankring, provenance og ledger ligger
utenfor grafen. En hosted agent bygget på as_agent() alene ville servert ugatede
forslag. Fase 4c er et signaturproblem rundt run_project, ikke et grafproblem.

Q2/Q3: direct references resolverer i BÅDE uv og pip, anonymt, uten publisering.
Base-image python:3.12-slim-bookworm; 3.14 faller fordi numpy 2.2.6 mangler
cp314-hjul — den ferske resolusjonen skjuler det bak en sdist.

Verktøykjeden reparert underveis: azd og docker manglet (Docker Desktop-casken
var en foreldet 0 B-oppføring). colima valgt framfor Desktop — kjører fra
terminalen, og VM-en er linux/amd64 nativt på Intel.

IKKE gjort: containeren er ikke bygget. docker build ble avvist av
tillatelsesklassifisereren. Uv-resolusjon viser at avhengighetene løser for
linux, ikke at koden kjører der — og det er forskjellen fase 4d trenger.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jk8tauXXAojNKC7Tzq7ziF
2026-08-13 21:37:26 +02:00
f5ec8c84f6 feat(4a): wheelen bærer shared/ som pakkede data — arbeidstreet er overstyringen
README:24 sa det rett ut: shared/ ble lest fra arbeidstreet ved kjøretid, og
derfor kunne repoet verken publiseres som wheel eller kjøre i container. Målt
før endringen: 1.0.0-wheelen bar 58 filer, null under shared/.

Endringen er én søm + én pakkelinje:
- hatchling force-include speiler shared/ byte-identisk til
  portfolio_optimiser/_shared/ (wheel 122 filer, 64 under _shared/; sdist
  bærer treet, målt via uv build sdist→wheel)
- shared_root() løser ved kall-tid: PORTFOLIO_SHARED_ROOT → arbeidstreets
  shared/ når det finnes (en checkout er autoritativ — det holder pull-only-
  subtree-kontrakten og goldenene urørt) → pakket kopi

Iron Law fulgt: tests/test_shared_packaged_data_loadbearing.py skrevet FØRST,
alle tre røde mot dagens kode (ordnings-testen felt av sin egen kontroll på at
pakket kopi finnes). Deretter fiks, deretter MÅLT mutasjon mot hele suiten:
- detach fallbacken → 1 rød (resolusjons-testen)
- detach force-include → 3 røde
- snu rekkefølgen (pakket før arbeidstre) → 1 rød (ordnings-testen, som var
  grønn før fiksen — flip-mutasjonen er beviset på at den diskriminerer)
Kontroll grønn: 813 passed / 4 skipped (baseline 810/4 målt på 142bfa9 samme
økt). Goldenene byte-uendret før og etter (shasum -c på demo-transkript +
begge nav-goldens). shared/ selv er urørt.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018hfm6sWTk17Cbh6ZHYhvCu
2026-08-13 21:13:08 +02:00
142bfa9666 docs(1b): Claude på Foundry er en TREDJE klientflate — FoundryChatClient kan ikke binde den
STATE bar påstanden «uverifisert: om FoundryChatClient kan binde en Claude-
deployment» som en bisetning. Operatøren ba om at slikt ikke får ligge. Målt mot
Microsoft Learn i stedet, og antakelsen er FALSIFISERT.

Claude-modeller solgt via Azure Marketplace kalles med Anthropics egen Messages
API på en egen endepunkt-form:

  https://<resource>.services.ai.azure.com/anthropic/v1/messages   <- Claude
  https://<resource>.services.ai.azure.com/api/projects/<project>  <- FoundryChatClient

Klienter: `anthropic`-pakka, `@anthropic-ai/foundry-sdk`, eller REST med
`anthropic-version: 2023-06-01`. Kun /v1/messages og /v1/messages/count_tokens er
eksponert på «Hosted on Azure»-versjonen. Deployment-navnet er `model`-parameteren.

Konsekvens for M1 (fase 1b), og den er en BESLUTNING, ikke en detalj:
- Microsoft-solgt modell (gpt-5-mini o.l.) -> 1b er REN KONFIG, azure-profilen
  som shippet er riktig søm, ingenting å bygge.
- Claude -> 1b krever en NY BACKEND-PROFIL i produksjonskode.

Samme klasse som notatet under «Endpoint format»: *.openai.azure.com og
*.cognitiveservices.azure.com er andre flater igjen. Tre flater, én ressurs-host.

Samme pass fanget forutsetningene som gater portalarbeidet: Marketplace-subscribe-
tillatelse, Contributor/Owner på ressursgruppa, prosjekt i støttet region, og data
retention PÅ abonnementet for Covered Models (ZDR gir 400, og Microsoft kan ikke
endre det for deg).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X737cbkG2uAXJ2Bvhf6X5M
2026-08-13 20:31:36 +02:00
cb809b683c docs(fase1a): «uansett modell» hvilte på en måling jeg aldri leste ferdig
Advisor felte påstanden ETTER at forrige commit var pushet. Rapporten sa at
600 s-taket er bindende «UANSETT modell», med `llama3.2:1b brukte OGSÅ >10 min»
som belegg. Men den målingen sprengte Bash-timeouten, gikk til bakgrunnen, og
ble aldri lest til slutt — «>10 min» var det jeg SÅ ved én kikk, ikke et resultat.

Målt nå ved manuell stopp: prosessen sto på 18 min 54 s UTEN å fullføre, og
loggen har ingen `POST "/api/generate"` for kallet. Det gir en NEDRE grense
(kallet oversteg taket med god margin) og ingen øvre.

Påstanden er derfor snevret til «på denne maskinen i denne tilstanden», med
konfunderingen uttalt: CPU-en strupet seg 62 % → 54 % underveis, og to fremmede
Python-prosesser holdt ~1,8 kjerner. Et generelt utsagn om modellstørrelse ville
krevd en ren maskin og en fullført måling; ingen av delene finnes her.
Verifiseringsloggen har fått raden + en eksplisitt «ikke verifisert»-note.

Samme defektklasse som grep-en samme økt, ett nivå opp: der var instrumentet i
stykker, her var det i orden og jeg leste det bare aldri.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X737cbkG2uAXJ2Bvhf6X5M
2026-08-13 20:19:31 +02:00
d2deb8ea38 docs(fase1a): lokal ende-til-ende feller på et 600 s-tak ingen har valgt
Fase 1a kjørt, og den leverte funn — ikke beviset. Stigen: endepunkt (http 200)
→ --live-dry-run (exit 0) → --scripted-replies (HELE loopen, null modellkall,
ValidatedProposal) → ekte modell. De tre første grønne; den fjerde exit 1.

Trinn 3 er det som gjør rapporten verdt noe: loopen er bevist sammenhengende UTEN
en modell i bildet, så feilen i trinn 4 er attribuerbar til levende modell-output.

Fire funn, alle målt:
1. Den lokale klienten har ingen timeout-søm (backends.py:130) — kjøringen døde
   etter 3 x 600 s = 30 min på SDK-standardverdier. Ikke fikset her: produksjons-
   kode krever feilende test først.
2. qwen3:4b er resonnerende og brukte hele budsjettet på tankerekka — målt 4,0
   tok/s, og 256 tokens ga TOMT svar.
3. Planens `--max-*`-flagg for token-tak finnes ikke; takene er kompilert inn.
   Inert lokalt, bærende for 1b: en betalt kjøring ville gått under et 100k-tak
   ingen har valgt.
4. Prompten (4388 tokens) ble STILLE kuttet mot Ollamas 4096-vindu, keep=4 — altså
   røk formatinstruksjonene først. Advarselen står i Ollamas logg, ikke i vår.

Kjøring 2 (qwen2.5:3b uten tankemodus, 16k kontekst) lukket funn 4 men traff samme
vegg: 600 s-taket er bindende på denne maskinvaren UANSETT modell — en 1B-modell
brukte også over ti minutter per kall mens CPU-en strupet seg til 54 %.

Ærlighetsgrense: null vellykkede modellkall. Prompt-former, VERDICT-linja og
runde-taket er fortsatt uverifiserte mot en levende modell.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X737cbkG2uAXJ2Bvhf6X5M
2026-08-13 20:12:50 +02:00
5871e397a3 docs: presentasjonsmateriellet fra demoen 2026-08-13
Frysen er opphevet — demoen er avholdt, og disse to filene er det den ble holdt
på. De lå utrackede med vilje gjennom hele frys-vinduet, så `git status --short
--untracked-files=no` kunne stå TOM som gate.

- `2026-08-13-demo-presentasjon.html` — slide-decket som ble vist, 51 slides i
  åtte deler, 15 håndskrevne tema-bevisste SVG-figurer, ingen eksterne
  avhengigheter. Alle tall er grunnet mot `tests/golden/demo-transcript.stdout`
  før de ble skrevet: 2 100 000 · 1 769 915 · 445 500 · 3 tidligere dommer.
- `2026-08-13-presentasjon-ledelse.html` — utdypende underlag med
  verifiseringslogg, der de to viktigste radene står TOMME med vilje
  (pilotkostnad ikke estimert, gevinst ikke målt).

Det ble INGEN live kjøring i møtet; golden-transkriptet er derfor det eneste
beviset decket viser, og all «dere ser om litt»-formulering er skrevet om til
et eksempel løsningen ER testet på.

Begge filer er innholdssjekket før publisering til den offentlige `open/`-flaten:
null treff på endepunkt/tenant/nøkkel-mønstre, null eksterne avhengigheter,
`<meta charset="utf-8">` på plass i begge.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UkogKWsM489mCza1BLJdtW
2026-08-13 18:45:06 +02:00
d71a4e7b4e docs: stempler v1.0.0 — [Unreleased] blir [1.0.0] - 2026-08-12
Datoen er LEST med `git log -1 --format=%cs` på Y, ikke skrevet på forhånd og
ikke tatt fra veggklokka. Re-leses på denne commiten før taggen settes (runbookens
§5 punkt 8): faller midnatt mellom Y og Z, står gårsdagens dato i commiten som
faktisk tagges. Denne commiten er Z.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DWeYxduEnFQeynXbrtEA6o
2026-08-12 20:16:54 +02:00
2e17ea32b6 docs(P4.5): §0s to målte felt fylt ut — frosset commit X og generalprøve-tidspunkt
Generalprøve ×2 grønn på 777b9f7 (exit 0, 61/4 begge ganger, golden-diff TOM
begge ganger, kjøringene byte-identiske med hverandre). K1 distinkt = 8,
linje-telling 9 som forventet. K2: REJECTED 2100000 mot P90 1769915 →
VALIDATED 445500, samme kandidat. Rent tre målt FØR X ble notert.
Frys-gaten prøvekjørt begge armer: arm 1 tom per konstruksjon (lime-inn-sjekk
av hashen passerte), arm 2 mot c255662 ikke-tom (6 filer) — gaten diskriminerer.
Utfyllings-gaten TOM. Denne commiten er Y.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DWeYxduEnFQeynXbrtEA6o
2026-08-12 20:16:23 +02:00
777b9f7225 docs(P4.5): min egen komprimering gjorde tag-kommandoen ukjørbar
Advisor felte en defekt DENNE ØKTA innførte. Forrige commit skrev
`git tag -a v1.0.0` i STATE og planens to rader — uten `-m`. Jeg fjernet
den for én-kopi-prinsippets skyld, men prinsippet gjelder MELDINGSTEKSTEN;
`-m` er ikke innhold, det er flagget som gjør `-a` ikke-interaktiv. Før
økta bar STATE en komplett kjørbar kommando. Etter bar den en ufullstendig.

Failure-moden er MÅLT, ikke arvet fra reviewet (som antok at den henger):
`GIT_EDITOR=true` er satt i sesjonsmiljøet, så kommandoen henger IKKE — den
gir `fatal: no tag message?`, exit 128, og INGEN tag. I et skall uten
`GIT_EDITOR` åpner den en editor. Begge veier: ukjørbar som skrevet, på
enveis-dagen, etter at frys-gaten alt har passert. Runbookens punkt 10 var
korrekt hele tiden — feilen satt kun i de to sammendragene.

Rettet til `-m "<ordrett fra runbookens §5 punkt 10>"`: `-m` er synlig og
obligatorisk, meldingsteksten bor fortsatt ett sted, og utfyllings-gaten
står på 2 (vinkelparentesene er enkle og små — de matcher ikke `<<[A-ZÆØÅ-]*>>`).

PUNKT 10 ER NÅ KJØRT EKSTRAHERT FRA FILA, ikke håndskrevet (økt 13s
presedens: en kommando som ser riktig ut kan lyve, og repoet har en
bash-3.2-multibyte-historie). Hentet ut av linje 281 via generert skript
(`eval` er hook-blokkert), kjørt mot engangs-repoet: exit 0, og
`git cat-file -p v1.0.0` gir meldingen byte-identisk med em-dash intakt.

TO PÅSTANDER FRA FORRIGE COMMIT VAR UMÅLTE, OG ER NÅ ERSTATTET AV MÅLINGER:

1. «vent et halvminutt» var et tall jeg aldri målte — jeg målte at porten
   svarte igjen, ikke hvor lenge den var stengt. Samme klasse som §1s
   `/tmp/po-sim-…`-sti økt 9 drepte. Erstattet av proben som FAKTISK ble
   observert virke: `ssh -T git@git.fromaitochitta.com` → `Hi there, ktg!`.
   En probe kan ikke bli foreldet slik et gjettet intervall kan.

2. «aldri re-push» var for absolutt. Målt: `git push` av en uendret tag gir
   `Everything up-to-date`, exit 0 — og hvis det var PUSH-en (punkt 10) som
   ble rate-limitet, ER retry den påkrevde utveien. Forbudet er nå snevret
   til det som faktisk er farlig: `git tag -f` + force. Uten force er selv
   det fail-closed — målt: `! [rejected] … already exists`.

Målt etter rettelsen: utfyllings-gaten 2 · §5 fortsatt elleve punkter ·
punkt 10 fortsatt på linje 281 og kjørbar · CHANGELOG urørt · planens to
rader uendret i linjeantall (705) og pipe-struktur (5/0 og 6/3) · frys-gaten
mot HEAD TOM.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019nZQkAJytbaUAYxMAU2wk7
2026-08-11 22:33:58 +02:00
2319f04420 docs(P4.5): taggens FORM var onsdagens siste ufattede beslutning
Punkt 10 sa `git tag v1.0.0` — lettvekts. Repoets eneste andre tag er
ANNOTERT (målt: `git cat-file -t v0.1.0` → `tag`, melding
`v0.1.0 — first tagged release`), så hovedreleasen ville blitt den eneste
taggen uten forfatter, dato eller melding. Ingen gate ville stoppet det:
`describe --tags` og `tag -l` svarer likt for begge former (målt).

Onsdagen skal MÅLE og UTFØRE, ikke avgjøre. Slik den sto, måtte dagen
enten tagge lettvekts uten å se avviket, eller oppdage det og improvisere
en `-m`-melding på en enveis-dag. Meldingen står nå literalt i §5 punkt 10
— og KUN der; planens to rader peker dit, så det finnes ingen andre kopi
å drifte fra. Utfyllings-gaten står derfor fortsatt på 2: en placeholder
ville gjort meldingen til et tredje felt onsdag måtte fylle.

Formen er tørrkjørt i et engangs-repo, ikke resonnert: `-a` med em-dash gir
`tag -l` → v1.0.0, `describe --tags --exact-match HEAD` → v1.0.0, filtrert
`ls-remote` → 1 linje, og em-dashen overlevde skallet.

MIN FØRSTE HYPOTESE VAR FEIL, OG MÅLINGEN FELTE DEN: jeg trodde punkt 11s
«én linje» brakk for annoterte tagger, siden `ls-remote --tags origin` viser
den peelede `^{}`-refen. Målt mot EKTE origin: MED refspec-filter gir den
annoterte v0.1.0 én linje — `^{}` matcher ikke pattern-et. Punkt 11 var
robust hele tiden. Presisert i teksten, fordi neste leser vil ha samme tvil.

EN ANDRE DEFEKT FALT UT AV Å MÅLE MOT EKTE REMOTE: origin rate-limiter SSH
på burst. Målt: de to første ls-remote gikk igjennom, de fire neste ga
`Connection refused`, porten svarte igjen etter en pause, og Forgejo-weben
var oppe hele tiden (HTTP 303) — serveren var aldri nede. Punkt 11 kjører
to SSH-kall rett etter en push, altså nøyaktig et burst.

Alvorligheten ligger i at BEGGE utfall gir null linjer på stdout (målt):
taggen mangler = exit 0 + tom stderr; kom ikke fram = exit 128 + melding.
Et `| wc -l` kan ikke skille dem — så en rate-limitet bekreftelse leses som
«taggen landet ikke» dagen etter at push-en faktisk lyktes, på enveis-dagen.
Diskriminatoren er exit-koden; retteslen er vent-og-kjør-på-nytt, aldri
re-push eller re-tag. Samme klasse som §5 punkt 5s arm: en gate må kunne
feile på riktig grunn, og de to måtene den svikter på må se ulike ut.

Ingen nye punkter, ingen renummerering: begge endringene sitter PÅ punkt 10
og 11. Målt: §5 fortsatt elleve punkter · utfyllings-gaten 2 · CHANGELOG
urørt ([Unreleased] = 1, null link-refs) · kun to diff-hunks, begge i §5, så
§0/§1/§2/§3/§4/§6 er byte-urørt og økt 9s 67 målinger av §2 står · planens
to rader uendret i linjeantall og pipe-struktur (5/0 og 6/3, før = etter) ·
frys-gaten mot HEAD TOM og diskriminerende (c255662 = 6 filer).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019nZQkAJytbaUAYxMAU2wk7
2026-08-11 22:17:29 +02:00
1d30a47e68 docs(P4.5): punkt 5s nye arm hadde tvetydig failure-mode
Armen mot c255662 ble lagt til i c50fd2e for å bevise at frys-gaten kan
diskriminere. Men den fortalte ikke onsdagens operatør hvordan man skiller
de to måtene den kan svikte på — og begge ville stoppet sekvensen på en
enveis-dag:

- stille tomt  = pathspec-en er ødelagt (det armen finnes for)
- fatal: bad revision = hashen er feilskrevet

MÅLT, ikke antatt:

    $ git diff --stat deadbeef..HEAD -- . ':(exclude)docs/' ':(exclude)CHANGELOG.md'
    fatal: bad revision 'deadbeef..HEAD'

Git roper altså på feil hash. En tom utskrift er derfor ALDRI «feil hash»,
og det står nå i punktet — så «ikke gå videre før armen er ikke-tom» kan
handles på uten å gjette.

Samme klasse som defekten c50fd2e lukket, ett nivå ned: en gate hvis
utfall er tvetydig blir under tidspress resolvert ved gjetning. c255662 er
nå load-bearing i runbooken, så hashen navngis i selve feilbeskrivelsen.

Målt: utfyllings-gaten 2 · §5 elleve punkter · frys-gaten mot HEAD TOM ·
kun §5 endret. Ingen suite-kjøring: identisk innholdsklasse som c50fd2e,
der 810/4 + ruff + format + mypy allerede ble målt rene.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0192WLzngWK5aFDYpkXWuLVh
2026-08-11 21:57:06 +02:00
c50fd2edb4 docs(P4.5): §5s frys-gate ved X kunne aldri feile
Punkt 5 kjørte frys-gaten mot <X> rett etter at punkt 4 leste X som
`git rev-parse HEAD`. På det tidspunktet ER X lik HEAD, og ingenting er
committet imellom — så <X>..HEAD er tom uansett hva treet inneholder.

Målt, ikke resonnert: med en endret src/-fil liggende i arbeidstreet
(` M src/portfolio_optimiser/__init__.py`) sto gaten fortsatt tom. Den
måler ingen tilstand.

Økt 7s begrunnelse for to kjøringer sa «den første måler en tilstand som
ikke lenger finnes når taggen settes». Det er for snill: den finner ingen
tilstand å måle. Dette er repoets egen defektklasse, anvendt på repoets
egen runbook — «en gate som bare kan bli grønn beviser ingenting» er
standarden hver load-bearing test måles mot.

Alvorlighet, uttalt presist: falsk trygghet, ikke falsk grønt. Punkt 3
(rent tre) og punkt 9 (etter Y og Z) dekker den faktiske risikoen, så
ingenting ved onsdagen blir rødt av dette. Men en gate operatøren ser
grønn ved X kan under tidspress gjøre punkt 9 til en gjentakelse man
hopper over — samme argument som ga §6 steg 1 sin `--untracked-files=no`.

Punktet er BEHOLDT, ikke fjernet: fjerning renummererer 6→5 … 11→10 og
bryter fem kryssreferanser (§0s «punkt 10» og «punkt 6», punkt 9s
tilbakereferanse, STATEs «elleve punkter», planens ons-12-rad) — på
frys-eve. I stedet har punkt 5 fått en arm den kan feile på:

    git diff --stat c255662..HEAD -- . ':(exclude)docs/' ':(exclude)CHANGELOG.md'

→ ikke tomt (målt: 6 filer). Den beviser at kommandoen kan diskriminere
FØR punkt 9 hviler på at den er tom. Uten den ville en feilskrevet
':(exclude)…' eller en quoting som ikke overlevde skallet (MULTIOS,
08-09) gitt grønn gate på feil grunnlag, og et utestet tre tagget.
c255662 ligger fast bak både Y og Z, så armen forblir ikke-tom uansett
hvor HEAD står onsdag.

To rettelser i samme pass, begge nødvendige for at teksten ikke skal lyve
på en ny måte:

- punkt 9 sa «Punkt 5 målte en tilstand som ikke lenger finnes» — under
  det nye punkt 5 er det galt på en ny måte, siden punkt 5 ikke målte noe
- §5s ingress sa «Fila skrives ÉN gang onsdag (punkt 6)», mens punkt 4
  instruerer om å skrive X inn i §0. Den bærende egenskapen er at
  ingenting skrives ETTER Y, ikke antallet skrivinger — nå sagt slik.
  Punkt 4 er operasjonelt riktig: å bære en hash i hodet gjennom tre
  gate-kjøringer er verre.

Begge gate-kommandoene er EKSTRAHERT LITERALT fra runbooken og kjørt
(ikke gjenskapt for hånd — repoets MULTIOS-lærdom): arm 1 tom, arm 2
seks filer.

Målt: utfyllings-gaten 2 · §5 elleve punkter · §0/§1/§2/§3/§4/§6/vedlegg
byte-urørt mot 24b5ed2 (kun §5 endret, så økt 9s 67 målinger av §2 står) ·
planens kalender-rader 3 usiterte pipes · frys-gaten mot HEAD TOM ·
810 passed / 4 skipped · ruff + format + mypy rene.

Pre-flighten er re-målt HELT på 24b5ed2, ikke arvet fra 4b9e5d9:
stale tag 0/0 · [Unreleased] 1, null link-refs · frys-gaten d0e8bb0..HEAD
TOM og diskriminerende (c255662..HEAD = 6 filer) · demo exit 0, golden-diff
TOM, 61 linjer, K1 distinkt 8 / linjer 9.

Åttende defekt i denne dokumentfamilien på åtte økter. Ny klasse: ikke
«feil tidspunkt» og ikke «implisitt steg», men en gate hvis utfall var
avgjort av sin egen plassering i sekvensen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0192WLzngWK5aFDYpkXWuLVh
2026-08-11 21:51:23 +02:00
24b5ed29df docs(P4.5): §5 navnga to commits den aldri opprettet
Y og Z navngis fem steder i §5 — punkt 3s parentes, punkt 8, punkt 9 og
ingressen — men ingen av de elleve punktene opprettet dem. Punkt 6 sa
«Runbooken fylt ut», punkt 7 «CHANGELOG-overskriften stemplet»; ingen sa
git commit, og punkt 8 forutsatte at Z fantes.

Konsekvenskjeden er målt mot gate-definisjonene, ikke resonnert:

- frys-gaten unntar BÅDE docs/ og CHANGELOG.md, så to ucommitterte endringer
  passerer punkt 5 og 9 stille
- punkt 11s anker passerer også, fordi HEAD da fortsatt er X og X ER det
  taggede
- v1.0.0 ville blitt tagget med `## [Unreleased]` fortsatt i CHANGELOG — en
  release-defekt, ikke bare en gate-defekt
- først torsdagens §6 steg 1 ville ropt, foran demoen

Rettet som klausuler PÅ punkt 6 og 7, ikke som nye punkter: commiten er det
som gjør handlingen varig, og å skille dem er nøyaktig defekten. Fortsatt
elleve punkter. Punkt 6→7→8 lenker nå: Y committet → dato lest på Y → stempel
→ Z committet → re-lest på Z → tag Z.

Klassen er ny for uka. De åtte foregående var «kommandoen var riktig,
tidspunktet var det ikke». Denne er «handlingen var riktig, steget som gjør
den varig var implisitt» — og den overlevde både gjennomlesningen og
propagerings-passen i 4b9e5d9, fordi begge lette etter tall og referanser,
ikke etter fravær.

Målt: utfyllings-gaten 2 · §5 elleve punkter · §1 og §2 byte-urørt mot
63a167a · frys-gaten d0e8bb0..HEAD TOM.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FfdfrMcEtkmZXPkzt7APQU
2026-08-11 14:16:14 +02:00
4b9e5d9520 docs(P4.5): §0s tag-felt var sirkulært, og begge utveier gjorde torsdagen rød
§0s tag-rad hentet verdien fra `git tag -l v1.0.0` ETTER push (§5 punkt 10),
mens §5 punkt 6 krever at utfyllings-gaten er TOM — altså FØR. Punkt 6 var
gatet på informasjon som først finnes etter punkt 10.

Ingen utvei holdt. Fylt ærlig krever den en commit ETTER taggen: HEAD forlater
Z, og torsdagens §6 steg 2 — identitets-ankeret økt 10 vant — blir rødt på
demo-morgenen. Ufylt bryter den gaten i punkt 6. Sekvensen modellerer heller
ingen tredje commit (X → Y → Z → tag). Lokal tag før utfylling hjelper ikke
(taggen står fortsatt på Z); --amend etter taggen flytter Zs sha ut under den.

Tvillingen: §5s haker settes i selve fila, og punkt 7-10 skjer ETTER
runbook-commiten Y. Målt: en redigert runbook gir « M docs/plan/…», altså rød
§6 steg 1 — eller en commit etter taggen, altså rød §6 steg 2. Samme
motsigelse, samme to gater.

Løsningen bevarer identiteten: §0 mistet tag-raden med begrunnelsen skrevet
inn, §5 fikk et ellevte punkt som bekrefter taggen der den settes (git tag -l ·
git ls-remote --tags origin · git describe --tags --exact-match HEAD — det
siste er torsdagens anker kjørt et døgn tidlig), og §5s ingress sier at hakene
aldri settes i fila. Ingenting skrives etter taggen.

En åttende defekt falt ut av gjennomlesningen, og den er økt 11s egen bom: §5
punkt 9 sa «Steg 2 målte en tilstand som ikke lenger finnes», mens første
frys-gate-kjøring er punkt 5. Verifisert mot 818b55a: da linja ble skrevet var
frysen TO kommandoer og gaten var nr. 2. Økt 11 gjorde den til tre, men
grep-passen lette etter strengen «to kommandoer», så «Steg 2» slapp forbi —
og økt 11 konkluderte eksplisitt at ingen kryssreferanse pekte på den gamle
nummereringen. Nå forankret i §5s egen nummerering.

Målt: utfyllings-gaten 3 → 2 · §5 ti → elleve punkter · §0-tabell 4 pipes per
rad · §1 og §2 byte-urørt (shasum likt før/etter — økt 9s 67 målinger er gjort
mot de bytene) · fire diff-hunks, alle i §0/§5 · alle 20 numeriske
kryssreferanser sveipet · frys-gaten d0e8bb0..HEAD TOM · ingen test leser
dokumentene.

Propagert til fem levende steder i planen og STATE; GJORT-blokkenes «3» står
som historikk.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FfdfrMcEtkmZXPkzt7APQU
2026-08-11 14:11:47 +02:00
63a167a8d8 docs(P4.5): frys-gaten er commit-til-commit, men prøven leser arbeidstreet
To en-linjes herdinger av onsdagen og torsdagen. Null kodeendring.

1) §5 manglet en arbeidstre-sjekk ved X. Frys-gaten er
   `git diff <X>..HEAD` — commit-til-commit — mens generalprøven kjører
   fra arbeidstreet. En ucommittet endring i src/ ved prøvetidspunktet
   gjør X til en beskrivelse av noe som aldri ble prøvd, og BEGGE
   kjøringene av gaten står tomme: de kan ikke se den. Verre om
   endringen er dét som gjør prøven grønn — da er den taggede koden rød,
   som er nøyaktig hullet kriterium 6 ikke dekker. Samme argument som ga
   §6 steg 1 sin plass (økt 10): rent tre er en REGEL, ikke et
   øyeblikksbilde. Punktet sier også hva man gjør ved ikke-tomt — commit
   eller forkast, og kjør så prøven OM IGJEN, siden en commit flytter
   HEAD og X ellers ville pekt på et tre prøven aldri så.

2) §3s abortsti brukte relativ sti til fasit-fila, og prosaen rett under
   navngir «feil katalog» som sannsynlig årsak. Målt: fra en annen
   katalog gir den `cat: tests/golden/…: No such file or directory`.
   En abortsti som deler failure-mode med det den aborterer fra, gjør
   ett synlig problem til to. Nå absolutt sti — målt kjørbar fra
   vilkårlig cwd (61 linjer).

Samme defektklasse som de fem forrige i denne dokumentfamilien:
kommandoen var riktig, konteksten var det ikke.

Klon-tørrkjøring av hele sekvensen ble VURDERT og forkastet: en lokal
klons `origin` peker på dette repoet, så §5 steg 10 (`git push origin
v1.0.0`) ville skapt nøyaktig den stale taggen økt 8 pre-flightet mot —
og den måtte vært stoppet ett steg for tidlig på eve-en av en enveis-dag.

Grep-passen fant fem kopier av «to kommandoer»; de to levende
instruksene er rettet til tre, de to i GJORT-blokkene er historikk og
står. Tir-11-raden sa TRE ØKTER (drift, samme klasse som økt 10 fant).

Målt: utfyllings-gaten 3 (uendret — ingen ny placeholder) · §5 ni → ti
avkryssinger · frys-blokka to → tre punkter · tabell-integritet 3
usiterte pipes per rad (ons-12s tre escapede er K1-kommandoens) ·
frys-gaten mot HEAD TOM (kjørestien urørt) · 810 passed / 4 skipped ·
ruff + format + mypy rene.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0186tGdSmqQZUfy4emjSFNy7
2026-08-11 13:50:13 +02:00
8e7fa54267 docs(P4.5): torsdagens anker var arvet, og arvet gjorde det fail-open
§6 landet i 859be2a med frys-gatens to unntak (`docs/`, `CHANGELOG.md`)
kopiert inn i tag-ankeret. Unntakene er ikke generelle: de er begrunnet i
at ONSDAGEN skriver nøyaktig dem. Torsdag skriver ingenting, så riktig
forventning er IDENTITET — og arvet dit gjorde `docs/`-unntaket gaten
blind for den ene skriveren vi vet er aktiv i repoet, den parallelle
sesjonen som eier docs/presentasjon-portfolio-optimiser.html.

Samme klasse som da frys-gaten selv ble snudd fra positiv liste til
eksklusjonsform: en gate arvet uten at begrunnelsen ble re-utledet.

Ankeret er nå `git describe --tags --exact-match HEAD` -> `v1.0.0`.
MÅLT fail-closed begge veier: `no tag exactly matches '<sha>'` (exit 128)
når HEAD ikke er tagget, `bad revision` når taggen mangler. Ingen av dem
kan forveksles med grønt.

Feiler ankeret er det en BESKJED, ikke en abort: `git diff --stat
v1.0.0..HEAD` UTEN unntak skiller kjøresti-endring (-> §3) fra ren
`docs/` (demoen upåvirket, men da vitende).

Steg 1 var også et øyeblikksbilde, ikke en regel: «kun den fremmede
HTML-fila» slutter å stemme i det den sesjonen committer eller legger
igjen en fil til, og en gate som roper på noe operatøren ikke eier lærer
ham å ignorere gaten. Nå `git status --short --untracked-files=no`
-> TOMT (målt).

Lagt til én setning om at en `Resolved`/`Audited`/`Installed`-linje fra
uv er miljø-sjekken, ikke en feil — målt at `uv run` er STILLE på varmt
miljø (stderr = de fire linjene §1 beskriver), men en første kjøring for
dagen kan si fra.

Målt: utfyllings-gaten uendret på 3 · 810 passed / 4 skipped · ruff +
format + mypy rene · kalendertabellens pipe-telling intakt.

Planens to beskrivelser av gaten rettet i samme pass (P4.5-blokka +
tor-13-raden) — de beskrev et design som ikke lenger står.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0143ESVxVd5srp9PGshHCdi4
2026-08-11 13:14:03 +02:00
859be2acfc docs(P4.5): runbooken manglet sjekken som kjøres FØR demoen
Golden-transkriptet ble sjekket inn nettopp for å fange «regresjon mellom
onsdag og torsdag» (egnethetsreview-planen P4 pkt. 3) — selv-identitet
fanger ikke-determinisme, men ikke at noe flyttet seg over natta.
Mekanismen fantes altså. Men i runbooken sto kommandoen under overskriften
«Hvis du vil vise at outputen er den frosne», altså som et show-element
UNDER demoen, og kjørt der oppdager den regresjonen samtidig med publikum.

Samme defektklasse som frys-gaten (x1 -> x2) og CHANGELOG-datoen (lest på
feil HEAD): kommandoen var riktig, tidspunktet var det ikke.

§6 flytter den til før rommet fylles og legger til tag-ankeret
`git diff --stat v1.0.0..HEAD` med frys-gatens to unntak. MÅLT at ankeret
er fail-CLOSED: en manglende v1.0.0 gir `fatal: bad revision` (exit 128),
ikke tomt — en gate mot en tag som ikke finnes kunne ellers vært stille
grønn. Kommandoen står fortsatt kun ÉN gang i dokumentet (§6 peker på
§1-blokka), så det er ikke laget en andre kopi å drifte fra.

Tatt tirsdag med vilje: onsdagen skal måle og utføre, ikke avgjøre.

Målt: demo på HEAD golden-diff TOM (61 linjer, exit 0) · to kjøringer
byte-identiske · 810 passed / 4 skipped · ruff + format + mypy rene ·
utfyllings-gaten uendret på 3 · kalendertabellens pipe-telling intakt.

Én påstand ble drept av måling: pre-flighten varmer IKKE venv-en
(2,67 s vs 2,77 s), så den setningen ble ikke skrevet.

Planen lukket i samme pass (grep-passen fant tre steder): P4.5-blokka,
tor-13-raden, og tir-11-raden — «TOM — GÅ RETT PÅ ONSDAG» var sant da den
ble skrevet mandag og sluttet å være det samme uke.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0143ESVxVd5srp9PGshHCdi4
2026-08-11 13:03:34 +02:00
c6d7ea7c1b docs(P4.5): §1 lovet en stderr-sti som aldri kan vises
Runbookens §1 sa `(arbeidskopi: /tmp/po-sim-…)`. Målt: `mkdtemp` følger
`TMPDIR`, som på macOS er `/var/folders/…/T/` — `tempfile.gettempdir()`
bekrefter det. Strengen `/tmp/po-sim-` kan altså aldri stå på skjermen.

Det er den dyre varianten av defekten: operatøren ser en lang
`/var/folders`-sti der runbooken lovet `/tmp`, og et sekunds tvil om
miljøet er ett sekund fra en unødvendig abortsti på scenen.

Rettet til FORMEN, ikke til denne maskinens sti — en hardkodet
`/var/folders/xc/…` ville gjenskapt defekten ett nivå ned. Fasiten gjør
allerede nøyaktig dette skillet: temp-katalogen tilhører miljøet,
`po-sim-`-prefikset tilhører programmet.

Funnet ved å måle §1s stderr-INNHOLD mot fasiten (testens egen
`normalise_stderr`) i stedet for bare å telle fire linjer — den ene §1-
påstanden økt 9s pass hadde tallfestet uten å innholds-sammenligne.

Én kopi: planen var allerede korrekt («`TMPDIR`-rota maskeres»).
Utfyllings-gaten står uendret på 3 placeholders; kjørestien er urørt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NaBAXKK1ippN8Zi62DMNoS
2026-08-11 12:50:06 +02:00
4f1c19dfb2 docs: CHANGELOG-datoen må RE-LESES etter stempel-commiten, ikke bare før
Sekvensen onsdag er X → Y (runbook) → les `%cs` → stempel → Z → tag Z.
Ved lesningen står HEAD på Y — og Y blir aldri tagget; verdien skrives inn
i Z. Det holder når Y og Z lander samme dag, men brekker ved midnatt MELLOM
dem: da bærer den taggede commiten gårsdagens stempel. Det er nøyaktig det
tilfellet setningen påberopte seg («også hvis dagen sklir til torsdag morgen»).

Retteslen er ett steg, ikke en omskriving: `git log -1 --format=%cs` én gang
til ETTER commit av Z og FØR `git tag`, med `--amend` ved avvik. §5 har nå ni
avkryssinger. Samme klasse som frys-gaten selv (amendert økt 7): kommandoen
var riktig, tidspunktet var det ikke.

Begge kilder rettet i samme pass — runbookens §5 og planens frys-blokk.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LefsEziBhiJgLFbBqTZxnK
2026-08-11 06:44:29 +02:00
818b55ae09 docs: frys-gaten kjøres TO ganger, og CHANGELOG-datoen leses av commiten
Tre retteser fra advisor-review, alle propagert til BEGGE kilder (runbook +
plan + STATE) — en oppskrift som står ulikt to steder er nøyaktig drift-klassen
denne økta har lukket.

1. GATEN KJØRES TO GANGER. Sekvensen er X (prøve) -> Y (runbook) -> Z
   (CHANGELOG-stempel) -> tag. En gate kjørt kun ved X måler en tilstand som
   ikke lenger finnes når taggen settes; bare et andre kall RETT FØR git tag
   beviser at det TAGGEDE treet er det prøvde. Samme kommando, annet tidspunkt.

2. DATOEN LESES AV COMMITEN SOM TAGGES: git log -1 --format=%cs, ikke date +%F.
   Veggklokka er ikke etterprøvbar og kan avvike fra commiten på en sen
   kveldsøkt; %cs gjør CHANGELOG, tag-objektet og commiten enige også hvis
   dagen sklir til torsdag morgen. Målt: %cs på HEAD gir 2026-08-10.

3. PORTEFØLJE-SVARET SNEVRET INN. Raden lovet "ja, den kan kjøre en hel
   portefølje" med et forbehold om CLI-taket. Spørsmålet den besvarer er
   bredere enn det vi har prøvekjørt denne uka: run_portfolio er testet, men
   demoen kjører ett prosjekt og CLI-porteføljestien er ikke prøvd. Raden sier
   nå hva biblioteket har, at demoen ikke viser det, og at porteføljestien ikke
   skal tilbys som live demonstrasjon.

Verifisert: null "date +%F" igjen i noen av de tre kildene; %cs-oppskriften i
alle tre; placeholders fortsatt 3; docs/-gaten grønn (10 passed).
2026-08-10 21:24:48 +02:00
eb631d6ba9 docs(plan): P4.5 lukket i planen — den instruerte om en runbook som nå er skrevet
Samme drift-klasse som økt 4, 5 og 6 hver for seg fant, og derfor lukket i SAMME
økt som arbeidet: planen er dokumentet operatøren følger onsdag under tidspress.

Grep-passen (økt 4s mottiltak) over "runbook|P4.5" fant FEM levende steder, ikke
ett:
- P4.5-blokka: ☐ -> ✔ SKREVET, med amendementet som forklarer hvorfor gaten
  ("VED frysen ... så den matcher frosset output") dekker målingene og ikke
  forfatterskapet
- ons-12-raden: "skriv runbooken" -> "FYLL UT runbooken", med utfyllings-gaten
- Spor 2 (§0): mandat-setningen merket ✔ — den sto der som en PLASSHOLDER for en
  beslutning, ikke som en beslutning
- Spor 2-oppsummeringen linje 63: "VED frysen" -> "SKREVET man 10., FYLLES UT"
- man-10-raden: nytt punkt (7)

Ett premiss rettet i samme pass: raden sa "SEKS ØKTER" og er nå syv.

Historiske oppføringer står urørt med vilje: I5-registeret i §6 og frys-blokkas
"runbook (commit Y)" beskriver beslutninger og en sekvens som fortsatt stemmer —
onsdag committer fortsatt den utfylte runbooken som Y.

Tabell-integritet verifisert etter redigering: hver kalenderrad har nøyaktig tre
usiterte pipes; de tre escapede på ons-12-raden er K1-kommandoens og er
uendret. docs/-gaten grønn (10 passed).
2026-08-10 21:18:41 +02:00
c7a57d8c76 docs(P4.5): demo-runbooken skrevet — beslutningene mandag, målingene onsdag
Runbooken var gatet til "VED frysen onsdag". Gaten gjelder hashen X og verbatim
output-utdrag — ikke forfatter-dømmekraften. Planens egen onsdags-regel er at
dagen skal MÅLE og UTFØRE, ikke avgjøre; en runbook skrevet fra bunnen på en
enveis-dag under tidspress er nøyaktig det den regelen forbyr. Derfor to-trinns
med vilje: alt kjennbart nå, tre målte felt onsdag.

Samler de fire spredte kildene I5 navnga (demo-uke-plan §1 · innholdsgate §5
JA-varianten · P4 pkt. 4 · §0 Spor 2) og forankrer hver setning i en LINJE i det
pinnede transkriptet, så operatøren finner stedet uten å lete.

MANDAT-SETNINGEN ER SKREVET. Den sto i Spor 2 som "én muntlig setning" og fantes
ikke som tekst noe sted — en udraftet setning til en live demo. Nå formulert mot
docs/bestille-en-kjoring.md: bestillingen styrer hva som VURDERES, aldri hva som
GODKJENNES.

UTFYLLINGEN ER GJORT TIL EN SJEKKET STEG, ikke en husket. Tre placeholders med
greppbar form, og grep-en er SELV-SIKKER: monsteret '<<[A-ZÆØÅ-]*>>' matcher
ikke sin egen tekst (målt: 3 treff, ingen av dem kommandolinjene). Et uutfylt
felt er samme drift-klasse som plan-radene økt 4, 5 og 6 hver for seg fant.

VEDLEGGET FELLER TRE STATE-PREMISSER. Alle tre "scene-kosmetiske" punkter er
målt mot det pinnede transkriptet, og INGEN er synlige:
- 23700 NOK/aar: rationale er 389 tegn, beløpet står ca. tegn 370, demoen
  klipper på 300 -> linja ender "pga. overes…". grep -c "23700" -> 0.
  STATEs "printes fortsatt ordrett" var et premiss, ikke en måling.
- 0.82 hører til bygg-goldenen, ikke veglys. grep -c -> 0.
- docs/ekspert-svar.md leses ikke av demoen.
Torsdagen slipper altså tre setninger den var fortalt at den måtte bære.

CHANGELOG-DATOEN GJORT TIL EN MÅLING: sjekklista sier "les datoen på dagen"
(date +%F), ikke det forhåndsskrevne 2026-08-12 — samme premiss-klasse som
hashen planen allerede nekter å skrive ned.

Frysen er IKKE flyttet fram. Tirsdagen er tom og fristet, men onsdag er en
dato-beslutning på en enveis-handling (tag + frys), og risikoen den ville hedget
er allerede retirert: økt 6 målte samme kjøresti grønn, og d0e8bb0..HEAD er
dokumenter alene.

Målt: 810 passed / 4 skipped uendret. docs/-gaten passert via datert sti.
Kjørestien urørt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011GtvZy6hn3k2iTFGjRVzLi
2026-08-10 21:16:10 +02:00
c4b0e08cc5 docs(plan): tirsdagen lukket i PLANEN — den instruerte om arbeid som var gjort
Planen er dokumentet operatøren FØLGER onsdag under tidspress, og etter
persona-pullen instruerte tir-11-raden fortsatt om en betinget subtree pull med
abortsti og 18:00-frist — arbeid som er utført og pushet. Samme drift-klasse økt
4 felte, så samme mottiltak: én grep-pass FØR redigering
(`persona|kontorbygg|tilsvarende anlegg|I4|18:00` over `docs/plan/` +
CHANGELOG), amendering med attribusjon (§6-mønsteret), aldri omskriving.

Grep-passen fant FIRE steder, ikke ett — som er hele grunnen til å kjøre den:
- tir-11-raden: betinget pull-instruks → **TOM, gå rett på onsdag**, med
  utfallet og de fire målingene som lukket den
- frys-blokkas X-note: «lander tirsdagens persona-pull, flytter X seg» →
  pullen ER landet (`d0e8bb0`); X leses fortsatt av `git rev-parse HEAD` etter
  grønn prøve, aldri skrevet ned her
- P4 pkt. 1s fersk-klon-måling: sto på `c9787cf`, altså to commits bak etter
  pullen. HOLDBARHETEN sagt eksplisitt i stedet for underforstått — delta er
  prosa i `shared/` + regenerert fasit, `pyproject.toml`/`uv.lock` MÅLT urørt,
  så målingen står; onsdagens generalprøve ×2 er bekreftelsen
- P3s I4-abortsti (i `<details>`): stemplet HISTORISK, med den ene målingen
  verdt å bære videre — den harde reset-formen blokkeres av hooken, `--keep`
  slipper igjennom

CHANGELOG: én Changed-linje for persona-formuleringen, slik at onsdagens
`[Unreleased]` → `[1.0.0]`-stempel ikke beskriver en artefakt-tekst som har
endret seg siden. Beslutningen tas her, ikke på en enveis-dag.

Tabell-integritet verifisert (pipe-telling; ons-12-radens seks er tre escaped
`\|` i grep-kommandoen, urørt). `test_doc_constant_sync_loadbearing` grønn.
Kalenderen: man 10. er nå SEKS økter.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017og6HMcP1WQcABRDogUMfx
2026-08-10 20:56:19 +02:00
d0e8bb058f test(golden): demo-transkriptet re-målt etter commons' persona-pull
Subtree-pullen (71b7b66) landet commons' domene-nøytrale formulering i
persona-artefaktets `rationale` («i kontorbygg» → «i tilsvarende anlegg»;
commons 73136eb — én fil, én linje, verifisert av OSS mot commons/main FØR
pullen). Linja printes ordrett i demoens Steg 7, så det pinnede transkriptet
sto rødt i pullens egen commit. Mellomtilstanden er uunngåelig: squash-commiten
kan ikke amendes (den bærer git-subtree-trailerne pullen leser neste gang).

Regenereringen er en RE-MÅLING, ikke en kopiering (testens egen docstring:
«whoever regenerates re-takes those measurements»). Den nye linja ble PREDIKERT
fra det innkommende artefaktet FØR pullen, og er byte-identisk med det
programmet faktisk skrev. Prediksjonen var bærende: `_clip(..., 300)` kapper på
fast bredde, og byttet er +8 tegn, så HALEN flytter seg også («overestimerte …»
→ «overes…»). Uten en nedskrevet prediksjon kan en regenerering ikke skille den
forventede forskyvningen fra drift — den ville bare vært «kopier det demoen
skriver nå».

MÅLT:
- rød-settet var NØYAKTIG én test (809 passed / 1 failed / 4 skipped) — ingen
  annen assertion matcher en delstreng av `rationale`; commons advarte om
  nøyaktig den klassen, og prediksjonen var at kun goldenen ville falle
- gammel vs ny fasit: KUN linje 32
- K1 distinkt 8 (linjer 9 — kjent, dokumentert avvik) · K2 hypotese #1 REJECTED
  2 100 000 mot P90 1 769 915 → VALIDATED 445 500, samme kandidat · K6
  selv-identitet: to kjøringer byte-identisk stdout, eneste stderr-diff er
  po-sim-suffikset · K8 bygg-energi-mikro + nav-golden-* uendret · 61/4 linjer
- stderr-goldenen URØRT · `pyproject.toml` og `uv.lock` urørt av pullen, så
  fersk-klon-målingen (P4 pkt. 1) står — delta er prosa i `shared/` + fasit
- 810 passed / 4 skipped · ruff check + ruff format + mypy rene

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017og6HMcP1WQcABRDogUMfx
2026-08-10 20:46:46 +02:00
71b7b66718 Merge commit '3ee70b01a6' 2026-08-10 20:39:06 +02:00
3ee70b01a6 Squashed 'shared/' changes from 27cdce9..73136eb
73136eb fix(persona): example-verdict begrunnet domene-nøytralt — «i tilsvarende anlegg»

git-subtree-dir: shared
git-subtree-split: 73136eb6eacd1825608d6b9c43e1e91bc230920c
2026-08-10 20:39:06 +02:00
5135f099e3 docs(plan): frys-gaten snudd til eksklusjonsform — den feilte OPEN
Første form listet kjørestien positivt (src/ tests/ shared/ pyproject.toml
uv.lock). En slik gate er blind for alt den ikke rakk å regne opp: målt på
1522e2a^..1522e2a rapporterer den to filer og slipper README.md OG CLAUDE.md
rett igjennom, mens eksklusjonsformen tar alle fire. På en enveis-dag skal en
gate feile lukket — en ny fil skal trippe den, ikke passere fordi ingen forutså
den.

Unntakene er nøyaktig det onsdagen skal skrive: docs/ (runbooken) og
CHANGELOG.md (stemples i samme trekk som taggen, så en gate som dekket den kunne
aldri blitt grønn). STATE.md er gitignorert og kan ikke dukke opp i en diff.

Begge de opprinnelige målingene re-kjørt på begge former med samme svar —
c255662..HEAD gir fire filer, a41272d..HEAD tomt. Funnet i advisor-review.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LVmQfqWWEVTQnv7PkuLE9U
2026-08-10 14:59:28 +02:00
6ae3bc7c49 docs(plan): P4-hashen inn i ✔-en (planens egen regel, linje 10)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LVmQfqWWEVTQnv7PkuLE9U
2026-08-10 14:53:57 +02:00
d306929c28 docs(plan): frysedagens to udefinerte steg lukket — FRYS gjort kjørbar, P4 lukket
«FRYS» hadde ingen operasjonell definisjon (40 treff i åtte plandokumenter; eneste
forsøk er demo-uke-planen linje 93, som er en regel, ikke en handling). Sekvensen er
prøve (X) → runbook (Y) → stempel+tag (Z), så taggen lander på Z mens prøven målte X.
Frysen er nå to kommandoer: noter X, og kjør frys-gaten før taggen. Gaten er målt at
den diskriminerer — c255662..HEAD gir fire filer (versjonssynken landet ETTER
generalprøve nr. 0), a41272d..HEAD gir tomt. CHANGELOG.md bevisst utenfor gaten.

P4 lukket: onsdagsraden sa «P4 re-målt» uten å si hva. Målt var det fersk-klon-
kriteriet (pkt. 1), stående på ab7f45a — elleve commits tilbake, før subtree-pullen,
P3/GO, det pinnede transkriptet, innholdsgaten (ny runtime-dep) og versjonssynken
(endret uv.lock). Re-målt på c9787cf: uv sync exit 0 med treet urørt, 810 passed /
4 skipped, ruff+mypy rene, demo-stdout byte-identisk mot goldenen, 61/4 linjer,
K1 distinkt 8.

Onsdagen har dermed fire steg, alle kjørbare. Null kodeendring.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LVmQfqWWEVTQnv7PkuLE9U
2026-08-10 14:53:07 +02:00
c9787cf10c docs(plan): frysedagens instrument gjort sant — to open/-instrukser felt, P2 lukket
Planen er dokumentet operatøren følger onsdag under tidspress. Den instruerte
fortsatt om en beslutning som ble felt 08-10, og på TO steder — nøyaktig
drift-klassen der en retting i én fil etterlater den i en annen:

- §0 S1.c-raden: «tag v1.0.0 på begge remotes» → taggen går til `origin` ALENE.
  Felt med målingen som felte den (open/main = 520e741 = v0.1.0; 26 commits =
  53 filer / 6087 innsettelser; seks plandokument-beslutninger, én åpen sak +
  fire aldri vurdert). Amendert i stedet for omskrevet, per §6-mønsteret.
- P4 pkt. 1s fresh-clone-notat: «publisering dit er S1.c onsdag» → korrigert,
  flyttet til P5-vinduet.

Bokføring i samme pass (planens egen regel, linje 10):
- P2/S1.b ☐ → ✔ (2026-08-09, c255662), både overskrift og Spor 1-tabellen;
  JA-varianten i ærlighets-teksten markert som den som gjelder.
- Kalenderen: man 10. = tre økter (var: kun generalprøven) · tir 11. = P3 er
  gjort søndag, tirsdag er commons-svaret eller tom · ons 12. = frysesekvensen
  i rekkefølge, med distinkt-tellingen (8, ikke 9) og taggen sist.

«ETT trekk» på tag-dagen er MÅLT inn i dokumentet, ikke antatt: `## [Unreleased]`
er verbatim og unik (1 treff, kun to `## `-overskrifter), ingen link-refs å
følge med. Sto den to steder, ble frysedagens ene trekk improvisasjon.

Null kodeendring; ingen test rørt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLpSfvCgBLmc3tPRMnr1JA
2026-08-10 05:15:36 +02:00
9708c15d07 docs(plan): S1.c synk + CHANGELOG lukket — taggen står igjen, frys-gatet
Forskuttert fra onsdag kveld, fordi den delen ikke er frys-gatet: kun taggen er.
Raden bærer nå de tre tingene målingen avgjorde — hvorfor overskriften står på
[Unreleased], hvorfor uv lock måtte kjøres eksplisitt og diffes før noen test, og
at fire versjonssteder ER alle fire.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ue1AnPZYsC9Tk7e5Tyv8Fv
2026-08-10 04:50:01 +02:00
a41272def5 chore(release): 1.0.0 version sync + the six undocumented feat commits, tag deliberately withheld
Version 1.0.0 across the four sites that carry it — pyproject.toml, __init__.py,
uv.lock, test_smoke.py. Measured that these are the only four: README carries no
version badge, and CHANGELOG's `## [0.1.0]` is history rather than a bump site.

The heading stays `[Unreleased]`. STATE authorises the CHANGELOG CONTENT now and
holds the TAG until after Wednesday's freeze, so stamping `## [1.0.0] - <date>`
today would be a future-dated claim about an event that has not happened — and
one to rewrite if the dress rehearsal fails or the freeze slips. `pyproject` at
1.0.0 with `[Unreleased]` populated is the release-prep state, not an
inconsistency; nothing machine-reads the CHANGELOG (measured). The global
versjonssync rule is read as CONTENT, not heading. Tag day is then one atomic
move: rename the heading, stamp the date, tag.

The re-lock was the hazard, and it was gated rather than assumed. Bumping the
version stales `uv.lock`, and the next `uv run` would have re-locked it
invisibly against a RANGE dependency (`agent-framework-core>=1.9.0,<2`) — while
the two ExperimentalWarning lines are pinned byte-for-byte in the stderr golden,
and STATE's own okf note records that a bare sync is enough for a guard to stop
guarding with no local diff. So: bump, then `uv lock` EXPLICITLY, then diff
before any test ran. The diff is the single `portfolio-optimiser` version line;
agent-framework-core, llm-ingestion-okf (v0.3.2) and llm-ingestion-guard
(v0.3.4) are untouched, and uv.lock was re-checked AFTER the suite to confirm no
silent re-lock.

CHANGELOG prose for the six feat commits `[Unreleased]` did not cover — it
carried only Step 5 and the scripted registry. Console entry points and the
golden transcript are Added; the Step-7 inbox, the anchored walkthrough, the
stderr damping and the derived provenance sentence are Changed, scoped as the
OFFLINE SIMULATION rather than framework runtime, since they change what the
walkthrough exercises and not the library's behaviour. The content gate is
Security, and carries its opt-in qualifier: `materialize` stays ungated by
design and `materialize_gated` is asked for by name — an entry claiming "ingest
now scans content before writing" without that clause would overclaim, and it
sits next to the sentence read on stage Thursday. A Notes line names the two
open boundaries (ingest stamp spec divergence, D7 mirroring) so 1.0.0 reads as a
stable surface rather than a finished programme.

Measured, not asserted: 810 passed / 4 skipped unchanged · ruff + mypy clean (31
source files) · no `0.1.0` remaining outside .venv/shared · and the demo RUN, not
just tested — stdout byte-identical to tests/golden/demo-transcript.stdout, exit
0, 61 stdout / 4 stderr lines, matching dress rehearsal nr. 0. The version string
appears nowhere in either golden (0 hits), so the bump could not move the fasit.

Two STATE premises corrected by measurement: 24 commits since v0.1.0, not 23;
and eight feat commits exist since the tag, of which six were undocumented.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ue1AnPZYsC9Tk7e5Tyv8Fv
2026-08-10 04:46:39 +02:00
887a8be677 docs(plan): generalprøve nr. 0 bestått — og kriteriets telling er foreldet, ikke demoen
Prøven kjørte mot LEVERT VEGLYS-FV-SOER, ikke den forankrede reserven planen
forutsatte: P3 falt to døgn før fristen, så prøven målte demoinnholdet selv.
Det er en strengere prøve enn planlagt — reserven validerer mekanikk, aldri
presentasjon — og reserve-stien er fortsatt målt, i suiten.

Alt grønt, null kodeendring: golden-diffen tom for begge kjøringene, K6
selv-identitet tom på stdout med kun po-sim-suffikset ulikt på stderr,
810 passed / 4 skipped, ruff og mypy rene, goldens uendret.

Ett avvik, og det ligger i kriteriets bokstav: §5 pkt. 1 teller
`grep -cE "^ *Steg [1-8]"` = 8, men P1/S1.a ga Steg 7 to merkede linjer, én
per tidsskala, så tellingen gir 9. Åtte distinkte steg står — intensjonen er
oppfylt. Kriteriet rettes ikke her: en gate justert i samme økt som den
feiler er ikke lenger en gate. Onsdagens generalprøve ×2 bruker
distinkt-tellingen, og §5 pkt. 1 rettes etter frysen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ATCeqRyvdL9qmk34o6HUCa
2026-08-10 04:34:05 +02:00
c255662802 feat(ingest): P2/S1.b — innholdsgaten står, rundt materialize og ikke i den
Planens §3 sa at `ingest.materialize` er repoets ENE skrivepunkt på Door A, og
det premisset ble felt av måling FØR bygging: `materialize` er en ren delegasjon
til pinnet llm_ingestion_okf v0.3.2s `materialize_bundle`, som stager i minnet og
utfører sin egen disk-fase. Det finnes ingen callback mellom de to, så en gate
plassert der kunne bare kjørt ETTER at bytene landet — en opprydding, ikke en gate.

Sømmen ble i stedet kopier bundelen → materialiser inn i kopien → skann det som
ble generert → publiser eller forkast. Kopien er bærende, ikke bekvemmelighet:
bibliotekets §3 eierskaps-skann, kollisjonsgaten mot kuratert innhold og §6
index-merge leser alle den EKSISTERENDE bundelen. Staging i tom katalog mister
alle tre og publiserer en bundle uten kuraterte naboer — datatap forkledd som
sikkerhetsfiks.

De fire §4-beslutningene, tatt og målt: (1) ingen av guardens to preset —
Origin.EXTERNAL/AUTOMATIC, fordi trust_for utleder policy fra origin alene og
PRESET_USER_UPLOAD bærer en quarantine-semantikk Door A ikke har; (2) utfall per
BUNDLE, diagnostikk per DOKUMENT — delvis publisering ville etterlatt bundle +
index som svarer til intet manifest, men import_bundle itererer forbi første
avvisning; (3) Report til log.md, aldri konsept-frontmatter, der fire golden-suiter
pinner bytene; (4) mypy-override OG adapter, siden override alene gjør sømmen
type-blind i stedet for type-sikker.

`materialize` forblir ugatet med vilje — goldenene pinner den, og en kaller som
vil ha gaten ber om den ved navn.

Fem mutasjoner alle røde + grønn kontroll (hele suiten, ~120 s hver): detach
gaten · la den fyre ETTER publisering · Origin.INTERNAL · tom staging-katalog ·
rapporter kun første avvisning.

Målingen felte en VAKUØS test først: en hard injeksjon scorer fail_secure under
BEGGE trust-tierene, så Origin.INTERNAL-mutasjonen lot alle tre avvisningstestene
stå grønne — beslutning 1 så dekket ut uten å være testet. Båndet der tieren
faktisk avgjør er høy-entropi-innhold (quarantine_review vs warn), og testen ble
skrevet mot nøyaktig det før mutasjonen ble re-målt. Mutasjon 4 ble på sin side
felt av KUN én test; 809 andre merket ikke at bundle-kopien forsvant.

Laveste disposition er `warn`, ikke `allow` — `allow` finnes ikke i guarden. En
gate skrevet mot == allow ville avvist hvert dokument som noensinne ingestes.

Kriterium 5 står: demo-stdout er byte-identisk med tests/golden/demo-transcript.stdout,
målt både i suiten og ved eksplisitt kjøring. shared/ er urørt.

801 -> 810 passed / 4 skipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DDu94KoyxAmhJsG2n63X8Q
2026-08-09 22:58:00 +02:00
50232fb88d feat(simulation): P4 pkt. 3+4 — demo-transkriptet pinnet, frø-setningen avledet [skip-docs]
Kriterium 6 er selv-identitet: to kjøringer av en regredert demo er like enige som
to av en riktig. Fasiten forlater derfor prosessen. stdout pinnes ORDRETT (og er
dermed demoens abortsti); stderr normaliseres på nøyaktig to MÅLTE miljø-spann —
site-packages-prefikset og temp-katalogen — med po-sim- holdt synlig, fordi det er
en egenskap ved programmet og ikke ved miljøet. Pinnet stderr = fire linjer.
Kontrollen som forbyr at masken vokser er load-bearing: en droppende normaliserer
med fasiten regenerert under seg holder BEGGE likhets-testene grønne.

Pkt. 4: planens forhåndsskrevne frø-setning sa «én av de TO tidligere dommene».
Målt mot levert VEGLYS-bundle henter Kjøring B TRE — én fulgte med kunnskapsbasen,
to er demoens egne, én per tidsskala. Splitten avledes derfor fra kjøringen; en
håndskrevet «én av tre» ville vært den andre kopien som drifter.

Fem mutasjoner alle røde + grønn kontroll (hele suiten hver gang): ett byte i en
stdout-linje · detach dempingen · over-normaliser stderr · literal splitt · detach
frø-setningens print. Byte- og detach-mutasjonene ble fanget av KUN golden-testen;
den literale splitten av KUN skille-testen.

793 -> 801 passed / 4 skipped.
2026-08-09 22:12:25 +02:00
7aef1feaa4 docs(plan): P3 lukket — GO, felt to døgn før fristen
Abortstien fulgt i rekkefølge, kriterium 8 målt to uavhengige veier, ingen reset.
Måletallene som avgjorde: P90 = 1 769 915, overdrivelsen 2 100 000 over begge terskler,
10 %-prøven mot LEVERT baseline feller i stage 0. Fem mutasjoner røde.

Planteksten slik den sto før utførelse er beholdt i en <details>-blokk — NO-GO-grenen er
død tekst nå, men reserven er fortsatt abortstien og skal kunne leses.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BUjfw4eJdwwnqHSXhfcY6i
2026-08-09 21:32:57 +02:00
02ddc6735f feat(simulation): GO — demoen kjører levert VEGLYS-bundle, forankret på deres tall [skip-docs]
P3 a-c lukket. Pullen hentet commons 002f000+27cdce9: kriterium 8 grønt (nav-goldens
byte-uendret, målt både med git diff og shasum) og suiten uendret 785 — ingen abort.

Retningen på tallene er SNUDD (P3 b): manus-registerets kostlinjer er skrevet FRA
shared/examples/veglys-fv-soer/cost-baseline.json, ikke ved siden av den, og
baseline_from_scripted_candidate brukes ikke på denne stien — main() leser levert fil.
Overdrivelsen 2100000 er valgt utenfor commons' tall-inventar: målt fraværende fra
bundelen og over målt P90 1769915 (deres anslag var ~1770000). 600000/900000 ville
klarert gaten og aldri utløst Steg 5.

Målingen felte en defekt reserven skjulte: :g slår over i eksponentform ved 7. signifikante
siffer, så levert baseline printet 4.38615e+06. Reservens 300000 har seks siffer og nådde
aldri overgangen. _num erstatter :g begge steder.

Load-bearing MÅLT mot hele suiten, fem mutasjoner alle røde + grønn kontroll:
detach main-wiringen · reverter _num til :g · drift registeret ETT siffer (4386151 —
innenfor 5 %-toleransen, fanget av ingenting i 792 tester bortsett fra den nye) ·
sett flip_key til et token som finnes i bundelen · detach forankringen på bundle-stien.

785 -> 793 passed / 4 skipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BUjfw4eJdwwnqHSXhfcY6i
2026-08-09 21:27:43 +02:00
04ae3b1599 Squashed 'shared/' changes from ddaae5d..27cdce9
27cdce9 examples(tunnel): TUNNEL-HAUGLIA — strekkmål nummer to, på et korrigert premissgrunnlag
002f000 examples(veglys): VEGLYS-FV-SOER komplett — den forpliktede leveransen til 11.08 18:00
b3f4789 docs(plan): SS12-underlaget — konsument-måling skiller «tilføy» fra «erstatt»
838a4b1 fix(spec): §7-raden siterte framework-nøytraliteten til feil sted
aa9eaa9 docs(plan): V1-etterspill — §12 mangler rader for `by`/`at`, presedens målt begge veier
54e0ec7 spec(ingest): V1 — `generated` til O2-formen, ratifisert 2026-08-02

git-subtree-dir: shared
git-subtree-split: 27cdce9eb2bc1199120de3c8e03af9711f3cca79
2026-08-09 20:59:13 +02:00
ce892be855 Merge commit '04ae3b1599' 2026-08-09 20:59:13 +02:00
7acd331e31 docs(plan): P4 pkt. 5, 2 og 1 lukket — og pinningen i pkt. 3 har fått sin egen måling
Punkt 5 (entry points), 2 (stderr) og 1 (fresh-clone) er merket ✔ med en UTFØRT-blokk
som bærer beslutningen og belegget, ikke bare utfallet.

Punkt 2 var øktas åpne beslutning, og den ble avgjort ved måling framfor preferanse:
rund-taks-linjene dempes, ExperimentalWarning-paret gjør det ikke — de fyrer før
simulation i det hele tatt importeres, så demping ville krevd et warnings-filter inne
i bibliotekpakken.

Punkt 3 får en konsekvens fra fresh-clone-målingen: fasiten kan ikke være literal.
De to gjenstående stderr-linjene bærer en absolutt sti inn i site-packages, som er
ulik i klon og arbeidskopi — normaliser på BÅDE den og po-sim-suffikset. Pinnet
stderr blir fire linjer, ikke to.

[skip-docs]

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C2bxLcCRguxXzpM4priTMn
2026-08-09 15:21:20 +02:00
ab7f45aa95 feat(cli): console entry points + the demo's stderr damping (P4 pkt. 5 og 2)
Two commands are now part of the install surface a fresh clone gets from `uv sync`:
`portfolio-optimiser` (run:main) and `portfolio-optimiser-demo` (simulation:main).
Deliberately two of five main()s — costsim/hitl/preflight stay module-invoked; every
name here is a name the freeze has to carry. Pinned against the INSTALLED distribution's
metadata, not the TOML: a [project.scripts] line that has never been synced is a claim,
not a command. Measured: stdout is byte-identical across both invocation forms.

stderr (P4 pkt. 2), the session's open decision, resolved by measurement rather than by
preference. Damped: the round-cap notice only, via a filter on the emitting logger, keyed
on the message and installed by main() — never at import, so a library consumer keeps its
own logging config. NOT damped: the two ExperimentalWarnings. They fire while the package
__init__ imports run -> agent_framework, always before simulation's own imports and under
both invocation forms, so silencing them would mean filtering warnings inside the library
package on every consumer's behalf; they are pinned in pkt. 3 instead. A console-script
wrapper was rejected for a second reason: the two forms would then write different stderr,
and a byte-fasit would pin the command rather than the program. stderr 6 -> 4 lines.

A first implementation wrapped simulation's own agent_framework import in a scoped mute.
Measurement showed it can never fire — the package __init__ has already imported
agent_framework by then — so it was removed rather than left as a green-but-dead seam.

Load-bearing MEASURED against the whole suite, five mutations all red + green control:
remove [project.scripts] · typo the target · detach the main() call · make the filter drop
everything · install the filter at import time. The typo mutation also felled a test: the
resolve-assert re-checked the expected constant against itself, and now resolves what the
distribution actually installs. 775 -> 785 passed / 4 skipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C2bxLcCRguxXzpM4priTMn
2026-08-09 15:13:09 +02:00
1522e2aaaa feat(simulation): the demo's gate is anchored to real cost lines (P4 pkt. 0)
The validator can reconcile a proposal against the project's actual cost lines
(S4.0 stage 0), but only when the knowledge base ships a cost-baseline.json —
and no bundle under shared/ has one. So on stage the gate reasoned only about
numbers the proposal supplied itself.

The reserve can never receive the file in shared/ (pull-only subtree, and demo
criterion 8 requires the goldens byte-unchanged). That is a placement
constraint, not an impossibility: materialize_anchored_bundle copies the bundle
and adds the file outside shared/, and the run path reads it through exactly
the seam a delivered bundle would use.

The baseline is DERIVED IN CODE from the scripted register, never typed beside
it — two sources of the same numbers drift, and drift is precisely what the
10 % probe models. On GO day the direction reverses (plan P3 b). Both scripted
replies must state the same cost lines or ValueError: were they to differ,
hypothesis #1 would be falsified by stage 0 instead of by P90 — the same
REJECTED line on screen, a different mechanism behind it.

10 % probe, measured: baseline x 1.10 -> FORKASTET at stage 0, before the
solver; corrected -> FORESLÅTT. Criterion 6 re-measured (stdout byte-identical
across two runs); stderr unchanged at 6 lines. The ONLY diff against the
un-anchored demo is the new KUNNSKAPSBASE block — everything else is
byte-identical, which is the problem: an anchoring nobody can see is one nobody
can check. Hence it is printed, and hence `provenance` is a required argument.
769 -> 775 passed.

Five mutations red + green control. The measurement failed the TEST first:
"ingen kostbaseline erklært" CONTAINS "kostbaseline erklært", and
ENERGI-TOTAL-EL already appears in the Step-2 line, so both assertions survived
the detach mutation. The two branches now share no wording.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GD6Y2Y23NZZxPYtSRoCmst
2026-08-09 14:35:07 +02:00
1e11dcb96c feat(simulation): the demo now RUNS the Step-7 file inbox it narrates (P1/S1.a)
The Step-7 trace line said "lang fil-løkke" while the verdict arrived as a
function argument (`verdict_input`) — the short, in-run capture. The long loop
was tested but never exercised by the thing on stage.

An expert now drops a real verdict FILE (`write_verdict`) into an inbox between
the runs, and Run B is given `verdict_dir=`, so `run_project` merges it into the
store before the Step-1 fold.

Not done as the plan point was worded, and the difference is load-bearing:
routing the PERSONA verdict through the inbox would have put ONE marker on two
paths — Step 7 (inbox) and Step 8 (promotion) both end in Run B's prompt, so
either could carry it alone and `test_simulation_loadbearing.py`'s promotion
assertion would have stayed green with promotion detached. A second verdict with
its own marker keeps both seams independently red-able; `simulate_learning_loop`
raises when the two markers are equal. The inbox sits beside the bundle copy,
never inside it, and the id is an explicit sentinel (a minted id would collide
with the promoted verdict's, and `VerdictStore.add` is first-write-wins).

766 -> 769 passed (773 collected). Criterion 6 re-measured: stdout byte-identical
across two runs; stderr unchanged at 6 lines. Mutations measured against the full
suite, four red + a green control: detach `verdict_dir=` · point Run B at an empty
folder while the file is still written · marker set to `realization_rate: 0.82`
(measured present in the verdict seed) · marker set to `energy performance gap`
(measured present in a navigated concept file) · benign rename of the inbox dir.

Honesty limit found while measuring: the last two mutations fell on the causality
assertion, not the Run A control — generation prompts carry the debate output, not
the bundle context. The pair holds, but each assert defends a different property.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FVYDeJ9evZicgU5r3roZVW
2026-08-09 13:13:21 +02:00
d0571ca408 docs(plan): six objections measured, all taken in — the anchoring risk moves to the weekend
I1: Funn 1 was measured one directory wide; the repo ships a working S4.0
baseline fixture and run.py:516 reads it. The anchored dry-run + the 10%%
deviation test move from Tuesday to the weekend (P4 pt 0); Tuesday becomes a
re-measurement with an explicit abort path (I4: pre-pull hash, reset rule,
18:00 NO-GO). I2: stderr damping decided YES, in the weekend BEFORE pinning —
measured today stderr is six lines, one deliberately non-deterministic. I3:
[project.scripts] moves off freeze day to before the fresh-clone measurement.
I5: a demo runbook post (P4.5) at the freeze. I6: every §4 claim re-measured
today on HEAD bb3df79; the 08-09 datings were commits from 2026-08-06.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UDHSsyMuBASJcRapddciHL
2026-08-07 16:55:30 +02:00
bb3df79204 docs(plan): six objections to the week plan, as a prompt the next session must measure before acting
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M1rEDj3QLNasKSceWFyE6k
2026-08-07 16:39:42 +02:00
f49a4d263b docs(plan): work starts Friday with full quota — front-load everything that needs no new content
Operator directive: full week available, weekend included, new quota,
high priority. The calendar now starts Friday with P1, pulls the whole
P4 advance (fresh-clone criterion, golden transcript, stderr muting,
both honesty sentences) into the weekend against the micro reserve, and
makes Monday dress rehearsal #0 — the NO-GO outcome is fully verified
BEFORE Tuesday's GO gate, leaving Tuesday/Wednesday thin: pull+measure,
re-measure, freeze, release cut, tag.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019xhQpH4oQBaf8dxCkXuB8Z
2026-08-07 08:20:06 +02:00
33f0a6857d docs(plan): §0 splits the week into two tracks — complete v1 by Thursday, and the demo
Operator decision 2026-08-09. Track 1 (complete v1, incl. other repos):
S1.a = P1 step-7 inbox, S1.b = P2 content gate, S1.c = release cut
(1.0.0 synced in four places, CHANGELOG, [project.scripts] moved in from
P9, tag only AFTER a green dress rehearsal). Other-repo accounting is
measured: commons already ordered with the Tuesday deadline and a
reserve, okf/guard/po-claude need nothing — no new coord message. Each
post carries a named degradation so v1 stays honestly complete at every
level. Track 2 (convincing demo): P3 + P4 + rehearsal + one spoken
mandate sentence, optional stderr-noise muting before the freeze. The
O4-vs-tag conflict is flagged for the operator, not decided.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019xhQpH4oQBaf8dxCkXuB8Z
2026-08-06 23:30:01 +02:00
0eb0f3d72b docs(plan): the review's findings as a ranked plan the NESTE block walks, one point per session
Fable-review 2026-08-09 made durable: P1-P10 in plain language with the
commands behind every number (evidence table §4). Pre-demo: step-7 inbox
wired into the walkthrough (P1), Spor B sharpening (P2), the stage-0
first-contact check on Tuesday's GO (P3), fresh-clone/stderr/golden
criteria plus two honesty sentences on Wednesday (P4). Post-demo: CLI
portfolio cap (P6), one consolidated commons amendment (P7), method
skill [Voyage] (P8), and an explicit NULL for orchestration swaps (P10).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019xhQpH4oQBaf8dxCkXuB8Z
2026-08-06 23:13:23 +02:00
c96ef9032d docs(plan): the Fable prompt, with the feature set as its main track and quota as its output
A prompt that lives only in a conversation dies at /clear, so it goes in the repo.

Three tracks, in the order the operator weighted them: the MAF feature set, the demo, and -- as
the actual deliverable rather than an appendix -- a ranked list of what the week's quota should
buy. Each item carries a mechanism, a hard [FØR TORSDAG]/[ETTER DEMOEN] tag, a cost in SESSIONS
rather than hours, and what would go red if the item were done. An item nothing can falsify is an
opinion, not a finding.

The measured starting points are embedded so the session does not re-derive them wrongly: two
debate agents rather than three, one orchestration in use out of the installed surface, a
hand-rolled portfolio fan-out, and a capability map organised by NEED that therefore never
compares TOPOLOGIES. The map is not stale on version -- 1.9.0/1.0.0 is what is installed -- which
matters, because "the map is old" would be the easy wrong conclusion.

The prompt carries its own discipline because Fable runs without an advisor: every figure must be
produced by a command shown beside it, and premises in STATE and in plan documents are named as
premises. This repo has measured at least three of them wrong, most recently today.

It is also forbidden from smuggling feature work in front of the demo. The demo is a hard date;
the feature set is not. Arguing otherwise is allowed -- but only out loud, with the consequence
spelled out.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XoHJCKBTjFKcjsfEQyGbzh
2026-08-06 20:01:53 +02:00
295e9665fe docs(plan): the content gate and the honesty sentence, on a track that does not touch the freeze
The demo shows "download and run". Implying you can point this at your own sources and build a
knowledge base claims three things the code does not carry -- and A5 (the code may not claim more
than it does) binds the presenter too, not just the source.

Measured first, and one measurement changed the plan: the guard is NOT v0.2 alpha. That figure came
from our own 2026-07-16 inclusion plan, which is a premise rather than a fact. It is v0.3.4, seven
published tags, `dependencies = []` -- stdlib only. Our okf pin (v0.3.2) declares no dependencies
either, so the guard is not coupled to it, and the 0.3.5-vs-0.4.0 release argument concerns the
release AFTER v0.3.4. Adoption moved from risky to tractable on that one reading.

The three claims, made precise: the demo bundle was hand-curated (honesty), the ingest path writes
unscanned (buildable), and the generic bundle factory does not exist (deferred at O1, not buildable
in four days). Two close with code, one with a sentence.

The two tracks are separated on a measured fact: `simulation.py` does not import `ingest`, so Door A
work cannot disturb what Wednesday freezes. Criterion 5 is the one that proves it -- the walkthrough
must stay byte-identical.

Four decisions are named as decisions rather than settled silently: which policy preset, fail-closed
versus flag-and-write, where the guard's report lands in provenance, and keeping `--strict`
meaningful across a seam that ships no py.typed. The honesty paragraph is written in BOTH variants
up front, so Wednesday is an observation and not a judgement call on stage.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XoHJCKBTjFKcjsfEQyGbzh
2026-08-06 19:49:13 +02:00
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
e93e921b1f docs(sim): the console trace walks all eight steps, one labelled line each
The simulation proved the loop but printed only four of its eight steps, so a
listener could not follow what they were looking at without narration. This is
presentation only: every value printed is read off the RunResult the run already
returned -- nothing is recomputed against the bundle, nothing is inferred, and
the run path is untouched. The week plan's assumption ("seven of eight steps are
pure presentation") therefore held; no new logic was needed.

Run A walks steps 1-7, the promotion between the runs IS step 8, and Run B is
not re-numbered -- it shows only what changed, which is the marker reaching the
hypothesis prompt. Two honesty limits are visible in what is printed rather than
papered over: `retrieved` is the post-hoc proposal-keyed retrieval, not the
Step-1 fold (the marker line is what evidences the fold reaching the prompt),
and the run carries the checker's DECISION, not its prose -- the decision is
what gates, so it is what is shown.

The working-copy path moves to stderr: mkdtemp is the one non-deterministic
value in the output, and stdout must be byte-identical across runs for the dress
rehearsal's diff check. Status tokens stay VALIDATED/REJECTED in English on
purpose -- the same vocabulary as provenance.validator_decision, which the
Step-6 line prints verbatim.

Verified: `... | grep -cE "^ *Steg [1-8]"` -> 8; two runs byte-identical on
stdout; both a REJECTED and a VALIDATED line for the same candidate; suite
759 passed / 4 skipped; ruff + mypy clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XoHJCKBTjFKcjsfEQyGbzh
2026-08-06 16:08:28 +02:00
d6f3359fae feat(step5): the falsification that informed the next hypothesis now leaves the loop
generate_via_llm consumed each validator Rejection internally (`last`), fed it into the
next attempt's prompt, and dropped it. So Step 5 was real but unobservable: a caller could
see THAT a proposal validated, never that it validated on attempt 2 after the deterministic
validator falsified attempt 1. It was the one step of the eight with no output to show.

The seam is a typed return value -- GenerationResult(outcome, refinements) -- rather than an
out-parameter or a callback: a returned value cannot be silently lost by a caller that forgets
to pass a collector, and mypy forces every call site to acknowledge it.

refinements carries ONLY rejections that were actually fed back. When the attempt budget runs
out the final rejection IS outcome; counting it here would be double-counting, and the bounded
control test goes red on the collect-everything implementation that gets this wrong.

The loop's bound is untouched: max_attempts and meter.tick_round stand, and `last` still drives
the prompt alone, so prompt growth is unchanged. run.py accumulates across _evaluate calls, so
_evaluate_mandate is untouched; RunResult.refinements defaults (the coverage precedent) and is
concatenated across approaches rather than keyed per approach -- stated as an honesty limit.

The simulation now shows it: the scripted proposer overclaims 250000, which the validator
falsifies against P90 = 90000, and the corrected 30000 validates. Only the overclaim is
scripted -- the rejection is computed. scripted_factory takes a per-role reply selector so this
needs no second scripted client body.

README records the two accuracy changes only (Step 5 is now inspectable; the simulation trace
shows the correction). The level-2 publishing claim stays deferred until after the demo (O4).

Load-bearing MEASURED against the full suite with a control, four mutations all red:
detach the returned history (4 tests) - collect-everything (control only) - detach the run
wiring (2 tests) - revert the simulation's proposer to a constant (the demo-protection test).
Control: 759 passed / 4 skipped; ruff, format and mypy clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017CcWFcREUi6YPjEpN3ACDP
2026-08-06 15:12:06 +02:00
cd011c4ac7 docs(plan): demo week, with the one real build separated from the presentation
Seven of the eight steps already have their data in RunResult and need a print;
one does not exist at all. Putting that distinction in a table is the point of
this plan -- it turns "show all eight steps" from an unbounded week into one
build on Friday and presentation work over the weekend.

The go/no-go on Tuesday is deliberate. The content is being built in another
repo on a deadline nobody here controls, so the week is designed to survive it
not arriving rather than to hope it does. The content-keyed reply selector
lands Monday, before the content, for the same reason: a new project should
then be a data entry rather than a hand-written script under time pressure.

Honesty framing is section 1 rather than a footnote, because the demo's own
subject is a system that refuses to claim more than it proves.
2026-08-06 13:23:19 +02:00
3313e9dcaa docs(qa): the four decisions the QA surfaced, with what constrains them
The 20 claims went un-corrected, so they stand as confirmed. What the operator
actually decided were the four choices the QA exposed: hand-built example with
the factory path explicitly deferred, step 5 built and shown live, commons
ordered with a fallback, README after the demo rather than before.

Two measurements are recorded because they bound the order, not because they
are interesting: bundle_context renders every navigated file's full body, and
summary-first reading is not built -- so the full 15-30 measure library would
put 40-90k characters into every hypothesis prompt. The order is size-capped
for that reason and says so.

Also recorded: simulate_learning_loop already takes the bundle directory as a
parameter, so new content plugs into an existing seam. The cost is the scripted
replies, which are written against the LED case.
2026-08-06 13:20:52 +02:00
8ecfa96934 docs(qa): the repo's intention, stated as claims the operator can correct
The demo-week brief was written by a session that read its way to the
intention through documents other sessions had written. Two of its frames
were overturned by the primary sources inside one conversation, so the
operator stopped planning and commissioned this: read the primary sources
directly, state the understanding back as numbered claims, and capture the
corrections where they survive.

Six gaps in the picture the brief rests on, all measured rather than argued:

- The intention has a SECOND axis that STATE's list of five primary sources
  never named. review-2026-07 (F1-F14) and sesjonsplan-fase2-6 (S2.0-S5.4,
  D-A-D-I, M1-M3) are where most of the repo's 31 modules come from: 20
  S-numbers, 18 with hits in src/+tests/. A plan written from the five named
  sources alone would describe a repo with eight steps and miss two thirds
  of what is there.
- D-H's DECIDED demo path ("clone -> unzip -> factory builds -> loop runs")
  is factory-dependent, and the factory (D-G/T0, `okf-toolkit`) does not
  exist -- measured, not assumed. The brief's "anyone who downloads the repo
  can run exactly the same" IS that path.
- The realistic example's content model is already decided (D-F): knowledge
  types with required source citation, strict separation from the verdicts.
  The commission to commons must reference it, not invent one.
- The demo is the programme's level-2 publishing proof (D-I), with an
  honesty ceiling agreed in advance and a README update as its consequence.
- The shared spec covers the loop + ingest and NONE of the surplus: mandate,
  notify, ledger, value report, cost simulation, dimension, portfolio
  budget, concurrency, preflight all measure 0 mentions. The comparison is
  therefore of the SPEC'd core, not of this repo.
- Step 5 is not a presentation-layer concern: `generate_via_llm` consumes
  the intermediate rejection internally, and today's demo validates on the
  first attempt, so the refinement never triggers. Steps 2 and 6 ARE
  printable from data RunResult already carries.

Two inventory numbers spot-checked independently (759 collected; the offline
simulation re-run, output identical). Nothing here is sourced from STATE.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TvjgY5NBg16D7kgQf14s6B
2026-08-06 13:09:45 +02:00
137 changed files with 28376 additions and 240 deletions

View file

@ -5,6 +5,151 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.1.0] - 2026-08-14
The release that makes the distribution stand on its own. `1.0.0` shipped a framework that could
only run from a checkout; this one runs from an installed wheel and inside a Foundry-hosted
container, and it is the first release whose artefacts are published alongside it.
### Added
- `shared/` now travels **as packaged data**. The wheel carries a byte-identical mirror of the tree
under `portfolio_optimiser/_shared/`, and `shared_root()` resolves at call time in a fixed order:
`PORTFOLIO_SHARED_ROOT`, then the working tree's `shared/` when one exists, then the packaged
copy. The working tree stays authoritative in a checkout — that is what keeps the pull-only
subtree contract and the byte-exact goldens untouched. Measured before and after: the `1.0.0`
wheel carried 58 files and none under `shared/`; this one carries 122, of which 64 are the mirror.
- A **hosted entry point**: `main.py` wraps `run_project` on a single asyncio loop and serves the
Foundry hosting contract (`GET /readiness`, `POST /invocations`, SIGTERM → exit 0), with a
`Dockerfile` and `azure.yaml` beside it. The invocation payload is whitelisted against
`run_project`'s own signature — an unknown field is refused by name with a 400 rather than
silently dropped — and a rejected proposal is a successful run (200), because the negative outcome
belongs to the payload and never to the transport.
- Wheel-install instructions in the README. A wheel is not installable on its own: two dependencies
are pinned to git tags, and `[tool.uv.sources]` does not travel with wheel metadata, so both must
be supplied as requirements alongside the wheel. The published command is the one that was
measured (65 packages, exit 0), not one composed afterwards.
- Two gates on claims the **published surface** makes about itself: that `env.template` names the
credentials `backends.py` actually constructs, and that the README's wheel-install command spells
the version the build stamps on the file. Both read raw text and are line-anchored, because prose
is the only place these claims live.
### Changed
- The AZURE profile now reads **its own environment** rather than the operator's laptop. The
endpoint resolves to the first non-empty of `PORTFOLIO_FOUNDRY_PROJECT_ENDPOINT` and Foundry's
injected `FOUNDRY_PROJECT_ENDPOINT`; precedence applies to *values*, so an exported-but-empty name
falls through instead of masking a real one. The credential follows the same environment:
`AzureCliCredential` on a developer host, `ManagedIdentityCredential` when
`FOUNDRY_HOSTING_ENVIRONMENT` holds a non-empty value — never `DefaultAzureCredential`, whose
probing would walk a credential chain that cannot succeed in a container and turn a config error
into a slow one.
- `SECURITY.md` states a **deprecation notice period**: at least one minor release and no fewer than
30 days between announcement and removal, with security-critical removals named as the explicit
exception. The supported-versions table is deliberately version-free, since a release number
written there would drift at the next tag.
- The README links the organisation's single canonical `GOVERNANCE.md` instead of vendoring a copy,
and states the maintenance model (solo-maintained, no SLA, fork-and-own) on the first screen.
- `CLAUDE.md` opens with an English note explaining what the file is for a visitor: the working
agreement with the AI agent that builds this repository, doubling as its invariant ledger.
### Fixed
- `env.template` claimed the AZURE profile resolved its credential through `DefaultAzureCredential`.
It never has. An operator copying the template was told the wrong identity would be used.
## [1.0.0] - 2026-08-12
### Added
- Step 5 is now observable: `generate_via_llm` returns a `GenerationResult` carrying the validator
falsifications that informed a later attempt, surfaced on `RunResult.refinements`. The offline
simulation exercises it — the scripted proposer overclaims, the deterministic validator falsifies
the number, and the refined proposal validates.
- Two console entry points ship with `uv sync`: `portfolio-optimiser` (the CLI) and
`portfolio-optimiser-demo` (the offline walkthrough). Deliberately two of the package's five
`main()` functions — `costsim`, `hitl` and `preflight` stay operator tools invoked as modules, and
every name here is one a release has to carry. Both invocation forms write byte-identical stdout.
The entry points are tested against the INSTALLED distribution's metadata rather than the TOML: a
`[project.scripts]` line that has never been synced is a claim, not a command.
- The offline walkthrough's transcript is checked in as a golden fixture
(`tests/golden/demo-transcript.stdout` and `.stderr`). Self-identity across two runs cannot detect
a regression — two runs of a regressed walkthrough agree exactly as well as two runs of a correct
one — so the fixture leaves the process. stdout is pinned verbatim; stderr is normalised on exactly
two measured environment spans, the `site-packages` prefix and the temporary directory, leaving the
`po-sim-` prefix visible because that belongs to the program rather than the environment. A
companion control forbids the mask from widening: a normaliser that dropped whole lines, with the
fixture regenerated beneath it, would keep both equality tests green.
### Changed
- **Breaking (library API):** `generate_via_llm` returns `GenerationResult` instead of
`ValidatedProposal | Rejection`; read `.outcome` for the previous value. The refinement loop's
bound is unchanged (`max_attempts` + token meter).
- `simulation.scripted_factory` accepts a per-role reply *selector* over `(prompt, role)` as well as
a constant reply, so a scripted role can answer differently on a later attempt.
- The offline simulation's scripted proposer is now a candidate **registry** rather than a
hand-written reply: `simulation.scripted_proposer(candidates)` builds the selector from
`ScriptedCandidate` entries keyed on the project id the prompt names, and
`simulate_learning_loop` takes `project_id` alongside `bundle_dir`. Adding a project to the
walkthrough is a data entry. A prompt matching no entry — or more than one — raises
`ScriptedCandidateError` rather than answering with another project's numbers.
- The offline simulation now EXERCISES the Step-7 file inbox it narrates. The verdict previously
arrived as a function argument — the short, in-run capture — while the trace line described the
long file loop. An expert now writes a real verdict file into an inbox between the two runs, and
the second run is given `verdict_dir=`, so `run_project` merges it before the Step-1 fold. The
inbox sits beside the bundle copy and never inside it: a verdict file within the bundle would reach
the next run as navigable context, which is a different mechanism wearing this one's clothes. The
two time-scales carry SEPARATE markers by construction, since one marker on both paths would let
either seam alone satisfy the assertion and leave the other free to rot; `simulate_learning_loop`
refuses equal markers.
- The offline walkthrough runs ANCHORED. Its deterministic gate reconciles each proposal against the
project's real cost lines, which activates only when the knowledge base ships a `cost-baseline.json`;
without one the gate reasoned solely about numbers the proposal supplied itself. The walkthrough now
reads that file through exactly the seam a delivered knowledge base would use. For the synthetic
fallback bundle, which cannot receive the file inside the pull-only `shared/` subtree, the baseline
is DERIVED IN CODE from the scripted register rather than typed beside it — two sources of the same
numbers drift, and drift is precisely what the walkthrough's own 10 % probe models. The declared
baseline is printed, because an anchoring nobody can see is one nobody can check.
- The walkthrough's stderr is quieter. The expected round-cap notice is dropped by a filter on the
emitting logger, keyed on the message and installed by `main()` — never at import, so a library
consumer keeps its own logging configuration. The two `ExperimentalWarning` lines are deliberately
NOT damped: they fire while the package `__init__` imports the agent framework, always before the
simulation's own imports and under both invocation forms, so silencing them would mean filtering
warnings inside the library on every consumer's behalf. They are pinned in the golden fixture
instead. stderr went from six lines to four.
- The walkthrough DERIVES its provenance sentence for prior verdicts instead of stating it. The line
above already computes the count, so a hand-written split would be a second copy of the same fact,
free to drift the moment a knowledge base ships another seeded verdict.
- The shared expert-reviewer persona's canonical example verdict is worded domain-neutrally
("i tilsvarende anlegg" rather than "i kontorbygg"), pulled from the upstream commons repository.
The walkthrough prints that `rationale` verbatim, so the wording was a building-type justification
read out over a road-lighting project; it could not be fixed downstream, because overriding the
text locally would re-stub the very artifact the shared skill exists to make load-bearing. The
`marker` value is byte-unchanged, and the pinned transcript fixture was regenerated against a
prediction written before the pull — the printed line is clipped at a fixed width, so the swap
moves the tail as well, and a regeneration without a written prediction could not tell that
expected shift apart from drift.
### Security
- Door A — the ingest path that materialises externally sourced documents into a knowledge base — can
now scan generated content before it is published, through `ingest.materialize_gated`. The gate is
**opt-in and requested by name**: `materialize` itself stays ungated by design, because golden
suites pin its bytes and a caller that wants the gate asks for it.
The seam sits *around* materialisation rather than inside it. The pinned upstream stages in memory
and then performs its own disk phase, with no callback between the two, so a gate placed "at the
write point" could only have run after the bytes had landed — a cleanup, not a gate. Instead the
bundle is COPIED, materialised into the copy, scanned, and then published or discarded as a whole.
The copy is load-bearing rather than convenient: the upstream's ownership scan, its collision gate
against curated content, and its index merge all read the EXISTING bundle, so staging into an empty
directory would publish a bundle stripped of its curated neighbours and their index links — data
loss dressed as a security fix.
Trust follows ORIGIN, never channel. The outcome is per BUNDLE, since partial publication would
leave a bundle and index answering to no manifest, while diagnostics are per DOCUMENT so a single
run reports every finding rather than only the first. Findings are written to the bundle's `log.md`
and never to concept frontmatter, where four golden suites pin the bytes.
### Notes
- The `1.0.0` version signals a stable public surface, not a finished research programme. Two
boundaries are open and named rather than implied: the ingest stamp predicate has diverged from the
upstream specification (a value literal here, a structured field upstream) and does not touch the
run path, and the mirroring of several seams to the sibling implementation is outstanding.
## [0.1.0] - 2026-08-06 ## [0.1.0] - 2026-08-06
First tagged release. There is no prior release, so the entries below describe what this version First tagged release. There is no prior release, so the entries below describe what this version

853
CLAUDE.md
View file

@ -1,5 +1,15 @@
# portfolio-optimiser # portfolio-optimiser
> **Note for visitors — what this file is.** This is the working agreement between the repository
> and the AI coding agent that builds it (the [Claude Code](https://claude.com/claude-code)
> convention), and it is written in Norwegian because that is the maintainer's working language.
> It doubles as the repository's **invariant ledger**: each block below records a design decision,
> the measurement that forced it, and the test that turns red when the decision is undone.
>
> **You need none of it to use the framework** — start with the [README](README.md). It is
> published anyway, because the reasoning behind a decision is worth more than the decision, and
> because a rule kept out of sight is a rule that drifts without anyone noticing.
## Kontekst ## 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. 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.
@ -14,10 +24,16 @@ Python ≥3.10. MAF (`agent-framework-core` 1.9.0). Pakkehåndtering: `uv`. To b
- Modell-valg som konfig (modell-map rolle→Foundry-deployment), ikke spredt i kode. - Modell-valg som konfig (modell-map rolle→Foundry-deployment), ikke spredt i kode.
- Metode kodifiseres som **Agent Skill** (`agentskills.io`: `SKILL.md` + `scripts/` + `references/`). - 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. - 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`. - `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å den private namespacen 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`. Wheelen bærer treet som pakkede data siden Fase 4a — se invarianten under.
## Kommandoer ## Kommandoer
- Sync: `uv sync` - Sync: `uv sync` — installerer to konsoll-kommandoer: `portfolio-optimiser` (CLI, `run:main`) og
`portfolio-optimiser-demo` (offline-beviset, `simulation:main`). `python -m`-formene virker
uendret og er byte-identiske på stdout (målt). Bevisst KUN to av fem `main()`
`costsim`/`hitl`/`preflight` er operatørverktøy, ikke produktets inngang, og hvert navn her er et
navn frysen må bære. Pinnet av `tests/test_console_entry_points.py` mot den INSTALLERTE
distribusjonens metadata, ikke mot TOML-en: en `[project.scripts]`-linje som aldri er `uv sync`-et
er en påstand, ikke en kommando.
- Test: `uv run pytest` - Test: `uv run pytest`
- Lint: `uv run ruff check .` + `uv run ruff format .` - Lint: `uv run ruff check .` + `uv run ruff format .`
- Type: `uv run mypy src` - Type: `uv run mypy src`
@ -47,12 +63,47 @@ Python ≥3.10. MAF (`agent-framework-core` 1.9.0). Pakkehåndtering: `uv`. To b
commons-eide nav-goldens (`shared/examples/nav-golden-*/expected-read-context.md`, byte-nivå 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 fasit): `test_nav_golden_hierarchy_*` (positiv) + `test_nav_golden_escape_*` (negativ — en gate
som bare kan bli grønn beviser ingenting). som bare kan bli grønn beviser ingenting).
**Et hopp er TOLERERT, men ikke lenger TAUST (21.08):** `_walk` registrerer hver lenke den ikke
fulgte på `Bundle.skipped` — hvilken fil lenken sto i, lenketeksten ORDRETT (operatøren redigerer
den teksten, ikke den resolverte stien), og hvilken av de TO grunnene som gjaldt: `outside-bundle`
(escape — ofte bevisst, en lenke til nabobasen) eller `missing` (inne i basen, ingen lesbar fil —
nesten alltid en skrivefeil). Den tredje grenen, `canonical in seen`, er DEDUP og registreres
ALDRI — den er korrekt navigasjon og dét som terminerer sykler; en implementasjon som logget hvert
`continue` ville rapportert en frisk base som halvlest. Toleransen er URØRT (§4 krever at det ikke
kastes) — dette er synlighet, ikke en ny nekt. **Feltet DEFAULTER til tom tuppel, og det er
MOTSATT av `cost_baseline_anchored`s «påkrevd uten default»:** en tom trace er et ærlig POSITIVT
utsagn («hver lenke ble fulgt», `external_calls`-presedensen), mens en manglende bool måtte påstå
noe om en hendelse og begge påstandene ville iblant vært usanne. Sporet forlater kjøringen på
`RunResult.skipped_links` (RUN-nivå — navigasjonen skjer ÉN gang per kjøring, før noe forslag
finnes) og `DryRunReport.skipped_links`, aldri på `ProvenanceStamp`, som beskriver gaten som dømte
ÉN kandidat. `run.skipped_links_notice` er ENESTE renderer, tar den alt oppløste tuppelen og
returnerer `None` når ingenting ble hoppet over (omisjon, aldri tom rad — `announce`-regelen);
reason-TOKENET printes rått, så det finnes ingen andre display-vokabular å drifte fra feltet.
Ingenting av dette når `bundle_context` (som bygges av `index_summary` + `context_files` alene) —
dét er hva som holder nav-goldenene byte-uendret, og `Bundle(` har fortsatt ÉN konstruksjons-sted
(`okf.py`, i `navigate_bundle`). Load-bearing MÅLT
(`tests/test_navigation_visibility_loadbearing.py`), åtte mutasjoner alle røde mot HELE suiten +
grønn kontroll 897/5: detach `missing`-registreringen (6 røde) · detach `outside-bundle` (2 røde) ·
kollaps de to grunnene til én (2 røde) · registrer dedup-grenen (1 rød) · renderer returnerer alltid
linja (3 røde — inkl. kontrollene, altså er omisjonen selv gatet) · detach dry-run-printen (1 rød) ·
detach full-run-printen (1 rød) · konstant tom trace ut av `run_project` (4 røde).
- **Kuraterte skrivere kan ikke forfalske ingest-stempelet** (`ingest-spec` §3): `write_concept_file` - **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, 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 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 `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 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. bli stille slettet av en senere re-materialisering, som fjerner nøyaktig det som bærer stempelet.
**`generated`-verdien er FAIL-CLOSED på YAML-1.1-sannhetsformer, ikke bare literalen `"true"`**
(funn 21.08, økt 52): `_YAML_TRUE_LITERALS` (`{"true", "yes", "on"}`, case-insensitivt) er
ENESTE vokabular, målt mot PyYAML sin `safe_load`-resolver — bare `1`/bare `y`/`n` er BEVISST
UTELATT (resolves til int/streng, aldri bool, så en YAML-leser ville uansett ikke lest dem som
stempelet). Uten dette var sjekken inert kun i kraft av at pinnet `llm-ingestion-okf v0.3.2`
skriver strengen `"true"` — en fremtidig `uv sync` mot en skrivemåte som `yes`/`on` ville latt
vakten slutte å vokte uten én lokal diff. Load-bearing MÅLT
(`tests/test_ingest_stamp_fail_closed_loadbearing.py`), fire mutasjoner alle røde mot HELE
suiten: revert til literalen `"true"` (2 røde — de nye sannhetsformene alene) · over-widen til å
inkludere `1`/`y` (1 rød) · `and``or` (4 røde, halv-stempel-lovligheten brutt) · detach gaten
helt (4 røde).
- **`IngestError` må overleve anyio-task-gruppene (kø-(x), 2026-08-03):** `stdio_client` og - **`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 `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`, `BaseExceptionGroup`. Derfor nådde `stdio_call_tool`s egne feil (`mcp_tool_error`,
@ -91,6 +142,165 @@ Python ≥3.10. MAF (`agent-framework-core` 1.9.0). Pakkehåndtering: `uv`. To b
mutasjoner alle røde: detach hele oversettelsen · revert til `asyncio.wait_for` · relabel koden · 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 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. (via `mcp`) til deklarert direkte dep (`pyproject.toml`) — modulen importerer den nå direkte.
- **Door A-innholdsgaten kan IKKE bo i `materialize` — den bor rundt den (P2/S1.b, 2026-08-09):**
`materialize` er en REN delegasjon til det pinnede `llm_ingestion_okf` v0.3.2s
`materialize_bundle`, som stager i minnet og utfører sin EGEN disk-fase; **det finnes ingen
callback mellom de to**, så en gate plassert «i skrivepunktet» kunne bare kjørt ETTER at bytene
hadde landet — en opprydding, ikke en gate (planens premiss, felt ved måling FØR bygging).
`materialize_gated` er derfor: **kopier bundelen → materialiser inn i kopien → skann det som ble
generert → publiser eller forkast.** **Kopien er BÆRENDE:** bibliotekets §3 eierskaps-skann,
kollisjons-gaten mot kuratert innhold og §6 index-merge leser alle den EKSISTERENDE bundelen —
staging i en tom katalog mister alle tre og publiserer en bundle uten kuraterte naboer og deres
index-lenker (datatap forkledd som sikkerhetsfiks; MÅLT av kun ÉN test, 809 andre merket
ingenting). `materialize` forblir UGATET med vilje — fire golden-suiter pinner bytene, og en
kaller som vil ha gaten ber om den ved navn. **Utfall per BUNDLE, diagnostikk per DOKUMENT:**
delvis publisering ville etterlatt bundle + index som svarer til INTET manifest, men
`import_bundle` itererer forbi første avvisning så hvert funn rapporteres. **Trust følger ORIGIN,
aldri channel** (`Origin.EXTERNAL`/`Channel.AUTOMATIC` = UNTRUSTED) — ikke et av guardens to
`Policy`-preset: `PRESET_USER_UPLOAD` bærer `quarantine_default=True` som Door A ikke har.
**Den laveste dispositionen er `warn`, ikke `allow`** (`warn < quarantine_review < fail_secure`;
`allow` finnes ikke) — en gate skrevet mot `== allow` ville avvist hvert dokument noensinne.
**Funnene til `log.md` (OKF §7), ALDRI konsept-frontmatter** — der ville de brutt fire goldener.
Guarden shipper ingen `py.typed`: mypy-override ALENE gjør sømmen type-BLIND, så `_stamp_line` +
koersering stopper `Any` ved grensen. Load-bearing MÅLT
(`tests/test_ingest_content_gate_loadbearing.py`), fem mutasjoner alle røde + grønn kontroll:
detach gaten · la den fyre ETTER publisering · `Origin.INTERNAL` · tom staging-katalog ·
rapporter kun første avvisning. **Målingen felte en VAKUØS test først:** en hard injeksjon
scorer `fail_secure` under BEGGE tierene, så `Origin.INTERNAL`-mutasjonen lot alle tre
avvisningstestene stå grønne — beslutningen så dekket ut uten å være testet. Båndet der tieren
faktisk avgjør er høy-entropi-innhold (`quarantine_review` vs `warn`), og testen ble skrevet mot
nøyaktig det.
- **`shared/` leses som PAKKEDE DATA, med arbeidstreet som overstyring (Fase 4a):** wheelen bærer
en byte-identisk speiling av hele `shared/`-treet under `portfolio_optimiser/_shared/`
(hatchling force-include i `pyproject.toml`), og `shared_root()` løser ved KALL-tid i fast
rekkefølge: `PORTFOLIO_SHARED_ROOT` → arbeidstreets `shared/` når det finnes → pakket kopi.
**Arbeidstreet er autoritativt i en checkout** — det er dét som holder pull-only-subtree-
kontrakten og de byte-eksakte goldenene urørt (målt: goldens shasum-identiske før/etter, og
`shared/` selv urørt). Den pakkede kopien er dét som gjør wheel og container mulig uten klone
(målt før: 1.0.0-wheelen bar 58 filer, null under `shared/`; etter: 122, hvorav 64 under
`_shared/`, og sdist→wheel-kjeden bærer treet). Speilingen er ALDRI en redigert derivat —
byte-identitet er egenskapen som lar commons-goldenene fortsatt gate den pakkede kopien.
Load-bearing MÅLT (`tests/test_shared_packaged_data_loadbearing.py`, ekte `uv build` i
fixturen — pakkekonfigen er selv en søm), tre mutasjoner alle røde mot hele suiten: detach
fallbacken (1 rød) · detach force-include (3 røde) · snu rekkefølgen (1 rød — ordnings-testen
var grønn før fiksen; dens kontroll på at pakket kopi FINNES er det som gjør flippen målbar).
- **AZURE-profilen leser MILJØET sitt ved kall-tid, ikke operatørens laptop (Fase 4b):** endepunktet
løses som første IKKE-TOMME av `_ENDPOINT_ENVS` — vårt eget `PORTFOLIO_FOUNDRY_PROJECT_ENDPOINT`
FØRST, deretter Foundrys injiserte `FOUNDRY_PROJECT_ENDPOINT`. **Vårt vinner** (det er dét enhver
doc, recipe og test setter, så en eksport av det er en bevisst handling; en plattformverdi som
stille overstyrte den ville vært uforklarlig utenfra), og fallbacken er dét som lar samme image
kjøre hostet uten ekstra wiring. **Presedensen gjelder VERDIER, ikke deklarasjoner** — et
eksportert-men-tomt eget navn faller igjennom i stedet for å skygge et ekte injisert inn i en
fail-fast. Feilmeldingen navngir BEGGE: operatøren i en container og operatøren på en laptop
leter etter hver sin variabel. Credential velges av samme miljø: `AzureCliCredential` lokalt
(konstruksjon henter INGEN token — `az login` er operatørens manuelle steg),
`ManagedIdentityCredential` når `FOUNDRY_HOSTING_ENVIRONMENT` er satt, fordi containeren ikke har
noen Azure CLI og plattformen mynter den en egen Entra-identitet ved deploy. **Ikke
`DefaultAzureCredential`:** Learns egen MAF-veiledning sier «prefer a specific credential such as
`ManagedIdentityCredential` to avoid unintended credential probing» — probing ville vandret en
kjede som ikke KAN lykkes der, og gjort en konfigfeil om til en treg en. Markøren leses på
**truthiness, ikke presence**: en eksportert tom verdi er et shell-uhell, ikke et hosting-signal.
Klienten eksponerer INGEN credential-attributt (målt), så testene observerer via en
`FoundryChatClient`-recorder — med én UPATCHET arm, ellers ville de kun bevist at vi sender
*noe* som heter `credential`. Load-bearing MÅLT
(`tests/test_hosted_backend_loadbearing.py`), fire mutasjoner alle røde mot hele suiten + grønn
kontroll: detach credential-valget · presence i stedet for truthiness · detach fallbacken · snu
presedensen. **Fail-fast-testen ble skrevet VAKUØS først** (repoets 08-09-klasse):
`PORTFOLIO_FOUNDRY_PROJECT_ENDPOINT` INNEHOLDER `FOUNDRY_PROJECT_ENDPOINT`, så asserten på det
injiserte navnet var oppfylt av vårt eget; den fjerner nå vårt navn før den sjekker.
- **Hostet inngang er en WRAPPER rundt `run_project` på ÉN asyncio-løkke (Fase 4d):** `main.py`
`hosting.py` serverer hosting-kontrakten (port 8088/`PORT` på truthiness, `GET /readiness`,
`POST /invocations`, SIGTERM → exit 0) med stdlib asyncio — ALDRI `as_agent()` (validator,
baseline-forankring, checker-gate og ledger ligger UTENFOR grafen, spike §5) og ALDRI tråder
(NG1-guarden: `http.server`s trådvariant ville lagt samtidige kjøringer på OS-tråder der
S3.3-resonnementet ikke holder; samtidige invocations interleaver som koroutiner — samme modell
som `run_portfolio`s bølger, og `/readiness` svarer mens en kjøring venter på modell-I/O, målt).
**Formen er MÅLT, ikke valgt:** hosting-pakkas `InvocationsHostServer` finnes kun i bygg som
krever core>=1.13.0 (treet låser 1.9.0; eneste 1.9-kompatible bygg er en forlatt alfa med defekt
metadata — importerer `mcp` udeklarert), og et gjenbrukt bygget workflow er SINGLE-USE på 1.9.0
(målt kall-serie [2, 0, 0] — rundetaket persisterer i objektet, så gjenbruk gir TOMME kjøringer;
ferskt objekt per kall er ren kontroll). Payloaden whitelistes på `run_project`s signatur —
ukjente felt NEKTES ved navn (400), aldri stille droppet (valg-doc §0-fella anvendt på vår egen
flate); `profile` defaulter til `azure` KUN her (containeren har ingen lokal endpoint;
`run_project`s egen default forblir LOCAL). Feilmapping ærlig: `ValueError` (pydantic-kontrakter
subklasser den) → 400, `BudgetExceeded` → 429 (EGEN rad under, 14.08), alt annet → 500
`{error_type, error}` (speiler `RunFailure`), og en
`Rejection` er en VELLYKKET kjøring → 200 — det negative utfallet tilhører payloaden, aldri
transporten. `outbox.outcome_payload` er den ENE kopien av validated/rejected-forgreningen
(delt av fil-skriveren og HTTP-responsen — to kopier drifter, kø-(p)-regelen). **Container-innpakningen
(`Dockerfile`/`azure.yaml`) ER FJERNET 14.08** — se python-only-invarianten under; resten av
denne raden står, for `main.py` startes nå direkte (`python main.py`). Load-bearing MÅLT (`tests/test_hosting_loadbearing.py`), seks mutasjoner alle
røde mot hele suiten på riktig test: detach felt-mappingen · dropp ukjente felt stille · flipp
400/500 · detach azure-defaulten · detach SIGTERM-handleren · detach main.py-shimen (de to
siste fanges KUN av subprosess-testen — P4-presedensen). Chunked request-bodies støttes ikke, og under CPU-bundne strekk (CBC-solven)
står readiness — uttalt, ikke skjult.
- **Whitelisten må komponere med den EKTE `run_project`, og artefaktene gates som RÅ TEKST
(Fase 4e):** alle 4d-testene ga `invoke` en stand-in som sluker `**kwargs`, så whitelisten kunne
navngi et felt `run_project` ikke tar — eller sende samme argument to ganger — uten at én test
merket det, mens en levende container svarte 500. **Sømmen er `run._default_factory`, ikke
payloaden:** `client_factory` NEKTES av whitelisten med vilje (en kaller av en hostet agent skal
aldri velge serverens modellklient), så å patche factory-defaulten er eneste injeksjonspunkt
flaten etterlater (samme argument `test_run_cli_loadbearing` gjør for `main()`). Testen sender
HVERT whitelistet felt og asserterer dekningen mot `_ALLOWED_FIELDS`, så et felt lagt til senere
ikke kan gli forbi uøvet. Profilen er **LOCAL, ikke den hostede defaulten**: AZURE-armen slår opp
et Foundry-deployment-navn i modell-mappet FØR noen klient bygges (`run.py` stempler provenance
med det), så den kan ikke fullføre offline — **containeren trenger altså `PORTFOLIO_MODEL_MAP`
eller et utfylt `data/model_map.json`, ikke bare et endepunkt** (målt her, ikke antatt).
**Artefakt-halvdelen av denne raden er PENSJONERT 14.08** sammen med `Dockerfile`/`azure.yaml`
(rå-tekst-gaten pinnet `--platform linux/amd64` + ÉN kopi av startkommandoen; to av radens fem
mutasjoner traff nettopp den). Whitelist-halvdelen står URØRT. Load-bearing MÅLT
(`tests/test_hosting_loadbearing.py`), de tre gjenværende mutasjonene alle røde på riktig test og
på INGEN annen: send `project_id` to ganger · whitelist et felt `run_project` ikke tar · fjern
`bundle_dir` fra whitelisten.
- **Et tak som fyrer er IKKE en krasj — `BudgetExceeded` får sin EGEN kanal (429), og trippelen
bæres som STRUKTUR (1b-køen, 14.08):** prosjektets første levende kjøring døde på
`rounds limit=12 observed=13`, og den hostede flaten svarte `500 {error_type, error}` — altså
nøyaktig det samme den sier når modell-endepunktet faller. **Beslutningen er S3.4-invarianten
anvendt på transporten:** `budget_stop` ble holdt UTENFOR `stop_reason` fordi de to stoppene
betyr motsatte ting, og å svare ressurs-utmattelse på krasj-kanalen gjør «det gikk ikke»
uleselig på nøyaktig samme måte. **IKKE 200, og det er dét som skiller den fra `Rejection`:**
en `Rejection` er en kjøring som KONKLUDERTE (og hører derfor i payloaden), mens et uttømt
budsjett produserte ingen `proposal` i det hele tatt — en 2xx ville latt en automatisk kaller
bokføre «analysert» for en kjøring som analyserte ingenting. **429 fordi betingelsen oppstår av
en TILDELING** (`max_rounds`/`max_tokens` er whitelistede request-felt, og å heve dem er
kallerens egen botemiddel), aldri av en serverfeil — derfor 4xx, ikke 5xx.
`kind`/`limit`/`observed` legges ut som felt, ALDRI `str(exc)` (kø-(y): de beskriver ÉN ledger,
og «hvilket tak bandt, og hvor langt forbi» er hele det operative spørsmålet); `error_type`
holdes UTE — den nøkkelen tilhører feilkanalen, og en kaller som switcher på dens
tilstedeværelse skal ikke finne den her. `budget_exhausted` er IKKE foldet inn i `outcome_type`,
og kunne ikke vært det: `outbox.outcome_payload` er den ENE kopien av den forgreningen og tar
`ValidatedProposal | Rejection`, som en uttømt kjøring ikke har noen av. **Ærlighets-grense,
uttalt:** ingen `Retry-After` — å vente endrer ingenting, botemiddelet er et større tak eller å
akseptere stoppet, og en header som lover tid ville vært en løgn. Load-bearing MÅLT
(`tests/test_hosting_loadbearing.py`), fem mutasjoner alle røde mot HELE suiten, hver med sin
egen signatur + grønn kontroll 867/4: detach armen (2 røde) · flat streng i stedet for struktur
(1 rød — struktur-testen ALENE, altså rir den ikke på status-asserten) · ekko `limit` som
`observed` (1 rød) · utvid armen til `Exception` (6 røde, inkl. 400-armen) · stemple
`error_type` på budsjett-kroppen (1 rød). **500-armens vitne ble byttet, ikke slettet:** den
eksisterende testen brukte `BudgetExceeded` som sin 500-prøve, så å bare legge til en ny arm
ville etterlatt krasj-kanalen uten vitne — den bærer nå en ekte ikke-budsjett-`RuntimeError`,
og er dét som holder den nye armen SMAL.
- **Påstander flaten gjør om SEG SELV gates som rå tekst, linjeforankret (Fase 3, A5):** to påstander
bodde i prosa der ingen test kunne se dem, og begge drev. (1) `env.template` sa at credential
resolves via `DefaultAzureCredential` — den har ALDRI gjort det; gaten leser de klassene
`backends.py` faktisk konstruerer **fra selve tilordningslinja**, ikke fra modulen, fordi
kommentarene NAVNGIR `DefaultAzureCredential` fire ganger for å begrunne hvorfor den ikke brukes —
en fil-bred substring-gate ville vært rød på nøyaktig den prosaen den beskytter (repoets
08-09-klasse, fjerde gang). (2) README-ens wheel-filnavn bærer versjonen bygget stempler på fila,
så en versjonsbump ville stille etterlatt en publisert install-kommando som peker på en fil som
ikke finnes. **Hver positiv assert er paret med en KONTROLL** på at det søkes etter noe som finnes
— en ekstraktor som stille finner null lager en gate som bare kan bli grønn. Load-bearing MÅLT
(`tests/test_public_surface_claims_loadbearing.py`) mot HELE suiten, begge røde på riktig test og
på INGEN annen: gjeninnfør credential-påstanden (2 røde, 844 grønne) · la wheel-filnavnet drifte
(1 rød, 845 grønne). Bumpen selv var den tredje målingen — `pyproject` 1.0.0 → 1.1.0 gjorde
README-gaten rød alene, FØR README ble rettet. **`repo-standard`-gaten kan IKKE verifisere denne
fasen:** den var OK/20 sjekker før arbeidet startet, og `RELEASE-STALE` er strukturelt blind for
repo med null utgivelser (org-ops hovedbok #18). Bevisene er Forgejo-APIet, filinnholdet og
ren-klon-kjøringen.
- **GOVERNANCE er en LENKE, aldri en kopi (org-ops D11):** én kanonisk `GOVERNANCE.md` bor i
`repo-standard` og hvert repo lenker den fra README. Å skrive vår egen ville gjort oss til kopi
nr. 12 av en fil D11-bølgen holder på å rydde vekk. Bus-faktor 1 står uttalt i den kanoniske
teksten, ikke i vår.
- **Stoppkriterier + budsjett-tak påkrevd ved oppstart** (fail-fast, aldri ubegrenset loop). - **Stoppkriterier + budsjett-tak påkrevd ved oppstart** (fail-fast, aldri ubegrenset loop).
- **Group Chat maker-checker** som debatt-default (IKKE Magentic, som er eksperimentell). - **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 - **To falsifiserere, samme kandidat (Steg 3/4, målbilde §2/§6):** den deterministiske validatoren
@ -112,6 +322,23 @@ Python ≥3.10. MAF (`agent-framework-core` 1.9.0). Pakkehåndtering: `uv`. To b
her) — så koden påstår ikke mer enn den gjør. Load-bearing: 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 `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. 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 - **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 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` per §10 R2) i en inbox-mappe ETTER en kjøring, og en separat, senere kjøring `load_verdicts_from_dir`
@ -182,6 +409,26 @@ Python ≥3.10. MAF (`agent-framework-core` 1.9.0). Pakkehåndtering: `uv`. To b
S3.2); **D7-speiling ÅPEN.** Load-bearing MÅLT (`tests/test_s40_cost_baseline_loadbearing.py`), seks 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 · 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. detach bundle-wiringen · ignorer det injiserte cap-registeret · gjør den valgfrie loaderen tolerant.
**En UFORANKRET kjøring sier det nå — og BEGGE utsagn stammer fra kjøringens ENE oppslag, aldri
en andre lesing av bundelen (21.08):** `ProvenanceStamp.cost_baseline_anchored` er PÅKREVD uten
default (begge defaults lyver: `True` lar en glemsom konstruktør påstå en ankring som ikke skjedde,
`False` underrapporterer en ekte — en binær kjensgjerning om en falsifiserer har ingen ærlig
default), og `DryRunReport` bærer det samme fordi en dry-run stopper før noe stempel finnes.
`run.cost_baseline_notice(anchored)` er ENESTE renderer, tar den alt oppløste BOOLEANEN, og
returnerer `None` når kjøringen ER forankret — omisjon, aldri en tom rad (`announce`-regelen).
**IKKE foldet inn i `mandate.announce`, og det er en MÅLING:** den fyrer kun med `--mandate`, så
nettopp de bare bundle-dry-runsene defekten ble målt på ville fortsatt sagt ingenting — og den
renderes FØR `run_project`, altså før noen har oppløst baselinen. Utboksen trengte ingen endring
(`write_proposal` dumper hele stempelet). Ankeringen forblir VALGFRI: dette er synlighet, ikke en
ny nekt, og golden-transkriptet er byte-uendret fordi demoen kjører en base som HAR fila.
Portefølje-armen er DEFENSIV og uttalt (ingen referanse-prosjekt setter `bundle_dir`, så den er
unåbar i dag — `budget_stop`-presedensen; testen driver en crafted `PortfolioResult`). Load-bearing
MÅLT (`tests/test_baseline_visibility_loadbearing.py`), seks mutasjoner alle røde mot HELE suiten +
grønn kontroll 885/5: konstant stamp-wiring (3 røde) · konstant dry-run-wiring (1 rød) · detach
dry-run-printen (1 rød) · renderer returnerer alltid linja (2 røde — inkl. den forankrede
kontrollen, altså er omisjonen selv gatet) · detach full-run-printen (1 rød) · detach
portefølje-printen (1 rød). Det PÅKREVDE feltet tvang fem eksisterende test-konstruktører til å ta
stilling — det er egenskapen, ikke friksjonen.
- **Globalt token-tak håndheves FØR kall, aldri bare etterpå (S3.4, F10):** `PortfolioBudget` + - **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 `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: av pass), mens per-run `Budget`/`TokenMeter` er uendret. Taket har tre tenner, med hver sin jobb:
@ -237,6 +484,35 @@ Python ≥3.10. MAF (`agent-framework-core` 1.9.0). Pakkehåndtering: `uv`. To b
`run.py` · endre avrundingsmodus · la `realize` gå utenom `to_ore`. **Ærlighets-grense:** `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 `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. aldri kvantiseres og aldri sammenlignes mot ledgeren, altså utenfor ordens-defekten.
- **En BETALT test får sin EGEN opt-in, og instrumentet bevises GRATIS (Fase 1b, siste trinn):**
`tests/test_full_run_live.py` kjører hele `run_project`-stien mot et ekte Foundry-deployment, og
gates på **fire** ting — de to Foundry-variablene, `PORTFOLIO_MODEL_MAP`, og et TREDJE, distinkt
`PORTFOLIO_LIVE_FULL_RUN` lest på **truthiness** (4b-invarianten). **Den tredje variabelen er
load-bearing, ikke pynt:** `test_foundry_profile_live.py` (klient-probe) og `test_portfolio_live.py`
(fan-out) gatet på nøyaktig SAMME to variabler, så å gjenbruke det paret ville betydd at en
operatør som eksporterer dem for den BILLIGE ett-ords-proben også fyrer den dyre fullkjøringen —
altså at måleprotokollens stige («bevis så mye som mulig før det dyre trinnet, så en feil er
attribuerbar») kollapser til ett trinn. **MÅLT:** med begge Foundry-variablene satt SKIPPET den
dyre, og den billige var grønn. **Regelen gjelder HVER betalt arm, ellers er den ingen regel:**
`test_portfolio_live.py` passerer ingen `client_factory` og er derfor selv en betalt kjøring — den
fyrte på to-variabel-paret fra et bart `uv run pytest`, og ble gatet på den TREDJE variabelen i
samme slengen. Å la den stå ville gjort denne raden halvt usann den dagen den ble skrevet; en
invariant som beskriver én av to armer er en påstand flaten gjør om seg selv uten dekning, som er
nøyaktig Fase 3-klassen. Den billige klient-proben beholder to-variabel-gaten med vilje — den ER
det billige trinnet. `PORTFOLIO_MODEL_MAP` er med av en annen grunn — attribusjon: uten
den feiler kjøringen av en KONFIGURASJONS-årsak som ser ut som en modell-feil.
**Asserten bor i ÉN kopi** (`conftest.assert_full_run_contract`, kø-(p)) og er smal med vilje:
fraværet av `{run_id}-parse-failures.json` (økt 35-invarianten «filens tilstedeværelse er
signalet») + at `validator_decision` avgjorde. **En `rejected` BESTÅR** — påstanden som felles er
at det strukturerte skjemaet ER akseptert av det levende endepunktet, ikke at modellen resonnerer
godt; å kreve `validated` ville vært en modell-dømmekraft-påstand ingen enkelt kjøring kan bære.
**Iron Law uten å betale to ganger:** et betalt kall kan ikke kjøres rødt-så-grønt, så
diskrimineringen bevises OFFLINE av `tests/test_live_full_run_contract.py` — to armer over samme
helper (én parse-feil → kontrakten MÅ feile; alle parser → MÅ passere). Load-bearing MÅLT, to
mutasjoner med hver sin distinkte signatur: detach artefakt-sjekken (T1 rød ALENE — kontrakten
degraderer da til `test_portfolio_live.py`s `len(runs)==1`-klasse) · raise ubetinget (T2 rød
ALENE — den motsatte vakuiteten, en live-test som bare kan bli rød). **Det betalte kallet er
MÅLINGEN, aldri beviset på at måleinstrumentet virker.**
- **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. - **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 - **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 IKKE MAF mot ekte modell (verken Azure/Foundry eller Ollama — API for begge repoene er for kostbart
@ -250,6 +526,103 @@ Python ≥3.10. MAF (`agent-framework-core` 1.9.0). Pakkehåndtering: `uv`. To b
lever på Claude-SDK-siden (minimal API-kjøring). Skriptet klient = MAF-side stillas, IKKE delt 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`. (`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. Load-bearing: `tests/test_simulation_loadbearing.py` blir RØD når promoteringen detaches.
- **Demoen KJØRER begge tidsskalaer, og de bæres av HVER SIN markør (P1/S1.a):** Steg 7-linja sa
«lang fil-løkke», men `simulate_learning_loop` kalte `run_project` UTEN `verdict_dir` — dommen kom
som funksjonsargument (`verdict_input`, den KORTE i-kjøring-fangsten). Nå skriver en ekspert en
faktisk fil (`write_verdict`) i en innboks MELLOM kjøringene, og Run B får `verdict_dir=`.
**Hvorfor en ANDRE markør og ikke persona-dommen gjennom innboksen:** Steg 7 (innboks) og Steg 8
(promotering) er to ULIKE mekanismer som begge ender i Run B's hypotese-prompt — med én delt
markør kunne hver av dem båret den alene, og `test_simulation_loadbearing.py`s promoterings-assert
ville stått GRØNN med promoteringen detached, altså blitt vakuøs. `simulate_learning_loop` raiser
derfor `ValueError` når `inbox_marker == marker`. Innboksen ligger VED SIDEN AV bundle-kopien,
aldri inni: en dom-fil inne i bundelen når neste kjøring som navigerbar kontekst, som er en annen
mekanisme i denne sin forkledning. Sentinel-`id` (aldri myntet) — `_mint_id` hasher kandidat-
featurene, så en myntet id kolliderer med den promoterte dommens, og `VerdictStore.add` er
first-write-wins per id. Load-bearing MÅLT (`tests/test_step7_demo_inbox_loadbearing.py`), tre
mutasjoner røde + grønn kontroll: detach `verdict_dir=` · la Run B lese en TOM mappe · sett
innboks-markøren til en verdi som FINNES i bundelen.
- **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.
- **Demoen kjører FORANKRET, og baselinen DERIVERES fra manuset (P4 pkt. 0):** før dette regnet
validatoren i demoen kun på tall forslaget selv oppga — S4.0-forankringen aktiveres bare når
kunnskapsbasen shipper `cost-baseline.json`, og ingen bundle under `shared/` har den. Reserven kan
aldri få fila DER (pull-only subtree + kriterium 8 krever goldenene byte-uendret), men det er en
*plasserings*-begrensning: `materialize_anchored_bundle` KOPIERER bundelen og legger fila til
utenfor `shared/`, og kjørestien (`run.py``load_optional_cost_baseline`) er da NØYAKTIG samme
søm en levert bundle ville brukt. **Retningen på avledningen er bærende:** reservens tall er
syntetiske, så manus-registeret er eneste grunnsannhet — `baseline_from_scripted_candidate`
avleder i KODE, aldri en andre håndskrevet kopi av de samme tallene (to kilder drifter, og drift
er nøyaktig det 10 %-prøven modellerer). På GO-dagen snus retningen (plan P3 b: registeret skrives
FRA levert fil). Begge skriptede svar må oppgi SAMME kostlinjer (`ValueError` ellers): var de
ulike, ville hypotese #1 blitt avvist av stage 0 istedenfor av P90 — samme REJECTED-linje på
skjermen, annen mekanisme. **Forankringen er usynlig i alt annet stdout** (målt: eneste diff mot
uforankret er KUNNSKAPSBASE-blokka), derfor printes den erklærte baselinen, og derfor er
`provenance` et PÅKREVD argument til `_baseline_lines` — kallstedet som velger bundelen er det
eneste som vet hvor tallene kom fra. Load-bearing MÅLT
(`tests/test_anchored_reserve_loadbearing.py`), fem mutasjoner røde + grønn kontroll: detach
main-wiringen · detach fil-skrivingen · la filnavnet drifte · detach to-svars-enigheten · returner
et literal i stedet for det avledede. **Målingen felte TESTEN først** (samme klasse som 08-06):
«ingen kostbaseline erklært» INNEHOLDER «kostbaseline erklært», og `ENERGI-TOTAL-EL` står allerede
i Steg 2-linja — begge assertene overlevde detach-mutasjonen. De to grenene deler nå ingen ordlyd.
- **Demoens stderr: rund-taket dempes, `ExperimentalWarning`-paret PINNES (P4 pkt. 2):** målt 08-09
var stderr seks linjer. `quiet_expected_round_cap_notice()` dropper KUN
«reached max_rounds=…; forcing completion» — en hendelse demoen selv provoserer (maker/checker
kjører til taket) — via et filter på den EMITTERENDE loggeren (`ROUND_CAP_LOGGER`, lest ut av MAFs
kilde). Logger-filtre gjelder kun loggeren posten ble logget GJENNOM; en forfars filtre konsulteres
aldri. Filteret installeres i `main()`, ALDRI ved import — en bibliotek-modul skal ikke
omkonfigurere loggingen til en konsument. **De to `ExperimentalWarning`-linjene dempes IKKE:** de
fyrer mens `portfolio_optimiser/__init__.py` importerer `run``agent_framework`, altså alltid FØR
`simulation` sin egen importblokk, under BEGGE kjøreformer — så å dempe dem ville krevd et
warnings-filter inne i bibliotekpakken, dvs. at rammeverket bestemmer hva MAF får si til enhver
konsument. En wrapper bak konsoll-kommandoen ble avvist av en andre grunn: da ville de to
kjøreformene skrevet ULIK stderr, og en byte-fasit ville pinnet kommandoen i stedet for programmet.
**Dempingen er smal ved konstruksjon** — nøklet på meldingen, ikke loggeren — nettopp så pkt. 3-pinnen
fortsatt kan felles av en NY advarsel. Load-bearing MÅLT
(`tests/test_demo_stderr_quiet_loadbearing.py`), fem mutasjoner røde + grønn kontroll: detach
`main()`-kallet (subprosess-testen er ENESTE som fanger det — de tre filter-testene installerer
filteret selv) · la filteret droppe alt · installer ved import · pluss de to entry-point-mutasjonene.
- **Demo-transkriptet er sjekket inn som fasit, og masken er SPANN-avgrenset (P4 pkt. 3):**
kriterium 6 er selv-identitet — to kjøringer av en REGREDERT demo er like enige som to kjøringer av
en riktig, så fasiten må forlate prosessen. `tests/golden/demo-transcript.stdout` er stdout ORDRETT
(målt byte-identisk over kjøringer OG i fersk klon), og er derfor også demoens abortsti: feiler
live-kjøringen, ER fila transkriptet. `…​.stderr` er normalisert på nøyaktig to MÅLTE miljø-spann —
`site-packages`-prefikset og temp-katalogen bak `(arbeidskopi: …)`, der `po-sim-`-prefikset holdes
SYNLIG fordi det er en egenskap ved programmet (`mkdtemp(prefix=…)`), ikke ved miljøet; pinnet
stderr er fire linjer. **Masken må ikke kunne vokse:** P4 pkt. 2 betalte for at en NY advarsel
fortsatt når stderr, og en normalisering som maskerte hele linjer ville opphevet det i ett trekk —
derfor er `test_normalisation_does_not_mask_a_new_warning` kontrollen som forbyr det (MÅLT: en
droppende normaliserer med fasiten regenerert under seg holder BEGGE likhets-testene grønne og
felles kun av kontrollen). `PYTHONIOENCODING` pinnes, ellers måler sammenligningen operatørens
locale i stedet for programmet. **Regenerering er en beslutning, aldri rydding** — fasiten kan ikke
bevise sin egen kjøring, den pinner outputen P3-kriteriene ble målt mot.
- **Frø-setningen AVLEDES fra kjøringen (P4 pkt. 4):** demoen sier høyt hvor Kjøring B's tidligere
dommer kommer fra, og splitten (`_verdict_origin_line`) regnes ut — linja rett over printer allerede
antallet, så en håndskrevet «én av tre» ville vært den andre kopien som drifter (samme regel som
pkt. 0-baselinen), og ville blitt sagt uendret etter at en framtidig bundle shipper en ANDRE frøsatt
dom. Klassifisereren er de to markørene demoen alt sporer; den hviler på at den frøsatte dommen
bærer INGEN av dem, som MÅLES på levert bundle. **Planens forhåndsskrevne ordlyd var FEIL mot
levert innhold** («én av de TO») — målt henter Kjøring B TRE: én fulgte med kunnskapsbasen, to er
demoens egne (én per tidsskala). Load-bearing MÅLT
(`tests/test_p4_honesty_sentences_loadbearing.py` + golden-transkriptet), fem mutasjoner alle røde
+ grønn kontroll: ett byte i en stdout-linje · detach dempingen · over-normaliser stderr · literal
splitt (fanget av INGENTING i 800 tester bortsett fra skille-testen) · detach frø-setningens print.
- **Delt ekspert-persona som Agent Skill (§8, framework-nøytral):** ekspert-reviewer-personaen bor i - **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 `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 delte artefakten begge stacker instansierer reviewer-en fra. `shared/` forblir REN DATA — MAF-siden
@ -264,6 +637,482 @@ Python ≥3.10. MAF (`agent-framework-core` 1.9.0). Pakkehåndtering: `uv`. To b
import), eksempelet er gyldig pipeline-input inkl. `FeedbackContract` (RØD på skjema-/kontrakt-drift, 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 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). det øyeblikk personaen re-inlines).
- **Den råe svarteksten fanges i en KALLER-EID SINK, ikke i en returverdi (Fase 1b, funn 1):**
`generate._fetch_parsed` kastet hvert uparsebart modellsvar i `except: continue`, så prosjektets
første levende kjøring brant tolv runder på formatfeil og etterlot **null tegn** av det modellen
faktisk sa — enhver videre betalt kjøring ville vært gjetning. **HVOR teksten overflates er avgjort
av en MÅLING, ikke av symmetri med Steg 5:** `meter.tick_round()` raiser `BudgetExceeded` INNE i
`_fetch_parsed`, og uten mandat fanger ingen den (`run.py`s ene `except BudgetExceeded` er
mandat-armen) — så på nøyaktig den stien fangsten finnes for, RETURNERER `generate_via_llm`
ingenting. Et felt på `GenerationResult` (Steg 5-formen) er derfor blindt for den, og et
outbox-artefakt skrevet ETTER kjøringen likeså. Sinken speiler i stedet `meter`: en kaller-eid
akkumulator løkka muterer, hvis innhold kalleren holder uansett hvordan løkka endte. Steg 5s
«returverdi, ikke out-parameter» gjelder en verdi som NÅR kalleren; her gjør den ikke det, og å
kopiere regelen blindt ville gjenoppbygd defekten ett lag opp. Artefaktet
`{run_id}-parse-failures.json` skrives fra en **`finally`**, ikke `except BudgetExceeded` — enhver
exception ut av genereringen ødelegger samme bevis, og en liste over exception-typer er en liste
som blir foreldet. **Teksten er VERBATIM** (en forkortelse gjør beviset om til en parafrase), og
fila skrives KUN når noe faktisk feilet, så dens tilstedeværelse ER signalet. Byte-determinisme
påstås IKKE for dette ene artefaktet — innholdet er en levende modells prosa. Load-bearing MÅLT
(`tests/test_parse_failure_capture_loadbearing.py`), seks mutasjoner alle røde mot HELE suiten +
grønn kontroll 859/4: detach fangsten (3 røde) · flytt skrivingen ut av `finally` (1 rød, KUN
budsjett-testen) · detach run-wiringen (2 røde, generate-testen grønn) · skriv artefaktet alltid
(kontrollen + den eksisterende `a5`-inerthetstesten) · trunker teksten til 40 tegn (3 røde) ·
trunker til 100 tegn slik at sentinelen OVERLEVER (1 rød — verbatim-asserten alene, den skarpe
diskriminatoren). Ærlighets-grense: `_charge_usage` kan raise FØR parse, og et svar tapt der er
ikke en parse-feil og fanges ikke.
- **Proposeren får en GRAMMATIKK, og skjemaet er DERIVERT + fail-closed (Fase 1b, funn 1b):**
`generate_via_llm` sender `options={"response_format": proposal_response_format()}` på hvert
genererings-kall. **Formen er MÅLT, ikke valgt:** `ChatOptions.response_format` tar
`type[BaseModel] | Mapping`, og BEGGE profiler ærer den — LOCAL
(`OpenAIChatCompletionClient`) sender en Mapping ordrett til Chat Completions, AZURE
(`FoundryChatClient``RawFoundryChatClient``RawOpenAIChatClient`) konverterer SAMME
envelope til Responses-APIets `text.format`. **Klassen er AVVIST på bevis:** gitt en klasse
konverterer klienten med `type_to_response_format_param`, som (målt) emitterer `minimum` /
`exclusiveMinimum` / `minItems` / `prefixItems` og et `assumptions`-node hvis
`additionalProperties` er et SKJEMA — fire ting Azures publiserte subset utelukker
(Learn: «Unsupported type-specific keywords» + `additionalProperties: false` i hvert objekt).
Vår egen mapping er eneste måte å styre hva som når tråden. **Å stripe beskrankningene koster
ingenting:** skjemaets jobb er FORM, validatorens jobb er VERDIER — `minItems`/`gt=0` gjenreises
av pydantic i `_parse_ir` og av `validate_proposal`. **Skjemaet DERIVERES fra `SavingsProposal`**
(`strict_json_schema`), aldri håndskrevet: en andre kopi av en form som alt bor i `ir.py` drifter
stille, og modellen ville fortsatt blitt bestilt for den gamle. **`assumptions` KAN IKKE bare
droppes, og det er en MÅLING:** feltet er det ene uttrykksløse (fri-form map av 2-tupler), men
`validator._monte_carlo` faller tilbake på `item.unit_cost` for hver kode uten bånd — uten bånd
i det hele tatt er alle 512 samples IDENTISKE og P10 == P50 == P90. Den stokastiske
falsifisereren ville gått inert mens den fortsatt rapporterte persentiler: repoets kardinalklasse
(en gate som bare kan bli grønn). Derfor bærer WIRE-en et array av navngitte entries og
`_parse_ir` folder det tilbake til IR-ens map — **additivt, aldri erstatning** (map-formen
parser uendret; alle scriptede svar i suiten og golden-transkriptet bruker den). Sanitiseren er
**fail-closed** (`StructuredOutputUnsupported`) på `prefixItems`/`oneOf`/`allOf`/fri-form map
uten deklarert override — validering, ALDRI reparasjon (speiler `write_concept_file`).
Prompt-linja «Respond with ONLY a JSON object» + parse-retry + funn-1-fangsten står URØRT: en
leverandør som ignorerer `response_format` må fortsatt få beskjed, og backstoppen er poenget.
Load-bearing MÅLT (`tests/test_structured_output_loadbearing.py`), seks mutasjoner alle røde +
grønn kontroll 864/4: detach wiringen (1 rød) · detach sanitiseren (3 røde) · dropp
`assumptions` fra skjemaet (1 rød) · fail-closed → stille reparasjon (1 rød) · detach
normaliseringen (3 røde) · erstatning i stedet for tillegg (2 røde — T5 PLUSS
golden-transkriptet, et uavhengig vitne). **T3 ble skrevet VAKUØS først** (repoets 08-09-klasse,
sjette gang): den påsto å bli rød når `assumptions` forsvant fra skjemaet, men den scriptede
klienten ignorerer skjemaet — påstanden ble bevist usann av M3 og testen fikk en DIREKTE
assert på skjemaet. **ÆRLIGHETS-GRENSE, UTTALT:** ingen betalt kjøring er gjort, så at det
emitterte skjemaet ER akseptert av det levende endepunktet er IKKE verifisert — testen beviser
konformitet med det DOKUMENTERTE subsettet, ikke aksept. Ollamas oppførsel på
`response_format` er likeledes uverifisert.
- **Overleverings-pakka ER `git archive HEAD`, aldri en kuratert kopi (Fase 5):**
`scripts/make-handover-package.sh` bygger én zip en ekstern organisasjon deployer uten å klone
repoet. **Tracked files only er hele eksponerings-kontrollen**`STATE.md`, `*.local.md` og
`.env` er gitignorert, så de KAN ikke komme inn; et filter vedlikeholdt i skriptet ville vært den
andre kopien av den regelen, og den andre kopien er den som drifter (kø-(p)). Mottakeren får altså
HEAD selv. Versjonen LESES fra `pyproject.toml` — et hardkodet tall her ville råtnet ved neste bump
nøyaktig som README-ens wheel-filnavn gjorde (Fase 3). `DEPLOY.md` ligger i treet og blir dermed
med i arkivet av seg selv; den bærer mottakerens tre første spørsmål — hvem gjør hva
(plattform-operatør / bestiller / fagperson), prosessen ende-til-ende, og **hvorfor det ikke
finnes et chat-grensesnitt** (flaten er `POST /invocations`, og `as_agent()` er bevisst vraket
fordi validator, baseline-forankring, checker-gate og ledger ligger UTENFOR grafen — et chat-lag
ville rutet forespørsler rundt nøyaktig det som gjør svaret etterprøvbart). Den navngir også det
4e målte deploy-kravet som ingen rad hadde skrevet ned: pakket `model_map.json` bærer
`REPLACE-WITH-*`, så uten `PORTFOLIO_MODEL_MAP` starter tjenesten, svarer på `/readiness` og
feiler HVER invocation. Gaten er `tests/test_handover_package_loadbearing.py`, og
DEPLOY.md-asserten er LINJEFORANKRET: `PORTFOLIO_FOUNDRY_PROJECT_ENDPOINT` INNEHOLDER
`FOUNDRY_PROJECT_ENDPOINT`, så en delstreng-assert på det injiserte navnet ville vært oppfylt av
vårt eget (repoets 08-09-klasse, femte gang).
- **Overleveringen er KUN kjørbar Python, og fraværet er FJERNING — ikke filtrering (14.08,
operatørdirektiv etter ekstern test):** `Dockerfile` og `azure.yaml` er slettet fra TREET.
**Sømmen er valgt av den eksisterende invarianten, ikke av smak:** pakka ER `git archive HEAD`, så
å ekskludere filene fra arkivet ville krevd en kurerings-mekanisme (skript-filter eller
`export-ignore`) — den andre kopien av «hva mottakeren får», fri til å drifte fra HEAD, altså
nøyaktig kø-(p)-regelen raden over finnes for. Å beholde dem som «opt-in» ville ikke oppfylt
direktivet i det hele tatt. Fjerning holder arkivet ukurert OG gjør fraværet til en egenskap ved
HEAD, som er det eneste en gate kan måle. **De to gatene som pinnet flaten er håndtert BEVISST,
aldri stille svekket:** 4e-rå-tekst-gaten (`--platform linux/amd64` + ÉN kopi av startkommandoen)
er SLETTET med et notat der den sto — en gate som pinner en fjernet flate kan bare bli grønn — og
handover-gatens `_REQUIRED_MEMBERS` er ikke bare fratatt de to navnene, men erstattet av en
POSITIV fraværs-assert; å kun slutte å KREVE dem ville gitt en gate som ikke kan skille «fjernet»
fra «shippes fortsatt». Matchingen skjer på arkiv-MEDLEMSNAVN, ikke på prosa (dokumentene må
kunne forklare at ingen image shippes — repoets 08-09-klasse, sjette gang), og dokument-gaten
forbyr kommando-FRAGMENTER (`docker build`, `azd deploy`), ikke ordet. **Startkommandoen har nå
ÉN kopi igjen — DEPLOY.md-ens `python main.py`** — og den navngir inngangen subprosess-testen
faktisk kjører. **Ærlighets-grense, uttalt:** azd/hosted-agent-stien finnes ikke lenger i pakka;
hvordan prosessen driftes er mottakerens valg. `git archive HEAD` leser HEAD, ikke arbeidstreet,
så gaten er ekte men forsinket med én commit (funn 35). Load-bearing MÅLT
(`tests/test_handover_package_loadbearing.py`).
- **Sporing er OPT-IN, og «av» betyr at MAF ALDRI kalles (U14, økt 55):** `PORTFOLIO_OTEL` leses på
**truthiness** (4b-regelen) og er ENESTE bryter; uten den kalles `configure_otel_providers` ikke i
det hele tatt — spans LAGES fortsatt (`ENABLE_INSTRUMENTATION` defaulter `True`,
`observability.py:697`) og kastes, så ingenting KAN forlate prosessen. Et kall med tom
exporter-liste ville derimot installert providere og lest hver `OTEL_EXPORTER_OTLP_*` i det
omkringliggende miljøet — «av» må være fravær av kall, ikke kall uten innhold. **To regler er
MÅLT, ikke valgt** (`observability.py:849` bygger exporter-lista i fast rekkefølge: (1) env-avledede
OTLP-exportere UBETINGET, (2) de innsendte, (3) `ConsoleSpanExporter()` — default-sink **stdout**
når `enable_console_exporters` er sann fra argument ELLER `ENABLE_CONSOLE_EXPORTERS`): (a)
`enable_console_exporters=False` sendes EKSPLISITT i BEGGE moduser, ellers gir en operatør med den
variabelen eksportert et span-dump på stdout — nøyaktig det S6 målte som ødeleggende for
golden-transkriptet; (b) `console` NEKTER når en OTLP-endepunkt-variabel finnes, fordi steg (1)
ville lagt til en nettverks-exporter ordet «console» lover ikke er der. **Validering, ALDRI
reparasjon** — vi fjerner ikke operatørens miljøvariabel bak ryggen på dem
(`write_concept_file`-regelen); nekten NAVNGIR variabelen. `otlp` uten deklarert endepunkt nektes
også: providere med ingenting å eksportere til er en kjøring som SER sporet ut og ikke er det. En
ukjent verdi nektes ved navn, aldri stille fallback til av. **`tracing_notice` er ENESTE renderer**,
tar den alt oppløste `TracingSetup` og returnerer `None` når sporing er av — omisjon, aldri tom rad
(`announce`-regelen), og her bærende utover stil: demoens pinnede stderr er FIRE linjer. **Tre
kallsteder, ikke ett** (`run.main`, `simulation.main`, `hosting.main`): demoen er et skriptet bevis,
ikke produktet, og en søm bare demoen når ville latt de to inngangene en virksomhet faktisk kjører
være usporbare. **OTLP-exporter-PAKKENE er BEVISST ikke deklarert** (egress + grpc/protobuf-vekt i
et publisert wheel; MAF raiser selv en `ImportError` som navngir pakka) — uttalt ærlighets-grense.
**`PLAN_CREATED`/`REPLANNED`/`PROGRESS_LEDGER_UPDATED`-eventene planen navngir er IKKE bygget:**
de hører til utforskningssløyfa (U4) som ikke finnes ennå, og en emitter skrevet før kallstedet er
en form gjettet i stedet for målt. Load-bearing MÅLT
(`tests/test_tracing_loadbearing.py`), ni mutasjoner alle røde mot HELE suiten + grønn kontroll
943/5: exporteren tar sin stdout-default (4 røde) · `enable_console_exporters` overlatt til miljøet
(3 røde — inkludert den ATFERDSMESSIGE, som kjører demoen med variabelen eksportert; uten den ville
raden bare vært en keyword-assert) · detach console-nekten (4 røde) · detach otlp-endepunkt-nekten
(1 rød) · ukjent modus faller stille til av (2 røde) · «av» kaller MAF likevel + renderer returnerer
alltid en linje (9 røde, hvorav TRE i tester som fantes fra før — `test_golden_transcript` sin
fire-linjers stderr og `test_portfolio_cli_offline`s stille-pass — altså er omisjonen gatet av
uavhengige vitner) · detach demo-wiringen + detach CLI-wiringen (5 røde) · detach hosting-wiringen
(1 rød, KUN subprosess-testen — P4-presedensen).
- **Utforskningssløyfa er en MANDAT-FORMER, og de tre garantinivåene er strukturelle (U4+U13
synkron, økt 56):** `explore.py` legger en Magentic-manager OVER den normative sløyfa — `prompt +
kunnskapsbaser → Mandate → run_project(mandate=…)` UENDRET, Steg 3s maker-checker urørt (commons-eid
og normativ). Manageren velger VEI; det som forlater friheten er `mandate.Mandate`, aldri et forslag.
**Nivå 1** = `quick_validate`-verktøyet (SAMME `validate_proposal`, SAMME baseline, men rådgivende —
når ALDRI provenance); **nivå 2** = pipelinen som stempler; **nivå 3** = skriverettigheter, som kun
pipelinen har. `explore()` skriver INGENTING. **Mandatet bygges fra hypotesiserens MERKEDE turer**
(`HYPOTHESIS: {"label","rationale"}`), aldri fra sluttsvaret — sluttsvaret er RÅTT per design, og en
parser på det ville gjort det til et forslag. Markøren er dét som gjør fail-closed mulig: en umerket
tur er ikke en påstand (ingen stillhet å lukke), mens en MERKET-men-uleselig linje raiser
(`write_concept_file`-regelen). **Frø-approaches bevares ALLTID og FØRST** — også når sløyfa fant
ingenting og også ved stopp (§ C.6 dør 1 er en bevaringsregel, ikke en belønning for å bli ferdig).
**Tre kanaler, aldri én:** tokens OG runder raiser `BudgetExceeded` (rundene som
`kind="exploration_rounds"`, oversatt av VÅRT lag fordi orkestreringen MÅLT ikke raiser ved sitt eget
rundetak — den returnerer en kanonisk assistent-melding som ved transporten er uskillbar fra suksess),
mens alt semantisk er en VERDI i `stop` (S3.4-splitten: utmattelse og utfall er ikke samme sak).
Diskriminatoren mellom «nådde taket» og «ble kappet av taket» er SISTE ledgers
`is_request_satisfied` — samme felt orkestratoren selv forgrener på (`:1106`) — aldri
termineringsmeldingen, som er en inline f-string (`:1253`) uten konstant å pinne mot og som en modells
eget sluttsvar kan inneholde. **`speaker_known` sjekkes FØRST og slår alt annet:** en `next_speaker`
uten treff gir stille sluttsvar med NULL deltakerarbeid (`:1128-1131`), altså et plausibelt svar
ingen jobbet for (E2-klassen) — sløyfas egne funn holdes da tilbake, frøene ikke.
**Kontrakten nekter tre ting ved konstruksjon:** hvert av seks felt er PÅKREVD uten default (MAF
defaulter `max_round_count`/`max_reset_count` til ubegrenset, så et utelatt felt faller ikke tilbake
til noe forsiktig, men til dét `method-spec` §8 forbyr); `max_reset_count=0` nektes — **MÅLT**, ikke
resonnert: `reset_count >= max_reset_count` mot en teller som starter på 0 gjør at kjøringen
terminerer FØR første runde med kun `facts`+`plan`, null ledger-events og «maximum reset count», altså
en utforskning som utforsket ingenting, forkledd som en stall som aldri skjedde (`max_stall_count=0`
er derimot LOVLIG — strengt `>` gjør 0 til «reset ved første stallede runde»); og
`max_plan_revisions>0` med `enable_plan_review=False` nektes (en cap på en hendelse som ikke kan skje).
`max_plan_revisions` finnes fordi A3 MÅLTE at en `revise` koster 2 manager-kall, **null** ledger-kall
og **null** runder og så spør PÅ NYTT — under rundetaket alene er en alltid-reviderende ekspert
ubundet forbruk under vakter som alle ser tilfredse ut. Ved cap: typet stopp, ALDRI en påtvunget
approve (repair av et menneskes beslutning er den verste sorten). **U14s tre utsatte events er
landet** (`plan_created`/`replanned`/`progress_ledger_updated` som span-events på ÉN
`exploration`-span) — emisjon er UBETINGET og «av» betyr at OTel kaster dem, samme form MAFs egen
instrumentering alt har; en flagget emitter ville vært en andre oppløsning av regelen `tracing.py`
eier. Uttalt ærlighets-grense: eventene registreres når event-strømmen foldes, så REKKEFØLGEN er
tro og tidsstemplene er ikke øyeblikkene manageren handlet.
**Load-bearing MÅLT** (`tests/test_explore_loadbearing.py`, 32 tester), tolv mutasjoner alle røde mot
HELE suiten + grønn kontroll 975/5: detach rundetak-oversettelsen (1 rød) · test rundetaket FØR
tilfredsstillelse (1 rød — den motsatte feilen, som gjør en fullført utforskning til en budsjettfeil) ·
detach ukjent-taler-sjekken (1) · `BudgetMiddleware` av manageren, deltakerne beholder den (1) ·
detach revisjons-capen (1) · dropp frø-bevaringen (3) · la en ukjent-taler-kjøring levere funnene
videre (1) · detach alle tre span-events (1) · tillat `max_reset_count=0` (1) · gjør en merket-men-
uleselig hypotese tolerant (1) · la `read_bundle` skrive i basen den leser (1) · send spans til OTels
default-sink (2). **TO av dem FALSIFISERTE testen først, og begge er repoets vakuøs-gate-klasse:**
(i) skrivefrihets-testen drev kun `explore()`, men en `ScriptedChatClient` returnerer TEKST og
emitterer aldri et verktøykall — så ingen scriptet kjøring når en verktøykropp, og hele lesesømmen
(eneste sted en skriving realistisk kan komme fra) lå utenfor gaten; testen kaller nå hvert verktøy
DIREKTE. (ii) stdout-testen brukte `capsys`, men `ConsoleSpanExporter`s `out`-default bindes når
`opentelemetry.sdk.trace.export` FØRST importeres — under pytest er det stdout ved COLLECTION, som
`capsys` aldri ser; spans lå faktisk på stdout mens asserten var grønn. Dét er ikke en test-quirk å
omgå, det er nøyaktig faktumet U14 finnes for, og arven er P4-presedensen: **subprosessen er
målingen**. Begge armene kjøres nå i et barn (av: null trace-data noe sted; `PORTFOLIO_OTEL=console`:
spanet + `progress_ledger_updated`**stderr** og stdout tomt), med `EXPLORATION-OK` på stderr som
kontroll — uten den ville «stdout var tomt» vært like sant om et barn som krasjet ved import.
**Ærlighets-grenser, uttalt:** multi-base-dispatch (`Approach.bundle_id`, § C.7) venter til
`run_project` tar mer enn én `bundle_dir` — å shippe feltet før konsumenten er en form gjettet i
stedet for målt; `quick_validate`-dommene hypotesiseren så bor ikke i `ExplorationResult`, de er nivå
1 og hører hjemme i `{run_id}-exploration.json` som CLI-wiringen skriver; utforskningsrollene løses
via `resolve_model`s `default`-fallback til en operatør mapper dem eksplisitt; at en LEVENDE modell
kaller verktøyene er ikke bevist offline (samme klasse som structured-output-grensen).
- **Utforskningens KALLSTEDER: sporet er kaller-eid, og whitelisten ble en TREDELING (økt 57):**
`--explore "<prompt>" --explore-config FILE` i `run.py`, `explore_prompt` + `explore_contract`
den hostede flaten, og `simulate_exploration` som et TREDJE sim-scenario — alle opt-in, alle over
den uendrede sløyfa. **`ExplorationTrace` er en KALLER-EID akkumulator (funn-1-sinken, ett lag
opp), og formen er tvunget av en måling, ikke valgt:** `explore()` raiser `BudgetExceeded`
rundetaket og tokentaket fyrer fra middleware midt i løpet — på BEGGE stier konstrueres aldri et
`ExplorationResult`, mens § C.2 krever at artefaktet er lesbart «uansett hvilken vakt som fyrte».
Steg-5-regelen («returverdi, ALDRI en out-parameter») styrer en verdi som NÅR kalleren; her gjør
den ikke det, og å kopiere regelen blindt ville gjenoppbygd defekten den ble skrevet mot.
`ExplorationResult.ledger_log`/`.plan_reviews` BYGGES FRA akkumulatoren (`tuple(trace.ledger)`),
aldri ved siden av — to beholdere om ett faktum er kø-(p). `{run_id}-exploration.json` skrives fra
en **`finally`** (`write_parse_failures`-presedensen) via `explore.trace_payload``outbox`s
plain-mapping-skriver (RAW-laget forblir MAF-fritt); **`completed` er et EGET påkrevd felt**, fordi
en `stop: null` som betyr BÅDE «avsluttet normalt» og «vi fikk aldri vite» er stillheten
`cost_baseline_anchored` ble påkrevd for å lukke. **Åtte CLI-nekter, alle ved navn**, hvorav to
bærer en beslutning: (i) `--explore` + `--mandate` er TO KILDER TIL ETT MANDAT og NEKTES, aldri
slås sammen — `explore()` tar objective fra prompten og hardkoder `allow_own_proposals=True`, så
komposisjon ville stille overskrevet tre felt operatøren skrev selv; nekten NAVNGIR
biblioteksdøra (`seed_approaches`), fordi § C.6 dør 1 er et ekte behov flaten ikke betjener.
(ii) `enable_plan_review=true` nektes på BEGGE flater FØR `explore()` kalles, og det er en
TYPE-måling: `ExplorationError` er en `RuntimeError` og ligger utenfor `main()`s
`(ValueError, FileNotFoundError, ValidationError)`-tuppel og utenfor hostings 400-arm, så å
overlate den til sløyfa ville gitt traceback på CLI-en og 500 — krasj-kanalen — på HTTP.
(`TracingConfigError` er derimot en `ValueError`; 400-armen dekket den alt.) Etter nektene er hver
konfig-formet `ExplorationError` UNÅBAR fra begge inngangene ved konstruksjon; det som fortsatt kan
slippe ut (uleselig merket hypotese, uttømt budsjett) er RUN-en som feiler, ikke kalleren som tar
feil. **Hostings whitelist er nå `_REQUIRED` / `_OPTIONAL` / `_CONSUMED`:** utforskningsfeltene er
IKKE `run_project`-parametre, så Fase 4e-beviset fikk en NEGATIV halvdel — hvert videresendt felt
MÅ finnes i `inspect.signature(run_project)`, hvert konsumert felt MÅ ikke; uten den ville et felt
som glir fra konsumert til videresendt vært nøyaktig driften 4e finnes for. **Demo-scenarioet er
nåbart ved NAVN og bare der** (`main()` kaller det ikke, og at golden-transkriptet er byte-uendret
etter at det ble lagt til ER målingen av det), med en **vakuitets-vakt**: en label kunnskapsbasen
ALLEREDE oppgir refuseres, fordi den ville nådd hypotese-prompten som ordinær kontekst enten
utforskningen kjørte eller ei — `simulate_learning_loop`s to-markør-vakt i demo-form. Manager-
manuset nøkles på PROMPT-STADIET, ikke prosjekt-ID-en, og det er ikke et unntak fra
`scripted_proposer`-regelen: manageren får FEM ulike spørsmål og prosjekt-ID-en er konstant over
alle fem. **`--outbox-dir` uten `--run-id` NEKTES i utforskningsblokka, og det er en HOIST — ikke
en andre kopi av regelen:** `run_project` eier outbox-kontrakten og nekter på sin FØRSTE setning,
tidlig nok for enhver sti som fantes før U4, men utforskningen kjører FORAN det kallet — uten
hoisten brukes hele utforskningsbudsjettet på modellkall før nekten, og artefakt-skrivingen hoppes
over, så ikke engang regnskapet over hva som ble brukt overlever. Funnet i review FØR commit;
testen asserterer at INGEN modellkall skjedde, ikke bare at rc er 1 — ved exit-koden ser en nekt
etter forbruket identisk ut. **Scenarioet har BEVISST intet `label_in_bundle`-felt:** vakten
raiser før et resultat finnes, så feltet kunne kun vært `False`, og en assert på det ville vært
grønn mot enhver implementasjon — vakten ER kontrollen, og en alltid-sann gjentakelse av den ville
bare gjort den ekte lettere å avfeie. Load-bearing MÅLT
(`tests/test_explore_callsites_loadbearing.py`, 24 tester), sytten mutasjoner alle røde mot HELE
suiten, hver mot kontrollen som gjaldt da (990/5 for CLI-en + sporet, 996/5 for hosting, 998/5 for
sim-scenarioet, 999/5 for de to siste): detach sink-appenden (1) · andre liste
for rundene (4) · detach `--mandate`-nekten (1) · detach `--explore-config`-nekten (1) · skriv
artefaktet kun ved fullført kjøring (1) · detach CLI-ens `mandate=` (1) · slipp
`enable_plan_review` gjennom, CLI (1) · detach `--bundle-dir`-kravet, CLI (1) · detach
`--live-dry-run`-nekten (1) · fjern `--explore` fra portefølje-partisjonen (1) · videresend de
konsumerte feltene (2) · detach hostings `mandate=` (1) · detach `enable_plan_review`-nekten,
hosting (1) · detach `bundle_dir`-kravet, hosting (1) · detach sim-scenarioets `mandate=` (1) ·
detach vakuitets-vakten (1) · detach outbox/run-id-hoisten (1). **ÉN MUTASJON FALSIFISERTE TESTEN FØRST (repoets vakuøs-gate-klasse,
syvende gang):** portefølje-testen asserterte kun at meldingen nevnte `--explore`, og sto GRØNN
uten partisjonen — kjøringen falt da gjennom til «`--explore` requires `--bundle-dir`», som nevner
`--explore` også. To nekter som deler en delstreng er «assert aldri på ordlyd to grener deler»,
fanget av sin egen mutasjon; testen navngir nå `--portfolio`. **Ærlighets-grenser, uttalt:**
multi-base (`Approach.bundle_id`, § C.7) er FORTSATT ikke bygget — `run_project` tar én
`bundle_dir`; utforskningens egne modellkall er UANNONSERTE (annonseringens kontrakt er at en
KOMMISJON erklæres før arbeidet den bestiller, og før `explore()` returnerer finnes ingen —
`exploration_notice` dekker gapet i det sløyfa er ferdig); `BudgetExceeded` ut av `--explore`
tracebacker som den gjør for debatten i dag; og **den HOSTEDE flaten gir ingen innsyn i hva som
formet mandatet** — det er ingen outbox der og intet utforskningsfelt i `_response_payload`, så
ledgeren og de rådgivende dommene når kun CLI-ens artefakt. En bevisst scope-grense, men uttalt,
fordi flatens hele argument er at svaret er etterprøvbart.
- **Multi-base er en PARTISJON, aldri en videre `run_project`-signatur (U4+U13 del 3, § C.7, økt 58):**
planens § C.7 og økt 56s egen ærlighets-grense leste som om leveransen var «`run_project` tar mer
enn én `bundle_dir`». **Den kan ikke det, og nekten er STRUKTURELL:** på bundle-stien avleder
`run_project` FIRE enkeltverdier fra DEN basen — prosjektet (`_project_from_bundle`, som
fail-faster når basens egen `validator-input.json` ikke navngir det forespurte prosjektet),
validatorens stage-0-baseline (S4.0s hele poeng er at gaten er forankret i DETTE prosjektets
kostlinjer), agentenes lesekontekst og ExpeL-nøkkelen — og returnerer ETT stemplet `RunResult`.
En andre katalog på den signaturen ville tvunget et stille velg-en for alle fire, som er den
gjettede-form-klassen repoet nekter. **Planens egen setning sier det samme lest nært:**
«pipelinen kjøres per bundle som i dag (`run_portfolio`-formen)» = N kall, ikke ETT kall med N.
Premisset ble felt FØR bygging; ordren ba selv om nettopp den sjekken. **Konsekvensen er at INGEN
eksisterende kaller endrer signatur** — CLI, hosting og simulation sender fortsatt én base hver,
og kan fortsatt gjøre det. Tre sømmer: (1) `mandate.Approach.bundle_id`, default `""`, så hvert
mandat skrevet før i dag er fortsatt gyldig OG dispatchbart uendret; (2) `mandate.route_by_bundle`
— ren partisjon i `bundle_ids`-rekkefølge (aldri i approach-rekkefølge: spend-ordenen er en
egenskap ved hvordan kjøringen ble konfigurert, ikke ved hvordan en modell tilfeldigvis sekvenserte
hypotesene), **fail-fast på et mandat som ikke kan utføres som skrevet** (`load_mandate`-regelen —
en kjøring skal aldri gå videre på en stille degradert bestilling); (3)
`run.run_mandate_across_bundles` — dispatchen. **Den tar INGEN `project_id`-parameter, og det er
designet:** hver bases prosjekt leses fra DEN basens egen IR-projeksjon, altså nøyaktig verdien
`_project_from_bundle` allerede fail-faster mot, så en kaller-oppgitt konstant kunne uansett bare
vært riktig for én base av N — den eksisterende fail-fasten blir rutingsnøkkelen, og gjetningen
forsvinner. Ett `VerdictStore` trådes på tvers (kryss-base-læring, `run_portfolio`-formen), og
**delt INSTANS er påstanden — ikke lik verdi** (se vakuitets-funnet under). En base ingen approach
navngir kjøres IKKE (en kjøring koster penger, og bestillingen ba om ingenting der); med NØYAKTIG
én base absorberer den alt uten navn, som ikke er en gjetning men det eneste mulige svaret — og
det er dét som holder hvert pre-multi-base-mandat dispatchbart. `explore()` stempler `bundle_id`
på hver MYNTET approach, men **skriver ALDRI om et frø** (§ C.6 dør 1 er en bevaringsregel — å
fylle inn feltet på ekspertens vegne ville satt deres navn på en rutingsbeslutning de ikke tok);
frøene VALIDERES i stedet, **FØR første modellkall** (økt-57-hoisten: ved unntaket alene ser en
nekt etter forbruket identisk ut med en før). En umerket markør med flere baser NEKTES
(`HypothesisParseError`), med én base resolveres den. **Budsjett: de to S3.4-tennene som HAR
mening her** — oppstartsnekt (`BudgetRefused`) og aldri-startet + `budget_stop` +
`not_evaluated`-rader i `MultiBaseResult.unreached`; bølge-reservasjonen har ingen motpart, for
dispatchen er SEKVENSIELL. **Ærlighets-grenser, uttalt:** en base som RAISER propagerer
(collect-and-continue tilhører `run_portfolio`, der kalleren sendte inn en batch uavhengige
prosjekter); uten `portfolio_meter` er taket antall rutede baser × `max_tokens`, hver kjøring
bundet for seg; outboxen er IKKE wiret (N kjøringer trenger N `run_id`-er, og å mynte dem her
ville defaultet en nøkkel repoet krever at en kaller oppgir); og **CLI-en er BEVISST urørt**
§ C.8 ber om ETT nytt kallsted i `run.py` (`--explore`, levert i 57), og et repeterbart
`--bundle-dir` er en NY operatørflate, altså en egen beslutning. Load-bearing MÅLT
(`tests/test_multibase_loadbearing.py`, 22 tester), tolv mutasjoner alle røde mot HELE suiten +
grønn kontroll 1020/5 og golden `demo-transcript.stdout` BYTE-UENDRET
(`ea8c534773acdbe41ae68f2c55724d69aaf8be4f`): detach myntet `bundle_id` (2 røde) · stille
gjennomfall ved >1 base (1) · ukjent id resolvert etter rekkefølge (1) · frø-sjekk etter forbruket
(2 — asserten er på at NULL modellkall skjedde, ikke på unntaket) · ruteren gjetter første base (1)
· uroutbar approach droppet (1) · dispatchen kollapser til én base (5) · `project_id` fra første
base (2) · detach aldri-startet-tannen (1) · `unreached` urapportert (1) · fersk store per base (1)
· detach oppstartsnekten (2). **ÉN MUTASJON FALSIFISERTE TESTEN FØRST (repoets vakuøs-gate-klasse,
åttende gang):** store-testen sammenlignet med `==`, og `VerdictStore` er en pydantic-modell med
VERDI-likhet — tre ulike TOMME stores er alle like, så «fersk store per base» lot HELE suiten stå
grønn. Delt instans er påstanden, så testen asserterer nå på `is`.
- **«Be om svar, BRUKE svarene» er nåbar fra CLI-en, og gaten er den ANDRE halvdelen (F4, økt 63):**
før dette nektet BEGGE operatørflatene `enable_plan_review` (`run.py`, `hosting.py`) og eneste dør
var `explore(..., plan_reviewer=...)` — MÅLT mot kilden, ikke lest ut av reviewens prosa.
`--plan-review` bygger en `terminal_plan_reviewer()` og gir den til den UENDREDE sløyfa: operatøren
vises planen og svarer `approve` eller `revise <hva>`; en revisjon går tilbake til manageren, som
replanlegger og spør IGJEN om den NYE planen. **Diskriminatoren er dét siste** — en dør som printer
planen, leser linja og kaster den består «operatøren ble spurt» og feiler målbildet (repoets
vakuøs-gate-klasse); T1 er derfor bygget som `test_explore_loadbearing`s T15 løftet til CLI-nivå og
er RØD mot en alltid-godkjenn-reviewer. **Vitnet er `{run_id}-exploration.json`, ikke skrapet
stdout:** `trace_payload` bærer alt tre (rekkefølge, beslutning, feedback verbatim) og skrives fra
en `finally`, så den ene kjøringen som mest trenger beviset — den et tak eller en ubesvart review
kappet — etterlater det. **Fail-closed på operatørens EGEN input:** alt utenfor det lukkede
vokabularet spørres på nytt (aldri lest som en beslutning), og **EOF raiser `PlanReviewInputError`**
— å lese stillhet som ja ville latt en autonom sløyfe kjøre på en plan ingen signerte, usynlig.
Strømmene resolveres ved KALL-tid (`shared_root()`-idiomet), ellers svarer reviewer-en fra strømmen
som fantes da den ble BYGGET. **Fire nekter, alle ved navn**, hvorav to lukker et stille dropp
ingen test dekket: `report_forbidden` (report-modus returnerer FØR hver utforsknings-nekt) og
portefølje-partisjonen. De to konfig-avhengige nektene DELER tokenet `enable_plan_review` og har
derfor bevisst ULIK særtekst («no reviewer was offered» / «no review is ever requested») — den
eksisterende testen asserterte på det delte tokenet og er rettet (økt-57-mutasjonen, niende gang).
**Hosting NEKTER fortsatt, og det er en beslutning:** reviewen er synkron, så den ville blokkert
HTTP-requesten på et menneske OG event-løkka som svarer `/readiness` — meldingen navngir nå
CLI-døra i stedet for å påstå at biblioteket er den eneste (Fase 3-klassen). **Mid-løp-spørsmål er
IKKE bygget, og fraværet er MÅLT:** `_magentic.py` har nøyaktig ETT `ctx.request_info` (`:1044`,
plan review) i hele modulen, så stacken kan ikke levere et spørsmål midt i løpet uten en ny
emitter. Reviewens «kun plan-review FØR løpet» er derimot upresist: samme forespørsel fyrer også
ved re-plan etter en stall (`is_stalled=True`), så døra ER nåbar midt i en kjøring på den ene
måten stacken støtter. Load-bearing MÅLT (`tests/test_plan_review_cli_door_loadbearing.py`, 12
tester), elleve mutasjoner alle røde mot HELE suiten + grønn kontroll 1040/5 og golden
`demo-transcript.stdout` BYTE-UENDRET (`ea8c534773acdbe41ae68f2c55724d69aaf8be4f`): detach
`plan_reviewer`-wiringen (5 røde) · EOF blir en godkjenning (1) · alltid-godkjenn (3) · alt som
ikke er en revisjon blir en signatur (1) · dropp `--plan-review` fra portefølje-partisjonen (1) ·
dropp den fra `report_forbidden` (1) · detach `--plan-review requires --explore` (1) · detach
review-uten-reviewer-nekten (2, hvorav én i en test som fantes fra før) · detach
reviewer-ingen-spør-nekten (1) · hostet nekt beholder påstanden fra før F4 (1) · strømmene fanget
ved bygge-tid (1).
- **Plan-reviewen kan besvares over DAGER, og det eneste som krysser prosessgrensen er DISK (U12 +
asynkron U13, planens § D.2 rad 3, økt 64):** F4 gjorde «be om svar, BRUKE svarene» nåbar, men
bare SYNKRONT — `terminal_plan_reviewer` blokkerer løkka på et menneske ved en terminal, så
svaret må komme mens prosessen lever. `--checkpoint-dir` PARKERER i stedet reviewen
(`FileCheckpointStorage` + `{run_id}-plan-review.json`), og `--resume <run_id>` leser svaret fra
`--review-inbox` i en prosess som ALDRI så kjøringen. **MÅLT FELLE (ansikt 4):**
`list_checkpoints` (`_checkpoint.py:386-388`) svelger en blokkert deserialisering til en
`logger.warning` og returnerer TOM liste — uten BEGGE
`MagenticPlanReviewRequest`/`…Response` i `allowed_checkpoint_types` feiler en resume som et
FRAVÆR, ikke som en feil, og en test som asserterte «listingen er tom, altså er det ingenting å
gjenoppta» ville vært GRØNN mot nøyaktig den defekten. `_ALLOWED_CHECKPOINT_TYPES` har derfor ÉN
kopi og `checkpoint_storage` er ENESTE konstruksjonssted (BEGGE prosesser må deklarere dem; en
andre kopi er kø-(p)-driften). **Vi er LOUDERE enn rammeverket der det tier:** en tom listing ved
park raiser `CheckpointUnreadable` i stedet for å skrive et spørsmål ingen kan besvare.
**Diskriminatoren er den ANDRE halvdelen:** en dør som skriver en spørsmålsfil og en resume som
leser en svarfil består begge «eksperten ble spurt» — så måltesten krever at et `revise` skrevet
dag 1 får manageren til å REPLANLEGGE og stille et NYTT spørsmål (indeks 1, nytt `request_id`) i
en fersk interpreter, med en approve-kontroll som beviser at døra også kan AVSLUTTE (en gate som
bare kunne parke igjen er en hengning i løkkeklær). **Budsjettet og revisjons-capen spenner over
suspensjonen:** `meter.charge(parked.tokens_spent)` (gjennom `charge`, ikke ved å sette `tokens`
— ladingen re-tester taket) og `trace.ledger.extend(parked.ledger)`, ellers får hver park et helt
budsjett på nytt: S3.4-klassen, ubundet forbruk under vakter som alle ser tilfredse ut. En
`revise` koster to manager-kall, emitterer null ledger og bruker null runde (§ F, A3), så
`max_plan_revisions` er det ENESTE båndet på den. **Fail-closed på ekspertens EGEN fil:**
`request_id`-mismatch, ord utenfor vokabularet og `revise` uten innhold refuseres alle ved navn.
`hitl.pending_plan_reviews` er registeret over hvem som fortsatt venter — tolerant på LESE-siden,
fail-closed på BESLUTTE-siden, og joinen er på `request_id` i BEGGE ender (to distinkte sømmer,
MÅLT: hver har sin egen mutasjon og sin egen røde test). Load-bearing MÅLT
(`tests/test_async_plan_review_loadbearing.py`, 17 tester), tretten mutasjoner alle røde mot HELE
suiten + grønn kontroll 1059/5 og golden `demo-transcript.stdout` BYTE-UENDRET
(`ea8c534773acdbe41ae68f2c55724d69aaf8be4f`): tom `_ALLOWED_CHECKPOINT_TYPES` (11 røde) · park
uten checkpoint (1) · resume alltid-approve (2) · tolerant `request_id` (1) · tolerant vokabular
(1) · `revise` uten feedback (1) · detach `meter.charge` (1) · detach
`trace.plan_reviews.extend` (1) · detach ledger/hypotese-carry-overen (1) ·
`pending_plan_reviews` ignorerer `request_id` (1) · detach to-dører-nekten (1) · detach
outbox/run-id-hoisten (1) · detach `--resume`-armen i `required_scripted_roles` (4 — MAJOR-2s
`KeyError: 'navigator'` på den andre flaten som bygger en utforskning).
**ÉN MUTASJON FALSIFISERTE SUITEN (repoets vakuøs-gate-klasse, TIENDE gang):** `trace.plan_reviews
.extend(parked.plan_reviews)` kunne detaches med HELE suiten grønn (1058/5) — capen leser
`parked.plan_reviews` DIREKTE, så den binder uansett, og de to første legene er identiske under
begge implementasjoner. Gaten måtte derfor bli det TREDJE leget, der artefaktet ellers taper dag
1s revisjon og to ULIKE planer deler indeks 1; den nye testen er rød mot mutasjonen og alene.
**Ærlighets-grenser, uttalt:** den hostede flaten NEKTER fortsatt (en synkron review ville
blokkert både requesten og event-løkka som svarer `/readiness`); en park MIDT i løpet (etter en
stall) har ingen nåbar sti under det skriptede manuset, så carry-overen som betjener den drives
gjennom en CRAFTED parkert tilstand (`budget_stop`-presedensen); og resume-legets
`PlanReviewParked` er et NORMALT utfall, ikke en feil.
- **Katalogkallet koster O(BASER), aldri O(KORPUS) — og det er stigens billigste trinn, ikke dens
dyreste (ordre `20260825T213645Z`, økt 65):** `list_bundles` returnerte hele rot-indeksens body
for HVER konfigurert base samtidig, pluss ett JSON-objekt per ufulgt kryss-lenke. Begge vokser med
korpuset, så prisen på å finne ut *hvilke baser som finnes* ble satt av hvor mye de *inneholder*
progressiv disclosure snudd på hodet (målbilde §2/§4). **MÅLT med `o200k_base`, instrumentet først
validert mot commons' egne fasittall:** 112 116 tokens over tre flate Vegnormal-baser, og
**124 942 over de 171 grenbasene** som erstattet dem — grenformen (`vegnormal-okf` `8145c23`)
lukket bundle-siden (82…92 % på `read_bundle`) og gjorde katalogsiden VERRE, nøyaktig som det
repoet forutså. Etter: **362** og **21 448** (per base 37 372 → 121 og 731 → 125).
**Et premiss ble felt FØR noe ble bygget på det:** «indeksbodyen forteller hva basen handler om»
er USANT for maskin-importerte baser — grenbasenes `index.md` har verken frontmatter eller prosa,
den er en ren lenkeliste (målt: 959 bytes, første tegn `-`), så feltet var dyrt OG innholdsløst
der. **Fast vindu, aldri en andel av basen** (`_CATALOGUE_EXCERPT_CHARS = 200`): en andel skalerer
med korpuset igjen, bare med mindre konstant. **Avkorting ANNONSERES som FELT**
(`index_truncated` ved siden av utdraget, aldri en markør limt inn i det — `BudgetExceeded`s
kø-(y)-regel), og en base som PASSER blir ikke merket avkortet og får hele bodyen: omisjon, aldri
en løgn i noen av retningene. **En ufulgt lenke overlever som ANTALL** — økt 51s «et hopp er
tolerert, men ikke lenger taust» står, mens per-lenke-detaljen blir liggende der den er
handlingsbar (`RunResult.skipped_links` / `DryRunReport.skipped_links`) og ikke rir med i et kall
hvis hele jobb er å være billig. Hele indeksen er fortsatt ETT `read_file(id, "index.md")` unna —
et disclosure-nivå, ikke datatap. **Taket (500 tegn/base) bor i TESTEN, ikke i `explore.py`:** en
test som importerte implementasjonens budsjett ville flyttet seg med det, og å heve budsjettet er
nøyaktig regresjonen gaten finnes for. Load-bearing MÅLT
(`tests/test_catalogue_cost_loadbearing.py`, 7 armer), **ni mutasjoner alle røde mot HELE suiten**
+ grønn kontroll 1066/5 og golden `demo-transcript.stdout` BYTE-UENDRET
(`ea8c534773acdbe41ae68f2c55724d69aaf8be4f`): ingen binding (4 røde) · bundet men vakuøst (2) ·
stille kutt (1) · over-annonsert (1) · per-lenke-lista rir med igjen (1) · det ufulgte faktumet
slettet (1) · suffiks i stedet for ordrett prefiks (2) · en andel i stedet for fast vindu (2) ·
`documents` gjort konstant (1). **M9 ble kjørt fordi `documents` var et felt uten gate** — et felt
ingen test kan se, råtner. **MAJOR-1 var IKKE nødvendig:** bindingen sitter i verktøykroppen bak
en uendret CLI-flate. Ærlighets-grenser, uttalt: `navigate_bundle` kalles fortsatt per base per
katalogkall (I/O og veggklokke, ikke tokens — ikke målt her); ingen LEVENDE modell har kalt det
nye verktøyet, så at en manager velger BEDRE med et utdrag enn med hele indeksen er ikke bevist
(structured-output-grensens klasse); og ordrens nevner for N100:2023 var 34 mens disken viser 40 —
tallene bruker den målte nevneren. Måling: `docs/2026-08-26-katalogkostnaden.md`.
- **Ekspertdommen kan ikke oppstå av STILLHET, og fraværet er en FØRSTEKLASSES tilstand (F2,
non-goal 3, økt 66):** `run_project` KREVDE `verdict_input` og kjørte `capture_verdict`
ubetinget, CLI-en defaultet det til `{"approved", "reviewed by expert"}`, og hosting listet det
som PÅKREVD. Netto: hver flaggløs kjøring myntet en ekspertgodkjenning ingen ga, den gikk inn i
den delte storen, og `run_portfolio` bar den inn i neste prosjekts hypotese-prompt som en
*prior expert verdict* — på flaten som ble overlevert 14.08. **`RunResult.verdict` er nå
`Verdict | None`**, og `None` er hva stillhet produserer: ingenting myntes, ingenting lagres,
ingenting varsles. Prinsippet sto allerede skrevet i repoet — `RunFailure`s docstring: å fylle et
felt med en dummy legger FABRIKKERT proveniens inn i aggregatet. **Traceability koster ingenting,
fordi nøkkelen DERIVERES fra kandidaten:** `RunResult.verdict_key` (property, ikke lagret felt —
en andre kopi av en nøklingsregel er kø-(p)) er `verdicts.verdict_key`s alt dokumenterte formål,
identisk med `verdict.id` når en dom BLE gitt, og fortsatt meningsfull når ingen ble det; det er
den outboxen og den hostede responsen stempler, så et artefakt fra en ukommentert kjøring er
fortsatt dømbart og joiner tilbake via Steg-7-innboksen. **Halv dom NEKTES på begge dører**
(`FeedbackContract` er ENESTE sted formen valideres, og CLI-en nekter ved navn FØR enhver
mode-dispatch): den manglende halvdelen er ekspertens å skrive, aldri vår å defaulte — validering,
ALDRI reparasjon (`write_concept_file`-presedensen). **Hosting er WIDENING, ikke bryting:**
`verdict_input` flyttet `_REQUIRED_FIELDS``_OPTIONAL_FIELDS`, så hvert kall som finnes ute
virker uendret; en kaller som utelot det fikk før 400 på et felt som ikke KUNNE fylles ærlig.
**De to mode-partisjonene fikk `--decision`/`--rationale` inn — og det er en KONSEKVENS, ikke
scope-krype:** kommentarene på begge stedene sa ordrett at en ærlig nekt var *uimplementerbar*
fordi de non-None argparse-defaultene gjorde en eksplisitt verdi uskillbar fra defaulten. Med
defaultene borte er den implementerbar, og «refused, never ignored» er partisjonens egen regel.
`run.verdict_notice` er ENESTE renderer og leser dommen av kjøringens EGET stempel, ikke av argv.
**Ærlighets-grense, uttalt:** referanse-fixturens SYNTETISKE `verdict_input`-rader står URØRT —
de er merket SYNTETISK på fire steder og er reviewens F5 (måling av misjonspåstanden), ikke F2;
`Project.verdict_input` er nå valgfri, så en rad UTEN dom er lovlig. Load-bearing MÅLT
(`tests/test_ungiven_verdict_loadbearing.py`, 15 armer), **åtte mutasjoner alle røde mot HELE
suiten** + grønn kontroll 1080/5 og golden `demo-transcript.stdout` BYTE-UENDRET
(`ea8c534773acdbe41ae68f2c55724d69aaf8be4f`): detach fangst-gaten (5 røde) · gjeninnfør
argparse-defaultene (22) · hosting krever fortsatt feltet (1) · CLI-en REPARERER en halv dom (2) ·
kontrakten reparerer en halv dom (1) · `verdict_key` lest av dommen i stedet for derivert (2) ·
begge partisjons-radene fjernet (2) · rendereren påstår en dom som aldri ble gitt (2). **ÉN
MUTASJON FALSIFISERTE TESTEN FØRST (repoets vakuøs-gate-klasse, ELLEVTE gang):** `--report`-armen
brukte et bart `--report`, som nekter med rc 1 uansett fordi `--ledger` mangler — testen sto
GRØNN med partisjons-raden fjernet. Den kjører nå mot en argv report-modus ellers ville AKSEPTERT
(gyldig `--ledger` + en kontroll som beviser rc 0 uten flaggene), så rc 1 er mutantens motsatte
utfall. **Migreringsnote:** ingen ekstern kaller brekker — hosting utvider, CLI-ens gamle
flaggform er uendret, og det som ENDRER seg er at en flaggløs kjøring nå SIER at ingen dømte i
stedet for å påstå `decision=approved`.
- **STATE.md er local-only** (gitignored). Voyage session-state er efemert; STATE.md er kanonisk kontinuitet. - **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. - Prosess: Voyage-plugin (`/trekbrief → /trekplan → /trekexecute → /trekreview`) per større fase.

232
DEPLOY.md Normal file
View file

@ -0,0 +1,232 @@
# Deploying and using this package
This archive is a complete, self-contained copy of `portfolio-optimiser`. You do not need to clone
anything or have an account anywhere to deploy it. Unzip it and follow this document.
It is written in English because its readers are implementing developers and platform operators.
The two documents that describe the *human* side of the loop are written in Norwegian and ship in
this same archive: [`docs/bestille-en-kjoring.md`](docs/bestille-en-kjoring.md) (ordering a run) and
[`docs/ekspert-svar.md`](docs/ekspert-svar.md) (giving the verdict).
---
## 1. What this is — and what it is not
It finds cost savings **inside** a single project: agents propose candidate measures, a
**deterministic validator** decides whether the numbers hold, a second agent (the "checker") gates
the *reasoning*, and a domain expert gives the final verdict. The system learns from those verdicts.
**There is no chat interface, and that is a design decision rather than a missing feature.**
The hosted surface is a task API — `POST /invocations` with a JSON body, `GET /readiness` — not a
conversation. The system is deliberately **not** exposed as a Microsoft Foundry agent via
`as_agent()`, because the validator, the cost-baseline anchoring, the checker gate and the savings
ledger all live *outside* the agent graph. Wrapping it as a chat agent would route requests around
precisely the components that make an answer trustworthy, and you would get a fluent assistant
instead of a validated result.
The dialogue with humans happens in two places instead, both file-shaped and both auditable:
- **before** a run, through a mandate file (what should be considered), and
- **after** a run, through a verdict file (what a qualified person decided).
If you want an interactive front end, build it on top of `POST /invocations` — but keep the verdict
step human. An approval loop that a model can close by itself is not an approval loop.
---
## 2. Who does what
The package has three distinct audiences. Only the first one is technical.
| Role | What they do | Where it is documented |
|---|---|---|
| **Platform operator** | Installs the package, sets two environment variables, starts the service, verifies with the offline preflight. One-time work. | This document, sections 36. |
| **Ordering domain expert** (*bestiller*) | Writes the mandate file: the objective, which approaches are worth trying, and why. The *why* is the part the system cannot read out of cost figures. | `docs/bestille-en-kjoring.md` |
| **Reviewing domain expert** (*fagperson*) | Receives a validated proposal and answers `approved` or `rejected` with a written rationale. That verdict is what the system learns from. | `docs/ekspert-svar.md` |
**The ordering expert steers what is *considered*, never what is *approved*.** If you ask for
something the numbers do not support, the deterministic validator rejects it and the rejection comes
back with its reason. That property is the point of the whole design; do not build around it.
---
## 3. The process, end to end
1. **Prepare a knowledge base** for the project — cost documents, technical sources, prior
experience — as a document bundle. A working five-document example ships in
`shared/examples/bygg-energi-mikro`, and `docs/knowledge-base-recipe.md` describes how to build
your own.
2. **Order a run** with a mandate file (optional but recommended). See
`docs/bestille-en-kjoring.md`.
3. **Run it** — from the CLI, or by `POST /invocations` against the running service (section 6).
The run navigates the knowledge base, debates candidate measures, validates the numbers against
the project's actual cost baseline, and returns either a **validated proposal** or a
**rejection with a reason**. A rejection is a successful run: the negative outcome belongs to the
answer, not to the transport, and the API returns HTTP 200 for it.
4. **A domain expert judges the outcome** and writes a verdict — either inline during the run, or as
a JSON file dropped into an inbox directory afterwards, possibly days later. See
`docs/ekspert-svar.md`.
5. **Approved verdicts can be promoted into the knowledge base**, so the next run reads them as
context and does not re-propose something the organisation has already rejected. Promotion is
gated: only an approved verdict can be promoted, and raw agent output never enters the knowledge
base on its own.
Steps 4 and 5 are what make this a learning loop rather than a report generator.
---
## 4. Prerequisites in your own tenant
You need, in Microsoft Foundry:
1. A **Foundry resource** with project management enabled and a **project** inside it. A Foundry
project is required — an Azure OpenAI resource (`kind: OpenAI`) is a different client surface and
will not work.
2. At least one **model deployment** (any current chat model; a small one is enough).
3. The **Foundry User** role (role definition id `53ca6127-db72-4b80-b1b0-d745d6d5456d`) for the
identity that will run it, assigned at project scope. Azure `Owner` and `Contributor` grant
management permissions only, **not** the data-plane permissions this needs.
---
## 5. Configuration — the two variables that matter, and one that is optional
**5.1 The project endpoint.** Resolved as the first non-empty of:
```
PORTFOLIO_FOUNDRY_PROJECT_ENDPOINT # yours; wins if set
FOUNDRY_PROJECT_ENDPOINT # injected by the Foundry hosting platform
```
Where something injects the second one for you — the Foundry hosting platform does — you need to
set nothing. Everywhere else (a laptop, a pipeline, your own runtime) set the first. The value looks
like `https://<resource>.services.ai.azure.com/api/projects/<project>`.
**5.2 The model map — this is the one that catches people.** The packaged
`src/portfolio_optimiser/data/model_map.json` ships **placeholders**:
```json
"azure": { "default": "REPLACE-WITH-FOUNDRY-DEPLOYMENT", ... }
```
Deployment names are tenant-specific, so they are deliberately never committed. The code
**fail-fasts** on a `REPLACE-WITH-` value rather than guessing. Point `PORTFOLIO_MODEL_MAP` at your
own file:
```json
{
"local": { "default": "qwen3:4b", "proposer": "qwen3:4b", "checker": "qwen3:4b" },
"azure": { "default": "my-deployment", "proposer": "my-deployment", "checker": "my-deployment" }
}
```
```bash
export PORTFOLIO_MODEL_MAP=/path/to/model_map.json
```
If you skip this, the service starts and answers `/readiness` — and fails every invocation. That
is the single most likely first-deployment failure, which is why it has its own section.
**5.3 Tracing — optional, and off until you ask.** `PORTFOLIO_OTEL` is the only switch:
```bash
export PORTFOLIO_OTEL=console # OpenTelemetry spans on STDERR — in a container, that is your log
```
Unset, no OpenTelemetry provider is configured at all: the spans are still created and then
discarded, so nothing can leave the process. `console` writes them to stderr and leaves stdout
byte-for-byte as it was, so you can trace a run without changing what the run prints. `otlp` sends
them to a collector, and only to an endpoint you name yourself:
```bash
export PORTFOLIO_OTEL=otlp
export OTEL_EXPORTER_OTLP_ENDPOINT=http://your-collector:4317
pip install opentelemetry-exporter-otlp-proto-grpc # not a declared dependency — egress is yours to opt into
```
Asking for `console` while an `OTEL_EXPORTER_OTLP_*_ENDPOINT` variable is set is **refused**, not
quietly honoured: those exporters are constructed unconditionally by the framework underneath, so
the run would have shipped its contents to the collector while calling itself console-only. The
service names its tracing destination on stderr at startup, before the first span exists.
**5.4 Verify before you pay for a model call:**
```bash
uv run python -m portfolio_optimiser.preflight --profile azure
```
Exit code 0 means every offline-checkable precondition holds: endpoint shape, model-map structure,
no placeholders left. It is **necessary but not sufficient** — RBAC (403), token or tenant problems
(401) and a well-formed but non-existent deployment (404) can only surface at the live call.
---
## 6. Running it
**This package is Python and nothing else.** There is no image to build and no platform-specific
deployment manifest — that was removed deliberately after an external trial, because it forced a
container toolchain on receivers who only needed to run the code. How the process is supervised,
packaged or placed in your environment is your decision, and this package does not pre-empt it.
Install the locked environment and start the service:
```bash
uv sync --frozen --no-dev
uv run python main.py
```
`uv sync --frozen` installs the exact resolution every measurement in this repository ran against —
never a fresh resolve, which picks other versions. Two dependencies are git-tag-pinned direct
references, so **`git` must be on PATH** for the install to work.
`main.py` serves port 8088 (override with `PORT`), answers `GET /readiness`, accepts
`POST /invocations`, and exits cleanly on SIGTERM. For a single run without the service, the CLI is
the same code path:
```bash
uv run portfolio-optimiser --help
```
**The invocations payload** is whitelisted against the run signature — unknown fields are refused by
name with HTTP 400, never silently ignored:
| Field | Required | Notes |
|---|---|---|
| `project_id` | yes | The project to analyse. |
| `docs_dir` | yes | Source documents. |
| `verdict_input` | no | An expert verdict for this run, `{"decision", "rationale"}`, **when a human actually gave one**. Omit it otherwise — no verdict is then recorded and the response's `verdict_id` is simply the key a later verdict on this candidate will arrive under. Supplying only one of the two keys is refused (400). |
| `bundle_dir` | no | Knowledge base to navigate. |
| `profile` | no | Defaults to `azure` on this HTTP surface. |
| `max_rounds` | no | Debate round cap. |
| `max_tokens` | no | **Per-invocation token ceiling.** Set it. The built-in default is 100 000 tokens per run, which is a ceiling nobody chose for your workload. |
| `top_k` | no | Retrieval breadth. |
HTTP 400 means the request was malformed; 500 means the run itself failed, with `error_type` and
`error` in the body. A rejected proposal is **200** — see section 3.
**429 is not a failure.** It means the run reached its own allowance — the round cap or the token
cap — and stopped, which is the cap doing its job rather than anything breaking. The body carries
the ledger's own numbers, so you can tell *which* cap bound and how far past it the run got:
```json
{"budget_exhausted": {"kind": "rounds", "limit": 12, "observed": 13},
"error": "budget exceeded: rounds limit=12 observed=13"}
```
There is no `error_type` (nothing failed) and no `Retry-After` (waiting changes nothing). Retrying
the same body hits the same cap; the remedies are raising `max_rounds`/`max_tokens` for that
invocation, or accepting that this project did not resolve within the allowance you set.
---
## 7. Honest limits
- **This framework has never been run against a live model in our environment.** Everything above is
measured against tests and an offline simulation. The first live run in *your*
tenant is the first live run, full stop.
- The system is a **technical framework**. Data protection assessments, lawful basis and risk
ownership belong to whoever deploys it — see `README.md` and `SECURITY.md`.
- Readiness does not respond during CPU-bound stretches of a run (the optimisation solve).
- Chunked request bodies are not supported.
- Verdict promotion writes to the knowledge base non-atomically; it assumes a single writing process.

331
README.md
View file

@ -12,6 +12,31 @@ generates candidate measures; a **mandatory deterministic validator** (solver +
decides the numbers; domain experts judge the outcomes (human-in-the-loop); and the system decides the numbers; domain experts judge the outcomes (human-in-the-loop); and the system
**learns from their verdicts** across runs. **learns from their verdicts** across runs.
> **Solo-maintained, fork-and-own.** This is a starting point, not a vendor product. One
> maintainer, no SLA, MIT licensed. Issues are welcome as signals; pull requests are not accepted.
> See the [organisation governance](https://git.fromaitochitta.com/open/repo-standard/src/branch/main/GOVERNANCE.md)
> for the full model — including what to adopt instead if you need vendor accountability.
*AI-generated: all code produced by Claude Code through dialog-driven development, with human
review, test and judgement before anything ships.*
A short visual introduction — 12 slides, in Norwegian, for a general audience — ships with the
repo: open [docs/kort-presentasjon.html](docs/kort-presentasjon.html) in any browser.
## Table of Contents
- [Install](#install)
- [Walk the whole chain offline](#walk-the-whole-chain-offline)
- [Non-goals](#non-goals)
- [Built on an LLM wiki: Karpathy's idea, Google's format](#built-on-an-llm-wiki-karpathys-idea-googles-format)
- [AI-first, humans on top](#ai-first-humans-on-top)
- [How it works](#how-it-works)
- [How it is set up](#how-it-is-set-up)
- [What this enables](#what-this-enables)
- [The task API — runnable Python, no wrapper](#the-task-api--runnable-python-no-wrapper)
- [Docs](#docs)
- [Stack & develop](#stack--develop)
## Install ## Install
Python ≥3.10, with [`uv`](https://docs.astral.sh/uv/). The package is not published to a package Python ≥3.10, with [`uv`](https://docs.astral.sh/uv/). The package is not published to a package
@ -23,8 +48,34 @@ cd portfolio-optimiser
uv sync uv sync
``` ```
Clone rather than install into an existing environment: the shared spec, the persona skill and the The clone is the documented path because the walkthrough below points at files in the tree. It is
example bundles under [`shared/`](shared/README.md) are read from the working tree at run time. no longer a technical requirement for the DATA: a built wheel carries the shared spec, the persona
skill and the example bundles under [`shared/`](shared/README.md) as packaged data, and the
resolver prefers a working tree when one is present (`PORTFOLIO_SHARED_ROOT` overrides both) — so
an installed distribution finds its knowledge without a checkout.
### Installing a built wheel
A wheel is **not installable on its own**, and the failure is a resolver error rather than a
missing file. Two dependencies are pinned to git tags, and `[tool.uv.sources]` is uv configuration
that does not travel with wheel metadata — so the wheel names `llm-ingestion-okf` and
`llm-ingestion-guard` as bare names that no package index can resolve. Supply the two requirements
alongside the wheel (measured: 65 packages, exit 0):
```bash
uv pip install portfolio_optimiser-1.1.0-py3-none-any.whl \
"llm-ingestion-okf @ git+https://git.fromaitochitta.com/open/llm-ingestion-okf.git@v0.3.2" \
"llm-ingestion-guard @ git+https://git.fromaitochitta.com/open/llm-ingestion-pipeline-security.git@v0.3.4"
```
Both are tag-pinned deliberately: they are security components, and a version that can move under
an install is a gate that can stop gating without a local diff. `uv sync` from a clone reads the
pins from `pyproject.toml`, which is why the source path above needs none of this.
`uv sync` installs two commands: `portfolio-optimiser` (the CLI) and `portfolio-optimiser-demo`
(the offline end-to-end proof). They are equivalent to the `python -m portfolio_optimiser.run` and
`python -m portfolio_optimiser.simulation` forms used throughout this README, which keep working —
the module form is spelled out below so a reader can see which module answers a given command.
Verify the install by running the whole suite from the clean clone: Verify the install by running the whole suite from the clean clone:
@ -54,11 +105,24 @@ ls shared/examples/bygg-energi-mikro/
demonstrably informed by the first: demonstrably informed by the first:
```bash ```bash
uv run python -m portfolio_optimiser.simulation uv run python -m portfolio_optimiser.simulation # or: uv run portfolio-optimiser-demo
``` ```
The trace ends with the approved verdict's marker present in Run B's prompt and absent from Run A's Each run shows the refinement step: the proposer's first claim is falsified by the deterministic
— knowledge crossing runs purely through the file-backed wiki (promote → re-seed → fold). validator, and the corrected claim validates. Between the runs, knowledge travels back on **both
feedback timescales, each carrying its own marker** so neither can stand in for the other: the
persona's approved verdict is promoted into the file-backed wiki (promote → re-seed → fold), and a
second verdict — an operations note an expert drops into an inbox folder *after* the run, the way a
reviewer would days later — is merged from disk into the next run's store before its hypothesis is
formed (write file → merge → fold). Both markers are present in Run B's prompt and absent from
Run A's. Nothing crosses in memory.
The run is **anchored**: the demo copies the example knowledge base and adds a `cost-baseline.json`
— the project's actual cost lines — so the validator's first stage reconciles every cost line a
proposal claims against them, before the solver runs. The declared baseline is printed at the top,
because it is the one property the rest of the output looks identical with or without. Those
numbers are synthetic, derived from the scripted replies themselves; a knowledge base that ships
its own baseline replaces them through the same seam, with no code change.
**3 — Run the loop over a knowledge base, with answers you supply.** Write the stand-in replies, **3 — Run the loop over a knowledge base, with answers you supply.** Write the stand-in replies,
then point the CLI at the bundle: then point the CLI at the bundle:
@ -90,11 +154,28 @@ project's own numbers cannot support, no matter how confidently the proposer ass
the part of the method that carries the weight — the agents propose, and something that cannot be the part of the method that carries the weight — the agents propose, and something that cannot be
argued with decides. argued with decides.
Read that summary line carefully: `Rejection (verdict id=…, decision=approved)` is not a Read that summary line carefully. Nobody reviewed this run, so it says exactly that:
contradiction. `Rejection` is the **validator's** outcome, while `decision=` echoes the `Rejection (no expert verdict given; verdict key=…)`. `Rejection` is the **validator's** outcome;
**human's** recorded verdict — here the `--decision` default, since nobody reviewed this run. the second half is about the **human**, and there was no human here. The key it quotes is the id
The two are deliberately separate: a machine gate that blocks, and a human judgement that under which an expert verdict on this candidate will arrive later — your join back into the
approves, are different questions and are never collapsed into one field. [expert-answer channel](docs/ekspert-svar.md).
Record one and the line changes:
```bash
uv run python -m portfolio_optimiser.run BYGG-KONTOR-NORD \
--docs-dir shared/examples/bygg-energi-mikro \
--bundle-dir shared/examples/bygg-energi-mikro \
--scripted-replies replies.json \
--decision approved --rationale "the retrofit is within scope"
```
Now it reads `Rejection (verdict id=…, decision=approved)`, which is not a contradiction: a machine
gate that blocks and a human judgement that approves are different questions and are never
collapsed into one field. The two flags go together or not at all — half a verdict is refused by
name, because the missing half is the expert's to write and never ours to default. Until 1.1.0
`--decision` defaulted to `approved`, so every flagless run recorded an approval nobody gave and
carried it into the next project's hypothesis; that default is gone.
**5 — See what it would cost with a real model**, before spending anything: **5 — See what it would cost with a real model**, before spending anything:
@ -197,13 +278,24 @@ per amount, before anything is summed.
- **Not a model benchmark.** The end-to-end proof runs offline against a scripted stand-in client: - **Not a model benchmark.** The end-to-end proof runs offline against a scripted stand-in client:
it shows that the loop closes, not how well a given LLM proposes or judges. it shows that the loop closes, not how well a given LLM proposes or judges.
> **Status:** the full 8-step agentic loop is wired and proven with load-bearing tests, and the > **Status:** the full 8-step agentic loop is wired and proven with load-bearing tests. The
> end-to-end proof is an **offline simulation** with a scripted stand-in client — no live-model > end-to-end proof is primarily an **offline simulation** with a scripted stand-in client, but one
> run yet. The **ingest layer** (real data sources) is implemented — file/CSV and SQL on both > **live run** against a real endpoint (`gpt-4.1-mini`, 2026-08-14) has also completed: it ended
> stacks with bit-identical golden extractions from the shared spec, plus HTTP as a MAF-only > in a correct `rejected` outcome — the deterministic validator caught a cost line the model had
> demonstrated extension point against a local mock — but exercised only against committed > invented outright (a code absent from the knowledge base), on the tolerance gate rather than the
> fixtures: no bundle has yet been materialized from a live source. A sibling implementation of > stricter existence gate, because the bundle it ran against ships no cost baseline to anchor
> the same method on the **Claude Agents SDK** is built in parallel from the same shared spec. > against. No run has yet produced a **validated** proposal against a live model, and every
> human-in-the-loop verdict currently seeded into the knowledge base is a synthetic, AI-authored
> seed marked as such — no genuine expert verdict has entered the tree yet. The **ingest layer**
> (real data sources) is implemented — file/CSV and SQL on both stacks with bit-identical golden
> extractions from the shared spec, plus HTTP as a MAF-only demonstrated extension point against a
> local mock — but exercised only against committed fixtures: no bundle has yet been materialized
> from a live source. Ingested content passes a **content gate** (`materialize_gated`) that scans
> every generated concept with
> [`llm-ingestion-guard`](https://git.fromaitochitta.com/open/llm-ingestion-pipeline-security)
> before any of it reaches the bundle; a refused run writes nothing. A sibling implementation of
> the same method on the **Claude Agents SDK** exists in a separate repository but is currently
> **parked**, not developed in parallel.
> **Disclaimer — technical framework only.** Deploying organizations own their processing > **Disclaimer — technical framework only.** Deploying organizations own their processing
> purposes and assessments (DPIA, risk/ROS, security review). The framework ships the technical > purposes and assessments (DPIA, risk/ROS, security review). The framework ships the technical
@ -233,6 +325,13 @@ cross-links, with progressive disclosure — never by keyword retrieval or stuff
bundle into a prompt. Query-time retrieval against the bundle is explicitly forbidden by the bundle into a prompt. Query-time retrieval against the bundle is explicitly forbidden by the
method spec: it would leak the verdict layer around the learning gate. method spec: it would leak the verdict layer around the learning gate.
A cross-link that cannot be followed is **tolerated** (OKF SPEC §4 — navigation never raises) but
no longer **silent**: each one is recorded on `Bundle.skipped` with the file it was written in, the
link text verbatim, and which of the two reasons applied (`missing` — resolves inside the bundle
with no readable file there; `outside-bundle` — resolves outside the bundle root). `--live-dry-run`
and a full run both print the list, and print nothing when every link was followed — so a bundle
that was only half read stops looking like a bundle that was simply smaller.
## AI-first, humans on top ## AI-first, humans on top
A traditional wiki is built for *people* — optimized for humans finding and reading A traditional wiki is built for *people* — optimized for humans finding and reading
@ -262,7 +361,9 @@ One run, one project, eight steps — with the learning loop closing across runs
is anchored to the project's declared cost baseline, so a proposal cannot invent the cost is anchored to the project's declared cost baseline, so a proposal cannot invent the cost
lines it claims to save against. lines it claims to save against.
5. **Refine** — a rejected attempt retries *informed* by the rejection reason, under hard 5. **Refine** — a rejected attempt retries *informed* by the rejection reason, under hard
attempt and token caps. Unbounded loops are forbidden everywhere. attempt and token caps. Unbounded loops are forbidden everywhere. The falsifications that
informed a later attempt are surfaced on the result (`RunResult.refinements`), so what the
run corrected in response to is inspectable, not just what it ended up with.
6. **Propose or discard** — a validated proposal with risk percentiles, or a typed rejection. 6. **Propose or discard** — a validated proposal with risk percentiles, or a typed rejection.
7. **Expert feedback** — days later, an expert drops a verdict file in an inbox folder; a 7. **Expert feedback** — days later, an expert drops a verdict file in an inbox folder; a
later run picks it up. Fully resumable; no live session assumed. later run picks it up. Fully resumable; no live session assumed.
@ -287,6 +388,30 @@ when the seam is detached, so the loop cannot silently degrade into theater.
extension point) via a deterministic, schema-validated manifest that runs *before* the loop is extension point) via a deterministic, schema-validated manifest that runs *before* the loop is
implemented and exercised against committed fixtures — no bundle has yet been materialized from implemented and exercised against committed fixtures — no bundle has yet been materialized from
a live source. a live source.
- **Backends:** a run binds to one of two profiles. `local` (the development default) talks to an
OpenAI-compatible endpoint on loopback and makes no egress. `azure` talks to a Foundry project
and resolves its configuration from the environment *at call time*:
- **Endpoint**`PORTFOLIO_FOUNDRY_PROJECT_ENDPOINT` first, then the `FOUNDRY_PROJECT_ENDPOINT`
that Foundry injects into a hosted agent. Ours wins, so exporting it is always decisive; the
injected name is what lets the same image run hosted with no extra wiring. Neither set is a
fail-fast naming both.
- **Credential**`AzureCliCredential` on a developer host (constructing it acquires no token;
`az login` stays your manual step), and `ManagedIdentityCredential` when
`FOUNDRY_HOSTING_ENVIRONMENT` is present, because a hosted container has no Azure CLI and the
platform mints it a dedicated Entra identity instead.
Deployment names are never committed: the role→model map ships `REPLACE-WITH-*` placeholders that
fail fast, and `PORTFOLIO_MODEL_MAP` points at an out-of-tree map that wins over the bundled one.
- **Tracing:** off unless `PORTFOLIO_OTEL` holds a value, and then it says where the spans go
before it emits one. `console` writes them to **stderr**, so a traced run's stdout is
byte-identical to an untraced one; `otlp` exports them over the network, and only to an endpoint
you named in one of the standard `OTEL_EXPORTER_OTLP_*_ENDPOINT` variables. Asking for `console`
while such a variable is set is refused rather than quietly honoured — those exporters are built
unconditionally by the framework underneath, so "console" would have been a false statement about
where the run's contents went. With the variable unset, no provider is configured at all: spans
are still made and discarded, and nothing can leave the process. The OTLP exporter packages are
not declared dependencies (they are egress, and heavy in a published wheel); install one yourself
if you use that mode.
- **Run:** the `run.py` CLI has **three modes** — a documented partition, since one invocation - **Run:** the `run.py` CLI has **three modes** — a documented partition, since one invocation
cannot exercise every flag: cannot exercise every flag:
- **Single-project**`PROJECT_ID --docs-dir <dir>`, plus optional `--bundle-dir`, - **Single-project**`PROJECT_ID --docs-dir <dir>`, plus optional `--bundle-dir`,
@ -294,7 +419,9 @@ when the seam is detached, so the loop cannot silently degrade into theater.
`--semantic-retrieval`, `--decision`/`--rationale`, `--live-dry-run`, and `--semantic-retrieval`, `--decision`/`--rationale`, `--live-dry-run`, and
`--scripted-replies <file>` (the offline whole-loop door — see `--scripted-replies <file>` (the offline whole-loop door — see
[Walk the whole chain offline](#walk-the-whole-chain-offline); mutually exclusive with [Walk the whole chain offline](#walk-the-whole-chain-offline); mutually exclusive with
`--live-dry-run`, which stops before the first model call rather than answering it). `--live-dry-run`, which stops before the first model call rather than answering it), and
`--explore "<prompt>" --explore-config <file>` (opt-in: run an exploration first and let it
shape the mandate this run evaluates — see below).
- **Portfolio**`--portfolio`, plus optional `--goals`, `--ledger`, `--dimension-config`, - **Portfolio**`--portfolio`, plus optional `--goals`, `--ledger`, `--dimension-config`,
`--semantic-retrieval`; it stops early and prints a `goal reached: …` line when the `--semantic-retrieval`; it stops early and prints a `goal reached: …` line when the
accumulated ledger meets a goal. accumulated ledger meets a goal.
@ -314,6 +441,104 @@ when the seam is detached, so the loop cannot silently degrade into theater.
uv run python -m portfolio_optimiser.run --report --ledger ledger.json uv run python -m portfolio_optimiser.run --report --ledger ledger.json
``` ```
`--explore` (U4) is **opt-in** and sits *over* the eight-step loop, never inside it. Given a
prompt and a knowledge base, a Magentic manager decides what to read and which cost-saving
directions are worth testing; what leaves that freedom is a **mandate**, and every number in it
is still gated by the same blocking deterministic validator. The exploration writes nothing —
no outbox artefact, no wiki entry, no verdict.
```bash
uv run python -m portfolio_optimiser.run FV42-GSV-E1 --docs-dir <docs> --bundle-dir <bundle> \
--explore "Find the cheapest saving worth testing here" --explore-config exploration.json
```
`--explore-config` states the bounds, and **every field is required**`max_rounds`,
`max_tokens`, `max_stall_count`, `max_reset_count`, `max_plan_revisions`, `enable_plan_review`.
None of them has a default, because an omitted cap falls back to an *unbounded* loop rather than
a conservative one.
**Answering the plan review (`--plan-review`).** With `enable_plan_review` set, the exploration
stops before the loop is allowed to run and asks you to sign the plan off. `--plan-review`
answers it *at your terminal*: you are shown the plan, and you type `approve` or
`revise <what to change>`. A revision goes back to the manager, which replans and asks you
again about the **new** plan; `max_plan_revisions` bounds how many revisions are applied. Every
round trip is recorded in `{run_id}-exploration.json` with your words verbatim. Input that ends
without an answer is an error, never a sign-off — an autonomous loop must not run on a plan
nobody approved. The two flags are refused apart: `enable_plan_review` without `--plan-review`
would stop at a review nobody can answer, and `--plan-review` without it would build a reviewer
nobody ever asks.
```bash
uv run python -m portfolio_optimiser.run FV42-GSV-E1 --docs-dir <docs> --bundle-dir <bundle> \
--explore "Find the cheapest saving worth testing here" --explore-config exploration.json \
--plan-review --outbox-dir out --run-id r1
```
The review is **synchronous**: the loop waits on you. That is why the hosted surface refuses it
— blocking an HTTP request on a human would also block the event loop that answers
`/readiness`.
**Answering it days later (`--checkpoint-dir` / `--resume`).** A domain expert is rarely at the
terminal when the loop reaches the plan, so the same review can be *parked* to disk instead.
`--checkpoint-dir` writes the suspended workflow there and the open question to
`{run_id}-plan-review.json`, and the process exits. Whenever the expert gets to it — another
day, in a process that never saw the run — they drop `{run_id}-plan-review-answer.json` into a
review inbox, and `--resume` picks it up:
```bash
# day 1 — park the review and exit
uv run python -m portfolio_optimiser.run FV42-GSV-E1 --docs-dir <docs> --bundle-dir <bundle> \
--explore "Find the cheapest saving worth testing here" --explore-config exploration.json \
--checkpoint-dir checkpoints --outbox-dir out --run-id r1
# day N — a fresh process, resuming from what is on disk and nothing else
uv run python -m portfolio_optimiser.run FV42-GSV-E1 --docs-dir <docs> --bundle-dir <bundle> \
--outbox-dir out --checkpoint-dir checkpoints --review-inbox inbox --resume r1
```
A `revise` answered this way does the same thing it does at the terminal: the manager replans
and asks again about the **new** plan. The answer names the `request_id` it answers, and a
mismatch is refused rather than applied — two reviews of one run share a file name, so an answer
left over from the previous round would otherwise sign off a plan the expert never saw. The
vocabulary is the same closed one, anything outside it is refused rather than read as approval,
and `revise` with nothing to revise is refused too. `hitl.pending_plan_reviews(outbox, inbox)`
lists every review still waiting on somebody.
The budget and the revision cap span the suspension — the resumed leg starts from what the
parked one already spent, so a park never hands back a fresh budget. `--plan-review` and
`--checkpoint-dir` are refused together (two doors onto one review), as are `--resume` and
`--explore` (two sources of one exploration).
`--explore` is refused together with `--mandate` — they are two sources of one mandate, and
merging would silently overwrite what you wrote. To seed an exploration with a domain expert's
own hypotheses, use `explore(..., seed_approaches=[Approach(...)])`; seeds are always preserved
and always come first, including when the loop stops early. With `--outbox-dir`/`--run-id` the
run also writes `{run_id}-exploration.json`: the per-round ledger, the plan reviews and the
in-loop advisory verdicts, written even when a cap cut the exploration short.
The hosted surface takes the same door as `explore_prompt` + `explore_contract` on
`POST /invocations`.
**Several knowledge bases (library API).** An exploration may be given more than one base
(`explore(..., bundle_dirs=[a, b])`). Each approach it shapes records which base it belongs to
(`Approach.bundle_id`), and `run_mandate_across_bundles(mandate, bundle_dirs, ...)` then runs the
pipeline **once per base** — the ordinary `run_project`, with that base's own sub-mandate, and
with each run's project read from that base's own `validator-input.json`. `run_project` itself
still takes one `bundle_dir`, deliberately: it derives the project, the validator's cost
baseline, the agents' read context and the retrieval key from the base it is handed, so a second
directory on that call would mean silently picking one of them. A hypothesis that names no base
is refused when several are configured, rather than routed to a guess. The CLI's `--bundle-dir`
stays single-valued; multi-base is a library door today.
Surveying those bases is deliberately cheap. `list_bundles` costs **O(bases), never O(corpus)**:
each entry carries the base's id, a bounded verbatim opening of its index (with
`index_truncated` beside it when the opening was cut), how many documents and prior expert
verdicts it holds, whether it ships a cost baseline, and how many cross-links could not be
followed — never the whole index. The full index stays one `read_file(id, "index.md")` away, so
the bound is a disclosure level rather than data loss. Measured 2026-08-26 over a real corpus:
112 116 → 362 tokens for three bases, 124 942 → 21 448 for 171
([report](docs/2026-08-26-katalogkostnaden.md)).
`--semantic-retrieval` (S3.1) is an **opt-in** ranking change, **off by default**. Off, prior `--semantic-retrieval` (S3.1) is an **opt-in** ranking change, **off by default**. Off, prior
verdicts are ranked exactly as before: a structural score over the affected cost-code set, verdicts are ranked exactly as before: a structural score over the affected cost-code set,
measure type and magnitude bucket, with surface text deliberately excluded. On, that score is measure type and magnitude bucket, with surface text deliberately excluded. On, that score is
@ -353,9 +578,19 @@ when the seam is detached, so the loop cannot silently degrade into theater.
reference-domain (non-bundle) path the project's own cost items are the baseline, so those runs reference-domain (non-bundle) path the project's own cost items are the baseline, so those runs
are always anchored. are always anchored.
**An un-anchored run says so.** Every run stamps `provenance.cost_baseline_anchored` (a required
boolean, so no stamp can omit it), which reaches the outbox in `{run_id}-proposal.json`; and when
a run is un-anchored the CLI prints one line naming the skipped stage — on `--live-dry-run`, on a
full single run, and per project in portfolio mode. An anchored run prints no such line at all:
a line for something the run does not have is omitted rather than rendered empty. Anchoring stays
optional; this is visibility, not a new refusal.
The **prior-verdict fold — the learning step — happens only on the `--bundle-dir` path**; a The **prior-verdict fold — the learning step — happens only on the `--bundle-dir` path**; a
plain `--docs-dir`-only run is single-shot (no fold). `--decision`/`--rationale` apply to the plain `--docs-dir`-only run is single-shot (no fold). `--decision`/`--rationale` apply to the
single-project path only and are inert in portfolio mode. **`--outbox-dir` must differ from single-project path only and are **refused** in portfolio and report mode rather than ignored —
a portfolio pass takes each project's verdict from its own row, so a run-level verdict flag has
nowhere to go, and silently dropping a judgement an expert actually typed is the failure this
partition exists to prevent. **`--outbox-dir` must differ from
`--verdict-dir`**: writing the raw outbox into a folder later read as an inbox would re-ingest `--verdict-dir`**: writing the raw outbox into a folder later read as an inbox would re-ingest
raw agent output past the promotion gate (self-contamination) — documented here, deliberately raw agent output past the promotion gate (self-contamination) — documented here, deliberately
not CLI-enforced. Stop criteria and budget caps are required at startup. Try the offline not CLI-enforced. Stop criteria and budget caps are required at startup. Try the offline
@ -390,6 +625,57 @@ only an expert has:
- **Vendor-neutral knowledge** — the same bundles drive two different agent stacks; switching - **Vendor-neutral knowledge** — the same bundles drive two different agent stacks; switching
frameworks does not orphan the organization's curated knowledge. frameworks does not orphan the organization's curated knowledge.
## The task API — runnable Python, no wrapper
`main.py` is the ONE process entry. It serves the hosted-agent runtime contract (port 8088 /
`PORT`, `GET /readiness`, `POST /invocations`, clean SIGTERM exit) from
`portfolio_optimiser.hosting`, wrapping `run_project` — never `Workflow.as_agent()`, which
would bypass the deterministic validator and the rest of the gate chain. No protocol library
is used: the prerelease `agent-framework-foundry-hosting` requires
`agent-framework-core>=1.13.0` while this tree locks 1.9.0, so the contract is a small
stdlib-asyncio server instead (single event loop, no threads — the same concurrency model as
the portfolio waves).
```bash
uv sync --frozen --no-dev # the exact locked resolution every measurement ran against
uv run python main.py # serves the task API
```
`git` must be on PATH for the install: two dependencies are git-tag-pinned direct references,
and wheel metadata alone cannot fetch them.
A `Dockerfile` and an `azure.yaml` shipped here until **14 August 2026** and were removed on an
operator directive after an external trial: what is delivered is runnable Python, and how the
process is containerised, supervised or deployed belongs to whoever runs it. Git history keeps
both files. The raw-text gate that pinned them (`--platform linux/amd64`, one copy of the start
command) was **deleted with them** rather than weakened into a check that could only pass — the
start command now has exactly one copy, in [`DEPLOY.md`](DEPLOY.md), and
`tests/test_handover_package_loadbearing.py` is what keeps it there.
An invocation is a JSON object whitelisted onto `run_project`'s signature — `project_id` and
`docs_dir` required; `verdict_input`, `bundle_dir`, `profile`, `max_rounds`, `max_tokens`
and `top_k` optional. `verdict_input` was required until 1.1.0, which forced an external caller to
invent an expert verdict just to get a run at all; omitting it now means nobody reviewed the run,
and the response's `verdict_id` is the key one would arrive under. Unknown fields are refused by name (400), never silently dropped.
`profile` defaults to `azure` on this surface: the AZURE profile reads its endpoint and
credential from the environment at call time, so the same process runs hosted (managed identity)
and locally (`az login`) without rewiring.
### Handing it to someone else
`scripts/make-handover-package.sh` builds one archive a receiving organisation can deploy without
cloning this repository or having an account here:
```bash
scripts/make-handover-package.sh # → dist/portfolio-optimiser-foundry-<version>.zip
```
The archive is `git archive HEAD` — tracked files only, which is why local-only files cannot enter
it and why nothing curates what a receiver sees. [`DEPLOY.md`](DEPLOY.md) rides
along inside it and answers the receiver's first questions: what the three roles do, what the
process is end to end, why there is no chat interface, and the two environment variables that decide
whether the first deployment works. Gated by `tests/test_handover_package_loadbearing.py`.
## Docs ## Docs
- [Bestille en kjøring](docs/bestille-en-kjoring.md) *(norsk)* — for the domain expert who - [Bestille en kjøring](docs/bestille-en-kjoring.md) *(norsk)* — for the domain expert who
@ -404,6 +690,11 @@ only an expert has:
- [Building a knowledge base](docs/knowledge-base-recipe.md) — the team recipe (technical + - [Building a knowledge base](docs/knowledge-base-recipe.md) — the team recipe (technical +
domain expert) for curating a bundle, with the honest expectation that a good base takes 12 domain expert) for curating a bundle, with the honest expectation that a good base takes 12
weeks of dedicated work. weeks of dedicated work.
- [Kunnskapsbase for én kjøring](docs/kunnskapsbase-for-en-kjoring.md) *(norsk)* — how to compose
the base for ONE specific run: which categories of knowledge follow the project, the domain and
the organisation; a content-type table (owner, delivery form, role in the loop, what happens
when it is missing); and a worked road project from the commission to a base that passes the
dry-run check. Every technical claim is marked verified or assumed.
- [Target picture](docs/plan/2026-06-26-maalbilde-agentic-loop.md) — the agentic loop + OKF - [Target picture](docs/plan/2026-06-26-maalbilde-agentic-loop.md) — the agentic loop + OKF
knowledge architecture (north star). knowledge architecture (north star).
- [Prior-art & platform research](docs/research/2026-06-23-prior-art-platform.md) (incl. - [Prior-art & platform research](docs/research/2026-06-23-prior-art-platform.md) (incl.

View file

@ -8,7 +8,7 @@ We take security seriously. If you discover a security vulnerability, please rep
### How to Report ### How to Report
Email: hello@fromaitochitta.com Email: security@fromaitochitta.com
Include: Include:
- Description of the vulnerability - Description of the vulnerability
@ -24,10 +24,31 @@ Include:
## Supported Versions ## Supported Versions
| Version | Supported | Support follows the tags, not a calendar, and the table is deliberately version-free — a release
| ------- | ------------------ | number written here would drift the moment the next tag lands.
| latest | :white_check_mark: |
| < latest| :x: | | Version | Security fixes | What that means |
| ------- | -------------- | --------------- |
| Newest tagged release | :white_check_mark: best-effort | Fixes land on `main` and ship in the next tag |
| Every earlier tag | :x: | Earlier tags are never re-released. Upgrade, or fork and patch |
There is no long-term-support branch and no backporting. One maintainer, best-effort.
## Deprecation Notice Period
When a supported surface is removed, or a dependency stops receiving security fixes:
- The deprecation is announced in [`CHANGELOG.md`](CHANGELOG.md) under the release that introduces
it, and repeated in the release notes on the forge.
- **At least one minor release — and no fewer than 30 days — passes between that announcement and
the removal**, so anyone reading the changelog has a version to move to before the old one goes.
- **The stated exception is a security-critical removal.** If leaving a surface in place is itself
the risk, it goes in the next release and the changelog says plainly why the notice period was
not used. This has not happened so far.
This is a notice period, not an SLA. See the
[organisation governance](https://git.fromaitochitta.com/open/repo-standard/src/branch/main/GOVERNANCE.md)
for what this project does and does not promise.
## Security Best Practices ## Security Best Practices

View file

@ -42,6 +42,41 @@ is accepted. The preflight requires `https://` + a host ending `.services.ai.azu
**not** require the `/api/projects/` path. A `*.openai.azure.com` or `*.cognitiveservices.azure.com` **not** require the `/api/projects/` path. A `*.openai.azure.com` or `*.cognitiveservices.azure.com`
endpoint is a **different** client surface (use `OpenAIChatClient`, not `FoundryChatClient`). endpoint is a **different** client surface (use `OpenAIChatClient`, not `FoundryChatClient`).
## Claude models on Foundry are a THIRD client surface (measured 2026-08-13)
Verified against Microsoft Learn (`microsoft_docs_search`, "Deploy and use Claude models in Microsoft
Foundry" / "Claude models in Microsoft Foundry — API overview"), because the question came up while
planning M1 and an assumption was cheaper to falsify than to inherit.
**`FoundryChatClient` CANNOT serve a Claude deployment.** Claude models sold through the Azure
Marketplace are called with Anthropic's own Messages API, on their own endpoint shape:
```
https://<resource>.services.ai.azure.com/anthropic/v1/messages # Claude
https://<resource>.services.ai.azure.com/api/projects/<project> # FoundryChatClient
```
Clients: the `anthropic` Python package (or `@anthropic-ai/foundry-sdk`, or REST with the
`anthropic-version: 2023-06-01` header). Only `POST /v1/messages` and `POST /v1/messages/count_tokens`
are exposed on the *Hosted on Azure* version. The **deployment name** is what goes in the `model`
parameter — same rule as the Foundry surface, different endpoint.
**What this means for M1 (fase 1b):**
- **A Microsoft-sold model** (`gpt-5-mini`, `gpt-4.1-mini`, …) → M1 is **configuration only**. The
`azure` profile as shipped is the right seam; nothing to build.
- **A Claude model** → M1 needs a **NEW backend profile** (production code, `anthropic` SDK, Entra ID
or key auth). That is a decision to take before the portal work, not a discovery during it.
This is the same class as the note under *Endpoint format*: `*.openai.azure.com` and
`*.cognitiveservices.azure.com` are other surfaces again. Three surfaces, one resource host.
**Extra prerequisites measured in the same pass** (they gate the portal work, not the code):
Marketplace subscription permission, **Contributor or Owner on the resource group**, a project in a
region the model supports, and — for Anthropic-designated *Covered Models* — data retention enabled
on the subscription (zero-data-retention subscriptions get a 400 `invalid_request_error`, and
Microsoft cannot change that setting for you).
## RBAC role ## RBAC role
Assign **`Foundry User`** (role GUID `53ca6127-db72-4b80-b1b0-d745d6d5456d`) to the identity, at the Assign **`Foundry User`** (role GUID `53ca6127-db72-4b80-b1b0-d745d6d5456d`) to the identity, at the

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,196 @@
# Fase 1a — første ende-til-ende-kjøring mot en levende modell (lokal)
**Dato:** 2026-08-13 · **Tre:** `5871e39` (v1.0.0 + de to presentasjonsfilene) · **Profil:** `local`
Rammeverket hadde aldri kjørt mot en levende språkmodell — bare mot skriptede stand-ins.
Denne rapporten er den første målingen av hva som faktisk skjer når en ekte modell settes
inn i loopen. Fase 1a er den **gratis** halvdelen: en lokal Ollama-kjøring feller alt som
ikke er autentisering, slik at den betalte Foundry-kjøringen (1b) bare har RBAC, token og
deployment-navn igjen å feile på.
> **Ærlighetsgrense.** Alt under er målt på ÉN maskin (Intel i9-9880H, CPU-only) med SMÅ
> lokale modeller. Tallene beskriver denne maskinen og disse modellene — ikke rammeverkets
> ytelse generelt, og ikke hva en Foundry-modell vil gjøre.
## Miljø
| | |
|---|---|
| Maskin | Intel Core i9-9880H, 16 logiske kjerner, ingen GPU-akselerasjon for Ollama |
| Ollama | 0.16.3 |
| Python / MAF | 3.12.13 / `agent-framework-core` 1.9.0 |
| Kunnskapsbase | `shared/examples/bygg-energi-mikro`, prosjekt `BYGG-KONTOR-NORD` |
| Tak | `max_rounds=3`, `max_tokens=100000` (kompilert inn, se funn 3) |
## Stigen: fire trinn, der de tre første er gratis
Rekkefølgen er ikke tilfeldig. Hvert trinn gjør neste trinns feil attribuerbar.
| # | Hva | Utfall |
|---|---|---|
| 1 | Endepunkt nås (`GET /api/tags`) | **http 200** |
| 2 | `--live-dry-run` — bygger klient, stopper før første kall | **exit 0**, `models={proposer: qwen3:4b, checker: qwen3:4b}` |
| 3 | `--scripted-replies` — HELE loopen, null modellkall | **exit 0**, `ValidatedProposal (decision=approved)` |
| 4 | Ekte kjøring mot levende modell | se under |
Trinn 3 er det som gjør rapporten verdt noe: det beviser at argumentrekka, kunnskapsbasen,
debatten, den deterministiske validatoren og domsavsigelsen henger sammen **uten** en modell
i bildet. Enhver feil i trinn 4 er dermed forårsaket av levende modell-output, ikke av
oppsettet.
Merk at et grønt trinn 2 **ikke** beviser at endepunktet virker: `--live-dry-run` bygger
klienten og stopper før første kall, så det går grønt også med serveren nede. Trinn 1 er
derfor en egen måling, ikke en formalitet.
## Kjøring 1 — den shippede konfigurasjonen (`qwen3:4b`)
```
uv run python -m portfolio_optimiser.run BYGG-KONTOR-NORD --profile local \
--docs-dir shared/examples/bygg-energi-mikro \
--bundle-dir shared/examples/bygg-energi-mikro
```
**Utfall: exit 1 etter 1804 sekunder (30 minutter), null forslag produsert.**
```
agent_framework.exceptions.ChatClientException: OpenAIChatCompletionClient service failed
to complete the prompt: Request timed out. (APITimeoutError)
```
Årsakskjeden er målt hele veien, ikke resonnert:
1. **Ollamas logg viser tre kall, hvert avbrutt på nøyaktig `10m0s`.** 3 × 600 s = 1800 s,
som er de 1804 sekundene kjøringen brukte. Det er OpenAI-SDK-ens standard timeout på
600 s pluss dens to standard retries — ingen av delene er valgt av oss (funn 1).
2. **Modellen rakk aldri å svare.** Målt direkte på samme maskin: `qwen3:4b` genererer
**4,0 tokens/sekund**. Et 600-sekunders vindu rommer altså ~2400 tokens.
3. **Og den brukte alt på å tenke.** `qwen3` er en resonnerende modell. I en kontrollmåling
med 256 tokens til rådighet var svaret **tomt** — hele budsjettet gikk med til den interne
tankerekka `<think>`, før ett eneste tegn av det faktiske svaret ble skrevet (funn 2).
## Funn
### Funn 1 — den lokale klienten har ingen timeout-søm (rammeverket)
`backends.py:130` konstruerer `OpenAIChatCompletionClient(model=..., api_key=..., base_url=...)`.
Målt mot klassens signatur: den tar **ingen** `timeout`- eller `max_retries`-parameter. Eneste
vei inn er å bygge og sende inn en egen `async_client`.
Konsekvensen er at enhver maskin som er tregere enn modellen krever, feiler etter 30 minutter
med en melding som ser ut som et nettverksproblem. Det er ikke en nettverksfeil — det er et
tak ingen har valgt.
**Ikke fikset her.** Det er produksjonskode og krever en feilende test først (Iron Law), altså
egen økt. Formen på fiksen er kjent: la `create_chat_client` bygge `AsyncOpenAI(timeout=…,
max_retries=…)` med verdier fra env, og la en load-bearing test bli rød når sømmen kobles fra.
### Funn 2 — en resonnerende modell bruker hele budsjettet på å tenke
Dette er ikke en defekt i rammeverket, men det er en **forutsetning ingen hadde skrevet ned**:
modell-mappets lokale rolle pekte på `qwen3:4b`, og en resonnerende modell på treg maskinvare
når aldri fram til svaret. For den lokale profilen bør standardvalget være en modell **uten**
tankemodus — eller tankemodus må skrus av eksplisitt.
### Funn 3 — planens token-tak-mekanisme finnes ikke i CLI-en
Planens fase 1b foreskriver «harde token-tak (`--max-*`-flaggene i `run.py`)». **Målt: slike
flagg finnes ikke.** Takene er kompilert inn — `_DEFAULT_MAX_ROUNDS = 3`,
`_DEFAULT_MAX_TOKENS = 100_000` (`run.py:110-111`) — og kan ikke settes fra kommandolinjen.
Gratis og uvesentlig lokalt. **For 1b er det bærende:** en betalt kjøring vil ellers gå under
et 100 000-tokens tak ingen har tatt stilling til. Dette er en beslutning for operatøren før
den første Foundry-kjøringen, ikke noe som skal oppdages under den.
### Funn 4 — kunnskapsbasen sprengte standard kontekstvindu, stille
Ollamas logg, kjøring 1:
```
level=WARN msg="truncating input prompt" limit=4096 prompt=4388 keep=4 new=4096
```
Ollamas standard kontekstvindu er 4096 tokens; prompten fra den navigerte kunnskapsbasen er
4388. Overskytende ble **kuttet** — og `keep=4` betyr at bare fire tokens fra starten ble
bevart, altså at instruksjonene om svarformat sto først i det som røk.
Advarselen står i Ollamas logg, ikke i vår. En operatør som kjører lokalt ser den ikke.
Rettet for kjøring 2 ved å starte serveren med `OLLAMA_CONTEXT_LENGTH=16384`; at dette må
gjøres bør stå i den lokale oppskriften.
## Kjøring 2 — med funn 2 og 4 kompensert
Modell byttet til `qwen2.5:3b` (**uten** tankemodus) via en modell-map utenfor treet
(`PORTFOLIO_MODEL_MAP`), og serveren restartet med `OLLAMA_CONTEXT_LENGTH=16384`. Repoets egen
konfig er urørt — dette er en erklært overstyring, ikke den shippede oppsettet.
**Utfall: samme vegg.** To kall, begge avbrutt på nøyaktig `10m0s`. Kjøringen ble stoppet manuelt
framfor å brenne det tredje forsøket mot et kjent utfall.
Ett funn ble likevel lukket: **null trunkering** i loggen. Det større kontekstvinduet løser funn 4.
**Diagnosen flyttet seg, og det er den viktigste setningen i rapporten:** flaskehalsen er ikke
tankemodusen. Det er at 600-sekunders-taket er bindende **på denne maskinen i denne tilstanden**,
også for modeller som er langt mindre enn den shippede.
Kontrollmålingen: `llama3.2:1b` — en modell på 1,3 GB — sto **18 min 54 s uten å fullføre ett
enkelt kall** med denne kontekststørrelsen, og ble så stoppet manuelt. **Presist hva dette er:**
den nedre grensen er målt (kallet overskred 600-sekunders-taket med god margin), men den øvre er
det ikke — målingen ble aldri lest til slutt, så det finnes ikke noe totaltall for den.
**Og påstanden er bevisst snevret til denne maskinen i denne tilstanden**, ikke «uansett modell»:
målingen er konfundert av at CPU-en strupet seg fra 62 % til 54 % klokkefrekvens underveis, og at
to fremmede Python-prosesser holdt ~1,8 kjerner samtidig. Et generelt utsagn om modellstørrelse
ville krevd en ren maskin og en fullført måling; ingen av delene finnes her.
## Hva som er bevist, og hva som ikke er
**Bevist:** loopen henger sammen fra ende til annen og produserer et validert forslag — men med
skriptede svar, null modellkall (trinn 3).
**IKKE bevist:** at en levende modell produserer noe loopen kan konsumere. **Null vellykkede
modellkall.** Dermed står disse fortsatt åpne, og de var hele grunnen til at 1a finnes:
- om prompt-formene gir parsebar JSON fra en ekte modell
- om checkeren faktisk avslutter med `VERDICT:`-linja
- om runde-taket oppfører seg som forutsatt
- om validatoren avviser kandidater av riktig grunn
## Hva dette betyr for 1b
**Fase 1a har levert funn, ikke beviset.** Den lokale halvdelen kan ikke fullføre på denne
maskinvaren uten at funn 1 fikses først — timeout-sømmen er dermed en *forutsetning* for lokal
ende-til-ende, ikke en forbedring.
To veier videre, og de utelukker ikke hverandre:
1. **Bygg timeout-sømmen** (egen økt, feilende test → fiks → målt mutasjon). Da blir lokal profil
kjørbar på treg maskinvare, og 1b arver en klient med et tak noen har valgt.
2. **Gå til 1b.** En Foundry-modell svarer på sekunder, ikke minutter — 600 s er ikke bindende der.
Ende-til-ende blir da bevist mot ekte modell, som er det operatøren opprinnelig ba om.
**Kostnad er ikke en begrensning for 1b.** Regnet på Anthropics egen prisliste for Claude Haiku 4.5
($1,00/MTok inn, $5,00/MTok ut — Claude er tilgjengelig på Microsoft Foundry til standard
API-priser): ~54 000 input-tokens + ~5 000 output-tokens per full kjøring ≈ **$0,08**, altså under
én krone. Ti kjøringer er en tier. Det som gjenstår for 1b er ikke penger, men Azure-oppsettet:
ressurs, prosjekt, én billig deployment og rollen `Foundry User` — portal-steg bare operatøren kan
gjøre.
**Uverifisert, må måles før det bygges på:** om MAFs `FoundryChatClient` kan binde en
Claude-deployment. Foundry-tilgjengeligheten er dokumentert; klient-kompatibiliteten er det ikke.
## Verifiseringslogg
| Påstand | Kilde |
|---|---|
| Trinn 13 grønne; trinn 4 exit 1 etter 1804 s | egne kjøringer, stdout/stderr fanget ordrett |
| 3 × 600 s timeout + 2 retries er SDK-standard | Ollamas logg (`500 \| 10m0s` × 3) + `agent_framework` traceback |
| Klienten har ingen `timeout`-parameter | `inspect.signature(OpenAIChatCompletionClient.__init__)` |
| `qwen3:4b` gir 4,0 tok/s; 256 tokens ga tomt svar | `/api/generate` med `eval_count`/`eval_duration` |
| Prompt 4388 tokens, kuttet mot 4096 | Ollamas `truncating input prompt`-advarsel |
| `_DEFAULT_MAX_ROUNDS = 3`, `_DEFAULT_MAX_TOKENS = 100_000` | `run.py:110-111`; CLI-en har ingen `--max-*` |
| Haiku 4.5-priser; Claude på Foundry til standard API-rater | Anthropics modell-/prisdokumentasjon (`claude-api`-skillen) |
| `llama3.2:1b` 18 min 54 s **uten å fullføre** — nedre grense, ikke totaltid | `ps -o etime=` ved manuell stopp; ingen `POST "/api/generate"` i loggen for kallet |
**Ikke verifisert, uttalt:** at taket ville vært bindende på en URØRT maskin, eller for en vilkårlig
liten modell. Kontrollmålingen ble kjørt under termisk struping og fremmed last, og ble aldri
fullført. Påstandene over gjelder denne maskinen i denne tilstanden.

View file

@ -0,0 +1,125 @@
# Fase 4· — de fire åpne `azure.yaml`-valgene, lukket mot skjemaet
**Dato:** 2026-08-13 · **Metode:** de to autoritative JSON-skjemaene hentet og lest, ikke referert
fra Learn-prosa · **Status:** beslutninger, ingen fil skrevet. `azure.yaml` skrives i 4d.
Spiken (`docs/2026-08-13-fase4-research-spike.md`) etterlot fire valg under «Ikke verifisert», med
begrunnelsen at Learn og Microsofts eget sample er uenige og at autoriteten —
`schemas/v1.0/azure.yaml.json` — ikke var lest. Den er lest nå.
## 0. Funnet som endrer hvordan de andre skal leses
**Skjemaet er PERMISSIVT, og kan derfor ikke avgjøre tre av de fire valgene.** Målt:
| Felt | Hva skjemaet faktisk sier |
|---|---|
| `host` | `type: string`, **ingen `enum`** — kun `examples`, der `azure.ai.agent` står |
| `language` | `type: string`, **ingen `enum`**`examples` inneholder BÅDE `python` og `docker` |
| service-objektet | `additionalProperties: **true**` |
Et ukjent felt blir altså ikke avvist — det blir *ignorert*. Å skrive `environmentVariables:` ville
ikke gitt en valideringsfeil; det ville gitt en fil som ser konfigurert ut og ikke er det. Det er en
skarpere failure-mode enn den planen fryktet, og den er grunnen til at «samplet er fasit» ikke er
godt nok som regel her.
**Den ekte autoriteten lå ett hopp videre.** `azure.yaml.json` delegerer agent-formen via `$ref`:
```
"if": { "properties": { "host": { "const": "azure.ai.agent" } } },
"then": { "required": ["project"],
"allOf": [ { "$ref": ".../extensions/azure.ai.agents/schemas/azure.ai.agent.json" } ],
"properties": { "config": { "deprecated": true, ... },
"k8s": false, "apiVersion": false, "network": false } }
```
Hentet: `azure.ai.agent.json` (http 200, 16 385 bytes). Den bærer `kind`, `protocols`,
`codeConfiguration`, `container`, `startupCommand` — feltene Learn omtaler, og som hovedskjemaet
ikke kjenner. To ting følger direkte: **`project` er påkrevd** for en hosted agent, og
**`config:` er deprekert** (agent-innstillingene er flyttet opp på service-nivå).
## 1. Protokollversjon: `2.0.0`
Skjemaet lukker ikke valget, men det avgrenser det. `ProtocolVersionRecord`:
```json
{"properties": {"protocol": {"type": "string"}, "version": {"type": "string"}},
"required": ["protocol"], "additionalProperties": false}
```
`version` er en fri streng **uten enum og uten default**, og er **valgfri**. Valget er altså vårt,
og det er et valg mellom å pinne og å la plattformen bestemme.
**Vi pinner `2.0.0`, uttalt.** Grunnen er ikke at skjemaet sier det — det gjør det ikke — men at
spikens §1.5 dokumenterer headere (`x-agent-user-id`, `x-agent-foundry-call-id`, og en gateway som
dropper alt utenfor en allowlist) som er **egenskaper ved 2.0.0**. Kjører plattformen oss under en
annen versjon enn den vi har lest kontrakten for, brytes antakelsene stille. Learn oppgir dessuten
at 1.0.0 er deprekert med blokkering etter fristen. En utelatt versjon ville gjort dette til noe vi
oppdager i drift; en pinnet gjør det til noe vi ser i diffen.
Skrives som `protocols: [{protocol: invocations, version: "2.0.0"}]` — **Invocations, ikke
Responses**, som spikens §5 allerede avgjorde: `run_project` er ett kall med strukturert inn og ut,
ikke en samtale.
## 2. Miljøvariabler: `env:`-map — dette lukkes AV skjemaet
MÅLT, i begge filer: `grep -c environmentVariables`**0 og 0**. Ordet finnes ikke i noen av dem.
`env` derimot er en deklarert service-property:
```json
{"type": "object", "title": "Environment variables for the service",
"description": "Optional. A map of environment variable names to values.
Supports environment variable substitution.", "additionalProperties": {"type": "string"}}
```
**`env:` er den eneste formen skjemaet kjenner.** `environmentVariables:` er ikke ulovlig (jf. §0),
men ingenting validerer den og ingenting leser den. Dette er det ene av de fire valgene som er
avgjort av autoriteten selv, ikke av vår vurdering.
Konsekvens for innholdet: `FOUNDRY_PROJECT_ENDPOINT` skal **ikke** redeklareres (spiken pkt. 2 —
det risikerer å skygge plattformens egen verdi). Modelldeployment-navnet må derimot deklareres, det
injiseres ikke.
## 3. `language: docker` — ikke `python`
Skjemaet tillater begge (ingen enum). Men `azure.ai.agent.json` gjør dem til to **gjensidig
utelukkende deploy-modi**, og sier det rett ut i `CodeConfiguration`:
> «Code deploy configuration. **When present, the agent is deployed from source (ZIP) instead of a
> container image.**» — påkrevd: `runtime` + `entryPoint`; `dependencyResolution` er
> `bundled` eller `remote_build`.
Vi velger container, og grunnen er vår egen avhengighetsform: de to sikkerhetskomponentene er
**git-pinnet via `[tool.uv.sources]`**, ikke publisert på PyPI (spikens §3 og §7.2). En
source-ZIP-deploy overlater resolusjonen til en byggetjeneste vi ikke kontrollerer og som må nå
`git.fromaitochitta.com` — og den formen bærer heller ikke `git` i base-imaget, som spikens §3
målte at pip-veien trenger. Vår Dockerfile er nettopp det stedet den kunnskapen kan bo.
Altså: `language: docker`, **ingen `codeConfiguration`**. `entryPoint`-uenigheten i spikens tabell
(`main.py` vs `["python", "main.py"]`) faller bort med den — feltet tilhører kun ZIP-modusen, og er
`type: string`, ikke en liste, i skjemaet.
## 4. Wrapper-form: IKKE avgjort her — og det er en måling, ikke en utsettelse
Skjemaet har ingenting å si om dette; det er et kodespørsmål. Men det ene alternativet er MÅLT
utilgjengelig i dag:
```
agent_framework_foundry_hosting -> ModuleNotFoundError
agent_framework.foundry -> IMPORT OK, 27 eksporter, 0 med "Host"/"Server" i navnet
agent_framework_foundry -> 1.8.2, 0 host-klasser
```
`InvocationsHostServer` finnes altså ikke i det installerte treet, og pakka som skal bære den har
ingen stabil release (spikens pkt. 8). **Valget kan ikke tas før pakka er installert og målt**, og
det hører uansett til 4c — som spikens §5 allerede har vist er et *signaturproblem*
(`run_project` tar `project_id` + to påkrevde keyword-argumenter og gir en frozen `RunResult`),
ikke et grafproblem. Ført videre som åpent, med en kjørbar grunn.
## Sidefunn, ikke handlet på
`agent_framework.foundry` eksporterer **`AnthropicFoundryClient`** og `RawAnthropicFoundryClient`.
STATEs 1b-blokk slår fast at `FoundryChatClient` ikke kan binde en Claude-deployment fordi Claude på
Foundry er en tredje klientflate (`/anthropic/v1/messages`, målt 13.08). Det står ved lag — men MAF
ser altså ut til å shippe en egen klient for nettopp den flaten. Det endrer potensielt 1b-valget
«Microsoft-solgt modell → ren konfig», og bør måles før 1b, ikke under. **Ikke verifisert:** at
klassen faktisk virker mot en Claude-deployment, og at den komponerer med `backends.py`-sømmen.

View file

@ -0,0 +1,438 @@
# Fase 4· — research-spike: hostingkontrakten for Foundry Agent Service
**Dato:** 2026-08-13 · **Commit ved måling:** `f5ec8c8` · **Metode:** agent-team, fire parallelle
spor, alle Opus/xhigh · **Leveranse:** svar som kommandoer og siterte kilder, aldri vurderinger.
Denne rapporten åpner fase 4 i den kanoniske planen. Den bygger ingen produksjonskode og rører
ikke `shared/`. Alt som står her er enten kjørt på denne maskinen eller sitert fra Microsoft Learn
med URL. Det som ikke lot seg verifisere står under **Ikke verifisert** — ikke i brødteksten.
---
## 0. Sammendrag for den som bare leser ett avsnitt
Alle fire spørsmål er besvart med kjørte kommandoer og siterte kilder, og containeren er bygget på
`linux/amd64` (§7). Underveis kom det ett funn ingen hadde spurt om: **wheelen er ikke installerbar
alene** — dens metadata bærer to avhengigheter som bare navn, og de finnes ikke på PyPI (§7.2). Det
er den skarpeste friksjonskanten spiken fant.
Spiken felte **tre premisser i planen**:
| Premiss i planen | Dom |
|---|---|
| «`azd`-utvidelsen heter enten `microsoft.foundry` eller `azure.ai.agents` — én er utdatert» | **Feil.** Begge er gjeldende; den ene er en meta-pakke over den andre |
| «Foundry injiserer `FOUNDRY_PROJECT_ENDPOINT`, `AZURE_AI_MODEL_DEPLOYMENT_NAME` og `APPLICATIONINSIGHTS_CONNECTION_STRING`» | **To av tre.** `AZURE_AI_MODEL_DEPLOYMENT_NAME` injiseres IKKE — den deklarerer vi selv |
| «`docker build` og `azd ai agent run` mot `localhost:8088` er testbare UTEN Azure» | **Feil for `azd ai agent run`.** Den krever `azd auth login` og provisjonerte ressurser |
Alt materialet er hentet mot det gjeldende hostingbackendet, ikke det gamle preview-et. Se §8 for
hvorfor det skillet betyr noe når man leser eldre oppskrifter.
---
## 1. Hostingkontrakten
### 1.1 Runtime-kontrakten er kort og eksplisitt
Kilde: <https://learn.microsoft.com/azure/foundry/agents/concepts/hosted-agent-contract>
| Krav | Detalj (sitert) |
|---|---|
| Port | «Listen on port 8088 … HTTP/1.1, plain HTTP. The platform terminates TLS.» |
| Helse | «Return `200 OK` from `GET /readiness`.» |
| Protokoll | «Serve at least one of `POST /responses` or `POST /invocations`.» |
| Miljø | «Read the variables the platform injects at startup.» |
| Avslutning | «Flush writes and close connections on `SIGTERM`.» |
Bind-adresse er `0.0.0.0`, og porten kan overstyres med `PORT`. `/readiness` implementeres ikke av
oss: «The protocol libraries automatically expose a `/readiness` endpoint for platform health
checks. You don't need to implement this yourself.»
### 1.2 Filsettet
Learn oppgir intet normativt «required files»-sett. Det som finnes er hva `azd ai agent init`
skaper, og hva Microsofts egen MAF-sample faktisk inneholder.
Scaffold (<https://learn.microsoft.com/azure/foundry/agents/how-to/init-agent-project>):
```
.
|-- azure.yaml # Unified azd project and hosted agent configuration
|-- src/
| \-- <agent-name>/
| |-- Dockerfile # Container build definition
| \-- ... # Agent source code
|-- .azure/ # Environment configuration
\-- infra/ # Optional IaC, created only after you eject infrastructure
```
**Agent-manifest er avviklet** — dette er en bruddendring mot enhver eldre oppskrift:
> «Agent manifests (`agent.manifest.yaml`) and standalone agent definitions (`agent.yaml`) are
> deprecated. As of the Foundry `azd` extensions (`azure.ai.agents` 1.0.0-beta.1), all hosted agent
> configuration lives in a single `azure.yaml`
Microsofts referansesample for Python/MAF bærer i `src/<agent>/`: `.azdignore`, `.dockerignore`,
`.env.example`, `Dockerfile`, `main.py`, `requirements.txt`. Merk: **ingen `pyproject.toml`** — den
bruker `requirements.txt`. Det er relevant for oss, se §3.
Samplets Dockerfile, ordrett
(<https://raw.githubusercontent.com/microsoft-foundry/foundry-samples/main/samples/python/hosted-agents/agent-framework/responses/01-basic/src/agent-framework-agent-basic-responses/Dockerfile>):
```dockerfile
FROM python:3.12-slim
WORKDIR /app
COPY . user_agent/
WORKDIR /app/user_agent
RUN if [ -f requirements.txt ]; then pip install -r requirements.txt; else echo "No requirements.txt found"; fi
EXPOSE 8088
CMD ["python", "main.py"]
```
At Microsofts egen sample lander på `python:3.12-slim` er verdt å merke seg mot §4: det er samme
base-image vår egen resolusjon peker på, av uavhengige grunner.
### 1.3 `azure.yaml` — skjema og det uavklarte
`$schema`-linja:
`# yaml-language-server: $schema=https://raw.githubusercontent.com/Azure/azure-dev/main/schemas/v1.0/azure.yaml.json`
`host:`-verdien for en hosted agent er **`azure.ai.agent`** («Must be `azure.ai.agent`»).
Sentrale felter (<https://learn.microsoft.com/azure/foundry/agents/concepts/azure-yaml-reference>):
`kind: hosted`, `project` (sti til kildekatalog), `language`, `uses`, `protocols`, `env`,
`container`, `startupCommand`, `codeConfiguration`, `image`.
**Learn og Microsofts eget sample er uenige på fire punkter.** Dette er ikke en detalj — det er
forskjellen mellom en `azure.yaml` som virker og en som avvises:
| Punkt | Learn-referansen | Samplet (det `azd ai agent init -m` faktisk adopterer) |
|---|---|---|
| Byggespråk | `language: docker` | `language: python` + `codeConfiguration` |
| Miljøvariabler | `env:` som map | `environmentVariables:` som liste av `{name, value}` |
| Entrypoint | `entryPoint: main.py` | også `entryPoint: ["python", "main.py"]` i en annen artikkel |
| Protokollversjon | `2.0.0` i azure.yaml-eksempler | `1.0.0` i SDK/REST-eksempler |
Anbefaling: **bruk samplet som fasit**, fordi det er fila `azd ai agent init -m` kopierer. Men
protokollversjonen må avklares før vi skriver vår egen — se **Ikke verifisert** pkt. 4.
### 1.4 Container-krav
`linux/amd64` er bekreftet påkrevd
(<https://learn.microsoft.com/azure/foundry/agents/how-to/deploy-hosted-agent>):
> «The hosting platform requires x86_64 (linux/amd64) container images. If you build on Apple
> Silicon or other ARM-based machines, use `docker build --platform linux/amd64 .` to avoid
> producing an incompatible ARM image.»
Planens observasjon står: på operatørens Intel-Mac er dette maskinens egen arkitektur, og vi har
målt at colima-VM-en rapporterer `linux/x86_64` (§6). Ingen emulering.
**CPU/minne motsier seg selv i Learn.** Referansesiden sier fritt intervall («`cpu` from `"0.25"`
up to `"4.0"`, and `memory` from `0.5Gi` up to `8.0Gi`»), mens konseptsiden gir tre faste tiers
(0.5 vCPU/1 GiB, 1/2, 2/4). Feilmeldingen avgjør tolkningen: `400 bad_request` med «CPU and Memory
must be specified as a valid resource tier». **Bruk tier-tabellen.**
Dokumenterte tidsgrenser: idle-timeout 15 minutter, sesjonslevetid maks 30 dager, diskbudsjett
opptil 20 GiB ved ≥1 vCPU hvorav ~20 % er systemreservert.
### 1.5 Headere under protokoll 2.0.0
Plattformen injiserer `x-agent-user-id` (per bruker) og `x-agent-foundry-call-id` (per request,
skal videresendes uendret på utgående Foundry-kall). Gatewayen **dropper alt annet enn en
allowlist** — `Authorization`, `Host`, `Cookie` og `x-forwarded-*` kommer aldri fram; egne headere
må prefikses `x-client-`. Begge de injiserte er «not guaranteed when you run locally», så koden må
tåle at de mangler.
---
## 2. `azd`-utvidelsens navn — planens premiss var feil
Planen antok at ett av navnene var utdatert. Målt mot azds offisielle register
(`https://aka.ms/azd/extensions/registry`, som redirecter til
`raw.githubusercontent.com/Azure/azure-dev/refs/heads/main/cli/azd/extensions/registry.json`,
451 555 bytes — byte-identisk med repo-URL-en, altså samme fil): **begge ID-ene finnes samtidig, og
de gjør forskjellige ting.**
| ID | Rolle | Siste versjon | Kommandogruppe |
|---|---|---|---|
| `microsoft.foundry` | Meta-pakke uten egne kommandoer og uten binærartefakter | 1.0.0-beta.2 | — |
| `azure.ai.agents` | Den faktiske utvidelsen; eier `service-target`-provideren «Deploys agents to the Foundry Agent Service» | 1.0.0-beta.9 (utgitt 2026-08-06) | `azd ai agent` |
Learn sier det selv rett ut
(<https://learn.microsoft.com/azure/foundry/agents/how-to/install-cli-foundry-extensions>):
> «The `microsoft.foundry` package is a thin meta-package that doesn't contribute its own commands.
> Installing it pulls in every individual extension, which is the recommended starting point.»
**Det som faktisk henger etter i doc-en er ikke ID-navnet, men versjonskravet.** Flere Learn-sider
sier `azd` 1.25.2 eller senere; registeret krever `>=1.27.1` for begge utvidelsene. Vi installerte
1.31.0 (§6), så vi ligger over.
`darwin/amd64`-artefaktet finnes for `azure.ai.agents` 1.0.0-beta.9 (sha256
`f0750469a17c7370534db21d5542eb9241f2ffc1093361bf373b5ef71ff43c1f`) — altså støttet på denne
maskinen.
Kommandoene, ordrett fra Learn:
```bash
azd ext install microsoft.foundry # meta-pakke, anbefalt start
azd ext install azure.ai.agents # bare agent-flaten (drar inn azure.ai.inspector)
azd ext list
azd ai agent version
```
`azd extension` og `azd ext` er samme kommando — verifisert i azds kildekode
(`cli/azd/cmd/extension.go`, linje 50: `Aliases: []string{"ext"},`), ikke bare i doc.
---
## 3. Avhengighetsformen i container — direct references, ingen publisering
Spørsmålet var om `[tool.uv.sources]`-git-pinnene overlever inn i en container, siden de ikke
finnes i wheel-metadata. **Svaret er ja, i begge pakkeverktøy, uten publisering og uten auth.**
| Måling | Kommando | Exit |
|---|---|---|
| uv resolverer direct references | `uv pip compile req-direct-min.txt --python-version 3.12 --python-platform linux` | 0 |
| pip gjør det samme | `pip install --dry-run --report … -r req-direct-min.txt` | 0 |
| Anonym git-lesing virker | `env -u GIT_ASKPASS GIT_TERMINAL_PROMPT=0 git ls-remote https://git.fromaitochitta.com/open/llm-ingestion-okf.git v0.3.2` | 0 |
At **pip** klarer det er poenget: en Dockerfile er ikke bundet til `uv`. uv oppgraderer dessuten
tag → commit-SHA i outputen, så formen er selv-pinnende.
Alle tre pakkenavn er ledige på PyPI (HTTP 404 på `/simple/`), så publisering ville vært mulig —
men er unødvendig, og ville lagt en release-forpliktelse på to sikkerhetskomponenter som i dag er
tag-pinnet med vilje.
**Én felle er målt.** Default-eksporten fra lockfila produserer en fil pip **avviser**:
```
ERROR: Can't verify hashes for these requirements because we don't have a way
to hash version control repositories
```
pips hash-modus er alt-eller-ingenting, og en git-URL kan ikke hashes. Den målte veien rundt, som
beholder hash-verifisering for de 64 PyPI-pakkene:
```bash
uv export --frozen --format requirements.txt --no-dev --no-emit-project -o requirements.txt
grep -v '^llm-ingestion' requirements.txt > requirements-pypi.txt # hashet
grep '^llm-ingestion' requirements.txt > requirements-git.txt # commit-pinnet
```
Base-imaget må da ha `git` installert. `-slim`-variantene har det ikke.
**Bygg fra eksporten, ikke fra `pyproject.toml`.** Fersk resolusjon velger
`agent-framework-core==1.13.0` og `mcp==2.0.0`, mens `uv.lock` pinner `1.9.0` / `1.28.0`. Begge er
lovlige innenfor spennet, men en container bygget fra `pyproject.toml` ville kjørt andre versjoner
enn utviklermaskinen — og MAF-pinnen `<2` er tosidig nettopp fordi privat-API-premissene må
re-verifiseres ved bump.
---
## 4. Base-image — 3.10 til 3.13, aldri 3.14
Resolusjonsmatrise mot `x86_64-unknown-linux-gnu`, hele `[project.dependencies]`:
| Python | Fersk resolusjon | Med wheel-krav på numpy | numpy | agent-framework-core |
|---|---|---|---|---|
| 3.10 | ✅ | ✅ | 2.2.6 | 1.13.0 |
| 3.11 | ✅ | ✅ | 2.2.6 | 1.13.0 |
| 3.12 | ✅ | ✅ | 2.2.6 | 1.13.0 |
| 3.13 | ✅ | ✅ | 2.2.6 | 1.13.0 |
| 3.14 | ✅ | ❌ | kun via sdist | 1.13.0 |
**Den ferske resolusjonen alene er villedende, og det er hele funnet.** Alle fem «resolverer»,
fordi numpy 2.2.6 har en sdist. Men numpy 2.2.6 har manylinux-x86_64-hjul kun for cp310cp313 —
ingen cp314. På 3.14 ville containeren kompilert numpy fra kilde, altså dratt inn en full
byggekjede og en byggetid ingen har budsjettert. `--only-binary numpy` gjør det til en hard feil i
stedet for en skjult kostnad:
```
× No solution found when resolving dependencies:
╰─▶ … numpy>=2.0.0,<=2.2.6 has no usable wheels …
your requirements are unsatisfiable.
```
Dette er `numpy<2.3`-taket i `pyproject.toml` som binder. Taket står der av to andre grunner
(3.10-støtte og mypys PEP 695-parsing), og 3.14-utfallet er en bivirkning av dem.
**Valg: `python:3.12-slim-bookworm`** — repoet pinner `.python-version` = 3.12, det ligger midt i
det lovlige båndet, og Microsofts egen MAF-sample bruker `python:3.12-slim` (§1.2).
---
## 5. `Workflow.as_agent()` — virker, men eksponerer feil ting
Målt mot det installerte (`agent-framework-core` 1.9.0), ikke mot dokumentasjonen:
- `fresh_workflow(...)` returnerer en ekte `agent_framework._workflows._workflow.Workflow`
- `.as_agent()` konstruerer en `WorkflowAgent(BaseAgent)` som tilfredsstiller `SupportsAgentRun`
- `await ag.run(...)` **kjører**, og returnerer proposer- og checker-meldingene
Learn bekrefter at dette er den offisielle mekanismen
(<https://learn.microsoft.com/agent-framework/hosting/foundry-hosted-agent>):
> «you can expose an `Agent`, including a workflow wrapped with `Workflow.as_agent()`, through the
> Foundry Responses or Invocations protocol with minimal code.»
**Og likevel er svaret «wrapper», ikke «direkte».** `fresh_workflow` er steg 3 av åtte i
`run_project` (`src/portfolio_optimiser/run.py:554`). Validatoren, kostbaseline-forankringen,
provenance-stempelet, checker-gaten, ledgeren og læringssløyfa ligger alle **utenfor**
`Workflow`-grafen. En hosted agent bygget på `as_agent()` alene ville servert ugatede agent-forslag
— altså brutt repoets egen invariant om at den deterministiske validatoren er obligatorisk og
blokkerende.
Signaturgapet er konkret: hosting-flaten tar `str`/`Message` inn og gir `AgentResponse` ut;
`run_project` tar `project_id` pluss to påkrevde keyword-argumenter uten default (`docs_dir`,
`verdict_input`) og gir en frozen `RunResult`-dataclass. **Fase 4c er derfor et signaturproblem,
ikke et grafproblem.**
To detaljer med konsekvens:
1. `AgentResponse.text` **konkatenerer** proposer + checker + orkestratorens sluttnotis til én
streng. Dagens kode skiller dem på `author_name` (`run.py:330`). En hosted flate som returnerer
`.text` ville slått de to falsifisererne sammen til én tekst.
2. `fresh_workflow` er en factory nettopp fordi et gjenbrukt workflow akkumulerer tråd på tvers av
kall. En hostet agent er per definisjon ett langlevd objekt som betjener mange forespørsler.
**Dette er den største uavklarte risikoen spiken fant**, og må måles før 4c-designet låses.
Dette bekrefter planens 4c-valg: **Invocations, ikke Responses.** `run_project` er ett kall med
strukturert inn og strukturert ut, ikke en samtale. Learn plasserer Invocations på «custom
payloads, non-conversational processing».
---
## 6. Miljøet — hva som ble reparert på maskinen
Verktøykjeden manglet ved oppstart. Målt før:
| Verktøy | Før | Etter |
|---|---|---|
| `az` | 2.84.0 (var installert) | uendret |
| `azd` | not found | 1.31.0 (formelen heter `azure-dev`) |
| `docker` | not found | klient 29.7.2 / server 29.5.2 |
| daemon | ingen | colima 0.10.3 → `linux/x86_64`, 6 CPU, 10 GB |
Docker Desktop-casken var registrert som installert (4.50.0) mens `/Applications/Docker.app` ikke
fantes og Caskroom-katalogen var 0 B — en foreldet brew-oppføring. **colima ble valgt framfor
Docker Desktop** fordi Desktop krever GUI-oppstart og admin-passord, mens colima kjører fra
terminalen. På en Intel-Mac er colima-VM-en `linux/amd64` nativt.
Én etterlatenskap måtte ryddes: `~/.docker/config.json` pekte på `credsStore: "desktop"`, en
credential-helper som ikke lenger fantes, og som feilet ethvert image-oppslag. `auths` var tom, så
linja ble fjernet (backup: `~/.docker/config.json.bak-20260813-213134`).
---
## 7. Containeren er bygget — to målinger
Byggekonteksten er `git archive HEAD` (311 sporede filer, 3,7 MB), altså **det en fremmed faktisk
laster ned** — ikke arbeidstreet med `.venv` og lokale artefakter.
### 7.1 Bygger og importerer på `linux/amd64`
```bash
docker build --platform linux/amd64 --build-arg PYVER=3.12 -f Dockerfile.measure -t po-measure:py312 .
```
Exit 0. `uv sync --frozen --no-dev` resolverte inne i containeren — inkludert de to git-pinnede
avhengighetene — og siste lag ga `import OK`. Det lukker gapet §3/§4 ikke kunne lukke: uv-resolusjon
viser at avhengighetene *løser* for linux; dette viser at koden *importerer og kjører* der.
### 7.2 Wheelen er IKKE installerbar alene — målt
Andre måling bygde wheelen, **slettet kilden**, og installerte kun wheelen i et rent miljø. Den
feilet:
```
× No solution found when resolving dependencies:
╰─▶ Because llm-ingestion-guard was not found in the package registry
and portfolio-optimiser==1.0.0 depends on llm-ingestion-guard …
your requirements are unsatisfiable.
```
**Dette er den viktigste friksjonsobservasjonen i hele spiken.** Wheelens metadata bærer de to
avhengighetene som *bare navn*, fordi `[tool.uv.sources]` er uv-konfigurasjon og ikke reiser med
wheelen. Navnene finnes ikke på PyPI (§3). En nedlaster som får et wheel — f.eks. fra et
release-objekt, som er fase 3-raden — treffer denne veggen med mindre de to git-kravene oppgis ved
siden av. Det må stå i installasjonsdokumentasjonen, eller løses ved publisering.
Med direct references ved siden av virker det:
```bash
uv pip install /dist/*.whl \
"llm-ingestion-okf @ git+https://git.fromaitochitta.com/open/llm-ingestion-okf.git@v0.3.2" \
"llm-ingestion-guard @ git+https://git.fromaitochitta.com/open/llm-ingestion-pipeline-security.git@v0.3.4"
```
`Installed 65 packages`, exit 0.
### 7.3 Fase 4a holder i container
Samme bygg verifiserte pakkede data uten arbeidstre — `/build` var slettet før installasjonen:
```
pakke: /app/venv/lib/python3.12/site-packages/portfolio_optimiser
shared_root: /app/venv/lib/python3.12/site-packages/portfolio_optimiser/_shared
filer under shared_root: 80
PAKKEDE DATA OK
```
`shared/skills/expert-reviewer/SKILL.md` er lesbar derfra. Fase 4a-invarianten — wheelen bærer
`shared/` som pakkede data, arbeidstreet er kun en overstyring — er dermed målt i den situasjonen
den ble bygget for, ikke bare i enhetstest.
---
## Ikke verifisert
1. **Lokal kjøring uten Azure.** Planen og STATE påsto at `azd ai agent run` mot `localhost:8088`
er testbart uten Azure. Learn sier det motsatte: kommandoen krever `azd auth login`, henter
miljøvariabler fra et azd-miljø, og «The local agent still calls cloud-hosted models».
`--local` sparer *deploy*, ikke *Azure*. **Dette er kostnadsrelevant og bør inn i planen.**
2. **`AZURE_AI_MODEL_DEPLOYMENT_NAME` injiseres ikke.** Bekreftet injisert:
`FOUNDRY_PROJECT_ENDPOINT`, `FOUNDRY_PROJECT_ARM_ID`, `FOUNDRY_AGENT_NAME`,
`FOUNDRY_AGENT_VERSION`, `FOUNDRY_AGENT_SESSION_ID`, `FOUNDRY_AGENT_ID`,
`APPLICATIONINSIGHTS_CONNECTION_STRING`, `FOUNDRY_HOSTING_ENVIRONMENT`, `HOME`, `PORT`.
Modelldeployment-navnet deklarerer vi selv. `FOUNDRY_PROJECT_ENDPOINT` skal **ikke** redeklareres
i `azure.yaml` — det risikerer å skygge plattformens verdi. Kilden til vår antakelse var
MAF-hostingsiden, som motsier plattformdoksettet og Microsofts eget sample.
3. **Rollen for deploy er `Foundry Project Manager` på prosjekt-scope**, ikke `Foundry User` som
1b-forberedelsen bruker. De to er ulike oppgaver (kjøre modellkall vs. deploye en agent) og bør
ikke blandes.
4. **Protokollversjon i `azure.yaml`: `1.0.0` eller `2.0.0`.** Learn viser begge i samme doksett.
Container-protokoll 2.0.0 er separat fra protokoll-versjonsfeltet, og 1.0.0 er deprekert med
blokkering etter fristen. Må avklares mot JSON-skjemaet før vi skriver fila.
5. **`env:`-map vs `environmentVariables:`-liste**, og **`language: docker` vs `language: python`**
— Learn og samplet er uenige, og skjemafila er ikke lest.
6. **Påkrevd vs valgfritt felt-for-felt** for `azure.ai.agent`-tjenesten. Learns tabell har ingen
Required-kolonne; autoriteten er `schemas/v1.0/azure.yaml.json`, som ikke er lest.
7. **Ikke-root-krav** finnes ikke dokumentert, og Microsofts sample kjører som root uten `USER`.
Fravær av dokumentasjon er ikke en garanti.
8. **`agent-framework-foundry-hosting` er ikke installert**, og import-stien er omstridt:
Learn bruker `agent_framework_foundry_hosting`, et MAF-sample bruker `agent_framework.foundry`.
Vår installerte 1.8.2 eksporterer ingen av host-server-klassene. Siste PyPI-versjon er
`1.0.0b260730` — **ingen stabil release finnes.**
9. **Kompatibilitet** mellom hosting-pakka og `agent-framework-core` 1.9.0 er ikke testet.
10. **Foundry Agent Service i Norway East** — regionslista inkluderer den, men modell- og
kvotedekning per region er ikke sjekket.
---
## 8. Hvorfor eldre oppskrifter ikke kan brukes
Foundry har byttet hostingbackend. Det gamle public-preview-backendet er under avvikling
(<https://learn.microsoft.com/azure/foundry/agents/how-to/migrate-hosted-agent-preview>):
> «The initial public preview hosting backend is retiring. … Existing agent deployments on the old
> backend aren't migrated automatically and are supported only until August 20, 2026.»
**Dette er ingen frist for oss** — vi har aldri deployet, og har derfor ingenting å migrere. Det er
en *dateringsregel*: en blogg, oppskrift eller Learn-side skrevet mot det gamle backendet beskriver
en flate som ikke lenger er den vi bygger mot, uansett hvor godt den er skrevet. Konkret er tre
ting fra den epoken allerede ugyldige: `agent.manifest.yaml`/`agent.yaml` (§1.2),
`Foundry-Features: HostedAgents=V1Preview`-headeren, og protokollversjonsformatet `"v1"`.
Alt materialet i denne rapporten er hentet mot det gjeldende backendet. Det er også grunnen til at
rapporten siterer URL og dato på hver påstand i stedet for å oppsummere: neste økt skal kunne se
hva som var sant da, ikke bare hva jeg konkluderte.

View file

@ -0,0 +1,450 @@
<meta charset="utf-8">
<title>Systemet som sier nei til seg selv</title>
<style>
:root {
--ground: #F6F5F1;
--surface: #FFFFFF;
--surface-2: #EFEDE6;
--ink: #22272B;
--muted: #5C6570;
--line: #D9D6CC;
--accent: #C89B00;
--accent-ink: #7A5F00;
--steel: #35566F;
--ok-bg: #E3F0E7; --ok-fg: #1F5C38;
--warn-bg: #F6ECD4; --warn-fg: #7A5410;
--bad-bg: #F5E0DD; --bad-fg: #8C3128;
--code-bg: #EEECE4;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--ground: #15181B;
--surface: #1D2126;
--surface-2: #23282E;
--ink: #E9E7E1;
--muted: #9AA3AC;
--line: #343A41;
--accent: #E3B93F;
--accent-ink: #E3B93F;
--steel: #8FB4D2;
--ok-bg: #1E3327; --ok-fg: #8FCCA6;
--warn-bg: #38301A; --warn-fg: #E0BE6A;
--bad-bg: #3A2523; --bad-fg: #E09A92;
--code-bg: #232830;
}
}
:root[data-theme="dark"] {
--ground: #15181B;
--surface: #1D2126;
--surface-2: #23282E;
--ink: #E9E7E1;
--muted: #9AA3AC;
--line: #343A41;
--accent: #E3B93F;
--accent-ink: #E3B93F;
--steel: #8FB4D2;
--ok-bg: #1E3327; --ok-fg: #8FCCA6;
--warn-bg: #38301A; --warn-fg: #E0BE6A;
--bad-bg: #3A2523; --bad-fg: #E09A92;
--code-bg: #232830;
}
* { box-sizing: border-box; }
body {
background: var(--ground);
color: var(--ink);
font-family: Charter, "Bitstream Charter", Cambria, Georgia, serif;
font-size: 17px;
line-height: 1.65;
margin: 0;
padding: 0 20px 80px;
}
.page { max-width: 860px; margin: 0 auto; }
.prose { max-width: 72ch; }
h1, h2, h3, h4, .sans {
font-family: -apple-system, "Segoe UI", system-ui, "Helvetica Neue", Arial, sans-serif;
}
h1 { font-size: 2.1rem; font-weight: 650; letter-spacing: -0.015em; line-height: 1.15; text-wrap: balance; margin: 0.4rem 0 0.6rem; }
h2 { font-size: 1.35rem; font-weight: 650; letter-spacing: -0.01em; margin: 0 0 0.9rem; text-wrap: balance; }
h3 { font-size: 1.05rem; font-weight: 650; margin: 1.6rem 0 0.5rem; }
p { margin: 0 0 1rem; }
a { color: var(--steel); text-decoration-thickness: 1px; text-underline-offset: 2px; }
a:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
strong { font-weight: 650; }
.eyebrow {
font-family: -apple-system, "Segoe UI", system-ui, sans-serif;
font-size: 0.72rem; font-weight: 650;
text-transform: uppercase; letter-spacing: 0.09em;
color: var(--accent-ink);
}
header.doc { padding: 56px 0 8px; }
.meta { display: flex; flex-wrap: wrap; gap: 8px; margin: 14px 0 0; }
.chip {
font-family: -apple-system, "Segoe UI", system-ui, sans-serif;
font-size: 0.78rem; color: var(--muted);
border: 1px solid var(--line); border-radius: 999px;
padding: 3px 11px; background: var(--surface);
}
.lead { font-size: 1.06rem; color: var(--muted); max-width: 66ch; margin-top: 10px; }
section { border-top: 1px solid var(--line); padding: 34px 0 10px; }
.secmark { display: flex; align-items: baseline; gap: 12px; margin-bottom: 14px; }
.secmark .no {
font-family: -apple-system, "Segoe UI", system-ui, sans-serif;
font-variant-numeric: tabular-nums;
font-size: 0.8rem; font-weight: 650; color: var(--accent-ink);
border-bottom: 2px solid var(--accent); padding-bottom: 2px;
}
.callout {
background: var(--surface);
border: 1px solid var(--line);
border-left: 3px solid var(--accent);
padding: 18px 22px;
font-size: 1.08rem;
max-width: 72ch;
}
.callout p { margin: 0; }
.callout p + p { margin-top: 0.8rem; }
.flag { color: var(--warn-fg); font-weight: 600; white-space: nowrap; }
code {
font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
font-size: 0.85em;
background: var(--code-bg);
border-radius: 3px;
padding: 1px 5px;
}
/* Forbehold */
.forbehold { display: grid; gap: 14px; margin: 14px 0 6px; }
.fb {
background: var(--surface);
border: 1px solid var(--line);
padding: 16px 20px;
display: grid; grid-template-columns: 34px 1fr; gap: 14px;
}
.fb .n {
font-family: -apple-system, "Segoe UI", system-ui, sans-serif;
font-weight: 650; font-size: 1.1rem; color: var(--accent-ink);
font-variant-numeric: tabular-nums; line-height: 1.5;
}
.fb p { margin: 0; }
.fb .t { font-family: -apple-system, "Segoe UI", system-ui, sans-serif; font-weight: 650; display: block; margin-bottom: 4px; }
/* Regnestykket på skjermen */
.tally { display: grid; gap: 8px; margin: 18px 0 20px; }
.tally-row {
display: grid; grid-template-columns: 132px 1fr;
gap: 16px; align-items: baseline;
background: var(--surface); border: 1px solid var(--line);
border-left: 3px solid var(--line);
padding: 12px 18px;
font-family: -apple-system, "Segoe UI", system-ui, sans-serif;
font-size: 0.92rem;
}
.tally-row.hit { border-left-color: var(--accent); background: var(--surface-2); }
.tally-row .amt { font-weight: 650; font-variant-numeric: tabular-nums; white-space: nowrap; font-size: 1.02rem; }
.tally-row .what { color: var(--muted); }
.tally-row .what b { color: var(--ink); font-weight: 650; }
@media (max-width: 560px) {
.tally-row { grid-template-columns: 1fr; gap: 4px; }
}
/* Faser / steg */
.phase {
background: var(--surface);
border: 1px solid var(--line);
margin: 0 0 18px;
padding: 20px 24px 14px;
}
.phase-head { display: flex; align-items: baseline; gap: 14px; border-bottom: 1px solid var(--line); padding-bottom: 12px; margin-bottom: 14px; flex-wrap: wrap; }
.phase-no {
font-family: -apple-system, "Segoe UI", system-ui, sans-serif;
font-weight: 700; font-size: 0.95rem;
color: var(--accent-ink);
border: 2px solid var(--accent); border-radius: 4px;
padding: 1px 8px; white-space: nowrap;
}
.phase-title { font-family: -apple-system, "Segoe UI", system-ui, sans-serif; font-weight: 650; font-size: 1.08rem; }
.phase-when { color: var(--muted); font-family: -apple-system, "Segoe UI", system-ui, sans-serif; font-size: 0.85rem; margin-left: auto; white-space: nowrap; }
.phase h4 {
font-size: 0.74rem; font-weight: 650; text-transform: uppercase; letter-spacing: 0.08em;
color: var(--muted); margin: 1.1rem 0 0.4rem;
}
.phase ul, .prose ul, .prose ol { margin: 0 0 1rem; padding-left: 1.3rem; }
.phase li, .prose li { margin-bottom: 0.45rem; }
.phase p:last-child { margin-bottom: 0.6rem; }
.krit { background: var(--surface-2); border-left: 3px solid var(--accent); padding: 10px 16px; font-size: 0.95rem; }
.krit p { margin: 0; }
/* Tabeller */
.table-scroll { overflow-x: auto; border: 1px solid var(--line); background: var(--surface); margin: 14px 0 20px; }
table {
border-collapse: collapse; width: 100%;
font-family: -apple-system, "Segoe UI", system-ui, sans-serif;
font-size: 0.86rem; line-height: 1.5;
font-variant-numeric: tabular-nums;
}
th {
text-align: left; font-weight: 650; font-size: 0.74rem;
text-transform: uppercase; letter-spacing: 0.06em;
color: var(--steel);
border-bottom: 2px solid var(--line);
padding: 10px 14px; white-space: nowrap;
}
td { border-bottom: 1px solid var(--line); padding: 10px 14px; vertical-align: top; }
tr:last-child td { border-bottom: none; }
td.num { white-space: nowrap; color: var(--muted); }
.pill {
display: inline-block; border-radius: 999px;
padding: 1px 10px; font-size: 0.78rem; font-weight: 600; white-space: nowrap;
}
.pill.ok { background: var(--ok-bg); color: var(--ok-fg); }
.pill.warn { background: var(--warn-bg); color: var(--warn-fg); }
.pill.bad { background: var(--bad-bg); color: var(--bad-fg); }
.foot { border-top: 1px solid var(--line); margin-top: 40px; padding-top: 18px; color: var(--muted); font-size: 0.85rem; font-family: -apple-system, "Segoe UI", system-ui, sans-serif; }
/* TOC */
nav.toc {
font-family: -apple-system, "Segoe UI", system-ui, sans-serif;
font-size: 0.88rem;
display: flex; flex-wrap: wrap; gap: 6px 18px;
padding: 16px 0 26px;
}
nav.toc a { color: var(--muted); text-decoration: none; }
nav.toc a:hover { color: var(--steel); text-decoration: underline; }
nav.toc .no { color: var(--accent-ink); font-weight: 650; font-size: 0.78rem; margin-right: 4px; }
</style>
<div class="page">
<header class="doc">
<div class="eyebrow">Demo-underlag · portfolio-optimiser v1.0.0</div>
<h1>Systemet som sier nei til seg selv</h1>
<p class="lead">Et rammeverk som leter etter kostnadsbesparelser inne i hvert prosjekt — der ingen besparelse er godkjent før et deterministisk regnestykke har fått avvise den, og der din fagvurdering blir varig kunnskap i systemet. Dette underlaget er skrevet for deg som kan faget, ikke maskineriet, og som skal kunne svare for dette overfor dem som sitter på budsjettet.</p>
<div class="meta">
<span class="chip">13. august 2026</span>
<span class="chip">Kode: v1.0.0, 810 tester grønne</span>
<span class="chip">Demoen er skriptet — se del 6</span>
<span class="chip">⚠️ = ikke verifisert</span>
</div>
</header>
<nav class="toc" aria-label="Innhold">
<a href="#anbefaling"><span class="no">1</span>Kortversjonen</a>
<a href="#problemet"><span class="no">2</span>Problemet</a>
<a href="#grepet"><span class="no">3</span>Grepet</a>
<a href="#skjermen"><span class="no">4</span>Det du ser</a>
<a href="#fagfolk"><span class="no">5</span>Fagfolkene</a>
<a href="#forbehold"><span class="no">6</span>Tre forbehold</a>
<a href="#status"><span class="no">7</span>Status i dag</a>
<a href="#neste"><span class="no">8</span>Hva vi ber om</a>
<a href="#sporsmaal"><span class="no">9</span>Spørsmål du får</a>
<a href="#verifisering"><span class="no">10</span>Verifiseringslogg</a>
</nav>
<section id="anbefaling">
<div class="secmark"><span class="no">1</span><h2>Kortversjonen</h2></div>
<div class="callout">
<p>Det finnes mange verktøy som kan <em>foreslå</em> kostnadskutt. Problemet i en offentlig etat er ikke å få forslag — det er å vite hvilke av dem som tåler å bli lagt fram. <strong>Dette systemet er bygget rundt en kontroll som kan avvise systemets eget beste forslag, og som gjør det uten å spørre modellen om lov.</strong></p>
<p>I demoen skjer nettopp det: forslaget påstår 2,1 millioner i besparelse, kontrollen regner etter og avviser det, og det som til slutt godkjennes er 445 500 kroner. <strong>Det er ikke en svakhet ved demoen — det er produktet.</strong></p>
</div>
<div class="prose">
<p>Kjernen er én setning: <em>maskinen får foreslå, men den får ikke godkjenne seg selv — og kontrollen som avgjør er vanlig regnekode, ikke en språkmodell.</em> Resten av dokumentet er belegg for den setningen, og forbeholdene i del 6 avgrenser hva den ikke betyr.</p>
</div>
</section>
<section id="problemet">
<div class="secmark"><span class="no">2</span><h2>Problemet vi prøver å løse</h2></div>
<div class="prose">
<p><strong>Et forslag om penger er verdiløst hvis ingen kan si om tallet holder.</strong> En språkmodell kan skrive et velformulert notat om at man sparer to millioner på å bytte armaturer. Notatet vil se riktig ut, argumentene vil henge sammen, og kildene vil bli nevnt. Det som mangler er den ene tingen en etat trenger før tallet kan brukes: noen som har regnet etter, uavhengig av den som foreslo.</p>
<p><strong>Det er derfor KI stopper ved notatet i dag.</strong> Forslaget må uansett gjennom en manuell fagvurdering før noen tør å bruke det, og da har man flyttet arbeid, ikke spart det. Verre: et flytende formulert feilaktig tall er farligere enn ingen tall, fordi det er vanskeligere å avvise i et møte.</p>
<p><strong>Og det andre problemet: fagvurderingen forsvinner.</strong> Når en erfaren fagperson sier «dette realiseres erfaringsvis ikke fullt ut i drift», blir det stående i en e-post eller i et referat. Neste gang samme spørsmål dukker opp, i et annet prosjekt, må vedkommende si det på nytt. Kunnskapen finnes i organisasjonen, men den akkumulerer ikke noe sted et system kan bruke den.</p>
</div>
</section>
<section id="grepet">
<div class="secmark"><span class="no">3</span><h2>Grepet — to uavhengige kontroller, og det er regnestykket som blokkerer</h2></div>
<div class="prose">
<p>Systemet setter to helt ulike kontroller på hvert forslag, og det er avgjørende at de er ulike:</p>
<ul>
<li><strong>Den ene leser resonnementet.</strong> En egen agent har som eneste jobb å angripe begrunnelsen: henger argumentet sammen, er forutsetningene rimelige, er noe utelatt? Dette er språkarbeid, og en språkmodell er god til det.</li>
<li><strong>Den andre regner.</strong> En deterministisk kontroll — vanlig programkode, ingen modell involvert — sjekker hver kostnadslinje mot prosjektets erklærte kostnadsgrunnlag og kjører beregningen som avgjør om beløpet er innenfor det som er praktisk oppnåelig. Den kan ikke overtales, den gir samme svar hver gang, og den er obligatorisk.</li>
</ul>
<p><strong>Når de er uenige, vinner den som regner.</strong> Det er hele arkitekturen i én setning. En godkjennelse fra språkmodellen er ikke nok til å slippe et tall gjennom; en avvisning fra regnestykket er nok til å stoppe det.</p>
<p>Kontrollen gjør dessuten én ting til, før den i det hele tatt begynner å regne: den sjekker at kostnadslinjene forslaget viser til, <em>finnes i prosjektet</em>, og at mengdene og enhetsprisene stemmer med det prosjektet faktisk har oppgitt. Et oppdiktet tall kommer altså aldri fram til beregningen. Og systemet retter ikke opp — det avviser. Å la maskinen «korrigere» et tall til noe som passer, ville vært den ene tingen som gjorde hele kontrollen verdiløs.</p>
</div>
</section>
<section id="skjermen">
<div class="secmark"><span class="no">4</span><h2>Det du ser på skjermen — fire bevegelser</h2></div>
<div class="prose">
<p>Demoen kjører på under tre sekunder og viser ett prosjekt: utskifting av veglysarmaturer på en fylkesvei. Den går gjennom åtte steg, men for å gjenfortelle den holder det med fire bevegelser.</p>
</div>
<div class="phase">
<div class="phase-head"><span class="phase-no">1</span><span class="phase-title">Den leser seg opp</span><span class="phase-when">skjermens øverste del</span></div>
<p>Systemet navigerer seg gjennom en kunnskapsbase om prosjektet — fagkilder, tidligere tiltak, metodebeskrivelser. Det er verdt å merke seg at det <em>navigerer</em>: det følger lenker mellom dokumentene slik et menneske ville gjort, i stedet for å klippe ut tekstbiter som ligner på søkeordene.</p>
<p>Legg merke til linja som sier <strong>«tidligere dommer hentet for kandidaten: 0»</strong>. Første kjøring skjer mot en tom erfaringsbase, med vilje. Det er kontrollen som gjør at vi senere kan bevise at læringen faktisk skjedde, og ikke bare lå der fra før.</p>
</div>
<div class="phase">
<div class="phase-head"><span class="phase-no">2</span><span class="phase-title">Den foreslår, og en annen agent utfordrer</span><span class="phase-when">Steg 23</span></div>
<p>Forslaget kommer med parametere og kostnadslinjer: bytte 2 500 eldre armaturer, påstått besparelse <strong>2 100 000 kroner</strong>. En andre agent går løs på begrunnelsen og konkluderer med at resonnementet holder.</p>
<p>På dette punktet ville de fleste KI-verktøy vært ferdige. Her er det halvveis.</p>
</div>
<div class="phase">
<div class="phase-head"><span class="phase-no">3</span><span class="phase-title">Regnestykket avviser det</span><span class="phase-when">Steg 46 · øyeblikket å legge merke til</span></div>
<p>Den deterministiske kontrollen regner, og avviser:</p>
<div class="tally">
<div class="tally-row"><span class="amt">2 100 000 kr</span><span class="what">påstått av forslaget, og <b>godkjent av den agenten som leste resonnementet</b></span></div>
<div class="tally-row"><span class="amt">1 769 915 kr</span><span class="what">det kontrollen regner ut som realistisk øvre grense for dette prosjektet</span></div>
<div class="tally-row hit"><span class="amt">445 500 kr</span><span class="what"><b>det som til slutt godkjennes</b>, etter at forslaget er bedt om å prøve på nytt med begrunnelsen for avvisningen i hånda</span></div>
</div>
<p>Avvisningen sendes tilbake til forslagsstilleren som en begrunnelse, ikke som et blankt nei — men forsøkene er <strong>tellet og begrenset</strong>. Systemet får ikke lov til å prøve i det uendelige til noe glir gjennom. Det er forskjellen på en kontroll og en formalitet.</p>
<div class="krit"><p><strong>Setningen som bærer det hele:</strong> den ene kontrollen godkjente resonnementet, den andre avviste tallet — og det er den som regner som blokkerer.</p></div>
</div>
<div class="phase">
<div class="phase-head"><span class="phase-no">4</span><span class="phase-title">En fagperson dømmer, og systemet husker det</span><span class="phase-when">Steg 78 og kjøring B</span></div>
<p>En fagekspert vurderer utfallet og godkjenner det — men med en korreksjon: i drift realiseres erfaringsvis rundt <strong>79 %</strong> av en slik beregnet besparelse. Den vurderingen løftes inn i kunnskapsbasen.</p>
<p>Så kjøres det samme prosjektet én gang til. Nå står det <strong>3</strong> tidligere dommer i stedet for 0, og fagpersonens korreksjon er med i grunnlaget når neste forslag formes. Utfallet blir det samme tiltaket til samme beløp — og det er riktig og verdt å si høyt: <strong>læringen endret ikke svaret her, den endret grunnlaget svaret ble formet på.</strong></p>
</div>
</section>
<section id="fagfolk">
<div class="secmark"><span class="no">5</span><h2>Din rolle — hvorfor dette ikke er «KI som erstatter fagvurdering»</h2></div>
<div class="prose">
<p>Mennesker er inne i begge ender av kjeden, og bevisst ikke i midten. Dere lager kunnskapsgrunnlaget systemet leser, og dere dømmer utfallet når maskinen er ferdig. Grovarbeidet i mellom — å gå gjennom prosjekt etter prosjekt og lete etter kandidater — er det maskinen gjør. <strong>Det er ikke dømmekraften som settes ut; det er letingen.</strong></p>
<p><strong>Vurderingen kan avgis når det passer deg.</strong> Systemet venter ikke med åpen skjerm. Du kan legge svaret ditt i en innboks dager etter kjøringen, i ditt eget fagspråk, og neste kjøring plukker det opp. Demoen viser begge tidsskalaene: en vurdering avgitt underveis, og et driftsnotat som kom etterpå.</p>
<p><strong>Bare det et menneske har godkjent, blir varig kunnskap.</strong> Porten inn til kunnskapsbasen er stengt for alt annet: rå maskinoutput kommer aldri inn. Det er den mekanismen som hindrer at systemet over tid lærer av seg selv og driver av gårde.</p>
<p>Fagpersonenes egen gjennomgang av hva dette betyr for dem, ligger i det andre underlaget til denne demoen — <em>«Fagfolk dømmer. Maskinen gjør grovarbeidet.»</em></p>
</div>
</section>
<section id="forbehold">
<div class="secmark"><span class="no">6</span><h2>Tre forbehold</h2></div>
<div class="prose"><p>Grunnregelen systemet er bygget på, er at det ikke får påstå mer enn det gjør. En demo som overselger, bryter med akkurat det den demonstrerer — så disse tre står like tydelig som resten.</p></div>
<div class="forbehold">
<div class="fb"><span class="n">1</span>
<p><span class="t">Agentenes svar i demoen er skriptet — det er ingen levende språkmodell i rommet.</span>Det som demonstreres er at dataflyten virker, at den deterministiske ryggraden faktisk blokkerer, og at læringssløyfa lukkes. Rammeverket selv har kjørt mot en levende modell én gang, 14. august 2026 — etter at dette underlaget ble skrevet: modellen svarte i den formen systemet bestiller, den fant opp en kostnadslinje som ikke finnes i kunnskapsbasen, og regneporten avviste forslaget. At et forslag fra en levende modell kommer <em>gjennom</em> porten, er fortsatt ikke vist.</p>
</div>
<div class="fb"><span class="n">2</span>
<p><span class="t">Kunnskapsbasen i demoen er laget for hånd.</span>Et menneske har skrevet den. Det finnes en vei for å hente eksterne kilder inn i formatet — og den skanner nå innholdet for manipulert kildetekst før det skrives — men eksempelet her gikk ikke gjennom den, og den generiske «fabrikken» som skal produsere slike baser for vilkårlige fagområder, er bevisst ikke bygget ennå.</p>
</div>
<div class="fb"><span class="n">3</span>
<p><span class="t">Tallene er modellerte, ikke målte.</span><span class="flag">⚠️</span> Ingen pilot har validert dem i drift. 445 500 kroner er hva beregningen gir for et syntetisk eksempel med oppgitte forutsetninger — det er ikke en besparelse noen har realisert. <strong>Ingen bør sitere et kronebeløp fra denne demoen som en oppnådd gevinst.</strong> Det demoen viser, er at metoden avviser det den ikke kan forsvare; hva den er verdt i kroner, er nettopp det en pilot skal svare på.</p>
</div>
</div>
</section>
<section id="status">
<div class="secmark"><span class="no">7</span><h2>Status i dag — hva finnes, og hva finnes ikke</h2></div>
<div class="table-scroll">
<table>
<thead><tr><th>Område</th><th>Status</th><th>Hva det betyr</th></tr></thead>
<tbody>
<tr><td>Hele kjeden fra kontekst til lagret dom</td><td><span class="pill ok">Bygget</span></td><td>Alle åtte steg er koblet sammen og kjører ende til ende. Det er dette demoen viser.</td></tr>
<tr><td>Den deterministiske kontrollen</td><td><span class="pill ok">Bygget</span></td><td>Obligatorisk og blokkerende — kan ikke slås av eller gjøres til en valgfri tilleggsmodul.</td></tr>
<tr><td>Læring fra fagvurderinger</td><td><span class="pill ok">Bygget</span></td><td>Begge tidsskalaer: vurdering avgitt underveis, og vurdering avgitt dager etterpå.</td></tr>
<tr><td>Kodekvalitet</td><td><span class="pill ok">810 tester</span></td><td>810 automatiske tester går grønt, 4 er hoppet over. Målt i dag på den versjonen som demonstreres.</td></tr>
<tr><td>Kjøring mot ekte språkmodell</td><td><span class="pill warn">Ikke prøvd i skala</span></td><td>Rammeverket støtter det (Azure og lokal profil), men er ikke kjørt i omfang — det koster penger vi ikke har brukt.</td></tr>
<tr><td>Fabrikk for kunnskapsbaser</td><td><span class="pill warn">Bevisst utsatt</span></td><td>Kunnskapsbaser lages for hånd i dag. Å automatisere det er et eget prosjekt.</td></tr>
<tr><td>Pilot på ekte prosjektdata</td><td><span class="pill bad">Ikke gjort</span></td><td>Dette er hovedhullet, og det er dette del 8 handler om.</td></tr>
</tbody>
</table>
</div>
<div class="prose">
<p>Koden er åpen og fritt tilgjengelig (MIT-lisens), bygget på Microsofts Agent Framework. Det er ingen leverandørbinding og ingen lisenskostnad i selve rammeverket.</p>
</div>
</section>
<section id="neste">
<div class="secmark"><span class="no">8</span><h2>Hva vi ber om</h2></div>
<div class="callout">
<p><strong>Vi ber ikke om en budsjettpost. Vi ber om en beslutning om å prøve metoden på ekte tall, én gang, i avgrenset form.</strong></p>
</div>
<div class="prose">
<p>Det er den ærlige bestillingen på dette stadiet. Å be om finansiering av et program før metoden har møtt ekte prosjektdata, ville vært å be om tillit vi ikke har målt oss fram til ennå — og det er den samme feilen systemet selv er bygget for å unngå.</p>
</div>
<h3>Hva en pilot krever</h3>
<div class="prose">
<ol>
<li><strong>Én portefølje med ekte kostnadstall.</strong> Ikke en stor en. Metoden trenger prosjekter med et oppgitt kostnadsgrunnlag å avstemme mot — det er nettopp det avstemmingen forutsetter.</li>
<li><strong>Navngitte fagpersoner som får dømme.</strong> Uten dem finnes ingen læringssløyfe, og da er halve poenget borte. Innsatsen per vurdering er liten, men den må være noens jobb, ikke noens overskuddstid.</li>
<li><strong>Et modellbudsjett.</strong> <span class="flag">⚠️</span> Størrelsen er ikke estimert ennå — den avhenger av hvor mange prosjekter piloten omfatter, og må regnes ut når omfanget er valgt. Rammeverket har harde tak på forbruk innebygd, nettopp fordi kostnadskontroll ikke kan være noe man husker på.</li>
<li><strong>En avtalt målestokk på forhånd.</strong> Hva skal piloten ha vist for at den regnes som vellykket? Det bør bestemmes før den kjøres, ikke etterpå.</li>
</ol>
</div>
<h3>Hva piloten skal svare på</h3>
<div class="krit"><p>Finner metoden besparelser i ekte prosjekter som fagfolk faktisk godkjenner — og hvor mange av maskinens forslag blir avvist av kontrollen underveis? Begge tallene er interessante. Et system som aldri avviser noe, er ikke et system som har kontrollert noe.</p></div>
<h3>Hva vi ber om fra dere i dag</h3>
<div class="prose">
<p>Dere er de eneste i rommet som kan avgjøre det som betyr noe her, og det er verdt å si rett ut: <strong>vi ber dere gjøre mot denne metoden nøyaktig det systemet ber dere gjøre mot hvert enkelt forslag.</strong> Døm den. Tre spørsmål, og et ærlig nei på noen av dem er et nyttigere utfall enn en høflig ja:</p>
<ol>
<li><strong>Er dette gjenkjennelig fra ditt fagfelt?</strong> Er den typen tiltak, og den typen forbehold om realisering i drift, slik du ville formulert det selv?</li>
<li><strong>Ville du stolt på et tall som har vært gjennom denne kontrollen?</strong> Ikke stolt nok til å slutte å se på det — men nok til at det er verdt din tid å vurdere det.</li>
<li><strong>Er det verdt å prøve på ekte tall?</strong> Og i så fall: hvilken portefølje er den riktige å begynne med?</li>
</ol>
<p>Sier dere ja til det tredje, er det den anbefalingen som skal videre til budsjettsiden — <em>fra dere</em>, ikke fra teknologimiljøet. En metode for å vurdere kostnadstall har ikke troverdighet fordi den er teknisk velbygget; den har troverdighet når fagfolk med ansvar sier at den regner riktig.</p>
</div>
</section>
<section id="sporsmaal">
<div class="secmark"><span class="no">9</span><h2>Spørsmål du kan få — og svarene</h2></div>
<div class="table-scroll">
<table>
<thead><tr><th>Spørsmål</th><th>Svar</th></tr></thead>
<tbody>
<tr><td>Er dette en ekte KI-modell?</td><td>Ikke i demoen — agentsvarene er skriptet, og det står i åpningsbildet. Det som er ekte er dataflyten, den deterministiske kontrollen og at læringen faktisk går gjennom fil.</td></tr>
<tr><td>Hva hvis modellen finner på et tall?</td><td>Kontrollen avstemmer hver kostnadslinje mot prosjektets erklærte kostnadsgrunnlag før beregningen i det hele tatt starter. En ukjent kostnadskode, eller en mengde som ikke stemmer, blir avvist. Systemet retter ikke opp — det avviser.</td></tr>
<tr><td>Erstatter dette fagfolk?</td><td>Nei. Mennesker lager grunnlaget og dømmer utfallet. Maskinen gjør letearbeidet i mellom.</td></tr>
<tr><td>Hvorfor viste første kjøring null tidligere erfaringer?</td><td>Med vilje — første kjøring går mot tom erfaringsbase. Uten den kontrollen kunne man ikke skille «systemet lærte noe» fra «det lå der fra før».</td></tr>
<tr><td>Kan vi styre hva som analyseres?</td><td>Ja. En kjøring kan bestilles med en oppdragsfil der du skriver hva den er til for og hvilke tilnærminger du vil ha vurdert. Men bestillingen styrer hva som <em>vurderes</em>, aldri hva som <em>godkjennes</em> — kontrollen gjelder uendret. Det er ikke vist i denne demoen.</td></tr>
<tr><td>Hva koster det å bruke?</td><td>Rammeverket er åpen kildekode uten lisenskostnad. Driftskostnaden er modellbruk, og den har innebygde tak. <span class="flag">⚠️</span> Konkret beløp avhenger av omfang og er ikke estimert.</td></tr>
<tr><td>Går dataene våre ut av huset?</td><td>Det bestemmer den som setter det opp. Rammeverket kan kjøre helt lokalt, og det gjør ingen nettverkskall som ikke er konfigurert eksplisitt. Personvernvurdering og risikovurdering tilhører den som tar systemet i bruk — det er bevisst ikke bygget inn påstander om compliance.</td></tr>
<tr><td>Kan den kjøre en hel portefølje?</td><td>Biblioteket har porteføljekjøring med globalt kostnadstak. Denne demoen kjører <em>ett</em> prosjekt, og porteføljestien er ikke prøvekjørt denne uka — ikke lov bort en live demonstrasjon av den.</td></tr>
</tbody>
</table>
</div>
</section>
<section id="verifisering">
<div class="secmark"><span class="no">10</span><h2>Verifiseringslogg — hvor tallene i dette dokumentet kommer fra</h2></div>
<div class="prose"><p>Hver tallpåstand over er målt, ikke gjengitt fra hukommelsen. Dette er kildene, slik at den som blir utfordret kan svare presist.</p></div>
<div class="table-scroll">
<table>
<thead><tr><th>Påstand</th><th>Kilde</th><th>Status</th></tr></thead>
<tbody>
<tr><td>2 100 000 påstått · 1 769 915 grense · 445 500 godkjent</td><td>Det innsjekkede demo-transkriptet, linje 19, 24 og 27</td><td><span class="pill ok">Målt</span></td></tr>
<tr><td>Realiseringsgrad 79 % (fagpersonens korreksjon)</td><td>Samme transkript, linje 32 og 45</td><td><span class="pill ok">Målt</span></td></tr>
<tr><td>3 tidligere dommer i andre kjøring, 1 fra basen + 2 lært</td><td>Samme transkript, linje 5051 — regnet ut av kjøringen selv</td><td><span class="pill ok">Målt</span></td></tr>
<tr><td>810 tester grønne, 4 hoppet over</td><td><code>uv run pytest</code> kjørt 13.08.2026 på v1.0.0</td><td><span class="pill ok">Målt</span></td></tr>
<tr><td>Demoen kjører på under 3 sekunder</td><td>Målt ved generalprøve 12.08.2026</td><td><span class="pill ok">Målt</span></td></tr>
<tr><td>Versjon v1.0.0 satt og publisert</td><td><code>git describe</code> og oppslag mot server, begge 13.08.2026</td><td><span class="pill ok">Målt</span></td></tr>
<tr><td>Kostnaden ved en pilot</td><td>Ingen — omfanget er ikke valgt ennå</td><td><span class="pill bad">Ikke estimert</span></td></tr>
<tr><td>Gevinst i kroner ved bruk i etaten</td><td>Ingen — ingen pilot har kjørt</td><td><span class="pill bad">Ikke målt</span></td></tr>
</tbody>
</table>
</div>
<div class="prose">
<p>De to nederste radene er de viktigste i tabellen. At de står der tomme, er ikke en mangel ved dokumentet — det er grunnen til at bestillingen i del 8 er en pilot og ikke et program.</p>
</div>
</section>
<div class="foot">
Underlag til demo 13. august 2026 · portfolio-optimiser v1.0.0 · åpen kildekode (MIT), bygget på Microsoft Agent Framework.<br>
Søsterdokument for fagekspertene: «Fagfolk dømmer. Maskinen gjør grovarbeidet.»
</div>
</div>

View file

@ -0,0 +1,247 @@
# Fase 1b — den første levende kjøringen (14. august 2026)
Dette dokumentet er et **måleprotokoll**, ikke en konklusjon. Alt under er kjørt; ingenting er utledet.
Der noe ikke er bevist, står det.
## 0. Hva som ble opprettet
| Ressurs | Verdi |
|---|---|
| Ressursgruppe | `<resource-group>` (eastus) |
| Foundry-ressurs | `<resource>``kind: AIServices`, `--allow-project-management` |
| Prosjekt | `<project>` |
| Deployment | `gpt-4-1-mini` (modell `gpt-4.1-mini`, versjon `2025-04-14`, GlobalStandard) |
| Prosjekt-endepunkt | `https://<resource>.services.ai.azure.com/api/projects/<project>` |
| Rolle | `Foundry User` (`53ca6127-…`) på **prosjekt**-scope |
Ressurs-, prosjekt- og vertsnavnene er byttet mot plassholdere (`<resource-group>` / `<resource>` /
`<project>`, samme form som `DEPLOY.md` og auth-oppskriften). Modellnavn, versjon og region er
offentlig Azure-nomenklatur og står som målt.
**`gpt-4o-mini` ble avvist av plattformen** med `ServiceModelDeprecating` — modellnavnet måtte måles
med `az cognitiveservices model list`, ikke hentes fra hukommelsen. Kommandoene er hentet fra
Microsoft Learn (`az cognitiveservices account create` / `account project create`), ikke formulert
fritt.
**Rotårsaken til at dette ikke fantes før** var prosedural, ikke teknisk: den påloggede identiteten
har vært **Owner på abonnementet** hele tiden, mens repoets egen state-fil hadde ført opprettelsen
opp som operatørens oppgave og samtidig sagt at operatøren aldri kjører kommandoer selv. Arbeidet
tilhørte ingen, og hver økt målte lydig på nytt at det ikke fantes.
## 1. Stigen — billigste trinn først
Disiplinen fra fase 1a: bevis så mye som mulig før det dyre trinnet, så en feil er attribuerbar.
| Trinn | Kommando | Utfall |
|---|---|---|
| 1 | `az account get-access-token --resource https://ai.azure.com` | exit 0 |
| 2 | `preflight --profile azure` (fra **utpakket overleveringspakke**) | `preflight OK (azure)` |
| 3 | `pytest tests/test_foundry_profile_live.py` (gatet triviell probe) | **1 passed, 5,02 s** |
| 4 | Full `run_project` mot levende modell | **RC=1 — `BudgetExceeded`** |
**Trinn 3 er prosjektets første levende modellkall noensinne.** Det beviser at auth, RBAC,
endepunkt-form og deployment-navn komponerer — og at en rød trinn 4 derfor *ikke* kan skyldes noen
av dem.
## 2. Trinn 4 — hva som faktisk skjedde
```
uv run python -m portfolio_optimiser.run BYGG-KONTOR-NORD \
--profile azure \
--docs-dir shared/examples/bygg-energi-mikro \
--bundle-dir shared/examples/bygg-energi-mikro \
--outbox-dir <tmp>/outbox --run-id live-001
```
stdout var **tom**. stderr bar to linjer som betyr noe:
```
GroupChatOrchestrator reached max_rounds=3; forcing completion.
portfolio_optimiser.budget.BudgetExceeded: budget exceeded: rounds limit=12 observed=13
```
Den første er forventet — maker/checker-debatten kjører til taket også i den offline demoen. Den
andre er funnet.
### Diagnosen, utledet av kode og ikke av flere betalte kjøringer
`generate._fetch_parsed` er en `while True` som kaller `meter.tick_round()` for **hvert** forsøk og
`continue`-er ved parse-feil. Rund-budsjettet i genereringsfasen er `max(max_rounds * 4, 4)` = **12**
(`run.py:546`). Hadde modellsvarene parset til IR-formen, ville tre validerings-forsøk kostet tre
runder. At alle tolv gikk med betyr at **de fleste svarene fra `gpt-4.1-mini` ikke lot seg parse**
taket ble brent på formatfeil, ikke på validator-avslag.
**Dette er et FUNN, ikke en diagnose som er ferdig.** Det som mangler for å lukke den, er den råe
svarteksten, og den finnes ikke i noen artefakt i dag (`--outbox-dir` skrev kun
`live-001-runconfig.json`). Å skaffe den krever en endring i koden, altså Iron-Law-arbeid — ikke en
rask omkjøring med høyere tak, som ville kostet penger og fortsatt ikke sagt hvorfor.
### En bieffekt som er verdt å uttale
`BudgetExceeded` forlot kjøringen som en **uhåndtert exception med traceback**, ikke som et
strukturert utfall. På den hostede flaten ville nøyaktig dette blitt `HTTP 500` — altså ville
ressurs-utmattelse (en normal, forventet tilstand) presentert seg for en ekstern kaller som en
serverfeil. Det er ikke rettet her; det er notert.
## 3. Hva som ER bevist, og hva som IKKE er det
**Bevist, målt:**
- Rammeverket når en levende Foundry-modell: auth, RBAC på prosjekt-scope, endepunkt-form,
deployment-oppslag og klientbygging virker.
- Kunnskapsbase-navigasjon, debatt-orkestrering og genererings-løkka kjører mot ekte modellsvar —
kjøringen døde *inne i* løkka, ikke før den.
- Preflight fra den **utpakkede overleveringspakka** er grønn mot et ekte prosjekt, både med vårt
eget endepunkt-variabelnavn og med plattformens injiserte.
**Ikke bevist:**
- At systemet produserer et **validert forslag** mot en levende modell. Det har det aldri gjort.
Kjøringen nådde aldri fram til validatoren med en parsebar kandidat.
- At `gpt-4.1-mini` er en egnet modell for proposer-rollen. Målingen peker mot at den ikke er det
uten endret prompting eller structured output — men én kjøring er én kjøring.
- Noe som helst om kostnad i drift. Denne kjøringen kostet noen få øre; det sier ingenting om en
reell portefølje.
## 4. Neste steg, i rekkefølge
1. **Fang den råe svarteksten** ved parse-feil (i dag forsvinner den i `except: continue`). Test
først — dette er en søm, ikke en logg-linje.
2. Vurder **structured output** mot Foundry for proposer-rollen, framfor å prompte fram JSON.
3. Vurder om `BudgetExceeded` skal bli et strukturert utfall i stedet for en traceback, særlig for
den hostede flaten.
4. Først når 1 og 2 er på plass: en ny bundet kjøring, og en gatet test som dekker **hele
`run_project`-stien** (den eksisterende gatede testen dekker kun klient-nivået).
**Rettelse til punkt 4, målt 14.08 (økt 40):** parentesen er upresis. `tests/test_portfolio_live.py`
fantes allerede og dekker `run_portfolio`-utfoldingen — altså hele `run_project`-stien — med samme
env-gate. Men den asserterer `len(result.runs) == 1`, og fordi `runs` og `failures` *partisjonerer*,
kan den kun skille «kjøringen fullførte» fra «kjøringen raiste». Den kan **ikke** skille et validert
forslag fra et avvist, og heller ikke en kjøring der svarene parset fra en der de ikke gjorde det.
Den bærer derfor ikke påstanden 1b skal felle. Punkt 4 står, men grunnen er en annen enn skrevet.
## 5. Pre-registrerte utfall (skrevet FØR kjøringen)
Denne kjøringen har ett formål: å felle den ene gjenstående ærlighets-grensen fra økt 37 — **at det
emitterte `response_format`-skjemaet ER akseptert av det LEVENDE endepunktet er uverifisert**;
testene beviser konformitet med Azures *dokumenterte* subset, ikke aksept.
**Instrumentet** er `conftest.assert_full_run_contract`, og diskriminatoren er et artefakt repoet
allerede eier: `{run_id}-parse-failures.json` skrives hvis og bare hvis et svar ikke lot seg parse
(økt 35). Artefaktets **fravær** ved siden av et `RunResult` beviser at hvert genererings-svar kom
tilbake i den bestilte formen. Kontraktens evne til å skille er bevist **offline og gratis**
(`tests/test_live_full_run_contract.py`, to mutasjoner med hver sin signatur: detach artefakt-sjekken
→ T1 rød alene; raise ubetinget → T2 rød alene). Det betalte kallet er *målingen*, ikke beviset på at
måleinstrumentet virker.
**Hva hvert utfall betyr — avgjort på forhånd:**
| Utfall | Betydning |
|---|---|
| Ingen parse-failure-artefakt + validatoren avgjorde (`validated` **eller** `rejected`) | **Ærlighets-grensen er felt.** Skjemaet ble akseptert av det levende endepunktet. En P90-avvisning er et *bestått* utfall — kjøringen KONKLUDERTE. |
| Parse-failure-artefaktet finnes | Skjemaet ble **ikke** honorert. Et FUNN, ikke et bevis — og denne gangen finnes den råe teksten (økt 35), så neste steg kan begrunnes i stedet for gjettes. |
| `BudgetExceeded` | Fortsatt ubevist, men artefaktet forklarer hvorfor. Samme form som 1a-kjøringen. |
**Taket heves IKKE.** `max_rounds`/`max_tokens` står på defaultene den første levende kjøringen døde
på: fyrer ledgeren igjen, er dét informasjon, og å heve taket ville brukt mer penger på en sti som
kanskje fortsatt er brukket.
**Gate-variabelen er en TREDJE, distinkt opt-in** (`PORTFOLIO_LIVE_FULL_RUN`, lest på *truthiness*).
Begge de eksisterende live-testene gater på nøyaktig `PORTFOLIO_FOUNDRY_PROJECT_ENDPOINT` +
`PORTFOLIO_FOUNDRY_DEPLOYMENT`; å gjenbruke det paret ville betydd at en operatør som eksporterer de
to for å kjøre den **billige** ett-ords-proben også fyrer den dyre fullkjøringen — altså at stigen i
§1 kollapser til ett trinn. `PORTFOLIO_MODEL_MAP` er med i skip-betingelsen av en annen grunn:
uten den feiler kjøringen av en *konfigurasjons*-årsak som ser ut som en modell-feil.
## 6. Den bundne fullkjøringen — målt 14.08 kl. 19:54
### Oppsettet, reproduserbart
Model-mappet ligger **out-of-tree** med vilje: `data/model_map.json` i treet bærer
`REPLACE-WITH-FOUNDRY-DEPLOYMENT`-plassholdere, og tenant-spesifikke deployment-navn skal aldri
committes (B12). `PORTFOLIO_MODEL_MAP` peker på en fil operatøren eier:
```json
{
"local": {"default": "qwen3:4b", "proposer": "qwen3:4b", "checker": "qwen3:4b"},
"azure": {
"default": "gpt-4-1-mini",
"proposer": "gpt-4-1-mini",
"checker": "gpt-4-1-mini"
}
}
```
```bash
export PORTFOLIO_FOUNDRY_PROJECT_ENDPOINT="https://<resource>.services.ai.azure.com/api/projects/<project>"
export PORTFOLIO_FOUNDRY_DEPLOYMENT="gpt-4-1-mini"
export PORTFOLIO_MODEL_MAP="/absolutt/sti/til/model_map.live.json"
export PORTFOLIO_LIVE_FULL_RUN=1 # BETALER — utelat den for alt annet enn trinn 4
```
Merk at `PORTFOLIO_LIVE_FULL_RUN` bevisst settes SIST og alene for det dyre trinnet: uten den er
trinn 2 og 3 gratis-nok til å kjøres fritt, og det er hele stigens poeng.
### Stigen
Stigen på nytt, samme disiplin som §1. Alt under er kjørt.
| Trinn | Kommando | Utfall |
|---|---|---|
| 1a | `az account get-access-token --resource https://ai.azure.com` (uten rør) | exit 0, `expiresOn 21:07:40` |
| 1b | `az ad signed-in-user show` (ekte Graph-kall, ikke lokal cache) | exit 0 — gmail-kontoen, ikke jobbkontoen |
| 2 | `preflight --profile azure` | `preflight OK (azure)` |
| 3 | `pytest tests/test_foundry_profile_live.py` | **1 passed, 5,48 s** |
| 3b | samme kall, med `test_full_run_live.py` samlet | **SKIPPED** — det tredje flagget holder stigen |
| 4 | `pytest tests/test_full_run_live.py` (`PORTFOLIO_LIVE_FULL_RUN=1`) | **1 passed, 22,87 s** |
Trinn 3b er verdt å uttale: den dyre testen hoppet over **selv med begge Foundry-variablene satt**.
Det er den empiriske bekreftelsen på at det tredje opt-in-flagget gjør jobben sitt design lover.
### Ærlighets-grensen ER felt
Outboksen inneholder `-proposal.json`, `-outcome.json`, `-runconfig.json` — og **ingen
`-parse-failures.json`**. Hvert eneste genererings-svar fra `gpt-4.1-mini` kom tilbake som et
parsebart objekt i den bestilte formen. Økt 37s uttalte grense — *«at det emitterte skjemaet ER
akseptert av det LEVENDE endepunktet er IKKE verifisert»* — er dermed **lukket ved måling**, ikke
ved resonnement. Kontrasten til den første levende kjøringen er hele funnet: der brant tolv runder
på formatfeil og etterlot null tegn; her feilet ingen.
`assumptions` kom tilbake som forventet (`{"EL-LIGHTING-OP-HR": [10.0, 13.0]}`), altså virker økt 37s
additive wire-form → IR-map-normalisering ende-til-ende mot et levende endepunkt. Det var
beslutningen som holdt den stokastiske falsifisereren fra å gå inert, og den er nå prøvd i felt.
### Utfallet: `rejected` — og det er et bestått utfall
```
outcome_type: rejected
reason: claimed saving 34500 exceeds P90 feasible 11488
checker_verdict: approve
validator_decision: rejected
token_usage: 15 306
```
Kjøringen KONKLUDERTE. Per §5s pre-registrering er dette det positive utfallet: validatoren ble nådd
med en parsebar kandidat og avgjorde. **Og de to falsifisererne skilte lag for første gang mot en
levende modell** — checkeren godkjente *resonnementet*, validatoren avviste *tallene*. Nøyaktig den
uavhengigheten `checker_verdict` holdes atskilt fra `provenance.validator_decision` for.
### FUNN som er viktigere enn den grønne testen: modellen fant opp en kostkode
Forslaget bar `code: "EL-LIGHTING-OP-HR"` (3 000 × 11,5). **Den koden finnes ikke noe sted i
kunnskapsbasen** (`grep` over hele bundelen: null treff). Kunnskapsbasen instruerer eksplisitt
mappingen `ENERGI-TOTAL-EL`, 300 000 kWh × 1,00 NOK — modellen konstruerte i stedet sin egen
kostlinje med en egen enhet.
Avvisningen var derfor **riktig, men skjedde på feil gate**: 30 %-cap-en fanget den på *magnitude*
(34 500 > P90 11 488), ikke stage 0 på *eksistens*. Grunnen er en kjent og uttalt egenskap, ikke en
defekt: S4.0-forankringen aktiveres på bundle-stien KUN når bundelen shipper `cost-baseline.json`,
og `bygg-energi-mikro` gjør ikke det (målt) — «en pre-amendment-bundle er legitimt uforankret».
Dette er akkurat den hallusinasjons-klassen S4.0 ble bygget for, observert i felt for første gang.
At den uforankrede gaten fanget den likevel er betryggende; at den fanget den på den dyre gaten
i stedet for den billige er en kø-post, ikke noe som endres her.
**Fortsatt ikke bevist:** at systemet produserer et *validert* forslag mot en levende modell. Denne
kjøringen avviste — korrekt, og med en begrunnelse som kan leses. Én kjøring er én kjøring, og
`gpt-4.1-mini`s egnethet for proposer-rollen er ikke avgjort av den.

View file

@ -0,0 +1,199 @@
# Vurdering: skal Azure-ressursene døpes om?
**Bestilt av** ordre `20260818T103716Z-251212929` (fra `.claude`). **Leveransen er en vurdering, ikke
en omdøping** — ingenting i Azure er rørt. Beslutningen er operatørens.
Alt under er enten **målt** i dette repoet eller **verifisert mot Microsoft Learn**. Der noe ikke er
verifisert, står det. Kildene er listet i §6.
## 1. Hva røper koordinatene faktisk?
Koordinatene som nådde en ekstern organisasjon 14.08 (i `dist/portfolio-optimiser-foundry-1.1.0.zip`,
bygget 14:24):
| Verdi | Type |
|---|---|
| `<resource-group>` | ressursgruppe (ARM-planet) |
| `<resource>` | Foundry-ressurs = **custom subdomain** (DNS) |
| `<project>` | prosjekt (ARM + data-plan-sti) |
| `eastus` | region |
| `https://<resource>.services.ai.azure.com/api/projects/<project>` | prosjekt-endepunkt |
De literale navnene er byttet mot plassholdere her, samme form som `DEPLOY.md`, auth-oppskriften og
måleprotokollen etter `241b50d`. Dokumentet handler om hva navnene *er*, ikke om hvilke de var — og
innholds-gaten i `tests/test_handover_package_loadbearing.py` avviste førsteutkastet som bar dem.
**Utledbart uansett — ikke lekket av oss:**
- **At det er en AIServices-ressurs med prosjekter.** URL-formen `…/api/projects/<project>` ER den
dokumenterte Foundry-prosjekt-endepunkt-formen. Enhver som ser en slik URL vet ressurstypen.
- **Modell, versjon og deployment-navn** (`gpt-4.1-mini`, `2025-04-14`, GlobalStandard). Offentlig
katalog-nomenklatur.
- **Rolle-GUID-en `53ca6127-db72-4b80-b1b0-d745d6d5456d`.** Azures **offentlige** innebygde
role definition id for `Foundry User`, identisk i hver tenant. Den står i Learn-dokumentasjonen.
Den er **ikke** en koordinat, og skal ikke plassholdes.
- **At vertsnavnet i det hele tatt er et globalt, gjettbart navnerom.** Custom subdomain ligger under
ett felles DNS-navnerom, og navnet er unikt på tvers av alle kunder — så *eksistensen* av et gitt
navn kan enhver bekrefte ved å slå det opp. Lekkasjen fjernet **gjettingen**, ikke muligheten.
**Kun kjent fordi navnene lekket:**
- **Ressursgruppenavnet.** Det finnes ikke i DNS og ikke i noen data-plan-URL. Det er rent
ARM-plan-informasjon.
- **Regionen** (`eastus`). Ikke utledbar fra vertsnavnet for `*.services.ai.azure.com`.
- **Prosjektnavnet** (`<project>`) — det står riktignok i endepunkts-URL-en, men URL-en er selv en
del av lekkasjen.
- **Koblingen mellom dem.** Det operativt verdifulle er ikke ett navn, men at ressursgruppe,
ressurs, prosjekt, region og rolle-scope kommer som ett ferdig sett.
**Kort:** dette er rekognoseringsinformasjon om ARM-planet. Det er ikke nøkler, og det er ikke
tenant-id eller abonnements-id — ingen av de to sto i dokumentet (målt: `0` treff på
`/subscriptions/<guid>` i hele treet og i den leverte pakka).
## 2. Hva skal til for å misbruke dem?
### Det Entra faktisk stopper
Et kall mot prosjekt-endepunktet krever **både** et gyldig Entra-token for scopet
`https://ai.azure.com/.default` **og** en RBAC-tildeling på ressurs- eller prosjekt-scope. Microsofts
egen feilkode-tabell skiller de to: **401** = manglende/utløpt token, **403** = manglende
rolletildeling. Å kjenne adressen gir altså i seg selv **null** inferens-tilgang.
Token-basert auth krever dessuten et custom subdomain — regionale endepunkter støtter ikke Entra i
det hele tatt. Vi bruker custom subdomain, altså er Entra-stien tilgjengelig.
### Det Entra ikke stopper
1. **Nøkkelbasert auth, hvis den er på.** Entra blir *eneste* autorisasjonsmetode først når
`disableLocalAuth` er satt til `true` — det er en eksplisitt handling (Azure Policy på
abonnement/ressursgruppe, `disableLocalAuth` i ARM/Bicep, eller `Set-AzCognitiveServicesAccount
-DisableLocalAuth $true`). Er den ikke satt, finnes det nøkler som omgår Entra fullstendig.
**Ikke verifisert for denne ressursen:** `az cognitiveservices account create`-kommandoen i
måleprotokollen (`docs/2026-08-14-fase1b-forste-levende-kjoring.md` §0) ba ikke om det, og om
abonnementet har policyen er ukjent herfra. **Dette er den ene sjekken som faktisk endrer
risikobildet** — se §4.
Merk også at avslåing ikke slår inn momentant: endringen skjer i kontrollplanet med én gang, men
gatewayen kan godta tidligere gyldige nøkler til cachen oppdateres — typisk minutter, opptil
flere timer. Og allerede utdelte nøkler må regenereres separat; å slå av lokal auth *tilbakekaller*
dem ikke.
2. **Målrettet phishing og consent-phishing.** Koordinatene gjør en henvendelse troverdig — avsender
kan navngi ressursgruppe, ressurs og prosjekt riktig. Entra beskytter identiteten, ikke
overtalelsen. Dette er den mest realistiske misbruksveien for denne typen lekkasje.
3. **Kvote- og kostnadsmisbruk ved kompromittert identitet.** Kvote tildeles **per abonnement, per
region, per modell og deployment-type**, i tokens-per-minutt, og deles av alle deployments av
samme modell i samme region i abonnementet. En misbrukt identitet med `Foundry User` på dette
prosjektet spiser altså av en pott som er felles, og kan strupe *andre* deployments av samme
modell i samme abonnement — ikke bare denne. Forbruket faktureres.
4. **Nettverksflaten.** `publicNetworkAccess` er en egenskap som må settes til `Disabled` for å stenge
den offentlige inngangen. Er den ikke det, er endepunktet nåbart fra internett — det var sant før
lekkasjen også. Forskjellen er at adressen nå er *kjent*, ikke at den ble *nåbar*.
**Ikke verifisert:** om et uautentisert kall skiller et eksisterende prosjektnavn fra et
ikke-eksisterende (altså om `<project>` kan bekreftes uten token). Det ville krevd et faktisk kall
mot en fremmed ressurs, og det er ikke gjort.
## 3. Hva koster omdøping?
### I repoet: null
Målt på `6d2837f` (= `open/main` = `origin/main`), nevner **327 sporede filer**:
| Sted | Treff på de tre literale navnene |
|---|---|
| Sporet tre (kode, tester, docs, `env.template`) | **0** |
| Usporet deck `docs/presentasjon-portfolio-optimiser.html` | **0** |
| `shared/` (subtree) | **0** |
De 18 gjenværende linjene med `services.ai.azure.com` er plassholderformen (`<resource>.`),
wildcard-formen (`*.`) eller testdummies (`x.`, `platform.`, `wrong.`). `env.template` bærer
variabelnavn, aldri verdier. **Omdøping krever altså ingen redigering i repoet**`241b50d` gjorde
allerede den jobben.
### I historikken og i den leverte pakka: kan ikke tilbakekalles
- **Git-historikken:** 3 linjer i 1 fil, i 3 commits (`5bd8e1c`, `bb4807a`, `241b50d`). Publisert på
`open/main`. Ordren forbyr å skrive om historikken, og vei B ble alt avvist 18.08.
- **Den leverte pakka:** 4 linjer, hos en tredjepart siden 14.08. En omdøping i Azure gjør ikke det
usett.
### I Azure: en full riving og gjenoppbygging
**Et custom subdomain kan ikke endres.** Microsoft er eksplisitt: navnet kan ikke endres etter at det
er opprettet og knyttet til ressursen, og for å gjenbruke et navn må den eksisterende ressursen
slettes. «Omdøping» finnes derfor ikke som operasjon — det er:
1. opprett ny AIServices-ressurs med nytt subdomain (+ `--allow-project-management`)
2. opprett nytt prosjekt
3. redeploy `gpt-4-1-mini` (ny kvotetildeling i regionen)
4. tildel `Foundry User` på nytt prosjekt-scope
5. oppdater `PORTFOLIO_FOUNDRY_PROJECT_ENDPOINT` og `PORTFOLIO_MODEL_MAP` lokalt
6. slett — og purge — den gamle ressursen (purge krever `Contributor` på abonnements-scope)
7. re-kjør stigen i måleprotokollen §1 for å bevise at auth/RBAC/endepunkt fortsatt komponerer
**Ikke verifisert:** om det gamle subdomain-navnet holdes reservert en periode etter sletting slik
App Service og API Management gjør (anti-subdomain-takeover). Learn dokumenterer den mekanismen for
de tjenestene, men ikke for Foundry-/Cognitive Services-subdomener. Ikke anta at navnet frigis — og
ikke anta at det er låst.
## 4. Anbefaling — og hva hvert valg koster
**Anbefalingen er: ikke døp om. Fjern i stedet den ene veien som ikke går gjennom Entra, og gjør
misbruk synlig.**
Begrunnelsen er at omdøping løser feil problem. Det lekkasjen ga en motpart er **målretting**, ikke
**tilgang**. Og målrettingen kan ikke trekkes tilbake: navnene ligger i publisert git-historikk og i
en zip hos en tredjepart. En omdøping ville altså kjøpt at *dagens* ressurs ikke er den som ble
navngitt — ikke at navngivingen forsvinner. Det er en reell, men liten gevinst, og den betales med en
full riving av det eneste levende Foundry-oppsettet prosjektet har.
**Valg A — behold navnene, herd oppsettet (anbefalt).**
- Sjekk `DisableLocalAuth` på ressursen. Er den ikke `true`: regenerer begge nøklene *og* sett den.
Dette er den eneste tiltaket som fjerner en autorisasjonsvei Entra ikke dekker.
- Sjekk at rolletildelingen står på **prosjekt**-scope og ikke bredere, og vurder
`Foundry Agent Consumer` framfor `Foundry User` dersom kjøringene bare gjør inferens.
- Sett et kostnadsvarsel på abonnementet og hold TPM-tildelingen på deploymentet lav. Kvotemisbruk
blir da både begrenset og synlig.
- *Konsekvens:* måleprotokollen forblir reproduserbar, ingen ny kjøring må betales, og navnene
fortsetter å stå i historikken — som de ville gjort uansett.
**Valg B — riv og bygg opp igjen med et intetsigende navn.**
- *Kjøper:* at et navn en motpart eventuelt sitter og venter på, ikke lenger peker på noe levende.
- *Koster:* de sju stegene i §3, ny betalt verifiseringskjøring, og at
`docs/2026-08-14-fase1b-forste-levende-kjoring.md` beskriver et oppsett som ikke finnes lenger.
- *Kjøper ikke:* at navnene forsvinner fra historikken eller fra den leverte pakka.
- Velg denne hvis vurderingen er at ressursgruppe- og prosjektnavnet i seg selv er sensitivt i
organisasjonssammenheng — det er en vurdering operatøren kan gjøre og ikke jeg.
**Valg C — gjør begge.** Herdingen i A er verdt å gjøre *uansett* hvilket av A og B som velges; B
uten A etterlater den samme nøkkel-veien åpen på en ny ressurs.
## 5. Hva denne vurderingen ikke dekker
- Ingenting i Azure er inspisert. Alle utsagn om *denne* ressursens faktiske konfigurasjon
(`disableLocalAuth`, `publicNetworkAccess`, rolle-scope) er markert som uverifiserte over.
- **Innholds-gaten fanger vertsnavnet, ikke ressursgruppe- og prosjektnavnet.** Et vertsnavn har en
struktur (`<label>.services.ai.azure.com`); en ressursgruppe heter hva som helst. De to andre
navnene ble bare oppdaget fordi de sto i samme tabell som verten. En gate kan ikke lukke det
gapet uten en navneliste, og en navneliste er den andre kopien av eksponeringsregelen.
- Ordre-teksten sa at koordinatene fortsatt sto i repoet. Det er **ikke** tilfelle per `6d2837f`
målt, se §3. Premisset var riktig da ordren ble skrevet (10:37Z) og sluttet å være det 13:03.
## 6. Kilder
Alle verifisert 18.08.2026 mot Microsoft Learn:
- Custom subdomain kan ikke endres; må slette ressursen for å gjenbruke navnet —
`learn.microsoft.com/azure/ai-services/cognitive-services-custom-subdomains`
- Entra-auth krever custom subdomain; 401 vs 403; scope `https://ai.azure.com/.default`
`learn.microsoft.com/azure/foundry/concepts/authentication-authorization-foundry`
- `disableLocalAuth` er en eksplisitt handling; propagering minutter til timer; nøkler må regenereres
separat — `learn.microsoft.com/azure/ai-services/disable-local-auth`
- Rolle-id `53ca6127-db72-4b80-b1b0-d745d6d5456d` = `Foundry User`, offentlig og lik i hver tenant;
`Foundry Agent Consumer` som minste-privilegium for ren inferens —
`learn.microsoft.com/azure/foundry/concepts/rbac-foundry`
- Kvote per abonnement/region/modell/deployment-type i TPM; deles av deployments i samme region —
`learn.microsoft.com/azure/foundry/openai/how-to/quota`
- `publicNetworkAccess` / `disableLocalAuth` som ARM-egenskaper —
`learn.microsoft.com/azure/templates/microsoft.cognitiveservices/accounts`
- Anti-subdomain-takeover-reservasjon (dokumentert for App Service / API Management, **ikke** for
Cognitive Services) — `learn.microsoft.com/azure/security/fundamentals/subdomain-takeover`

View file

@ -0,0 +1,327 @@
# Misjons-review — nærmer portfolio-optimiser seg faktisk målet? (2026-08-25)
> **Reviewer:** Fable 5 (xhigh, uten advisor), ordre `20260825T104711Z-7149413623-from-.claude`.
> **Mandat:** uavhengig, adversarial review av om det LEVERTE systemet gjør det README og operatøren
> har sagt det skal gjøre — ikke en kodekorrekthets-review. Ingen kodeendringer.
> **Metode:** hvert tall under kommer fra en kommando kjørt i denne økten (verifiseringslogg i § 6);
> hvert U-punkt er grep-et mot `src/` og kallstedet lest; ingen tall er sitert fra `STATE.md`.
> Målestokkene er sitert ordrett fra kildene ordren navngir. Eksterne MAF-påstander (§ 3) er
> verifisert av en egen Opus-agent mot Microsoft Learn / PyPI / GitHub der det står «[ekstern]»;
> alt annet er lokalt målt.
## 1. Sammendrag — er «nærmer seg mål» rettferdig?
**DELVIS.** Tre setninger, én per akse:
- **Mekanisme (README-løftet, steg 18): JA.** Alle åtte steg finnes i kode, er load-bearing-testet,
og suiten er grønn målt i dag: **1021 passed / 5 skipped (166 s)**, `ruff` ren, `mypy` ren
(34 filer), golden-transkriptet `ea8c534773acdbe41ae68f2c55724d69aaf8be4f` (målt `shasum`).
- **Misjonsbevis (operatørens målbilde, pkt. 4): NEI.** Null validerte forslag mot en levende modell
(den ene levende fullkjøringen 14.08 KONKLUDERTE med `rejected` — korrekt, men modellen fant opp
en kostkode), null ekte ekspertdommer (hver dom i treet er merket `SYNTHETIC` / «AI-forfattet»),
null kunnskapsbase materialisert fra en levende kilde, og null måling av at utforskningen finner
noe debatten ikke finner (planens rad 4 er ugjort). «Stille spørsmål, be om svar, bruke svarene»
er ikke nåbart fra noen operatørflate — kun fra bibliotek-APIet.
- **MAF-ekspertise (§15.1, 19 punkter): LAV mot en ærlig telling.** **5 fullt brukt, 5 delvis,
9 ikke** — og av de fem fullt brukte er to opt-in utenfor den normative stien (Magentic, MCP).
Alt som er bærende — fan-out, blokkerende validator, HITL, læringsinjeksjon — er håndrullet
rundt MAF, ikke bygget i MAF. Det er i stor grad bevisst og begrunnet (§ 3), men det gjør
«eksperter på bruken av MAF» til en påstand om tre orkestrerings-konstrukter og én chat-klient
— på en versjon (1.9.0, 18.06) som ligger **seks minor-releaser bak** dagens 1.15.0 (21.08), og
som allerede shipper en primitiv (`MiddlewareTermination`) registeret ba om under U8 og repoet
ikke bruker.
Kort: repoet er nær målet *«en komplett, ærlig, testet mekanisme»*. Det er ikke nær målet
*«finner innsparinger mennesker ikke ser»* — for det målet finnes det i dag **ingen måling**, og
den offentlige README-statusen er stale i begge retninger (§ 5, F1).
## 2. U1U19 mot faktisk kildekode
Nevner: `grep -rn --include="*.py" -F <konstrukt> src` over 34 moduler / 11 650 linjer (`wc -l`),
deretter kallstedet lest. `tests/` og `spikes/` teller IKKE som bruk. «Bevis» er der konstruktet
faktisk kalles, ikke der det nevnes i prosa.
| ID | Kapabilitet (§15.1) | Brukt | Bevis (fil:linje) | Kommentar |
|----|----|----|----|----|
| U1 | `ConcurrentBuilder` fan-out | **nei** | `run.py:1274` `await asyncio.gather(` | Håndrullet bølgemodell. Begrunnet (`docs/plan/2026-08-23-…:368`: bølgen bærer budsjett-reservasjon S3.4). Ikke B-listet — et bevisst avvik fra USE, dokumentert. |
| U2 | `WorkflowBuilder` graf | **nei** | 0 treff i `src` | P10 «Eksplisitt NULL» (`docs/plan/2026-08-09-egnethetsreview-plan.md:534`). Begrunnet. |
| U3 | `GroupChatBuilder` maker-checker | **ja** | `workflow.py:24` import · `:104` `GroupChatBuilder(` · `:108` `termination_condition=` · `:113` `.with_max_rounds` | Eneste MAF-orkestrering på den normative stien. Debatten er en fast 2-agent round-robin på 3 runder; hver kjøring ender i «forcing completion» (`docs/2026-08-14-…:59`, målt live). |
| U4 | Magentic (åpne delsteg) | **ja** (opt-in) | `explore.py:32-37` import · `:596-603` `MagenticBuilder(…).build()` | Landet økt 5658 OVER den normative sløyfa. Ikke eksperimentell i installert `orchestrations 1.0.1` (0 `@experimental`-treff i `_magentic.py`) — §15 G8/A2 sin premiss er utdatert. |
| U5 | Agent Skills / `SkillsProvider` | **nei** | 0 treff; `persona.py:27/:47-51` leser KUN `references/example-verdict.json` med egen loader | `SKILL.md`-prosaen når aldri en agent. Metode-skillen finnes ikke (`find shared -name SKILL.md` → kun `expert-reviewer`). `SkillsProvider` er `@experimental` i installert core (`_skills.py:76/:120`). Begrunnet (`…2026-08-23:370`), men CLAUDE.md-konvensjonen «Metode kodifiseres som Agent Skill» er uoppfylt. |
| U6 | MCP-tools | **ja** (opt-in) | `mcp_tools.py:154` `MCPStdioTool(` · `:170` `MCPStreamableHTTPTool(` | Null nettverkskall uten `--mcp-config`. `as_mcp_server()`: 0 treff (ikke savnet). |
| U7 | Solver/validator/MC som Function Tools | **delvis** | `datasource.py:65/:69` `@tool` retrieval · `explore.py:461/:487` `quick_validate` | Retrieval ER et Function Tool. Validatoren er det IKKE på den normative stien — den kalles etter generering i `run.py`; kun den RÅDGIVENDE `quick_validate` i utforskningen er et tool. Registerets formål er ikke realisert slik det ble skrevet; B1-valget forklarer hvorfor (§ 3). |
| U8 | Middleware (intercept av tool-calls → blokkerende validator) | **delvis** | `budget.py:228` `class BudgetMiddleware(ChatMiddleware)` · `mcp_tools.py:197` `class ToolCallRecorder(FunctionMiddleware)` | Middleware brukes — til budsjett og til OBSERVASJON av tool-kall. Ingen middleware blokkerer noe; validatoren gater utenfor grafen. Konstruktet: ja. Formålet i U8: nei. |
| U9 | Læringssløyfe-injeksjon via `ContextProvider` | **delvis** | `verdicts.py:319` `class ExpeLContextProvider(ContextProvider)` · `:353` `extend_instructions` — MEN `run.py:736-739` kaller `.format_fewshot()` direkte og string-konkatenerer inn i prompten; `:848-857` kjører `before_run` inn i en `SessionContext` som (egen kommentar) «is NOT what reaches the prompt» | MAF-hooken er **dekorativ**: konstruktet subklasses, men injeksjonen som bærer læringen går utenom MAF. Fungerer — men er ikke «bruk av `ContextProvider`» i MAF-forstand. |
| U10 | Vektorlagre (MAF-integrasjoner) | **nei** | `semretrieval.py:1-70`: numpy brute-force, «MAF-free (D7-portable)» | Begrunnet (D-C / D7). Shippet embedder er «a semantics-free sha256 projection» (`run.py:1681`-hjelpetekst) — utvidelsespunkt, ikke semantisk gjenfinning. |
| U11 | Citation-aware RAG (`TextSearchProvider`/agentic retrieval) | **delvis** | `provenance.py:18` `from agent_framework import Annotation, TextSpanRegion` (display only); egen `datasource.retrieve_chunks` | Sitatbæring finnes, med MAF-TYPER. MAF-providerne: 0 treff. Begrunnet («navigate, never stuff», `…2026-08-23:371`). |
| U12 | Checkpointing / kræsj-overlevelse | **nei** | 0 kode-treff; `workflow.py:14` prosa «no checkpoint» | P10 sa NULL; gjenåpnet 23.08 som planens rad 3 = STATE-ens NESTE. Spike `spikes/e_magentic_resume.py` MÅLT grønn (fersk interpreter). Ikke bygget. |
| U13 | HITL-gates (`request_info` / plan review / tool approval) | **delvis, uøvd** | `workflow.py:116` `with_request_info(agents=[agents[-1]])` bak `enable_layer1_hitl=False` (`run.py:536`, intet CLI-flagg); `explore.py:363` `PlanReviewer`, `:849` `plan_reviewer=`; `approval_mode`: 0 treff | Layer-1 er «green-but-dead»: ENESTE test er `tests/test_workflow.py:58` som bygger, aldri kjører gaten. Plan review er bibliotek-only: CLI nekter (`run.py:1975-1988`), hosting nekter (`hosting.py:167-172`). |
| U14 | OpenTelemetry | **ja** (opt-in) | `tracing.py:160` `configure_otel_providers` · `:265` `get_tracer`; span-events i `explore.py` | `PORTFOLIO_OTEL` på truthiness; OTLP-pakker bevisst ikke deklarert. |
| U15 | Evaluering (`LocalEvaluator`/`evaluate_workflow`) | **nei** | 0 treff | «AVVIST som flate» (`STATE.md:99`); begrunnelse `…2026-08-23:390` (`@experimental`, 20 dekoratorer målt i installert `_evaluation.py`; «feil form»). Egen sammenligning (rad 4) IKKE gjort → ingen måling finnes. |
| U16 | `CompactionProvider` | **nei** | 0 treff | **Uadressert**: kun telt (`…2026-08-23:58` «Utenfor de 15»), ingen beslutning noe sted. Relevant for lange Magentic-løp der token-taket er eneste vern. |
| U17 | Agent-som-verktøy `as_tool()` | **nei** | 0 treff | **Uadressert.** Lav relevans: A3 knyttet det til kryss-prosjekt, som er non-goal 2. |
| U18 | `FoundryChatClient` + modell-map | **ja** | `backends.py:29` import · `:150` `FoundryChatClient(project_endpoint=…, model=…, credential=…)`; B12 `model_map` 24 treff | Bevist mot levende Foundry 14.08 (`docs/2026-08-14-…:42`). |
| U19 | Agent Harness (`HarnessAgent`, `FileMemoryStore`, …) | **nei** | 0 treff | **Uadressert.** Installert pakke sier selv `[HARNESS] MemoryStore is experimental` (pytest-warning målt) — legitimt å hoppe over, men ingen har sagt det. |
**Telling:** ja 5 (U3, U4, U6, U14, U18) · delvis 5 (U7, U8, U9, U11, U13) · nei 9 (U1, U2, U5,
U10, U12, U15, U16, U17, U19). Av de ni: 6 med skriftlig begrunnelse i repoet, 3 uten (U16, U17,
U19). **Nevneren er 19, ikke 15:** «6 av 15» stammer fra `docs/plan/2026-08-23-magentic-utforskningssloeyfe.md:58`,
som teller U1U15 og setter U16U19 «utenfor» — filen `docs/research/2026-06-23-prior-art-platform.md`
har én commit (`ec9ac74`) og 19 rader (`awk`-lest i dag).
## 3. B-lista re-eksaminert — er «SKAL bygges selv» fortsatt riktig på 1.9.0?
Installert (`uv pip list`): `agent-framework-core 1.9.0`, `-orchestrations 1.0.1`, `-foundry 1.8.2`,
`-openai 1.8.2`; `uv.lock` pinner de samme. Registeret er datert 23.06; MAF-status i dag er sjekket
mot installert pakke (lokalt) og mot offisielle kilder ([ekstern] — se § 3.1).
| ID | BUILD-valget | Status i kode | Fortsatt riktig? |
|----|----|----|----|
| B1 | Blokkerende hybrid-validator | `validator.py:31` `import pulp` · `:106` `PULP_CBC_CMD` · `:127` seedet MC; kalles i `run.py` ETTER generering, utenfor grafen | **HALVVEIS.** Tall-halvdelen (solver + MC + baseline-avstemming + `Rejection` som VERDI med provenance) er fortsatt ikke en MAF-primitiv — riktig egenbygg. Men emisjons-halvdelen («svaret forlater ikke agenten uten sjekk») ER nå en MAF-primitiv: `MiddlewareTermination` finnes i installert 1.9.0 uten experimental-markør (§ 3.1). Registerets U8 beskrev nøyaktig den formen; repoet har den ikke (0 treff). `response_format` (brukt, `generate.py`) gir FORM, ikke VERDI-kontroll. |
| B2 | VerdictStore + ExpeL | `verdicts.py` + `run.py:736-739` (fold) + `run.py:1331` `_merge_wave` (kryss-prosjekt) | **JA, men med et forbehold:** kravet i misjonen er «learns from their verdicts across runs» — MENNESKE-dommer om TIDLIGERE OUTPUT, hentet strukturelt. Ingen MAF-memory-provider gjør det (de lagrer samtalehistorikk/fakta, ikke dommer nøklet på kandidat-features). Forbeholdet er at koden i dag ikke bruker MAF-hooken for injeksjonen (U9) — så B2 er «bygg selv» i sterkere grad enn registeret sa. |
| B3 | To-lags HITL | Layer-1 `with_request_info` (uøvd, § 2 U13); Layer-2 = fil-innboks (`verdicts.load_verdicts_from_dir`, Steg 7) + `verdict_input`-argumentet | **DELVIS RIKTIG.** Den asynkrone fil-innboksen er riktig egenbygg (MAF gir ingen fler-dagers HITL uten checkpoint + ekstern varsling). Men `verdict_input` (dom gitt FØR forslaget finnes) er ikke HITL — se F2. |
| B4 | Termineringskontrakt + budsjett-breakers | `contracts.py` `TerminationContract` · `budget.py` `Budget`/`TokenMeter`/`PortfolioMeter` · `explore.py:50-87` `ExplorationContract` (alle felt påkrevd) | **JA.** Magentic defaulter fortsatt `max_round_count`/`max_reset_count` til `None` (målt, `explore.py:54-56`). |
| B5 | Generisk data-source-konfig | `mcp_tools.McpServerConfig` (pydantic), `datasource.py`; `jsonschema`: 0 treff | **JA** (pydantic i stedet for JSON Schema — form, ikke prinsipp). |
| B6 | Provenance | `provenance.py:18` MAF-typer for display; egen `ProvenanceStamp` | **JA.** Ingen MAF-native provenance. |
| B7 | State-isolasjon i fan-out | `workflow.py` `fresh_workflow`; `explore.py` `manager_agent_factory` | **JA** (G2 målt i spikes). |
| B8 | Sandboxing av skill-scripts | 0 treff (`sandbox`, `Subprocess`) | **IKKE AKTUELT** — ingen brukerleverte scripts kjøres, fordi U5 ikke er bygget. Blir aktuelt den dagen metode-skillen finnes. |
| B9 | Onboarding-intervju | `preflight.py` (operatørverktøy) | **DELVIS.** Preflight sjekker konfig; det finnes intet intervju som tvinger fram termineringskontrakt + feedback-skjema FØR første kjøring — `--decision` defaulter til `approved` i stedet (F2). |
| B10 | Feedback-skjema + rejection-taksonomi + konfliktregel | `contracts.FeedbackContract` (binær `approved`/`rejected`); taksonomi: 0 treff (`wrong_assumption`, `overestimated`); `verdicts.py:313-314` «deliberately deferred until real experts produce conflicting verdicts» | **UTSATT, uttalt.** Riktig å vente på ekte dommer — men det betyr at læringssignalet i dag er én bit + fri prosa. |
| B11 | Ekstern varsling | `notify.py:117` webhook-notifier (fail-closed opt-in) | **JA — levert**, ikke lenger stub. |
| B12 | Modell-map | `backends.py` `resolve_model`, `data/model_map.json` | **JA.** |
### 3.1 Ekstern verifisering (MAF-status per 25.08.2026) [ekstern]
Utført av en Opus-agent (xhigh) mot `learn.microsoft.com` (WebFetch — `microsoft-learn`-MCP-en var
utilgjengelig i agentens økt), PyPI (to uavhengige hentinger, samsvar) og GitHub-releases (delvis
summert av fetch-laget → merket LAVERE KONFIDENS). Hver rad under er deretter kryss-sjekket
LOKALT mot installert pakke der det var mulig (`grep` i `.venv`).
| Tema | Ekstern kilde (verbatim-utdrag) | Lokalt målt i installert 1.9.0 | Konsekvens |
|---|---|---|---|
| **Versjoner** | `agent-framework-core` siste = **1.15.0 (2026-08-21)**; i vinduet 23.06→25.08: 1.10.0, 1.11.0, 1.12.0, 1.12.1, 1.13.0, 1.14.0, 1.15.0. Pinnet 1.9.0 er fra 18.06 — FØR registeret ble skrevet. `orchestrations` siste = 1.1.1, krever `core>=1.15.0`. (pypi.org/pypi/agent-framework-core/json) | `uv pip list`: core 1.9.0, orch 1.0.1 | Repoet er **seks minor-versjoner bak**, og `STATE.md:99` kaller pinnen «ENDELIG, operatørbekreftet 23.08» — ni dager etter at 1.14.0 kom. Se F15. Upgrade-guiden (`python-2026-significant-changes`) stopper angivelig ved 1.8.0 — **IKKE VERIFISERT** (summert fetch). |
| **B2** — læring fra menneskedommer på tvers av kjøringer | «`ContextProvider` and `HistoryProvider` are the canonical Python base classes.» `FileMemoryProvider`: «the model should decide what to store and recall». Foundry Memory: «**Memory (preview)** … subject to terms applicable to "Previews"», typene er «User profile / Chat summary / Procedural memory». **NOT FOUND:** verdict-store, experience replay, feedback-API. | — | **B2 står som BUILD.** Ingen MAF-mekanisme lagrer eller henter menneskedommer om tidligere output. G5 (Foundry memory = preview) står. |
| **B1** — blokkér output uten deterministisk sjekk | Side «Termination & Guardrails»: «middleware stops execution by setting `context.result` when needed and raising `MiddlewareTermination`, or by short-circuiting the chain without calling `call_next()`.» «**Post-termination middleware** — Middleware that terminates after agent execution — useful for validating responses». `response_format` «parses but does not reject». (learn.microsoft.com/…/agents/middleware/termination) | `class MiddlewareTermination` finnes i `agent_framework/_middleware.py`, **uten** `@experimental` (de to markørene i fila gjelder `PROGRESSIVE_TOOLS`) | **B1 er DELVIS flippet — i den installerte versjonen.** Halvdelen «nekt at svaret forlater agenten» er nå en MAF-primitiv; halvdelen «avgjør TALLENE mot ekstern baseline og stemple provenance» er fortsatt vår. Registerets U8 beskrev nøyaktig denne formen. Ikke brukt (0 treff `src`). Se F16. |
| **Magentic** | Konseptsiden (oppdatert 2026-08-10) har **ingen** experimental-admonisjon for Python; eneste markør er i C#-eksempelet (`MAAIW001`). Python-API-referansen for `MagenticBuilder` er stale (2025-12-12, fluent form). Verbatim: «in Python, plan review is **off** by default (`enable_plan_review=False`)». | 0 `experimental`-treff i `agent_framework_orchestrations/_magentic.py` | §15 G8/A2 («Magentic er eksperimentell») er **utdatert** for Python. Økt 56-avgjørelsen om å legge Magentic OVER, ikke I, den normative stien hviler da på arkitektur (mandat-former), ikke lenger på modenhet. Riktig konklusjon, foreldet premiss. |
| **U5 SkillsProvider** | «`SkillsProvider` (Python) is a context provider that exposes skills to an agent. It advertises the available skills in the system prompt and registers the tools the agent uses to load skills, read resources, and run scripts.» Fire stadier; «**SKILL.md is NOT auto-loaded**» — kroppen hentes kun via `load_skill`. Ingen preview-banner på siden; kun `MCPSkillsSource` er experimental. | `@experimental(feature_id=ExperimentalFeature.SKILLS)` `_skills.py:76/:120` | Docs (1.15) og installert kode (1.9) er uenige om status — enda et argument for at pinnen koster. Avvisningen i `…2026-08-23:370` var riktig FOR 1.9.0. |
| **U15 Evals** | «In Python, the evaluation framework is part of the core `agent_framework` package … `evaluate_agent()` and `evaluate_workflow()`». `LocalEvaluator` «runs checks locally without API calls». Sjekker: `keyword_check`, `tool_called_check`, `tool_calls_present`, `tool_call_args_match`; custom via `@evaluator`. Foundry-siden: «agent evaluation remains in public preview». **«Foundry Adaptive Evals» — NOT FOUND** som produktnavn. | 20 `@experimental` i `_evaluation.py` | Registerets U15-tekst navngir et produkt som ikke finnes på Learn. `tool_call_args_match` er relevant for «kaller en levende modell faktisk `quick_validate`?» — nettopp grensen CLAUDE.md sier er ubevist. Avvisningen var riktig for 1.9.0; for 1.15 er den ikke re-vurdert. |
| **U12 Checkpointing** | Tre providere (`InMemory`/`File`/`Cosmos`), samme protokoll, ingen preview-banner. Verbatim: «both providers use a **restricted unpickler** by default … Any unsupported type causes deserialization to fail with a `WorkflowCheckpointException`.» «pass them via the `allowed_checkpoint_types` parameter using `"module:qualname"` format». «Starting in Python version 1.13.0, workflows also create an entry checkpoint … when responses to request events are delivered … minor breaking changes». | `FileCheckpointStorage` + `allowed_checkpoint_types` finnes i `_workflows/_checkpoint.py` | STATE-ens «MÅLT FELLE» (`STATE.md:27-29`) er bekreftet av dokumentasjonen. Rad 3 (NESTE) bygger på en 1.9.0-form som 1.13.0 endret — en bump etter rad 3 vil kreve re-måling av resume-stien. |
| **Sequential / Handoff** | Begge i `agent_framework.orchestrations`, ingen status-admonisjon (kun handoff «autonomous mode» er experimental). `SequentialBuilder(...).with_request_info(agents=[…])` «pause[s] after specific agents respond, allowing external input (such as human review)». | `class SequentialBuilder` / `class HandoffBuilder` finnes | P10-nullen står (debatten er 2 agenter; Sequential gir ingen ny kapabilitet), men `with_request_info` finnes ALLEREDE på GroupChat (`workflow.py:116`) og er uøvd (F7). |
| **U16 Kompaksjon** | «**Important** — The compaction framework is currently experimental in Python.» Python har INGEN `CompactionProvider`-klasse (C#-navn); Python-formen er `Agent(..., compaction_strategy=..., tokenizer=...)` / `apply_compaction(...)` med `SlidingWindowStrategy`, `SummarizationStrategy`, `TokenBudgetComposedStrategy` m.fl. | `SlidingWindowStrategy`/`SummarizationStrategy`/`apply_compaction` finnes i `_compaction.py`, under `@experimental` | Registerets U16 navngir C#-konstruktet. Grep-en i § 2 fant 0 fordi Python-navnet er et annet — **re-grep på `compaction_strategy\|apply_compaction`: fortsatt 0.** Uadressert står. |
| **U19 Harness** | «`create_harness_agent` is released. Background agents, file access, and looping remain experimental». `HarnessAgent` er **.NET-only**; `ToolApprovalAgent`**NOT FOUND** som Python-navn (Python: `ToolApprovalMiddleware`). | `create_harness_agent`, `ToolApprovalMiddleware`, `AgentLoopMiddleware` finnes; 28 `@experimental`-markører på COMPACTION/HARNESS/LOOP | Registerets U19 lister to navn som ikke finnes i Python. F5 i §15.6 korrigerte versjonen, ikke navnene. |
| **Nytt siden 23.06, rangert av agenten** | (1) `AgentLoopMiddleware` «re-invokes an agent until a completion condition is satisfied … `(continue, feedback)` to pass feedback to the next iteration» — **experimental**; ≈ Steg 5. (2) Evals (over). (3) `MiddlewareTermination` (over). (4) Structured outputs som førsteklasses sti (`response.value`). (5) Entry-checkpoints + Cosmos (1.13/1.15). (6) `SequentialBuilder.with_request_info`. (7) `SkillsProvider`. LAVERE KONFIDENS (GitHub): 1.14.0 «provider-based Foundry state stores for agent sessions, checkpoints, and function approvals», 1.15.0 «process-wide workflow checkpoint type registry». | `AgentLoopMiddleware` finnes alt i 1.9.0 (`_harness/_loop.py`, experimental) | Steg 5s håndrullede `max_attempts`-løkke har nå en MAF-form med samme bindingsdisiplin («Always bound autonomous loops»). Experimental → riktig å ikke bygge på den; men den finnes, og ingen har målt den. |
**Konklusjon § 3:** B2 står. B1 er halvveis flippet i den versjonen repoet allerede kjører. B3/B4/B6/B7/B12
står. B8/B9/B10 er uttalt utsatt. Fire av registerets U-rader (U15, U16, U19 og G8/A2 om Magentic)
bærer premisser som ikke stemmer for Python i dag — og repoet har ingen rutine som re-verifiserer
registeret mot MAF-releasene (målt: siste re-lesing var 23.08-planen, mot 1.9.0).
## 4. Misjonstekst-samsvar
### 4.1 README-løftet (første skjerm, `README.md:9-13`), ordrett
> A generic, open framework — built on **Microsoft Agent Framework (MAF)** — that finds cost
> savings *inside* each project of a portfolio of independent projects. A swarm of agents
> generates candidate measures; a **mandatory deterministic validator** (solver + Monte Carlo)
> decides the numbers; domain experts judge the outcomes (human-in-the-loop); and the system
> **learns from their verdicts** across runs.
| Løfte | Kode | Samsvar |
|---|---|---|
| «built on MAF» | 12 moduler importerer `agent_framework`; 3 orkestrerings-konstrukter (GroupChat, Magentic, middleware) + 2 chat-klienter | **Delvis.** Bygget *rundt* MAF (`hosting.py:2` «a wrapper, never `Workflow.as_agent()`»). Riktig valg, men ordet «built on» lover mer MAF enn det er. |
| «A swarm of agents» | Debatt: 2 agenter (`workflow.py:26`); utforskning: 3 (`explore.py`) | **Salgsspråk.** To-tre agenter er ikke en sverm. |
| «mandatory deterministic validator (solver + Monte Carlo)» | `validator.py:106` CBC · `:127` seedet MC · `run.py` kaller den ubetinget | **JA.** Verifisert, blokkerende, forankret (S4.0) når baselinen finnes. |
| «domain experts judge the outcomes» | Ekte kanal: Steg 7 innboks (`docs/ekspert-svar.md`). Parallell kanal: `verdict_input` PÅKREVD FØR kjøring (`run.py:525`; `hosting.py:80`; CLI-default `approved`, `run.py:1691-1692`) | **BRUTT på den parallelle kanalen** — dommen gis før utfallet finnes (F2). |
| «learns from their verdicts across runs» | `run.py:736-739` fold; `test_portfolio_learning_loadbearing.py` | **JA mekanisk**, men hver dom som finnes i treet er syntetisk (`reference_projects.json:2`; `shared/examples/bygg-energi-mikro/verdict-led-fro.md` «frø — AI-forfattet»). Ingen ekte dom har noen gang gått gjennom sløyfa. |
### 4.2 Non-goals (`README.md:248-262`), fem punkter
1. **«Not a compliance product.»** — Samsvar. Ingen compliance-funksjoner; disclaimer står.
2. **«Not a portfolio-level reallocator. … Moving budget between projects, ranking projects
against one another and portfolio governance sit above the method and are out of scope.»** —
Samsvar med ett forbehold: `--goals`/`--ledger` (`run.py:1669-1680`) stopper et porteføljepass
når et PORTEFØLJE-mål er nådd. Det er ikke reallokering eller rangering (bølgerekkefølge =
konfigrekkefølge), men det er portefølje-nivå-logikk i metoden. Uttalt grense, ikke brudd.
3. **«Not autonomous decision-making. The deterministic validator can only block; approving a
measure is a domain expert's call (human-in-the-loop), and the framework implements nothing on
the agents' say-so.»** — **BRUTT i én søm:** `portfolio-optimiser <PROJECT>` uten flagg fanger
en dom med `decision="approved"`, `rationale="reviewed by expert"` (`run.py:1691-1692`
`:2231`/`:2289``:861` `capture_verdict``store.add`). Ingen ekspert er involvert. I et
porteføljepass når den dommen neste prosjekts hypotese-prompt (`run.py:1331` `_merge_wave`;
bevist av `tests/test_portfolio_learning_loadbearing.py:108`). Validatoren blokkerer fortsatt
— men «godkjent» er defaulten, ikke en ekspertbeslutning.
4. **«Not a turnkey vertical solution.»** — Samsvar.
5. **«Not a model benchmark. The end-to-end proof runs offline against a scripted stand-in
client.»** — Samsvar. (Planens rad 4 — utforskning vs. debatt — ville vært en METODE-måling,
ikke en modell-benchmark, og er ikke gjort.)
### 4.3 README-ens status-advarsel (`README.md:264-273`), ordrett
> **Status:** the full 8-step agentic loop is wired and proven with load-bearing tests, and the
> end-to-end proof is an **offline simulation** with a scripted stand-in client — no live-model
> run yet. The **ingest layer** (real data sources) is implemented — … — but exercised only against
> committed fixtures: no bundle has yet been materialized from a live source. … A sibling
> implementation of the same method on the **Claude Agents SDK** is built in parallel from the
> same shared spec.
| Påstand | Målt | Samsvar |
|---|---|---|
| «no live-model run yet» | `docs/2026-08-14-fase1b-forste-levende-kjoring.md:42` «prosjektets første levende modellkall»; `:217` `outcome_type: rejected`, 15 306 tokens, `checker_verdict: approve`, `validator_decision: rejected` | **STALE.** Sist endret `d8ee8d3` 2026-07-04 (`git log -L`). En levende fullkjøring HAR konkludert. Det som fortsatt er sant: «ikke bevist at systemet produserer et *validert* forslag mot en levende modell» (`:245`). |
| «no bundle has yet been materialized from a live source» | Ingen dokumentasjon på det motsatte funnet | **Står** (ikke falsifisert i dag; nevner: `docs/` grep). |
| «sibling … is built in parallel» | `STATE.md:103` «po-claude PARKERT» | **STALE / ikke verifiserbar herfra.** |
### 4.4 Operatørens målbilde (23.08, gjengitt av .claude), ordrett
> «Vi skal bli EKSPERTER på bruken av Microsoft Agent Framework.» Målbildet: gitt EN prompt +
> OKF-bundles skal løsningen stille nødvendige spørsmål, teste hypoteser, be om svar, bruke svarene
> og utforske løsningsrommet — og finne innsparinger mennesker ikke ser, eller regne/utforske
> hypoteser fagpersoner leverer, langt raskere.
| Delmål | Kode | Status |
|---|---|---|
| «gitt EN prompt + OKF-bundles» | `--explore "<prompt>" --explore-config F --bundle-dir D` (`run.py:1607-1627`) | **Delvis:** én base fra CLI/hosting; flere baser kun via `run.run_mandate_across_bundles` (bibliotek). Åpen operatørbeslutning (STATE). |
| «stille nødvendige spørsmål» | Eneste spørsmål sløyfa kan stille et menneske er plan-review — én gang, før løpet (`explore.py:837-838` `_pending_plan_reviews`) | **Nei fra operatørflatene.** CLI nekter (`run.py:1975-1988`), hosting nekter (`hosting.py:167-172`). |
| «teste hypoteser» | `quick_validate` (`explore.py:461-500`) — samme validator, rådgivende | **Ja** (offline; at en levende modell faktisk kaller verktøyet er ikke bevist — uttalt i CLAUDE.md). |
| «be om svar, bruke svarene» | `plan_reviewer` bibliotek-only; ingen «spør eksperten»-tool blant de fire (`list_bundles`/`read_bundle`/`read_file`/`quick_validate`) | **Nei.** Planens rad 3 (U12 + asynkron U13) er nøyaktig dette, og er NESTE. |
| «utforske løsningsrommet» | Magentic-manager + navigator (`explore.py:596-603`) | **Mekanisme ja**, virkning umålt. |
| «finne innsparinger mennesker ikke ser» | — | **Ingen måling.** Null validerte levende forslag; den ene levende kjøringen fant opp `EL-LIGHTING-OP-HR` (`docs/2026-08-14-…` § 6). |
| «regne/utforske hypoteser fagpersoner leverer» | `--mandate` (`run.py:1597`) + `seed_approaches` (`explore.py:848`, bibliotek) | **Ja** for regning (mandat-stien); utforskning av ekspertfrø kun via bibliotek. |
| «EKSPERTER på MAF» | § 2: 5/19 fullt | **Nei.** Ekspertisen som finnes er *hvorfor MAF ikke passer* (as_agent, evals, Skills, graf, Concurrent — alle målt og avvist). Det er kompetanse, men ikke den operatøren beskrev. |
## 5. Funn, sortert etter alvorlighet
**BLOCKER** — ingen. Ingenting hindrer at systemet gjør det README lover *mekanisk*; problemene er
påstander som ikke stemmer og misjonsmål uten måling.
### MAJOR
**F1 — README-status er stale, offentlig, i begge retninger.** `README.md:264-273`. «no
live-model run yet» — falsk siden 14.08 (`docs/2026-08-14-fase1b-forste-levende-kjoring.md:217`).
«sibling … built in parallel» — `STATE.md:103` sier parkert. Status-avsnittet sist endret
2026-07-04 (`d8ee8d3`). `tests/test_public_surface_claims_loadbearing.py` gater to andre påstander
(credential-klasse, wheel-navn), ikke denne. Operatøren lever av tillit; en README som
underrapporterer er mindre farlig enn en som overrapporterer, men den er fortsatt usann.
**F2 — Ekspertdommen gis FØR forslaget finnes, og defaulter til `approved`.** `run.py:525`
(`verdict_input: dict[str, str]` påkrevd), `:861` `capture_verdict(features, verdict_input["decision"], …)`,
`:1691-1692` (`--decision` default `"approved"`, `--rationale` default `"reviewed by expert"`),
`hosting.py:80` (`verdict_input` i `_REQUIRED_FIELDS`), `DEPLOY.md:198` («The expert verdict for
this run»). `reference_domain.py:48` kaller det selv «SYNTHETIC Layer-2». Konsekvens: (a) hver
CLI-kjøring uten flagg produserer et `RunResult.verdict` med en ekspertgodkjenning ingen ekspert
ga; (b) i `run_portfolio` når den dommen neste prosjekts prompt som «prior expert verdict»
(`run.py:1331`; `tests/test_portfolio_learning_loadbearing.py:108` beviser dataflyten); (c) på
den hostede flaten MÅ en ekstern kaller dikte opp en dom for å få kjørt i det hele tatt — den
når ikke svaret (`hosting.py:181-190` legger kun `verdict_id` ut), men den er et påkrevd felt som
ikke kan fylles ærlig. Dette er non-goal 3 brutt i én søm, på flaten som ble overlevert 14.08.
Den ærlige kanalen (Steg 7-innboksen, `docs/ekspert-svar.md`) finnes ved siden av — F2 handler om
at den uærlige ikke er fjernet.
**F3 — `ExpeLContextProvider` er en MAF-hook som ikke bærer noe.** `run.py:736-739` folder
læringen inn ved `.format_fewshot()` + string-konkatenering; `run.py:848-857` kjører `before_run`
inn i en `SessionContext` kommentaren selv kaller «NOT what reaches the prompt». For U9 betyr det
at MAF-konstruktet subklasses for å *se* brukt ut, mens injeksjonen skjer utenom. Funksjonelt
riktig; som MAF-ekspertise er det motsatt av registerets intensjon.
**F4 — Målbildets «be om svar, bruke svarene» er ikke nåbart fra noen operatørflate.**
`run.py:1975-1988`, `hosting.py:167-172`: begge nekter `enable_plan_review`. Eneste dør er
`explore(…, plan_reviewer=…)` i bibliotek-APIet (`explore.py:849`). Ingen mid-løp-spørsmål
finnes overhodet — kun plan-review før løpet. Planlagt (rad 3), ikke bygget.
**F5 — Ingen måling av misjonens kjernepåstand.** «finner innsparinger mennesker ikke ser» har
null belegg: null validerte forslag mot levende modell (`docs/2026-08-14-…:245`), null ekte
ekspertdommer i treet (`reference_projects.json:2`; alle `type: verdict`-filer merket frø/AI),
planens rad 4 (utforskning vs. debatt på golden-bundelen) ugjort. Systemet kan i dag ikke skille
«mekanismen virker» fra «metoden gir verdi».
**F15 — MAF-pinnen er erklært «ENDELIG» seks minor-releaser bak, uten re-verifisering.**
`STATE.md:99` («orchestrations låst til `1.0.1` (core 1.9.0) — ENDELIG, operatørbekreftet 23.08»),
`pyproject.toml` `agent-framework-core>=1.9.0,<2`. Målt [ekstern]: core 1.15.0 (21.08) og
orchestrations 1.1.1 (krever core ≥1.15) var ute før bekreftelsen; 1.13.0 endret checkpoint-formen
rad 3 skal bygges på. Pinnen er begrunnet (privat-API-premisser, `tests/test_maf_version_guard.py`),
men den er ikke en MAF-ekspertise-posisjon — det er en frys. Ingen rutine i repoet re-leser §15 mot
releasene; registeret har én commit (`ec9ac74`, 23.06). For målbildets «EKSPERTER på MAF» er
dette det tyngste enkeltfunnet: ekspertisen er datert.
**F16 — B1-premisset er halvveis foreldet i den installerte versjonen.** Registeret (`§15.2 B1`):
«Deterministisk verdikontroll er ikke en MAF-primitiv». Installert `agent_framework/_middleware.py`
shipper `class MiddlewareTermination` uten `@experimental`; Learn (termination-siden): «Post-termination
middleware — … useful for validating responses». Det er formen U8 ba om, og den ville gjort
«forslaget forlater aldri agenten uvalidert» til en egenskap ved grafen i stedet for ved `run.py`.
Tall-halvdelen (CBC/MC/baseline/provenance) står som egenbygg. 0 treff i `src`. Ikke et krav om å
bygge om — et krav om at avvisningen av «graf-adopsjon» (P10) re-vurderes med dette premisset.
### MINOR
**F6 — U16/U17/U19 er uadresserte, ikke avviste.** Eneste spor er en telling
(`docs/plan/2026-08-23-…:58`). U16 (`CompactionProvider`) er den relevante: Magentic-løp har kun
token-taket som vern mot kontekstvekst.
**F7 — Layer-1 HITL er «green-but-dead».** `workflow.py:116` bak `enable_layer1_hitl=False`
(`run.py:536`), intet CLI-flagg, eneste test `tests/test_workflow.py:58` bygger uten å kjøre.
Repoets egen defektklasse (CLAUDE.md «grønn-men-død»).
**F8 — «Swarm» og «built on» er salgsspråk** (`README.md:9-10`). To agenter i debatt, tre i
utforskning; MAF er en klient + én orkestrering på den normative stien.
**F9 — Metode-skillen finnes ikke.** CLAUDE.md-konvensjon «Metode kodifiseres som Agent Skill»;
`find shared -name SKILL.md` → kun `expert-reviewer`, og dens prosa lastes aldri (`persona.py:27`
leser kun JSON-eksempelet). U5 er dermed «nei» også som artefakt.
**F10 — Privat-API-avhengighet er pinnet, ikke fjernet.** `explore.py:73/:152` siterer
`_magentic.py:1118/:1128-1131` linjenumre som premisser; `tests/test_maf_version_guard.py:1-14`
pinner `_inner_get_response`/`_build_response_stream`; `orchestrations` låst til `1.0.1`. Riktig
håndtert (tripwire + to-sidig pin), men det betyr at «MAF-ekspertise» her inkluderer avhengighet
av interna som kan flytte seg ved neste minor.
**F11 — Debatten er en fast sekvens som alltid tvinges ferdig.** `workflow.py:99-113` round-robin,
terminerings-nett `max_rounds*2+1` fyrer aldri; «forcing completion» i hver kjøring, også live
(`docs/2026-08-14-…:59`). P9 i egnethetsplanen noterte det («Group Chat beholdes av byttekost») —
det er ikke en debatt som konvergerer, det er tre tvungne turer.
**F12 — Feil nevner har sirkulert.** «6 av 15» kommer fra `docs/plan/2026-08-23-…:58`; registeret
har 19 rader. Rett tall etter denne tellingen: 5 fullt / 5 delvis / 9 nei av 19.
**F17 — Fire register-rader bærer premisser som ikke stemmer for Python i dag** [ekstern +
lokalt]: U15 navngir «Foundry Adaptive Evals» (ikke funnet på Learn); U16 navngir
`CompactionProvider` (C#-navn — Python-formen er `compaction_strategy=`/`apply_compaction`, 0 treff
i `src` også med det navnet); U19 navngir `HarnessAgent`/`ToolApprovalAgent` som Python 1.7.0
(.NET-only; Python er `create_harness_agent`/`ToolApprovalMiddleware`); G8/A2 kaller Magentic
eksperimentell (0 markører i installert `_magentic.py`, ingen Python-admonisjon på Learn).
§15.6 «FIX» korrigerte åtte påstander i juni; ingen av disse fire er blant dem.
### NICE
**F13 — Non-goal 2-grensen bør uttales i README.** `--goals`/`--ledger` er portefølje-nivå-logikk
(stopp, ikke reallokering). Én setning i Non-goals ville lukket tvetydigheten.
**F14 — B10 er utsatt til ekte dommer finnes** (`verdicts.py:313-314`) — riktig, men bør stå i
README-status som en uttalt grense for hva «learns» betyr i dag (én bit + prosa).
## 6. Verifiseringslogg (kommandoer kjørt i denne økten, 2026-08-25)
| # | Påstand | Kommando → resultat |
|---|---|---|
| 1 | Suiten grønn | `PYTHONIOENCODING=utf-8 uv run pytest -q`**1021 passed, 5 skipped, 2 warnings in 166.18s**, `EXIT=0` |
| 2 | De 5 skip er env-gatede live-tester | `uv run pytest -q -rs tests/test_*_live.py` → 4 SKIPPED med grunn (Foundry / `PORTFOLIO_LIVE_FULL_RUN` / LOCAL endpoint); den femte er i samme klasse (full kjøring ga 5) |
| 3 | Lint/typer rene | `uv run ruff check .` → «All checks passed!»; `ruff format --check` → 168 filer formatert; `uv run mypy src` → «Success: no issues found in 34 source files» |
| 4 | Golden-hash | `shasum tests/golden/demo-transcript.stdout``ea8c534773acdbe41ae68f2c55724d69aaf8be4f` |
| 5 | Testomfang | `ls tests/*.py \| wc -l` → 115 filer; `grep -rhE "^\s*(async )?def test_" tests \| wc -l` → 974 funksjoner |
| 6 | src-omfang | `find src -name "*.py" \| xargs wc -l` → 34 filer, 11 650 linjer |
| 7 | MAF-versjoner | `uv pip list` → core 1.9.0, orchestrations 1.0.1, foundry 1.8.2, openai 1.8.2; `uv.lock` samme |
| 8 | §15 har 19 U-rader og én commit | `awk '/^## 15/,/^## 16/'` → U1U19; `git log --follow -- docs/research/2026-06-23-prior-art-platform.md` → kun `ec9ac74` |
| 9 | Hvert U-konstrukt mot src | `for pat in …; grep -rn --include="*.py" -F "$pat" src` — tabellen i § 2 |
| 10 | README-status sist endret | `git log -L '/^> \*\*Status:\*\*/,+3:README.md'``d8ee8d3 2026-07-04` |
| 11 | Levende kjøring konkluderte | `sed -n '156,260p' docs/2026-08-14-fase1b-forste-levende-kjoring.md``outcome_type: rejected`, `token_usage: 15 306` |
| 12 | `verdict_input`-flyt | `grep -n verdict_input src/portfolio_optimiser/run.py``:525/:861/:2231/:2289`; `grep -nE '"--decision"\|"--rationale"'``:1691-1692`; `hosting.py:80` |
| 13 | ExpeL-fold utenom hooken | `sed -n '700,760p;848,870p' src/portfolio_optimiser/run.py` |
| 14 | Magentic ikke eksperimentell i installert pakke | `grep -n "experimental" .venv/…/agent_framework_orchestrations/_magentic.py` → 0 treff; `_skills.py``@experimental` `:76/:120`; `_evaluation.py` → 20 |
| 15 | Multi-base-ordren arkivert (stoppbetingelse) | `find ~/.claude/coord/portfolio-optimiser -name "*080753Z*"``orders/archive/…` |
| 16 | Ingen ekte ekspertdom i treet | `grep -rl "type: verdict" shared/examples src/…/data/bundles` → 12 filer; frontmatter `provenance: "frø — AI-forfattet …"`; `reference_projects.json:2` «SYNTHETIC» |
| 17 | Nye MAF-konstrukter finnes i INSTALLERT 1.9.0 | `grep -rl -F "class MiddlewareTermination" .venv/…/agent_framework``_middleware.py` (kun `PROGRESSIVE_TOOLS`-markører i fila); `AgentLoopMiddleware``_harness/_loop.py`; `apply_compaction``_compaction.py`; `create_harness_agent``_harness/_agent.py`; `SequentialBuilder`/`HandoffBuilder` → orchestrations; `FileCheckpointStorage` + `allowed_checkpoint_types``_workflows/_checkpoint.py`; 28 `@experimental` på COMPACTION/HARNESS/LOOP |
| 18 | Ingen av dem brukt | `grep -rn --include="*.py" -E "compaction_strategy\|apply_compaction\|MiddlewareTermination\|AgentLoopMiddleware\|create_harness_agent" src` → 0 |
| 19 | MAF-versjoner på PyPI/Learn [ekstern] | Opus-agent, WebFetch mot `pypi.org/pypi/agent-framework-core/json` (×2, samsvar) + `learn.microsoft.com` — rapporten er gjengitt i § 3.1; GitHub-release-punkter merket LAVERE KONFIDENS der fetch-laget summerte |
| 20 | Doc-gaten godtar det nye dokumentet (datert sti) | `uv run pytest -q tests/test_doc_constant_sync_loadbearing.py tests/test_public_surface_claims_loadbearing.py` → 20 passed |
**Ikke verifisert i denne økten:** at ingen bundle er materialisert fra en levende kilde (bare
ikke funnet motbevis); Claude-SDK-søskenets faktiske tilstand (annet repo); at en levende modell
faktisk kaller utforskningens verktøy (uttalt grense i CLAUDE.md, ikke målt her).

View file

@ -0,0 +1,283 @@
# Syretesten vei A/B — de tre Vegnormal-basene gjennom portfolio-optimiser
**Ordre:** `20260825T111038Z-1174613178-from-.claude` (programplanens spor 3, gap G12).
**Dato:** 2026-08-25 (økt 60). **Mandat: MÅL, IKKE BYGG.** Ingen fil under `src/` er endret;
`uv run pytest -q`**1021 passed / 5 skipped (166 s)**, identisk med tallet før økten.
**Eksponerings-grense (ordrens harde krav, holdt):** dette repoet pusher til `open/`. Rapporten
bærer derfor kun **tall, stier, kommandoer og egne observasjoner**. Ingen bundle-fil er kopiert,
og ikke én linje kravtekst fra et konsept er lest inn eller gjengitt — alle konsept-tall under er
lengdemålinger, ikke innhold.
**Stopp-betingelsen var oppfylt:** multi-base-ordren `20260825T080753Z-103813595` lå i
`orders/archive/` (commit `18af86e` + `785261f`) da økten startet. Multi-base-formen er lest slik
den **faktisk landet** (`run.py`, `explore.py`, `mandate.py`, README), ikke slik planens § C.7
omtalte den.
---
## 1. Sammendrag — én setning per målepunkt
| # | Punkt | Status | Kjernetall |
|---|---|---|---|
| 1 | Katalogen (`index_summary`, konsepter, cost-baseline, verdicts) | **MÅLT** | 446 / 1017 / 270 konsepter; **0 av 3** har `cost-baseline.json`; **0 av 3** har `validator-input.json`; **0** `type: verdict`-filer i alle tre |
| 2 | Kontekstkostnad (`bundle_context(navigate_bundle(...))`) | **MÅLT** | 93 422 / 250 785 / 85 937 o200k_base-tokens — **sum 430 144** mot 3861/12595/10406 for de tre eksempelbundlene (instrumentet reproduserte commons' tall eksakt) |
| 3 | Dry-run med tre baser i multi-base-formen | **MÅLT — og formen finnes ikke fra CLI-en** | Fire `--bundle-dir` gir **exit 0** og en kjøring mot ÉN base (siste vinner, stille); bibliotekdøra `run_mandate_across_bundles` **ruter korrekt** men feiler i dispatch: `FileNotFoundError @ okf.py:433` |
| 4 | Offline-simuleringen med samme oppsett | **MÅLT** | Golden `demo-transcript.stdout` **BYTE-UENDRET** (`ea8c534773acdbe41ae68f2c55724d69aaf8be4f`), stderr 4 linjer; demoen **kan ikke** peke på en Vegnormal-base (samme `okf.py:433`) |
| 5 | `--explore` med full seks-felts `--explore-config` | **MÅLT — delvis vakuøst, som ordren forutså** | Sløyfa **fullfører** offline mot tre baser og returnerer et **rutet** mandat (`bundle_id='B-n100-…'`, `stop=None`); men **0 verktøykall** og **0 `quick_validate`** — navigatoren åpnet aldri en base. Fra CLI-en er utforskningen **ikke kjørbar offline i det hele tatt** (`KeyError: 'navigator'`) |
| 6 | G14: navigasjon inn i nestede `index.md` | **IKKE PRØVBAR HER — nevner oppgitt** | **0 nestede `index.md` av 1733 konsepter** i alle tre basene (0 underkataloger); kjent-positiv kontroll `nav-golden-hierarchy/bundle` finner 2 nestede index og 2 dypt-nådde kontekstfiler, så instrumentet **kan** se dem |
---
## 2. Tabellen (punkt 1 + 2)
Kommando bak hver rad: `okf.navigate_bundle(d)``okf.bundle_context(bundle)`, med
`tiktoken.get_encoding("o200k_base")` (kjørt via `uv run --with tiktoken`; `tiktoken` er **ikke**
lagt til som prosjekt-avhengighet).
| Base | Konsepter | Kontekstfiler | `index_summary` (tegn / tokens) | `bundle_context` tegn | bytes | **o200k_base-tokens** | verdicts | cost-baseline | validator-input | skipped links |
|---|---:|---:|---|---:|---:|---:|---:|:--:|:--:|---:|
| `B-n100-2023-uten-sources-importert` | 446 | 446 | 51 214 / 28 289 | 221 916 | 226 430 | **93 422** | 0 | nei | **nei** | 0 |
| `B-n200-2024-uten-sources-importert` | 1017 | 1017 | 116 879 / 64 764 | 616 179 | 626 034 | **250 785** | 0 | nei | **nei** | 0 |
| `B-n500-2024-uten-sources-importert` | 270 | 270 | 30 974 / 17 197 | 240 714 | 245 251 | **85 937** | 0 | nei | **nei** | 0 |
| **Sum, tre baser** | **1733** | **1733** | 199 067 / 110 250 | 1 078 809 | 1 097 715 | **430 144** | 0 | — | — | 0 |
| *kontroll:* `veglys-fv-soer` | 6 | 5 | 3 646 / — | 32 201 | 32 884 | **10 406** | 1 | ja | ja | 1 |
| *kontroll:* `tunnel-hauglia` | 6 | 5 | 4 763 / — | 39 583 | 40 475 | **12 595** | 1 | ja | ja | 1 |
| *kontroll:* `bygg-energi-mikro` | 5 | 4 | 1 884 / — | 12 005 | 12 270 | **3 861** | 1 | nei | ja | 1 |
**Instrumentet er validert mot kjent fasit** (Verifiseringsloven ansikt 4): de tre
kontrollradene reproduserer commons' egne tall — 3861 / 12 595 / 10 406 — eksakt. Uten den
kontrollen ville Vegnormal-tallene vært en måling ingen visste kunne treffe.
**Ordrens tall bekreftet mot ground truth** før noe ble bygget på dem: `447 / 1018 / 271` `.md`-filer
på disk = `446 / 1017 / 270` konsepter + `index.md` i hver. Hver `index.md` har nøyaktig like mange
lenker som det er konsepter (446 / 1017 / 270), alle unike, alle fulgt — `skipped = 0`.
**Det manageren faktisk ser.** Ett `list_bundles()`-kall (`explore.py:419-438`) returnerer hele
`index_summary` for **alle** baser samtidig:
| | tegn | bytes | **o200k_base-tokens** |
|---|---:|---:|---:|
| `list_bundles()` over de tre basene | 201 196 | 201 196 | **112 116** |
Dette er ett verktøykall, og det er katalogverktøyets **eneste** form.
### Hva tallene sier om `.claude`s § 9.1-analyse
`.claude` sin `docs/okf-bundle-prosessen.md § 9.1` («en fil uten lenke finnes ikke; alt som lenkes
leses helt») er **bekreftet mot et ekte korpus, og den er kostbar her**: alle 1733 konsepter er
lenket fra rot-`index.md`, `skipped = 0`, og «leses helt» betyr 430 144 tokens for de tre basene.
Progressiv disclosure gir ingen lettelse på denne bundle-formen, fordi importformen legger *alt*
på ett nivå — se funn **MINOR-1**.
---
## 3. Funn
### BLOCKER-1 — kontekstkostnaden gjør en live utforskning mot disse basene ugjennomførbar som de står
`list_bundles()` = **112 116 tokens** i ett kall; `read_bundle("B-n200-…")` = **250 785 tokens**.
`ExplorationContract.max_tokens` (`explore.py:70`) er ledgeren `BudgetMiddleware` håndhever, og et
enkelt katalogkall bruker mer enn et normalt tak. En 128k-modell kan ikke ta N200 i det hele tatt.
**Dette er ikke en defekt i rammeverket** — det er korpusets form møtt av § 9.1-kontrakten. Men det
er den harde grensen for vei A/B live, og den var ikke målt før i dag.
**Fil:linje:** `src/portfolio_optimiser/explore.py:419-438` (`list_bundles`), `:444-445` (`read_bundle`).
> **Oppdatert 2026-08-26 (økt 65, ordre `20260825T213645Z-9019120455`):** katalog-halvdelen er
> **lukket**. `list_bundles()` over de samme tre basene koster nå **362 tokens** (fra 112 116), og
> over alle 171 grenbaser **21 448** (fra 124 942). `read_bundle`-halvdelen ble lukket på korpussiden
> av `vegnormal-okf` `8145c23` (grener som egne baser). Måling og gate:
> [docs/2026-08-26-katalogkostnaden.md](2026-08-26-katalogkostnaden.md). Setningen over står som
> den ble målt 25.08 — den er historikk, ikke en gjeldende tilstand.
### MAJOR-1 — gjentatt `--bundle-dir` forkastes STILLE; kjøringen ser ut som multi-base og er det ikke
Ordrens pkt. 3 forutsatte at CLI-en tar tre baser. Målt:
```
uv run python -m portfolio_optimiser.run VEGLYS-FV-SOER \
--docs-dir shared/examples/veglys-fv-soer \
--bundle-dir <N100> --bundle-dir <N200> --bundle-dir <N500> \
--bundle-dir shared/examples/veglys-fv-soer --live-dry-run
→ EXIT 0, "VEGLYS-FV-SOER: LIVE-DRY-RUN OK (…)"
```
**Exit 0.** De tre Vegnormal-basene ble droppet uten ett ord. Bytter man rekkefølgen slik at en
Vegnormal-base står sist, feiler samme kommando i stedet (`live-dry-run refused: IR projection not
found in bundle: 'validator-input.json'`) — altså **siste `--bundle-dir` vinner**, som er argparse
sin default når `action="append"` mangler.
Dette er repoets egen defektklasse, anvendt på operatørflaten: hosting-whitelisten nekter ukjente
felt **ved navn** nettopp fordi stille dropping er uleselig utenfra, og multi-base-invarianten
avviser en andre `bundle_dir``run_project`-signaturen fordi den ville tvunget «et stille
velg-en». Her *er* det et stille velg-en — bare i argv i stedet for i signaturen.
**Fil:linje:** `src/portfolio_optimiser/run.py:1581-1583` (`add_argument("--bundle-dir", default=None…)`,
ingen `action="append"`), konsumert `run.py:2070` (`bundle_dirs=(args.bundle_dir,)`) og `run.py:607-610`.
**Minste ærlige rettelse (ikke bygget — ordren er MÅL, IKKE BYGG):** nekt et gjentatt `--bundle-dir`
ved navn, på linje med de åtte eksisterende utforskningsnektene. STATE fører allerede
«repeterbart `--bundle-dir`» som en **åpen operatørbeslutning** fra økt 58; denne målingen sier at
inntil den er tatt, er *stillheten* selv problemet — ikke fraværet av funksjonen.
### MAJOR-2 — `--explore --scripted-replies` krasjer med rå traceback: `KeyError: 'navigator'`
Den ene offline-døra CLI-en har til `--explore` er ubrukelig. `_SCRIPTED_ROLES = ("proposer",
"checker")` er debattens to roller; utforskningen trenger i tillegg `navigator`, `hypothesiser` og
`manager`. `_load_scripted_replies` er eksplisitt fail-fast **for de to den kjenner** («a missing
role would otherwise surface as a `KeyError` deep inside `scripted_factory`'s lookup, mid-run») —
og så inntreffer nøyaktig det den advarer mot, for de tre den ikke kjenner:
```
File ".../explore.py", line 576, in fresh_exploration_workflow
client_factory(role),
File ".../simulation.py", line 470, in factory
reply = replies[role]
KeyError: 'navigator'
```
Ingen `run refused:`-linje, ingen rc-1 med forklaring — en traceback, som er den kanalen
økt 57 betalte for å holde konfigurasjonsfeil UTE av.
**Positivt målt i samme kjøring:** `finally`-blokka holdt. `{run_id}-exploration.json` ble skrevet
selv om kjøringen krasjet, med `"completed": false` og `"stop": null` — nøyaktig det
`completed`-feltet finnes for.
**Fil:linje:** `src/portfolio_optimiser/run.py:1531` (`_SCRIPTED_ROLES`), `:1559-1564`
(fail-fast-listen), krasjer i `src/portfolio_optimiser/simulation.py:470`.
**Merk:** `simulation.scripted_exploration_factory` (`simulation.py:713-736`) dekker allerede alle
fem rollene. Sømmen finnes; CLI-en når den bare ikke.
### MAJOR-3 — regelverksbaser kan ikke være baser i multi-base-dispatchen (arkitektonisk, ikke en bug)
Bibliotekdøra `run_mandate_across_bundles` ble målt direkte med de tre basene og et mandat med én
approach per base:
```
STEG 1 route_by_bundle → B-n100…: ['a0'] B-n200…: ['a1'] B-n500…: ['a2'] ✅ korrekt partisjon
STEG 2 run_mandate_across_bundles → FileNotFoundError @ okf.py:433
"IR projection not found in bundle: 'validator-input.json'"
```
**Partisjonen virker perfekt.** Dispatchen gjør det ikke, fordi `run.py:1491` leser hver bases
prosjekt fra **den basens egen** IR-projeksjon — som er selve multi-base-invariantens designvalg
(«en kaller-oppgitt konstant kunne uansett bare vært riktig for én base av N»).
Konsekvensen er den viktigste innsikten i hele syretesten: **multi-base betyr N prosjekter, ikke
1 prosjekt × N referansebaser.** Vegnormal-basene er *regelverk* — de har verken prosjekt eller
kostbaseline, og skal ikke ha det. Ordrens mentale modell («ett veglysprosjekt + tre normalbaser
som kontekst») er en **annen form**, og den finnes allerede — bare ikke i dispatchen:
| Dør | `bundle_dirs` betyr | Passer regelverk? |
|---|---|---|
| `run.run_mandate_across_bundles` (`run.py:1388`) | N **prosjektbaser** → N kjøringer | **Nei** — krever `validator-input.json` per base |
| `explore.explore` (`explore.py:~840`) | N **lesekilder** for navigator/hypothesiser | **Ja** — målt, se under |
**Fil:linje:** `src/portfolio_optimiser/okf.py:431-433`, kalt fra `run.py:1491` (dispatchen),
`run.py:609` (enkeltkjøringen) og `simulation.py` (demoen) — alle tre feiler på samme sted.
### MINOR-1 — importformen legger alt på ett nivå, så progressiv disclosure gir null lettelse
0 underkataloger, 0 nestede `index.md`, 1733 av 1733 konsepter lenket direkte fra rot. Navigasjonen
har ingenting å utsette; hele korpuset er ett flatt nivå. Dette er en egenskap ved **kilden**, ikke
ved `okf.py`.
> **Rettet 2026-08-26 (økt 65): tilskrivelsen var feil, og `vegnormal-okf` har rett.** Flatheten er
> **Dør C** sin, ikke vegnormals emitterform. Verifisert mot kilden, ikke mot deres melding:
> `llm-ingestion-okf` `src/llm_ingestion_okf/importer.py` (§6-index-blokka) kaller
> `link_in_index(bundle, entry.path.name, _index_label(entry.concept_path))` per merget oppføring —
> altså én flat lenke i rot-`index.md` for hvert konsept, uansett hvor nestet konseptstien er.
> Vegnormals emitter skriver allerede et tonivåtre. Setningen over sto uendret som «vegnormal-okf
> sin importform» til dette punktet.
Dette var også hele grunnen til BLOCKER-1: med nestede indekser kunne manageren åpnet én gren om
gangen. Løsningen ble en annen — grener som **egne baser** (`vegnormal-okf` `8145c23`), med den
målte begrunnelsen at basegrensen er der OKF-navigasjonen stopper, så ingen indeksstruktur INNE i en
base senker prisen på å åpne den.
### NICE-1 — `read_bundle` nekter ukjent base ved navn, som lovet
```
read_bundle("finnes-ikke") → ExplorationError: unknown knowledge base 'finnes-ikke';
configured: B-n100-2023-…, B-n200-2024-…, B-n500-2024-…
```
Nekten navngir det konfigurerte settet. **Fil:linje:** `explore.py:390-393`.
---
## 4. Hva punkt 5 faktisk viste — og hvor grensen for offline går
Ordren ba om at vakuiteten skulle måles, ikke antas. Målt, med `scripted_exploration_factory`
(alle fem roller) mot de tre basene:
| Arm | Utfall |
|---|---|
| **B** — hypotese **uten** `bundle_id`, tre baser | `HypothesisParseError @ explore.py:732` — «a marked hypothesis must name its knowledge base when several are configured». **Multi-base-nekten fyrer korrekt mot et ekte korpus.** |
| **C** — hypotese **med** `bundle_id`, tre baser | `stop=None`, **1 approach**, `bundle_id='B-n100-2023-uten-sources-importert'`, 2 ledger-runder, 6 modell-prompts. **Sløyfa fullfører og produserer et rutet mandat.** |
Og så det ærlige forbeholdet, som er poenget:
- `quick_validate`-kall: **0**
- Prompt-strøm-forekomster av basenavnene: `N100` **2**, `N200` **0**, `N500` **0** — begge fra
instruksjonene, ingen fra et verktøyresultat.
**Navigatoren åpnet aldri en base.** Den scriptede klienten returnerer tekst og emitterer ingen
verktøykall — nøyaktig den grensen økt 56s måling allerede slo fast, nå bekreftet mot et eksternt
korpus. Lesesømmen **selv** er derimot bevist mot disse basene, ved direkte kall (samme kontroll
økt 56 måtte innføre da den oppdaget at armen lå utenfor gaten): `list_bundles()` → 3 baser,
`read_bundle()` → 221 916 / 616 179 / 240 714 tegn.
**Konklusjon for punkt 5, uten pynt:** *plumbingen* er bevist ende-til-ende mot ekte eksterne
baser — ruting, nekt, mandatform, artefaktskriving. *Verdien* er ikke bevist, og kan ikke bli det
offline. **Syretesten trenger en levende modell.** Det er et funn, ikke en feil.
---
## 5. Hva som MÅ til for en live-kjøring
Målt i denne økten, ikke antatt:
| # | Mangler | Målt tilstand | Konkret |
|---|---|---|---|
| 1 | **Kontekstbudsjettet** | `list_bundles()` = 112 116 tokens; `read_bundle(N200)` = 250 785 | **Den harde blokkeringen.** Enten en modell med svært stort vindu, eller — mer realistisk — en bundle-form med nestede indekser slik at manageren kan åpne én gren. Kilden eies av `vegnormal-okf`. |
| 2 | **Foundry-endepunkt** | `PORTFOLIO_FOUNDRY_PROJECT_ENDPOINT` **ikke satt**, `FOUNDRY_PROJECT_ENDPOINT` **ikke satt** | Én av de to må eksporteres. Låst av operatørbeslutningen om `DisableLocalAuth` (ordre `20260821T094949Z`, `docs/2026-08-18-vurdering-azure-omdoeping.md`). **Ikke rørt her.** |
| 3 | **Modell-map** | `PORTFOLIO_MODEL_MAP` ikke satt; `src/portfolio_optimiser/data/model_map.json` bærer `REPLACE-WITH-FOUNDRY-DEPLOYMENT` for alle azure-roller | `resolve_model("azure", r)` **nekter for alle fem roller**, inkl. `manager`/`navigator`/`hypothesiser`. Under `local` faller alle fem til `qwen3:4b` via `default` — utforskningsrollene er fortsatt ikke eksplisitt mappet (kjent ærlighets-grense fra økt 56). |
| 4 | **En prosjektbase** | 0 av 3 Vegnormal-baser har `validator-input.json` eller `cost-baseline.json` | Kjøringen trenger et **prosjekt** å optimere. Vegnormal-basene er regelverket det optimeres *innenfor*. Riktig oppsett: `--bundle-dir <prosjektbase>` for pipelinen + de tre normalbasene som `explore(bundle_dirs=…)`-lesekilder — men det krever MAJOR-1 løst, siden CLI-en i dag sender **én** base til begge. |
| 5 | **Offline-generalprøve** | `KeyError: 'navigator'` | MAJOR-2 må lukkes før en betalt kjøring, ellers er første live-kjøring også første gjennomkjøring. Repoets egen måleprotokoll: bevis så mye som mulig gratis, så en feil er attribuerbar. |
**Rekkefølge, uten å foregripe operatørens valg:** 5 → 1 → 2/3 → 4. Punkt 5 er gratis, punkt 1
avgjør om vei A/B i det hele tatt er mulig med denne bundle-formen, og punktene 23 koster penger
og er Azure-gatet.
---
## 6. Kommandologg
Hver tabellverdi over stammer fra én av disse, kjørt i denne økten:
```bash
# pkt 1: konsepter, index-lenker, verdicts, cost-baseline
find <base> -name '*.md' | wc -l ; grep -oE '\]\([^)]+\)' <base>/index.md | wc -l
grep -lE '^type: *verdict' <base>/*.md | wc -l
# pkt 1+2: navigasjon, kontekst, tokens (instrument validert mot commons' tre fasittall)
uv run --with tiktoken python # okf.navigate_bundle / okf.bundle_context / o200k_base
# pkt 3: CLI, fire --bundle-dir, begge rekkefølger
uv run python -m portfolio_optimiser.run VEGLYS-FV-SOER --docs-dir … --bundle-dir … --live-dry-run
# pkt 3: bibliotekdøra
python # mandate.route_by_bundle + run.run_mandate_across_bundles
# pkt 4: golden-regresjon
uv run python -m portfolio_optimiser.simulation | shasum # ea8c534773acdbe41ae68f2c55724d69aaf8be4f
# pkt 5: CLI-en, og deretter explore() direkte med alle fem roller scriptet
uv run python -m portfolio_optimiser.run … --explore … --explore-config … --scripted-replies …
# pkt 6: nestede index, med nav-golden-hierarchy som kjent-positiv kontroll
# regresjon
uv run pytest -q # 1021 passed, 5 skipped, 166.00s
```

View file

@ -0,0 +1,120 @@
# Katalogkallet — `list_bundles()` fra 112 116 til 362 tokens
**Ordre:** `20260825T213645Z-9019120455-from-.claude`. **Dato:** 2026-08-26 (økt 65).
**Kode:** `src/portfolio_optimiser/explore.py` (`_index_excerpt`, `navigator_tools`).
**Gate:** `tests/test_catalogue_cost_loadbearing.py` (7 tester). **Suite:** 1066 passed / 5 skipped.
**Golden `demo-transcript.stdout` byte-uendret** (`ea8c534773acdbe41ae68f2c55724d69aaf8be4f`).
**Eksponerings-grense (arvet fra syretesten, holdt):** dette repoet pusher til `open/`. Rapporten
bærer kun tall, stier, kommandoer og egne observasjoner. Ingen bundle-fil er kopiert, og ingen
kravtekst er gjengitt — alle konsept-tall er lengdemålinger, ikke innhold.
---
## 1. Instrumentet, validert før noe tall ble trodd
`tiktoken.get_encoding("o200k_base")` over `json.dumps(list_bundles(), ensure_ascii=False)`, kjørt
med `uv run --with tiktoken` (`tiktoken` er fortsatt **ikke** en prosjekt-avhengighet). Samme
instrument som syretesten 25.08.
**Kjent-positiv kontroll (Verifiseringsloven ansikt 4):** de tre flate Vegnormal-basene målte
**201 196 tegn / 112 116 tokens** før endringen — tallet syretesten publiserte, reprodusert eksakt.
Uten den kontrollen ville «etter»-tallet vært en måling ingen visste kunne treffe.
## 2. Før og etter, med nevner
| Katalog | Baser (nevner) | Tegn før | **Tokens før** | Tegn etter | **Tokens etter** | Endring |
|---|---:|---:|---:|---:|---:|---:|
| Tre flate baser (syretestens sett) | 3 | 201 196 | **112 116** | 877 | **362** | **99,7 %** |
| Grener, N100:2023 | 40 | 63 375 | **33 889** | 11 679 | **5 017** | 85,2 % |
| Grener, N200:2024 | 99 | 134 667 | **71 726** | 28 894 | **12 396** | 82,7 % |
| Grener, N500:2024 | 32 | 36 569 | **19 329** | 9 408 | **4 037** | 79,1 % |
| **Alle grener samlet** | **171** | 234 611 | **124 942** | 49 981 | **21 448** | **82,8 %** |
| *kontroll:* commons' tre eksempelbaser | 3 | 11 050 | 3 472 | 989 | 321 | 90,8 % |
Per base: **731 → 125 tokens** i grenformen, **37 372 → 121** i den flate.
**Nevner-avvik mot ordren, uttalt:** ordren oppgir 34 grener for N100:2023. Målt på disk
(`ls ~/repos/vegnormal-okf/build | grep -c '^B-n100-2023-gren-.*-importert$'`) er tallet **40**.
N200:2024 = 99 og N500:2024 = 32 stemmer. Tallene over bruker den målte nevneren, ikke ordrens.
**Ordrens hypotese bekreftet:** grenformen lukket bundle-siden og gjorde katalogsiden **verre**
124 942 tokens over 171 grener mot 112 116 over tre flate baser. Etter endringen er hele
maksimal-konfigurasjonen 21 448 tokens, altså under en femtedel av ett 128k-vindu.
## 3. Hva som ble endret, og hvorfor akkurat det
`list_bundles` returnerte `Bundle.index_summary`**hele** rot-indeksens body — for **hver**
konfigurert base samtidig, pluss ett JSON-objekt per ufulgt kryss-lenke. Begge vokser med korpuset.
Prisen på å finne ut *hvilke baser som finnes* ble altså satt av hvor mye de basene *inneholder*.
Det er progressiv disclosure snudd på hodet (målbilde §2/§4): katalogen er stigens billigste trinn,
og den var loopens dyreste kall.
Hver oppføring er nå bundet ved konstruksjon: `id`, en **ordrett prefiks** av indeksbodyen på maks
`_CATALOGUE_EXCERPT_CHARS` (200) tegn, `index_truncated`, `documents`, `verdict_count`,
`cost_baseline`, `unreachable_links`. Kostnaden er **O(baser), aldri O(korpus)**.
**Et premiss ble felt FØR noe ble bygget på det.** «Indeksbodyen forteller en manager hva basen
handler om» er **usant** for maskin-importerte baser: grenbasenes `index.md` har verken frontmatter
eller prosa — den er en ren lenkeliste (målt: `B-n200-2024-gren-1-1-importert/index.md`, 959 bytes,
første tegn er `-`). Feltet var altså ikke bare dyrt, det var dyrt **og** innholdsløst der. En
avkortet prefiks taper ingenting en manager brukte.
Tre valg, hvert med sin begrunnelse:
- **Fast vindu, ikke en andel av basen.** En andel ville skalert med korpuset igjen — bare med en
mindre konstant. Mutasjon M8 (`body[:len(body)//10]`) er rød mot nettopp den formen.
- **Avkorting ANNONSERES, som felt.** `index_truncated` står **ved siden av** utdraget, ikke som en
markør limt inn i det — samme grunn som `BudgetExceeded` bærer trippelen sin som felt (kø-(y)):
en konsument som må re-parse prosa for å vite om den holder helheten, har fått en diagnostikk den
ikke kan handle på. En base som **passer** blir ikke merket avkortet og får hele bodyen —
omisjon, aldri en løgn i noen av retningene (M3 og M4 er begge røde, hver sin vei).
- **En ufulgt kryss-lenke overlever som ANTALL.** Fakta fra økt 51 («et hopp er tolerert, men ikke
lenger taust») står; per-lenke-detaljen blir liggende der den er handlingsbar — på
`RunResult.skipped_links` / `DryRunReport.skipped_links` — og rir ikke med i et kall hvis hele
jobb er å være billig.
Hele indeksen er fortsatt **ett** `read_file(id, "index.md")` unna. Dette er et disclosure-nivå,
ikke datatap.
## 4. Gaten
Taket (500 tegn per base) bor i **testen**, ikke i `explore.py`: en test som importerte
implementasjonens budsjett ville flyttet seg med det, og å heve budsjettet er nøyaktig den
regresjonen fila finnes for.
Syv armer: (a) taket over mange store baser · (b) kostnaden sporer **ikke** korpus-størrelse ·
(c) katalogen identifiserer fortsatt hver base — anti-vakuitets-armen, uten den består «returner
bare id-ene» (a) perfekt · (d) avkorting annonsert, utdraget ordrett, begge retninger · (e) stigen
er intakt, hele indeksen er ett kall unna · (f) ufulgte lenker overlever som antall ·
(g) **kontrollen** — én bases rå indeksbody alene sprenger taket for hele katalogen, så en grønn (a)
betyr at bindingen fyrte, ikke at fixturen var liten.
**Ni mutasjoner, alle røde mot HELE suiten, hver med sin egen signatur** (grønn kontroll 1066/5):
| # | Mutasjon | Røde tester |
|---|---|---:|
| M1 | ingen binding i det hele tatt (tilbake til hele bodyen) | 4 |
| M2 | bundet, men vakuøst (utdraget tømt) | 2 |
| M3 | avkorting ikke annonsert (stille kutt) | 1 |
| M4 | over-annonsert (en hel indeks meldt avkortet) | 1 |
| M5 | per-lenke-lista rir med igjen | 1 |
| M6 | det ufulgte faktumet slettet | 1 |
| M7 | bundet, men suffiks i stedet for ordrett prefiks | 2 |
| M8 | en andel av basen i stedet for et fast vindu | 2 |
| M9 | størrelses-signalet (`documents`) gjort konstant | 1 |
M9 ble kjørt fordi `documents` var et felt uten gate — et felt ingen test kan se, råtner.
## 5. Grenser, uttalt
- **MAJOR-1 (repeterbart `--bundle-dir`) var ikke nødvendig for dette.** Bindingen sitter i
verktøykroppen, bak en uendret CLI-flate. Operatørbeslutning 26.08: repeterbart `--bundle-dir`
er **nei** — multi-base forblir bibliotek-partisjonen (`run_mandate_across_bundles`).
- **`navigate_bundle` kalles fortsatt per base per katalogkall**, altså leses hver fil på disk for å
telle dokumenter og dommer. Det er I/O og veggklokke, ikke tokens, og er utenfor denne
bestillingen — men det er ikke gratis ved 171 baser, og det er ikke målt her.
- **Ingen levende modell har kalt det nye verktøyet.** Formen er bevist offline; at en manager
faktisk velger bedre med et utdrag enn med hele indeksen er ikke målt (samme klasse som
structured-output-grensen).
- **N101 er ikke berørt** — utenfor bestillingen (operatørpresisering 26.08).

View file

@ -0,0 +1,230 @@
# MAF-gjelden — omfang, ikke bygg (2026-08-29)
> **Ordre:** `20260825T214801Z-2660820373-from-.claude`, oppfølging av
> [misjons-reviewen](2026-08-25-fable-misjonsreview.md) (25.08). **Mandat:** mål omfanget av fire
> funn — F3, F15, F16, U16/U17/U19 — og lever kost/risiko/gevinst + en anbefalt rekkefølge. **Ingen
> kode er endret.** Hvert tall under kommer fra en kommando kjørt i DENNE økten (verifiseringslogg
> § 6); der reviewens tall er sitert direkte, er det merket.
## 0. Ferskhets-korreksjon FØR noe annet — reviewens telling er fire dager gammel
Reviewen (25.08) talte **5 ja / 5 delvis / 9 nei av 19**. Siden da har `c08ae91` (26.08, «U12 +
asynkron U13, rad 3») landet checkpoint-persistens i `explore.py` (`checkpoint_storage=`
`_ALLOWED_CHECKPOINT_TYPES`, bak `--checkpoint-dir`). Frisk grep i dag (§ 6, linje 119) flytter
**U12 fra nei til ja (opt-in)**:
**Ny telling, 29.08: 6 ja (U3, U4, U6, U12, U14, U18) · 5 delvis (U7, U8, U9, U11, U13) · 8 nei
(U1, U2, U5, U10, U15, U16, U17, U19).** Av de åtte nei-radene er fem begrunnet i repoet (U1, U2,
U5, U10, U15); tre er det ikke — **nøyaktig U16, U17, U19, uendret fra reviewen**, som er dette
dokumentets fokus. Dette bekrefter at ordrens premiss («disse tre er uadresserte») fortsatt
stemmer, men med et korrigert nevnertall — nok en instans av F12s advarsel («feil nevner har
sirkulert»): selv reviewens eget tall råtner på fire dager.
## 1. F3 — `ExpeLContextProvider` er en MAF-hook som ikke bærer noe
**Bekreftet uendret** (linjenumre skiftet med commits siden 25.08, men koden og kommentarene er
identiske i sak): `run.py:789-792` kaller `.format_fewshot()` og string-konkatenerer inn i
`gen_context` FØR generering — dette er det som faktisk når prompten. `run.py:906-910` kjører
`ExpeLContextProvider(...).before_run(...)` inn i en lokalt konstruert, kastet `SessionContext`
**etter** at forslaget er generert — kommentaren sier det selv, ordrett: «this block's SessionContext
is NOT what reaches the prompt» (`run.py:905`). Det andre kallet finnes utelukkende for å holde en
regresjonsvakt i live (den to-argument `extend_instructions`-signaturen, «Critical Fase-1 GA-signature
guard») — ikke for funksjon.
**Hvorfor det er vanskeligere enn å «bare koble den til» (målt § 6, linje 20-22):**
`context_providers` er et konstruksjonsargument på `Agent`/`ChatAgent` (`_agents.py:385/676/1740`,
også eksponert via `BaseChatClient.create_agent(context_providers=...)`, `_clients.py:577`) — det
er **ikke** et argument `get_response()` tar. `generate_via_llm` (`generate.py:460`) kaller derimot
`chat_client.get_response(...)` **direkte**, uten noen `Agent`-wrapper. Å la MAF-hooken faktisk bære
injeksjonen krever at proposer-kallet flyttes fra rå `get_response()` til
`chat_client.create_agent(context_providers=[...]).run(...)` (eller tilsvarende) — en strukturell
endring av selve kall-stien Step 5 bygger på, ikke en lokal one-liner.
| | |
|---|---|
| **Filer som røres** | `src/portfolio_optimiser/generate.py` (kall-sti, strukturert-output-wiring, retry/refine-løkke), `src/portfolio_optimiser/run.py:787-792/901-910` (fold + det dekorative kallet fjernes eller erstattes), `src/portfolio_optimiser/verdicts.py` (`ExpeLContextProvider`, mulig signaturendring) |
| **Tester som må skrives/re-verifiseres** | `tests/test_step5_refine_loadbearing.py`, `tests/test_step5_history_loadbearing.py`, `tests/test_structured_output_loadbearing.py`, `tests/test_parse_failure_capture_loadbearing.py` (alle fire hviler på `generate_via_llm`s nåværende kall-form) |
| **Oppgraderingsløp nødvendig?** | Nei isolert — `context_providers` finnes i installert 1.9.0 (verifisert § 6, linje 23). Uavhengig av F15. |
| **Risiko** | **MEDIUM-HØY.** Rører den mest load-bearing løkka i repoet (budsjett-metering, strukturert-output-skjema, retry-historikk); en `Agent`-wrapper må bevise at den overflater SAMME usage/token-metadata `TokenMeter` leser i dag. |
| **Gevinst** | Injeksjonen blir en egenskap ved GRAFEN i stedet for ved `run.py`s prosa — det U9 faktisk ba om. Fjerner det dekorative kallet (mindre kode, ingen «ser brukt ut»-forkledning). |
| **Kost, isolert** | 1,52,5 økter (Opus 5/xhigh, TDD). Se § 4 for hvorfor F3 og F16 bør bygges SAMMEN. |
## 2. F15 — MAF-pinnen, re-verifisert EKSTERNT i dag (29.08, ikke 25.08)
**Reviewens tall var allerede fire dager gammelt da den ble skrevet; mitt tall er fra i dag.**
PyPI (§ 6, linje 24-25): `agent-framework-core` siste er **1.16.0 (28.08 — GÅR I GÅR)**, ikke 1.15.0.
Sekvensen siden 1.9.0: 1.10.0, 1.11.0, 1.12.0, 1.12.1, 1.13.0, 1.14.0, 1.15.0, **1.16.0**. Repoet er
altså **SYV minor-releaser bak**, ikke seks. `agent-framework-orchestrations` siste er **1.1.1
(21.08)**, og krever **`core<2,>=1.15.0`** — orchestrations kan IKKE oppgraderes uten å ta core med
seg; det finnes ingen delvis bump.
**Ny, konkret ting reviewen ikke gjorde: jeg installerte 1.16.0 + orchestrations 1.1.1 i et isolert
scratch-venv (`/tmp`, intet repo-treet berørt, slettet etter bruk) og diffet de to KONKRETE
private-API-premissene `test_maf_version_guard.py` og `explore.py` navngir** (§ 6, linje 26-31):
- `_inner_get_response` / `_build_response_stream` (`agent_framework/_clients.py`) — **strukturelt
uendret**, kun linjeskift på +3 (227→230 osv.). Samme nøkkelord-signatur.
- `explore.py`s siterte `_magentic.py:1118`/`:1128-1131` (`speaker_known`/`next_speaker`-fallback-
blokken) — **samme logikk, samme rekkefølge**, linjeskift +10 (filen vokste fra 1805 til 1810
linjer). `next_speaker not in self._participant_registry.participants` — samme sjekk, samme sted
relativt til resten av metoden.
Dette er en reell de-risking reviewen ikke hadde: de to konkrete tingene som gjør pinnen til en
**test-time tripwire** (ikke bare en frys av bekvemmelighet) holder seg stabile over syv minor-
releaser. `assert_supported_maf_version` (§ 6, linje 32) godtar for øvrig ALLEREDE `1.16.0` — guarden
er en åpen range-sjekk (`major == 1 and minor >= 9`), ikke en eksakt pin; det er `uv.lock` som låser
til nøyaktig `1.9.0`, ikke testen.
**Tre reelle breaking changes i vinduet (GitHub-releases, § 6, linje 33), rangert etter relevans for
VÅR kode:**
1. **1.13.0 (30.07): «Workflow checkpoints are now fully replayable from initial input and
human-in-the-loop responses, requiring architectural adjustments.»** Dette treffer DIREKTE
`c08ae91`s checkpoint-wiring (`explore.py:478-493/1611`) — kode som er **tre dager gammel**
relativt til en releasenote som endret akkurat det formatet. `tests/test_async_plan_review_loadbearing.py`
(17 tester) må re-kjøres mot den nye formen FØR noen bump vurderes trygg.
2. **1.15.0 (21.08): OTel GenAI-konvensjonene konsolidert** («stable and experimental modes with
explicit message-event controls»). Treffer `tracing.py` (U14) og — fordi demoens pinnede stderr
er en byte-fasit (P4) — potensielt `tests/golden/demo-transcript.stdout`/`.stderr`.
`tests/test_tracing_loadbearing.py` (9 mutasjoner) må re-kjøres.
3. **1.15.0: `MiddlewareFailure`** som nytt fatalt signal for function middleware. Lav risiko for
oss (vi bruker `ChatMiddleware`/`FunctionMiddleware` til budsjett og observasjon, ikke til å
blokkere), men bør sjekkes mot `budget.py:228`/`mcp_tools.py:197`.
**Er begrunnelsen for pinnen fortsatt gyldig?** **Delvis.** De to spesifikke private-API-premissene
tripwiren finnes for, holder (målt i dag, ikke antatt). Det som IKKE lenger holder, er STATE.md:99s
ordvalg «ENDELIG» — «endelig» beskriver en beslutning som ikke trenger re-evaluering; det denne
målingen viser er at prisen for å UTSETTE fortsetter å vokse (syv releaser og stigende), mens selve
oppgraderingsrisikoen på de to tingene som faktisk kan knekke oss, er lav og MÅLT lav. «Endelig» bør
leses som «endelig for denne runden, re-vurder ved neste ordre», ikke som en frys uten utløpsdato.
| | |
|---|---|
| **Filer som røres** | `pyproject.toml` (begge nedre grenser: `agent-framework-core>=1.15.0`, `agent-framework-orchestrations>=1.1.0` el. l.), `uv.lock` (`uv lock --upgrade-package agent-framework-core --upgrade-package agent-framework-orchestrations`), `tests/golden/demo-transcript.stdout`/`.stderr` (mulig regenerering + manuell diff-gjennomgang), `STATE.md`/`CLAUDE.md` (pin-språket) |
| **Tester som må skrives/re-verifiseres** | Hele suiten (1080/5, ~200s) + spesielt `tests/test_async_plan_review_loadbearing.py`, `tests/test_tracing_loadbearing.py`, `tests/test_maf_version_guard.py` (bør IKKE trenge endring — se over) |
| **Oppgraderingsløp nødvendig?** | **Ja, ETT sprang til 1.16.0** — orchestrations tvinger core≥1.15.0 uansett, så det finnes ingen billigere mellomstopp. |
| **Risiko** | **MEDIUM.** De to harde premissene er målt stabile; tre navngitte breaking changes treffer nylig bygget/skjørt kode (checkpoint 3 dager gammel, OTel byte-fasit). Ikke en rewrite, men heller ikke en formalitet. |
| **Gevinst** | Lukker syv-minor-gapet FØR det blir åtte/ni; fjerner den tyngste enkeltinnvendingen mot «eksperter på MAF»-påstanden; åpner orchestrations 1.1.1 (innhold ikke undersøkt her — egen sak); `_compaction.py` mister sine `@experimental`-dekoratorer i 1.16.0 (se § 4, U16 — mulig delvis relevant for U16, men Learn-dokumentasjonen kaller compaction fortsatt eksperimentell i prosa, så dette er IKKE en avklart «nå er U16 trygt» — kun et signal verdt å re-sjekke ETTER bump). |
| **Kost, isolert** | 12 økter (Sonnet 5/xhigh for selve bumpen + re-kjøring; eskaler til Opus 5/xhigh hvis checkpoint- eller OTel-re-verifiseringen faktisk finner brudd). |
## 3. F16 — B1-premisset er halvveis foreldet i INSTALLERT 1.9.0 (bekreftet, også i 1.16.0)
**Bekreftet uendret, og bekreftet i BEGGE versjoner** (§ 6, linje 34-35): `class
MiddlewareTermination` finnes i `agent_framework/_middleware.py` UTEN `@experimental`-markør, i
både installert 1.9.0 og scratch 1.16.0 (som i tillegg legger til `MiddlewareFailure` ved siden av
den — samme ikke-eksperimentelle status). `grep -rn "MiddlewareTermination" src/` gir **0 treff**
(§ 6, linje 3) — registerets U8 («intercept av tool-calls → blokkerende validator») beskrev nøyaktig
denne formen; repoet har den ikke. Vår gate (`validate_proposal`, CBC + Monte Carlo + baseline-
avstemming) kalles i dag ETTER generering, i `run.py`, utenfor grafen (P10-avgjørelsen).
**Ordrens eget premiss («ikke et krav om å bygge om») stemmer, og det er viktig:** dette funnet ber
IKKE om at validatoren flyttes inn i middleware — det ber om at **P10-avvisningen av «graf-adopsjon»
re-vurderes MED dette premisset**. Å faktisk flytte tall-gaten inn i middleware ville krevd nøyaktig
samme forutsetning som F3 (proposer-kallet må gå via `Agent`/`create_agent()`, ikke rå
`get_response()`, for at noen middleware-pipeline skal ha noe å hekte seg på) — **F3 og F16 deler
samme forutsetning-refaktor.** De bør IKKE bestilles som to uavhengige jobber; en løsning som bygger
den ene kall-sti-endringen løser forutsetningen for begge.
| | |
|---|---|
| **Kost — TIER 1 (bare re-vurdere P10, dokumentere)** | **Nær null.** Én linje i STATE/CLAUDE.md: «P10 (avvis graf-basert validering) står, MEN premisset var 'MAF har ingen blokkerende-svar-primitiv' — det er nå delvis usant (`MiddlewareTermination`); beslutningen opprettholdes av en ANNEN grunn: validatoren trenger IR-parsing + solver + Monte Carlo, som ikke er en ting man vil kjøre synkront inne i response-middleware uten videre design.» Kan gjøres i denne leveransen om operatøren ønsker det — se § 5. |
| **Kost — TIER 2 (faktisk bygge om)** | Delt med F3s refaktor (se over). Isolert tillegg utover F3: `src/portfolio_optimiser/validator.py`-kallet må flyttes/wrappes som middleware, `tests/test_checker_gate_loadbearing.py` og `tests/test_s40_cost_baseline_loadbearing.py` må re-verifiseres mot den nye kall-formen. |
| **Risiko** | TIER 1: ingen. TIER 2: **HØY** — rører selve den «obligatoriske, blokkerende» invarianten (CLAUDE.md «Arbeidsflyt (invarianter)», første linje). Enhver regresjon her er alvorlig per repoets eget prinsipp. |
| **Gevinst** | TIER 1: ærlighet — P10 slutter å hvile på et utdatert premiss. TIER 2: «forslaget forlater aldri agenten uvalidert» blir en egenskap ved grafen, ikke ved `run.py`s disiplin — mer robust mot en fremtidig kalles feil, men samme FUNKSJONELLE utfall som i dag (suiten dekker begge like godt). |
| **Kost, isolert** | TIER 1: 0 økter (kan gjøres nå). TIER 2: bygg SAMMEN med F3 — se § 4s samlede anslag, ikke additivt. |
## 4. U16/U17/U19 — uadresserte fordi ingen har sett, eller fordi de ikke passer?
Alle tre svares ut individuelt, per ordrens § 3 («et begrunnet 'nei' er et gyldig utfall»). To av de
tre er små nok til at MÅLINGEN selv lukker raden (ordrens unntak) — gjort under, i dette dokumentet.
**U17 — `as_tool()` (agent-som-verktøy): IKKE PASSER. LUKKES HER.** Registerets egen kobling
(`§15.5 A3`) knytter `as_tool()` til KRYSS-PROSJEKT agent-komposisjon («bruk in-process
agent-as-tool først [for kryss-prosjekt], kun A2A ved sikkerhets-/compliance-grense»). Kryss-prosjekt
er non-goal 2 i README («Not a portfolio-level reallocator … out of scope»). Uten kryss-prosjekt-
komposisjon finnes det ingen naturlig bruksplass for «agent som verktøy for en annen agent» i denne
arkitekturen — hvert prosjekt kjøres uavhengig. **Vedtak: NEI, begrunnet, permanent** (til non-goal 2
eventuelt oppheves — usannsynlig, det er en README-forpliktelse). 0 treff bekreftet (§ 6, linje 15).
**U19 — Agent Harness: FORTSATT EKSPERIMENTELL, OGSÅ I 1.16.0. IKKE PASSER NÅ. LUKKES HER.**
Scratch-venv-diffen (§ 6, linje 36) viser `@experimental(feature_id=ExperimentalFeature.HARNESS)`
`create_harness_agent`, `FileMemoryStore`/`_memory.py`, `_background_agents.py`, `_file_access.py`
like mange markører i 1.16.0 som i 1.9.0. Reviewens observasjon («installert pakke sier selv
`[HARNESS] MemoryStore is experimental`») er IKKE et 1.9.0-artefakt som forsvinner ved en bump — det
holder seg over syv minor-releaser, i motsetning til Magentic (som MISTET sin eksperimentelle status
og DERFOR ble tatt i bruk, § 3 i reviewen). Funksjonelt overlapper harness («bakgrunnsagent med
fil-tilgang og løkke») med det utforskningssløyfa (`explore.py`, Magentic + checkpoint + plan-review)
allerede gjør med et ANNET, ikke-eksperimentelt sett byggeklosser. **Vedtak: NEI, begrunnet, for nå**
— revisit-utløser: harness mister `@experimental`-markøren PÅ NYTT målt, ELLER et konkret behov
oppstår som Magentic+checkpoint ikke dekker (f.eks. en agent som må overleve utenfor request/response-
syklusen helt, ikke bare over dager via checkpoint). 0 treff bekreftet (§ 6, linje 17).
**U16 — Kontekst-kompaktering: EKTE GAP, INGEN HAR SETT PÅ DET. IKKE LUKKET, DEFERRED MED VILKÅR.**
I motsetning til U17/U19 er dette IKKE en «passer ikke»-sak. Lange Magentic-utforskningsløp
(`explore.py`) har i dag KUN token-taket (`ExplorationContract.max_tokens`) som vern mot
kontekstvekst — det er et KOST-tak, ikke et vindus-tak; en løkke kan i prinsippet nå modellens
kontekstvindu-grense (avvisning fra API-et) FØR den når token-BUDSJETTET, og de to feiler ulikt
(én er en strukturert `BudgetExceeded`, den andre er en rå leverandørfeil). Ingen levende
utforskningskjøring har vart lenge nok til å utløse dette (F5: null validerte forslag mot levende
modell), så det er ikke observert i praksis — men det er heller ikke DESIGNET bort. Python-API-en er
`compaction_strategy=``Agent`-konstruksjon eller frittstående `apply_compaction()`
(`_compaction.py`), IKKE C#-navnet `CompactionProvider` registeret bruker (F17 — feil navn, samme
konklusjon). Kost til å lukke det EKTE gapet (ikke bare dokumentere fraværet): en design-beslutning
om HVOR i Magentic-løkka kompaktering skal anvendes uten å miste frø-hypotesene (§ C.6 dør 1s
bevaringsregel — kompaktering må aldri kunne kutte en frø-tilnærming), pluss ny wiring + tester.
**Vedtak: NEI, utsatt, begrunnelse skrevet ned HER** (tilfredsstiller ordrens unntak for U-rader) —
revisit-utløser: en `--explore`-kjøring mot en levende modell treffer et kontekstvindu-avslag, ELLER
operatøren ønsker å bygge lange (>10 runder) utforskningsløp proaktivt. **Kost hvis/når utløst:**
uestimert i dag — krever egen scoping-økt (design + § C.6-bevis), IKKE inkludert i totalanslaget § 5.
## 5. Anbefalt rekkefølge og ærlig totalanslag
1. **F15 (versjonsbump 1.9.0→1.16.0).** Gjøres FØRST fordi de to spesifikke tingene som kunne gjort
den farlig, er nå MÅLT stabile (§ 2), og fordi hvert videre arbeid på F3/F16 uansett må
re-verifiseres mot HVILKEN core-versjon som til slutt kjører — å bygge F3/F16 mot 1.9.0 først
betyr dobbelt verifiseringsarbeid hvis F15 kommer etterpå. **12 økter.**
2. **F16 TIER 1 (re-vurder P10, dokumentér — kan gjøres NÅ, se under).**
3. **F3 + F16 TIER 2, bygget SOM ÉN JOBB** (delt forutsetning: proposer-kallet flyttes fra rå
`get_response()` til `Agent`-basert konstruksjon). Gjøres ETTER F15, slik at refaktoren skjer mot
den versjonen som faktisk skal kjøre i produksjon. **2,54 økter** (Opus 5/xhigh, TDD, høy
testtetthet — anbefal adversarial review før commit gitt at dette rører den obligatoriske
validator-invarianten).
4. **U16** — ingen bygg nå; revisit ved utløser (§ 4). **0 økter i dag.**
5. **U17/U19** — LUKKET i dette dokumentet, ingen videre arbeid med mindre revisit-vilkårene inntreffer.
**Totalanslag for det operatøren FAKTISK må velge å betale for: 3,56 økter** (F15 + F3/F16 TIER 2).
F16 TIER 1 og U17/U19 er reelt gratis — de er dokumentasjon, ikke bygg, og kan committes sammen med
dette dokumentet uten å krysse ordrens «bygg ingenting»-grense (ingen kjørende kode eller pin røres).
**Det jeg IKKE anbefaler:** å bestille F3/F16 TIER 2 uten F15 først (dobbelt verifiseringsarbeid), og
å bestille alle fire som én pakke (ordrens egen begrunnelse for hvorfor dette ble målt separat —
ulik størrelse, ulik risiko — står; F15 er en ren versjons-øvelse, F3/F16 TIER 2 er et
arkitektur-inngrep i den mest sensitive invarianten i repoet).
## 6. Verifiseringslogg (kommandoer kjørt i DENNE økten, 2026-08-29)
| # | Påstand | Kommando → resultat |
|---|---|---|
| 1-19 | Fresh U1-U19 grep-tally | `grep -rn <konstrukt> src/` per rad, § 0 — se full liste i øktloggen; U12 flyttet nei→ja siden `c08ae91` |
| 20 | Suiten grønn i dag | `PYTHONIOENCODING=utf-8 uv run pytest -q`**1080 passed, 5 skipped, 2 warnings in 196.69s** |
| 21 | Ingen kildeendring siden reviewen berører F3/F16/U16/U17/U19 | `git log --oneline 6020f44..HEAD` → 3 commits (F2, katalogkall-kost, U12-checkpoint) — ingen treffer disse fem radene |
| 22 | pyproject/uv.lock pin | `grep -n agent-framework pyproject.toml`; `grep -A1 'name = "agent-framework' uv.lock` → core/orch/foundry/openai alle pinnet eksakt |
| 23 | F3 kodesti | `sed -n '740,920p' src/portfolio_optimiser/run.py`; `grep -n "class ExpeLContextProvider\|def before_run\|extend_instructions" src/portfolio_optimiser/verdicts.py` |
| 24 | `context_providers` kun på Agent, ikke `get_response()` | `grep -n context_providers .venv/…/agent_framework/_agents.py` (7 treff, alle på `Agent`/`ChatAgent`-konstruktør) vs. `grep -n get_response src/portfolio_optimiser/generate.py` (rå klientkall) |
| 25 | PyPI core siste versjon [ekstern] | `WebFetch pypi.org/pypi/agent-framework-core/json` → 1.16.0 (28.08); sekvens 1.10.0→1.16.0 |
| 26 | PyPI orchestrations siste + dependency [ekstern] | `WebFetch pypi.org/pypi/agent-framework-orchestrations/json` → 1.1.1 (21.08), krever `core<2,>=1.15.0` |
| 27 | GitHub releases changelog 1.13-1.16 [ekstern] | `WebFetch github.com/microsoft/agent-framework/releases` → breaking changes sitert § 2 |
| 28-31 | Scratch-venv privat-API-diff | `uv venv /tmp/maf-scratch-1160 --python 3.12`; `uv pip install --python … "agent-framework-core==1.16.0" "agent-framework-orchestrations==1.1.1"`; `grep -n _inner_get_response\|_build_response_stream` mot begge versjoner av `_clients.py`; `sed -n '1110,1135p'` mot begge versjoner av `_magentic.py`; scratch-venv slettet etter bruk (`rm -rf /tmp/maf-scratch-1160`) |
| 32 | Versjonsguarden godtar 1.16.0 allerede | Lest `tests/test_maf_version_guard.py:assert_supported_maf_version``major == 1 and minor >= 9`, ingen øvre minor-grense |
| 33 | Tre breaking changes identifisert | Samme GitHub-fetch som # 27, kryssjekket mot `explore.py`/`tracing.py`/`budget.py` |
| 34 | F16: MiddlewareTermination i 1.9.0 | `grep -rn MiddlewareTermination src/` → 0 treff; `grep -n "class MiddlewareTermination\|@experimental" .venv/…/agent_framework/_middleware.py` |
| 35 | F16: MiddlewareTermination i 1.16.0 | Samme grep mot scratch-venv → identisk status (ikke eksperimentell), pluss ny `MiddlewareFailure` |
| 36 | U19: harness eksperimentell i 1.16.0 | `grep -rn "@experimental" scratch-venv/…/agent_framework/_harness/*.py` → 16 treff, samme mønster som 1.9.0 |
**Ikke verifisert i denne økten:** innholdet i `orchestrations` 1.1.0/1.1.1 utover changelog-
overskriftene (egen sak om operatøren vil vite HVA som vinnes der utover selve versjonstvangen);
hvorvidt `_compaction.py`s manglende `@experimental`-dekoratorer i 1.16.0 betyr at Microsoft Learn-
dokumentasjonens «currently experimental in Python»-advarsel (sitert i reviewen § 3.1) er utdatert
eller om den fortsatt gjelder på et annet nivå (prosa vs. kode) — nevnt som åpent i § 4, ikke avgjort
her; hvorvidt en faktisk 1.16.0-bump ville endret golden-transkriptets byte-fasit (krever å faktisk
kjøre demoen mot den bumpede pinnen, som er byggearbeid utenfor denne ordren).

View file

@ -23,6 +23,15 @@ Systemet tar imot en ekspertdom på to steder, og de gjør forskjellige ting:
Form B er den som lærer systemet noe. Form A er en synkron snarvei. Form B er den som lærer systemet noe. Form A er en synkron snarvei.
**Ingen av dem er default, og det er poenget.** Utelater du begge flaggene, blir det ikke registrert
noen dom i det hele tatt — kjøringen sier det høyt (`no expert verdict given; verdict key=…`), og
ingenting går inn i læringsstoren. Fram til 1.1.0 defaultet `--decision` til `approved`, slik at
hver flaggløs kjøring produserte en ekspertgodkjenning ingen ekspert hadde gitt, og bar den videre
inn i neste prosjekts hypotese. Nøkkelen linja skriver ut er den du fører inn i `id`-feltet i Form
B, og det er slik en dom avgitt dager senere finner tilbake til kandidaten den gjelder.
**Halv dom nektes:** `--decision` og `--rationale` må gis sammen. Den manglende halvdelen er
ekspertens å skrive, aldri systemets å fylle inn.
**`decision` er binær på kjørestien: `approved` eller `rejected`.** Det finnes ikke et tredje **`decision` er binær på kjørestien: `approved` eller `rejected`.** Det finnes ikke et tredje
«godkjent med justering» her — en realiseringskorreksjon er en **godkjenning** der forbeholdet står «godkjent med justering» her — en realiseringskorreksjon er en **godkjenning** der forbeholdet står
i begrunnelsen. Det er med vilje: forbeholdet er fagkunnskap som skal leses, ikke en enum-verdi som i begrunnelsen. Det er med vilje: forbeholdet er fagkunnskap som skal leses, ikke en enum-verdi som

View file

@ -74,9 +74,10 @@ layer does **not** produce: ingest materializes *source documents*, while the IR
the *candidate measure*, and no connector can infer one from the other. A bundle may also ship an the *candidate measure*, and no connector can infer one from the other. A bundle may also ship an
optional `cost-baseline.json`; without it the validator still runs, but unanchored to the optional `cost-baseline.json`; without it the validator still runs, but unanchored to the
project's real cost lines. Both are hand-authored today. For the IR projection the shape reference project's real cost lines. Both are hand-authored today. For the IR projection the shape reference
is `shared/examples/bygg-energi-mikro/validator-input.json`; **no bundled example ships a is `shared/examples/bygg-energi-mikro/validator-input.json`; for the cost baseline it is
`cost-baseline.json`** (checked), so its shape — `{code: {quantity, unit_cost}}` — comes from `shared/examples/veglys-fv-soer/cost-baseline.json`**two bundled examples ship one** (that one
`ir.CostBaseline` and the README. Writing them from ingested content is and `tunnel-hauglia`, checked 2026-08-21), and its shape is `ir.CostBaseline`: a `project_id` plus
an `items` map of `{code: {quantity, unit_cost}}`. Writing them from ingested content is
unbuilt, and is not on the 90 %-principle side of the line: what candidate to propose is the unbuilt, and is not on the 90 %-principle side of the line: what candidate to propose is the
agents' job, not the connector's. agents' job, not the connector's.

View file

@ -45,6 +45,15 @@ The verdict is always the human's; the machine only ever translates and structur
the gaps it names. Repeat until the base is comprehensive enough to be useful. This iteration the gaps it names. Repeat until the base is comprehensive enough to be useful. This iteration
loop is where most of the 1-2 weeks is spent. loop is where most of the 1-2 weeks is spent.
## Composing the base for one specific run
This recipe describes the *process*. It does not say which categories of knowledge a given run
needs, what each content type is for, or what happens when one is missing. That is covered, in
Norwegian for the domain expert and the technical person together, in
[`kunnskapsbase-for-en-kjoring.md`](kunnskapsbase-for-en-kjoring.md) — including a worked road
project from the commission to a base that passes the dry-run check. The two documents are
deliberately disjoint: phases and roles live here, composition lives there.
## Reading the bundle ## Reading the bundle
Bundles are plain markdown, so **Obsidian or VS Code** are enough to read and navigate them — no Bundles are plain markdown, so **Obsidian or VS Code** are enough to read and navigate them — no

611
docs/kort-presentasjon.html Normal file
View file

@ -0,0 +1,611 @@
<!doctype html>
<meta charset="utf-8">
<title>Pengene inne i porteføljen</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
:root {
--ground: #F6F5F1; --surface: #FFFFFF; --surface-2: #EFEDE6;
--ink: #22272B; --muted: #5C6570; --line: #D9D6CC;
--accent: #C89B00; --accent-ink: #7A5F00; --steel: #35566F;
--ok-bg: #E3F0E7; --ok-fg: #1F5C38;
--warn-bg: #F6ECD4; --warn-fg: #7A5410;
--bad-bg: #F5E0DD; --bad-fg: #8C3128;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--ground: #15181B; --surface: #1D2126; --surface-2: #23282E;
--ink: #E9E7E1; --muted: #9AA3AC; --line: #343A41;
--accent: #E3B93F; --accent-ink: #E3B93F; --steel: #8FB4D2;
--ok-bg: #1E3327; --ok-fg: #8FCCA6;
--warn-bg: #38301A; --warn-fg: #E0BE6A;
--bad-bg: #3A2523; --bad-fg: #E09A92;
}
}
:root[data-theme="dark"] {
--ground: #15181B; --surface: #1D2126; --surface-2: #23282E;
--ink: #E9E7E1; --muted: #9AA3AC; --line: #343A41;
--accent: #E3B93F; --accent-ink: #E3B93F; --steel: #8FB4D2;
--ok-bg: #1E3327; --ok-fg: #8FCCA6;
--warn-bg: #38301A; --warn-fg: #E0BE6A;
--bad-bg: #3A2523; --bad-fg: #E09A92;
}
* { box-sizing: border-box; }
html, body { height: 100%; }
body {
background: var(--ground); color: var(--ink);
font-family: Charter, "Bitstream Charter", Cambria, Georgia, serif;
margin: 0; overflow: hidden;
}
h1, h2, .kicker, .btn, .counter, .pill, .cmd, ul.plist {
font-family: -apple-system, "Segoe UI", system-ui, "Helvetica Neue", Arial, sans-serif;
}
.slide {
display: none; position: absolute; inset: 0;
padding: clamp(30px, 4.4vw, 72px) clamp(28px, 7vw, 116px) clamp(56px, 7vh, 88px);
flex-direction: column; justify-content: center;
}
.slide.active { display: flex; }
.inner { width: 100%; max-width: 1060px; margin: 0 auto; }
.kicker {
font-size: clamp(0.68rem, 1vw, 0.8rem); font-weight: 650;
text-transform: uppercase; letter-spacing: 0.12em;
color: var(--accent-ink); margin-bottom: 16px;
}
h1 { font-size: clamp(2rem, 4.8vw, 3.5rem); font-weight: 650; letter-spacing: -0.02em; line-height: 1.09; margin: 0 0 22px; text-wrap: balance; }
h2 { font-size: clamp(1.4rem, 3vw, 2.25rem); font-weight: 650; letter-spacing: -0.015em; line-height: 1.18; margin: 0 0 18px; text-wrap: balance; max-width: 26ch; }
/* selvbaerende brodtekst - hver slide skal kunne leses alene */
.say {
font-size: clamp(1rem, 1.55vw, 1.24rem);
line-height: 1.6; max-width: 62ch; margin: 0 0 1rem;
}
.say.dim { color: var(--muted); }
.say strong { font-weight: 650; }
.say:last-child { margin-bottom: 0; }
.lead-sub { font-size: clamp(1.05rem, 1.8vw, 1.4rem); color: var(--muted); max-width: 46ch; line-height: 1.5; }
code { font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; font-size: 0.88em;
background: var(--surface-2); border-radius: 3px; padding: 1px 5px; }
.cmd {
background: var(--surface); border: 1px solid var(--line); border-left: 3px solid var(--accent);
padding: 20px 26px; margin: 6px 0 20px;
font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
font-size: clamp(0.82rem, 1.35vw, 1.02rem); line-height: 2.0; overflow-x: auto; white-space: pre;
}
.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 16px; margin-top: 6px; }
.card { background: var(--surface); border: 1px solid var(--line); border-top: 3px solid var(--accent); padding: 20px 22px; }
.card .t { font-family: -apple-system, system-ui, sans-serif; font-weight: 650; font-size: clamp(0.98rem, 1.4vw, 1.1rem); display: block; margin-bottom: 12px; }
.card.good { border-top-color: var(--ok-fg); }
.card.good .t { color: var(--ok-fg); }
.card.risk { border-top-color: var(--bad-fg); }
.card.risk .t { color: var(--bad-fg); }
ul.plist { list-style: none; padding: 0; margin: 0; display: grid; gap: 11px; }
ul.plist li { font-size: clamp(0.86rem, 1.22vw, 1rem); line-height: 1.5; padding-left: 16px; position: relative; }
ul.plist li::before { content: "—"; position: absolute; left: 0; color: var(--muted); }
ul.plist .dim { color: var(--muted); }
.foot-note { color: var(--muted); font-size: clamp(0.82rem, 1.12vw, 0.96rem); margin-top: 20px; max-width: 64ch; line-height: 1.55; }
svg { display: block; max-width: 100%; height: auto; }
.fig { margin: 2px 0 16px; }
.svg-ink { fill: var(--ink); } .svg-muted { fill: var(--muted); } .svg-accent { fill: var(--accent-ink); }
.svg-box { fill: var(--surface); stroke: var(--line); stroke-width: 1.5; }
.svg-box-hi { fill: var(--surface-2); stroke: var(--accent); stroke-width: 2.5; }
.svg-line { stroke: var(--muted); stroke-width: 1.8; fill: none; }
.svg-line-hi { stroke: var(--accent); stroke-width: 2.5; fill: none; }
text { font-family: -apple-system, "Segoe UI", system-ui, sans-serif; }
.bar-ui {
position: fixed; left: 0; right: 0; bottom: 0;
display: flex; align-items: center; justify-content: space-between;
padding: 9px clamp(16px, 3vw, 32px); gap: 16px;
border-top: 1px solid var(--line); background: var(--ground);
font-family: -apple-system, system-ui, sans-serif; font-size: 0.78rem; color: var(--muted);
}
.btn { background: var(--surface); border: 1px solid var(--line); color: var(--ink);
padding: 5px 13px; font-size: 0.8rem; cursor: pointer; border-radius: 4px; }
.btn:hover { border-color: var(--accent); }
.counter { font-variant-numeric: tabular-nums; letter-spacing: 0.03em; }
.where { font-weight: 600; color: var(--accent-ink); }
.nav { display: flex; gap: 10px; align-items: center; }
.progress { position: fixed; top: 0; left: 0; height: 3px; background: var(--accent); transition: width 0.18s ease; z-index: 5; }
@media print { .bar-ui, .progress { display: none; } }
</style>
<div class="progress" id="prog"></div>
<!-- ============ 1 · TITTEL ============ -->
<section class="slide" data-part="">
<div class="inner">
<div class="kicker">portfolio-optimiser · åpen kildekode</div>
<h1>Pengene inne i porteføljen</h1>
<p class="lead-sub">Et åpent rammeverk som leter etter kostnadsbesparelser i hvert enkelt prosjekt — og som ikke får lov til å godkjenne sine egne funn.</p>
<p class="foot-note">Tolv sider. Kjøringstallene underveis er hentet fra en eksempelkjøring som følger med i repoet — du kan gjenta den selv, uten kostnad.</p>
</div>
</section>
<!-- ============ 2 · PROBLEMET ============ -->
<section class="slide" data-part="Problemet">
<div class="inner">
<div class="kicker">Problemet</div>
<h2>Besparelsene ligger inne i prosjekter som allerede er vedtatt</h2>
<div class="fig">
<svg viewBox="0 0 900 150" role="img" aria-label="En rekke vedtatte prosjekter, der noen bærer tiltak ingen har regnet på">
<rect class="svg-box" x="6" y="14" width="104" height="76" rx="3"/>
<rect class="svg-box" x="118" y="14" width="104" height="76" rx="3"/>
<rect class="svg-box" x="230" y="14" width="104" height="76" rx="3"/>
<rect class="svg-box" x="342" y="14" width="104" height="76" rx="3"/>
<rect class="svg-box" x="454" y="14" width="104" height="76" rx="3"/>
<rect class="svg-box" x="566" y="14" width="104" height="76" rx="3"/>
<rect class="svg-box" x="678" y="14" width="104" height="76" rx="3"/>
<rect class="svg-box" x="790" y="14" width="104" height="76" rx="3"/>
<rect x="130" y="58" width="80" height="22" rx="2" fill="var(--warn-bg)" stroke="var(--accent)" stroke-width="1.5"/>
<text x="170" y="74" font-size="11.5" font-weight="650" text-anchor="middle" fill="var(--warn-fg)">kr</text>
<rect x="466" y="58" width="80" height="22" rx="2" fill="var(--warn-bg)" stroke="var(--accent)" stroke-width="1.5"/>
<text x="506" y="74" font-size="11.5" font-weight="650" text-anchor="middle" fill="var(--warn-fg)">kr</text>
<rect x="690" y="58" width="80" height="22" rx="2" fill="var(--warn-bg)" stroke="var(--accent)" stroke-width="1.5"/>
<text x="730" y="74" font-size="11.5" font-weight="650" text-anchor="middle" fill="var(--warn-fg)">kr</text>
<text class="svg-muted" x="6" y="124" font-size="13">vedtatte prosjekter — hvert med sitt budsjett og sine kostlinjer</text>
<text class="svg-accent" x="894" y="124" font-size="13" font-weight="650" text-anchor="end">tiltak ingen har regnet på</text>
<path class="svg-line-hi" d="M 806 114 C 776 104 748 96 736 86" marker-end="url(#m2)"/>
<defs><marker id="m2" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path d="M0 0 L8 4 L0 8 z" class="svg-accent"/></marker></defs>
</svg>
</div>
<p class="say">En portefølje er mange uavhengige prosjekter, hvert med sitt budsjett og sine kostlinjer. Erfaringen er at flere av dem rommer tiltak — en utskifting, en smartere styring, en annen metode — som ville spart penger uten å true det prosjektet skal levere.</p>
<p class="say dim">Besparelsene står ikke i noen rapport. De må letes fram, kostlinje for kostlinje — og det er letingen som er dyr, ikke tiltakene.</p>
</div>
</section>
<!-- ============ 3 · HVORFOR DE IKKE HENTES ============ -->
<section class="slide" data-part="Problemet">
<div class="inner">
<div class="kicker">Problemet</div>
<h2>Å lete koster tid, folk og fagkompetanse — per prosjekt</h2>
<div class="fig">
<svg viewBox="0 0 900 156" role="img" aria-label="Én fagperson mot en lang rekke prosjekter som blekner bortover">
<circle cx="60" cy="34" r="13" fill="none" stroke="var(--ink)" stroke-width="2"/>
<path d="M60 47 V84 M60 58 L40 72 M60 58 L80 72 M60 84 L46 112 M60 84 L74 112" fill="none" stroke="var(--ink)" stroke-width="2" stroke-linecap="round"/>
<text class="svg-muted" x="60" y="140" font-size="12.5" text-anchor="middle">én fagperson</text>
<g opacity="1"><rect class="svg-box" x="150" y="22" width="72" height="64" rx="3"/></g>
<g opacity="0.92"><rect class="svg-box" x="232" y="22" width="72" height="64" rx="3"/></g>
<g opacity="0.84"><rect class="svg-box" x="314" y="22" width="72" height="64" rx="3"/></g>
<g opacity="0.76"><rect class="svg-box" x="396" y="22" width="72" height="64" rx="3"/></g>
<g opacity="0.68"><rect class="svg-box" x="478" y="22" width="72" height="64" rx="3"/></g>
<g opacity="0.6"><rect class="svg-box" x="560" y="22" width="72" height="64" rx="3"/></g>
<g opacity="0.52"><rect class="svg-box" x="642" y="22" width="72" height="64" rx="3"/></g>
<g opacity="0.44"><rect class="svg-box" x="724" y="22" width="72" height="64" rx="3"/></g>
<g opacity="0.36"><rect class="svg-box" x="806" y="22" width="72" height="64" rx="3"/></g>
<path class="svg-line" d="M150 98 v6 H878 v-6"/>
<text class="svg-muted" x="514" y="126" font-size="13" text-anchor="middle">dager med lesing og regning — per prosjekt</text>
</svg>
</div>
<p class="say">Den som skal finne et slikt tiltak, må lese prosjektgrunnlaget, kjenne tiltakstypene og regne på tallene. Det er dager med arbeid for hvert eneste prosjekt — og det krever folk som allerede er opptatt med å levere.</p>
<p class="say dim">De fleste leter allerede. Det som mangler er ikke vilje, men en leting som skalerer — over hele porteføljen, igjen og igjen.</p>
</div>
</section>
<!-- ============ 4 · HVORFOR KI ALENE IKKE LØSER DET ============ -->
<section class="slide" data-part="Problemet">
<div class="inner">
<div class="kicker">Problemet</div>
<h2>En språkmodell gir deg gjerne et pent tall ingen har regnet på</h2>
<div class="fig">
<svg viewBox="0 0 900 184" role="img" aria-label="Et pent notat med et stort beløp, og to ubesvarte spørsmål som peker på det">
<rect class="svg-box" x="330" y="10" width="240" height="150" rx="4"/>
<rect x="352" y="30" width="180" height="8" rx="2" fill="var(--line)"/>
<rect x="352" y="46" width="196" height="8" rx="2" fill="var(--line)"/>
<rect x="352" y="62" width="150" height="8" rx="2" fill="var(--line)"/>
<text class="svg-ink" x="450" y="112" font-size="24" font-weight="700" text-anchor="middle">«Spar 2,1 mill.»</text>
<text class="svg-muted" x="450" y="138" font-size="11.5" text-anchor="middle">grunnlag: ikke vedlagt</text>
<text class="svg-muted" x="6" y="66" font-size="13">Hvilke kostlinjer hviler det på?</text>
<path class="svg-line" d="M 214 62 H 320" marker-end="url(#m4)"/>
<text class="svg-muted" x="894" y="118" font-size="13" text-anchor="end">Er beløpet i det hele tatt oppnåelig?</text>
<path class="svg-line" d="M 646 114 H 580" marker-end="url(#m4)"/>
<text class="svg-accent" x="450" y="180" font-size="13" font-weight="650" text-anchor="middle">et overbevisende notat er en påstand — ikke en besparelse</text>
<defs><marker id="m4" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path d="M0 0 L8 4 L0 8 z" class="svg-muted"/></marker></defs>
</svg>
</div>
<p class="say">KI-modeller leser fort og skriver overbevisende — det er nettopp problemet. Et velskrevet notat som lover «spar 2,1 millioner», er ikke en besparelse; det er en påstand. Skal funn brukes i beslutninger, må hvert tall kunne etterprøves.</p>
</div>
</section>
<!-- ============ 5 · GREPET ============ -->
<section class="slide" data-part="Grepet">
<div class="inner">
<div class="kicker">Grepet</div>
<h2>Maskinen får foreslå — aldri godkjenne seg selv</h2>
<div class="fig">
<svg viewBox="0 0 900 230" role="img" aria-label="Forslaget går til to uavhengige kontroller før det når fagpersonen">
<rect class="svg-box" x="6" y="86" width="170" height="64" rx="3"/>
<text class="svg-ink" x="91" y="112" font-size="15" font-weight="700" text-anchor="middle">Forslaget</text>
<text class="svg-muted" x="91" y="132" font-size="12" text-anchor="middle">fra KI-debatten</text>
<path class="svg-line" d="M176 104 C 220 96 230 70 274 64" marker-end="url(#m5)"/>
<path class="svg-line" d="M176 132 C 220 140 230 172 274 178" marker-end="url(#m5)"/>
<rect x="280" y="20" width="330" height="88" rx="3" fill="var(--warn-bg)" stroke="var(--accent)" stroke-width="2.5"/>
<text x="445" y="46" font-size="15" font-weight="700" text-anchor="middle" fill="var(--warn-fg)">Regnekoden — blokkerer</text>
<text x="445" y="68" font-size="12" text-anchor="middle" fill="var(--warn-fg)">avstemmer kostlinjene mot prosjektets faktiske tall</text>
<text x="445" y="88" font-size="12" text-anchor="middle" fill="var(--warn-fg)">beregner taket for hva som er oppnåelig</text>
<rect class="svg-box" x="280" y="140" width="330" height="76" rx="3"/>
<text class="svg-ink" x="445" y="168" font-size="15" font-weight="700" text-anchor="middle">Utfordreren</text>
<text class="svg-muted" x="445" y="190" font-size="12" text-anchor="middle">en KI-rolle med motsatt oppdrag: angripe begrunnelsen</text>
<path class="svg-line" d="M612 64 C 660 64 672 100 700 112" marker-end="url(#m5)"/>
<path class="svg-line" d="M612 178 C 660 178 672 142 700 136" marker-end="url(#m5)"/>
<rect x="706" y="92" width="188" height="64" rx="3" fill="var(--ok-bg)" stroke="var(--ok-fg)" stroke-width="2.5"/>
<text x="800" y="114" font-size="15" font-weight="700" text-anchor="middle" fill="var(--ok-fg)">Fagpersonen</text>
<text x="800" y="132" font-size="11.5" text-anchor="middle" fill="var(--ok-fg)">ser bare det som</text>
<text x="800" y="147" font-size="11.5" text-anchor="middle" fill="var(--ok-fg)">overlevde begge</text>
<defs><marker id="m5" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path d="M0 0 L8 4 L0 8 z" class="svg-muted"/></marker></defs>
</svg>
</div>
<p class="say">Hvert forslag må overleve to uavhengige kontroller: en <strong>deterministisk regnekode</strong> som ikke kan overtales, og en <strong>utfordrer</strong> som angriper resonnementet.</p>
<p class="say dim">I eksempelkjøringen virker det slik: påstanden på 2&nbsp;100&nbsp;000&nbsp;kr ble avvist fordi taket var 1&nbsp;769&nbsp;915&nbsp;kr. Det korrigerte forslaget — 445&nbsp;500&nbsp;kr — slapp gjennom. Avvisningen er ikke en feil; den er systemet som virker.</p>
</div>
</section>
<!-- ============ 6 · ARKITEKTUREN SOM KRETSLOEP ============ -->
<section class="slide" data-part="Slik virker det">
<div class="inner">
<div class="kicker">Slik virker det</div>
<h2>Systemet er et kretsløp med fem deler</h2>
<div class="fig">
<svg viewBox="0 0 900 282" role="img" aria-label="Fem deler i et kretsløp: kunnskapsbasen, KI-debatten, regnekoden, fagpersonen og porten som skriver godkjente dommer tilbake i basen">
<rect class="svg-box-hi" x="8" y="100" width="206" height="76" rx="3"/>
<text class="svg-ink" x="111" y="126" font-size="14.5" font-weight="650" text-anchor="middle">Kunnskapsbasen</text>
<text class="svg-muted" x="111" y="146" font-size="11" text-anchor="middle">dokumentene fagfolk eier</text>
<text class="svg-muted" x="111" y="162" font-size="11" text-anchor="middle">start for hver kjøring</text>
<rect x="306" y="4" width="288" height="94" rx="3" fill="var(--surface-2)" stroke="var(--line)" stroke-width="1.5"/>
<text class="svg-muted" x="450" y="24" font-size="13.5" font-weight="650" text-anchor="middle">KI-debatten</text>
<rect class="svg-box" x="318" y="34" width="132" height="54" rx="3"/>
<text class="svg-ink" x="384" y="56" font-size="13" font-weight="650" text-anchor="middle">Foreslår</text>
<text class="svg-muted" x="384" y="74" font-size="10.5" text-anchor="middle">finner kandidattiltak</text>
<rect class="svg-box" x="462" y="34" width="120" height="54" rx="3"/>
<text class="svg-ink" x="522" y="56" font-size="13" font-weight="650" text-anchor="middle">Utfordrer</text>
<text class="svg-muted" x="522" y="74" font-size="10.5" text-anchor="middle">angriper begrunnelsen</text>
<rect x="686" y="100" width="206" height="76" rx="3" fill="var(--warn-bg)" stroke="var(--accent)" stroke-width="2.5"/>
<text x="789" y="126" font-size="14.5" font-weight="650" text-anchor="middle" fill="var(--warn-fg)">Regnekoden</text>
<text x="789" y="146" font-size="11" text-anchor="middle" fill="var(--warn-fg)">avstemmer mot prosjektets tall</text>
<text x="789" y="162" font-size="11" text-anchor="middle" fill="var(--warn-fg)">blokkerer resten</text>
<rect x="560" y="200" width="232" height="72" rx="3" fill="var(--ok-bg)" stroke="var(--ok-fg)" stroke-width="2.5"/>
<text x="676" y="226" font-size="14.5" font-weight="650" text-anchor="middle" fill="var(--ok-fg)">Fagpersonen</text>
<text x="676" y="246" font-size="11" text-anchor="middle" fill="var(--ok-fg)">ser bare det som overlevde</text>
<text x="676" y="262" font-size="11" text-anchor="middle" fill="var(--ok-fg)">godkjenner eller avviser</text>
<rect class="svg-box-hi" x="222" y="200" width="228" height="72" rx="3"/>
<text class="svg-ink" x="336" y="226" font-size="14.5" font-weight="650" text-anchor="middle">Porten</text>
<text class="svg-muted" x="336" y="246" font-size="11" text-anchor="middle">bare en godkjent dom</text>
<text class="svg-muted" x="336" y="262" font-size="11" text-anchor="middle">skrives inn i basen</text>
<path class="svg-line" d="M 150 100 C 198 66 240 56 296 60" marker-end="url(#mA)"/>
<path class="svg-line" d="M 598 60 C 656 60 700 74 746 96" marker-end="url(#mA)"/>
<path class="svg-line" d="M 788 176 C 788 190 782 196 770 197" marker-end="url(#mA)"/>
<path class="svg-line" d="M 556 236 H 460" marker-end="url(#mA)"/>
<path class="svg-line-hi" d="M 218 236 C 158 236 111 226 111 184" marker-end="url(#mAh)"/>
<path class="svg-line-hi" d="M 682 138 C 640 134 610 120 594 102" stroke-dasharray="5 4" marker-end="url(#mAh)"/>
<text class="svg-muted" x="674" y="184" font-size="11.5" text-anchor="end">avvist — nytt forsøk</text>
<text class="svg-muted" x="450" y="136" font-size="12.5" text-anchor="middle">ingenting når fagpersonen uten å ha passert regnekoden</text>
<text class="svg-accent" x="450" y="156" font-size="12.5" font-weight="650" text-anchor="middle">ingenting går inn i basen uten en dom</text>
<defs>
<marker id="mA" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path d="M0 0 L8 4 L0 8 z" class="svg-muted"/></marker>
<marker id="mAh" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path d="M0 0 L8 4 L0 8 z" class="svg-accent"/></marker>
</defs>
</svg>
</div>
<p class="say">Alt starter i kunnskapsbasen. Derfra leser KI-modellene seg fram til et kandidattiltak og angriper hverandres begrunnelse. Regnekoden avstemmer tallene mot prosjektets egne kostlinjer og slipper bare gjennom det som lar seg bekrefte — fagpersonen får aldri se noe som ikke har passert der.</p>
<p class="say dim">De to veiene tilbake er det som lukker kretsen: et avvist forslag går tilbake til modellene for et nytt forsøk, i et begrenset antall runder, og en dom et menneske har felt går gjennom porten og inn i basen igjen. Neste kjøring starter dermed på et litt bedre grunnlag enn den forrige.</p>
</div>
</section>
<!-- ============ 7 · KUNNSKAPSBASENE ============ -->
<section class="slide" data-part="Slik virker det">
<div class="inner">
<div class="kicker">Slik virker det</div>
<h2>Grunnlaget er flere kunnskapsbaser fagfolk kan lese og rette</h2>
<div class="fig">
<svg viewBox="0 0 900 202" role="img" aria-label="Prosjektets inngang peker til fem kunnskapsbaser, og hver base har sine egne dokumenter">
<rect class="svg-box-hi" x="290" y="4" width="320" height="46" rx="3"/>
<text class="svg-ink" x="450" y="25" font-size="14" font-weight="650" text-anchor="middle">Prosjektets inngang</text>
<text class="svg-muted" x="450" y="42" font-size="11" text-anchor="middle">innholdsfortegnelsen som samler basene</text>
<path class="svg-line" d="M450 50 v14"/>
<path class="svg-line" d="M86 64 H814"/>
<path class="svg-line" d="M86 64 v14" marker-end="url(#m6)"/>
<path class="svg-line" d="M268 64 v14" marker-end="url(#m6)"/>
<path class="svg-line" d="M450 64 v14" marker-end="url(#m6)"/>
<path class="svg-line" d="M632 64 v14" marker-end="url(#m6)"/>
<path class="svg-line" d="M814 64 v14" marker-end="url(#m6)"/>
<rect class="svg-box" x="6" y="84" width="160" height="56" rx="3"/>
<text class="svg-ink" x="86" y="106" font-size="13" font-weight="650" text-anchor="middle">Prosjektdata</text>
<text class="svg-muted" x="86" y="122" font-size="10.5" text-anchor="middle">kostlinjer og rammer</text>
<text class="svg-muted" x="86" y="134" font-size="10.5" text-anchor="middle">egen for hvert prosjekt</text>
<rect class="svg-box" x="188" y="84" width="160" height="56" rx="3"/>
<text class="svg-ink" x="268" y="106" font-size="13" font-weight="650" text-anchor="middle">Tiltakstyper</text>
<text class="svg-muted" x="268" y="122" font-size="10.5" text-anchor="middle">hva som pleier å virke</text>
<text class="svg-muted" x="268" y="134" font-size="10.5" text-anchor="middle">felles fagstoff</text>
<rect class="svg-box" x="370" y="84" width="160" height="56" rx="3"/>
<text class="svg-ink" x="450" y="106" font-size="13" font-weight="650" text-anchor="middle">Erfaringstall</text>
<text class="svg-muted" x="450" y="122" font-size="10.5" text-anchor="middle">tidligere dommer</text>
<text class="svg-muted" x="450" y="134" font-size="10.5" text-anchor="middle">vokser for hver runde</text>
<rect class="svg-box" x="552" y="84" width="160" height="56" rx="3"/>
<text class="svg-ink" x="632" y="106" font-size="13" font-weight="650" text-anchor="middle">Fagkilder</text>
<text class="svg-muted" x="632" y="122" font-size="10.5" text-anchor="middle">håndbøker og normtall</text>
<text class="svg-muted" x="632" y="134" font-size="10.5" text-anchor="middle">fageier oppdaterer</text>
<rect class="svg-box" x="734" y="84" width="160" height="56" rx="3"/>
<text class="svg-ink" x="814" y="106" font-size="13" font-weight="650" text-anchor="middle">Metoden</text>
<text class="svg-muted" x="814" y="122" font-size="10.5" text-anchor="middle">hvordan det regnes</text>
<text class="svg-muted" x="814" y="134" font-size="10.5" text-anchor="middle">fastsatt én gang</text>
<path class="svg-line" d="M166 112 H188" stroke-dasharray="5 4"/>
<path class="svg-line" d="M348 112 H370" stroke-dasharray="5 4"/>
<path class="svg-line" d="M530 112 H552" stroke-dasharray="5 4"/>
<path class="svg-line" d="M712 112 H734" stroke-dasharray="5 4"/>
<rect class="svg-box" x="38" y="148" width="96" height="8" rx="1"/>
<rect class="svg-box" x="38" y="159" width="96" height="8" rx="1"/>
<rect class="svg-box" x="38" y="170" width="96" height="8" rx="1"/>
<rect class="svg-box" x="220" y="148" width="96" height="8" rx="1"/>
<rect class="svg-box" x="220" y="159" width="96" height="8" rx="1"/>
<rect class="svg-box" x="220" y="170" width="96" height="8" rx="1"/>
<rect class="svg-box" x="402" y="148" width="96" height="8" rx="1"/>
<rect class="svg-box" x="402" y="159" width="96" height="8" rx="1"/>
<rect class="svg-box" x="402" y="170" width="96" height="8" rx="1"/>
<rect class="svg-box" x="584" y="148" width="96" height="8" rx="1"/>
<rect class="svg-box" x="584" y="159" width="96" height="8" rx="1"/>
<rect class="svg-box" x="584" y="170" width="96" height="8" rx="1"/>
<rect class="svg-box" x="766" y="148" width="96" height="8" rx="1"/>
<rect class="svg-box" x="766" y="159" width="96" height="8" rx="1"/>
<rect class="svg-box" x="766" y="170" width="96" height="8" rx="1"/>
<text class="svg-muted" x="450" y="197" font-size="12.5" text-anchor="middle">fem baser, hver med sine egne dokumenter — alle i klartekst, alle mulige å rette</text>
<defs><marker id="m6" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path d="M0 0 L8 4 L0 8 z" class="svg-muted"/></marker></defs>
</svg>
</div>
<p class="say">Maskinen graver ikke i løse dokumenthauger. Den navigerer <strong>flere kunnskapsbaser</strong> — wikier av korte tekstdokumenter som lenker til hverandre. Ett prosjekt trenger flere av dem samtidig: prosjektdata er prosjektets egne, fagkildene er felles og gjenbrukes fra prosjekt til prosjekt. Hver base har sin egen innholdsfortegnelse, sin egen eier og sin egen oppdateringstakt.</p>
<p class="say dim">Alt står i klartekst: en fagperson kan åpne en base, se hva systemet «vet», og rette det som er feil. Prosjektets inngang avgjør hvilke baser som er med — maskinen når ingenting utenfor den. Eksempelet som følger med repoet er lite: der ligger alle fem som dokumenter i én base. I et virkelig prosjekt er hver av dem en base for seg.</p>
</div>
</section>
<!-- ============ 8 · ARKITEKTUREN ============ -->
<section class="slide" data-part="Slik virker det">
<div class="inner">
<div class="kicker">Slik virker det</div>
<h2>Fire lag — og bare det godkjente går tilbake</h2>
<div class="fig">
<svg viewBox="0 0 900 252" role="img" aria-label="Fire lag fra grunnlag til dom, med en returpil for godkjent kunnskap">
<rect x="40" y="10" width="700" height="48" rx="3" fill="var(--surface-2)" stroke="var(--line)" stroke-width="1.5"/>
<text class="svg-ink" x="58" y="40" font-size="14.5" font-weight="650">1 · Grunnlaget</text>
<text class="svg-muted" x="724" y="40" font-size="12" text-anchor="end">kunnskapsbasen — dokumentene fagfolk eier</text>
<rect class="svg-box" x="40" y="72" width="700" height="48" rx="3"/>
<text class="svg-ink" x="58" y="102" font-size="14.5" font-weight="650">2 · Forslaget</text>
<text class="svg-muted" x="724" y="102" font-size="12" text-anchor="end">KI-modeller foreslår og utfordrer hverandre</text>
<rect x="40" y="134" width="700" height="48" rx="3" fill="var(--warn-bg)" stroke="var(--accent)" stroke-width="2"/>
<text x="58" y="164" font-size="14.5" font-weight="650" fill="var(--warn-fg)">3 · Kontrollen</text>
<text x="724" y="164" font-size="12" text-anchor="end" fill="var(--warn-fg)">regnekoden blokkerer alt den ikke kan bekrefte</text>
<rect x="40" y="196" width="700" height="48" rx="3" fill="var(--ok-bg)" stroke="var(--ok-fg)" stroke-width="2"/>
<text x="58" y="226" font-size="14.5" font-weight="650" fill="var(--ok-fg)">4 · Dommen</text>
<text x="724" y="226" font-size="12" text-anchor="end" fill="var(--ok-fg)">fagpersonen dømmer det som står igjen</text>
<path class="svg-line" d="M390 58 V 66" marker-end="url(#m7)"/>
<path class="svg-line" d="M390 120 V 128" marker-end="url(#m7)"/>
<path class="svg-line" d="M390 182 V 190" marker-end="url(#m7)"/>
<path class="svg-line-hi" d="M 744 220 H 806 Q 826 220 826 200 V 54 Q 826 34 806 34 H 750" marker-end="url(#m7a)"/>
<text class="svg-accent" x="838" y="118" font-size="12" font-weight="650">godkjent</text>
<text class="svg-accent" x="838" y="134" font-size="12" font-weight="650">kunnskap</text>
<text class="svg-accent" x="838" y="150" font-size="12" font-weight="650">tilbake</text>
<defs>
<marker id="m7" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path d="M0 0 L8 4 L0 8 z" class="svg-muted"/></marker>
<marker id="m7a" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path d="M0 0 L8 4 L0 8 z" class="svg-accent"/></marker>
</defs>
</svg>
</div>
<p class="say">Grunnlaget mater forslagsarbeidet. Kontrollaget feller alt som ikke tåler etterregning. Fagpersonen dømmer det som står igjen. Og returpilen er poenget: en godkjent dom skrives tilbake i kunnskapsbasen, så neste kjøring starter klokere enn den forrige.</p>
</div>
</section>
<!-- ============ 9 · DE ÅTTE STEGENE ============ -->
<section class="slide" data-part="Slik virker det">
<div class="inner">
<div class="kicker">Slik virker det</div>
<h2>Arbeidsprosessen: åtte steg, med menneskene i begge ender</h2>
<div class="fig">
<svg viewBox="0 0 900 268" role="img" aria-label="Åtte steg på rad, med bånd som viser hvem som utfører hvert steg">
<rect x="6" y="14" width="328" height="34" rx="3" fill="var(--surface-2)" stroke="var(--line)" stroke-width="1.5"/>
<text class="svg-muted" x="170" y="36" font-size="14" font-weight="650" text-anchor="middle">KI-modellene arbeider</text>
<rect x="342" y="14" width="104" height="34" rx="3" fill="var(--warn-bg)" stroke="var(--accent)" stroke-width="2"/>
<text x="394" y="36" font-size="14" font-weight="650" text-anchor="middle" fill="var(--warn-fg)">Regnekoden</text>
<rect x="454" y="14" width="216" height="34" rx="3" fill="var(--surface-2)" stroke="var(--line)" stroke-width="1.5"/>
<text class="svg-muted" x="562" y="36" font-size="14" font-weight="650" text-anchor="middle">KI + regnekode</text>
<rect x="678" y="14" width="216" height="34" rx="3" fill="var(--ok-bg)" stroke="var(--ok-fg)" stroke-width="2"/>
<text x="786" y="36" font-size="14" font-weight="650" text-anchor="middle" fill="var(--ok-fg)">Fagpersonen</text>
<g font-size="13">
<rect class="svg-box" x="6" y="70" width="104" height="76" rx="3"/>
<text class="svg-accent" x="58" y="96" font-size="19" font-weight="700" text-anchor="middle">1</text>
<text class="svg-muted" x="58" y="122" text-anchor="middle">Les</text>
<text class="svg-muted" x="58" y="138" text-anchor="middle">grunnlaget</text>
<rect class="svg-box" x="118" y="70" width="104" height="76" rx="3"/>
<text class="svg-accent" x="170" y="96" font-size="19" font-weight="700" text-anchor="middle">2</text>
<text class="svg-muted" x="170" y="122" text-anchor="middle">Foreslå</text>
<text class="svg-muted" x="170" y="138" text-anchor="middle">tiltak</text>
<rect class="svg-box" x="230" y="70" width="104" height="76" rx="3"/>
<text class="svg-accent" x="282" y="96" font-size="19" font-weight="700" text-anchor="middle">3</text>
<text class="svg-muted" x="282" y="122" text-anchor="middle">Utfordre</text>
<text class="svg-muted" x="282" y="138" text-anchor="middle">begrunnelsen</text>
<rect x="342" y="70" width="104" height="76" rx="3" fill="var(--warn-bg)" stroke="var(--accent)" stroke-width="2.5"/>
<text x="394" y="96" font-size="19" font-weight="700" text-anchor="middle" fill="var(--warn-fg)">4</text>
<text x="394" y="122" text-anchor="middle" fill="var(--warn-fg)">Regn etter</text>
<text x="394" y="138" text-anchor="middle" fill="var(--warn-fg)">— blokkerer</text>
<rect class="svg-box" x="454" y="70" width="104" height="76" rx="3"/>
<text class="svg-accent" x="506" y="96" font-size="19" font-weight="700" text-anchor="middle">5</text>
<text class="svg-muted" x="506" y="122" text-anchor="middle">Forbedre</text>
<text class="svg-muted" x="506" y="138" text-anchor="middle">og prøv igjen</text>
<rect class="svg-box" x="566" y="70" width="104" height="76" rx="3"/>
<text class="svg-accent" x="618" y="96" font-size="19" font-weight="700" text-anchor="middle">6</text>
<text class="svg-muted" x="618" y="122" text-anchor="middle">Lever</text>
<text class="svg-muted" x="618" y="138" text-anchor="middle">utfallet</text>
<rect x="678" y="70" width="104" height="76" rx="3" fill="var(--ok-bg)" stroke="var(--ok-fg)" stroke-width="2.5"/>
<text x="730" y="96" font-size="19" font-weight="700" text-anchor="middle" fill="var(--ok-fg)">7</text>
<text x="730" y="122" text-anchor="middle" fill="var(--ok-fg)">Døm</text>
<text x="730" y="138" text-anchor="middle" fill="var(--ok-fg)">resultatet</text>
<rect x="790" y="70" width="104" height="76" rx="3" fill="var(--ok-bg)" stroke="var(--ok-fg)" stroke-width="2.5"/>
<text x="842" y="96" font-size="19" font-weight="700" text-anchor="middle" fill="var(--ok-fg)">8</text>
<text x="842" y="122" text-anchor="middle" fill="var(--ok-fg)">Lagre det</text>
<text x="842" y="138" text-anchor="middle" fill="var(--ok-fg)">godkjente</text>
</g>
<path class="svg-line-hi" d="M506 152 V 176 Q 506 192 486 192 H 190 Q 170 192 170 176 V 152" marker-end="url(#m8)"/>
<text class="svg-accent" x="338" y="212" font-size="13" font-weight="650" text-anchor="middle">avvisningen sendes tilbake — begrenset antall forsøk</text>
<path class="svg-line-hi" d="M842 152 V 232 Q 842 250 820 250 H 80 Q 58 250 58 232 V 152" marker-end="url(#m8)"/>
<text class="svg-accent" x="450" y="266" font-size="13" font-weight="650" text-anchor="middle">det fagpersonen godkjente blir grunnlag for neste kjøring</text>
<defs><marker id="m8" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path d="M0 0 L8 4 L0 8 z" class="svg-accent"/></marker></defs>
</svg>
</div>
<p class="say">Grønt er der fagfolkene står. Gult er regnekoden som eier tallene. KI-modellene gjør lese- og skrivearbeidet imellom — og de to pilene tilbake er de eneste veiene noe går i retur.</p>
</div>
</section>
<!-- ============ 10 · MENNESKENE ============ -->
<section class="slide" data-part="Menneskene">
<div class="inner">
<div class="kicker">Menneskene</div>
<h2>Dommen din blir systemets hukommelse</h2>
<div class="fig">
<svg viewBox="0 0 900 212" role="img" aria-label="En port slipper godkjente dommer inn i kunnskapsbasen og stopper rå maskinoutput">
<rect x="6" y="28" width="250" height="56" rx="3" fill="var(--ok-bg)" stroke="var(--ok-fg)" stroke-width="2"/>
<text x="131" y="52" font-size="14" font-weight="650" text-anchor="middle" fill="var(--ok-fg)">Dommen din — godkjent</text>
<text x="131" y="70" font-size="11.5" text-anchor="middle" fill="var(--ok-fg)">med begrunnelse, sporbar</text>
<rect x="6" y="124" width="250" height="56" rx="3" fill="var(--bad-bg)" stroke="var(--bad-fg)" stroke-width="2"/>
<text x="131" y="148" font-size="14" font-weight="650" text-anchor="middle" fill="var(--bad-fg)">Rå maskinoutput</text>
<text x="131" y="166" font-size="11.5" text-anchor="middle" fill="var(--bad-fg)">forslag ingen har dømt</text>
<rect x="452" y="16" width="8" height="58" fill="var(--surface-2)" stroke="var(--accent)" stroke-width="1.5"/>
<rect x="452" y="104" width="8" height="88" fill="var(--surface-2)" stroke="var(--accent)" stroke-width="1.5"/>
<path class="svg-line-hi" d="M258 56 C 360 58 400 86 468 88 H 634" marker-end="url(#m9)"/>
<path class="svg-line" d="M258 152 H 434" marker-end="url(#m9m)"/>
<path d="M441 144 l14 16 M455 144 l-14 16" stroke="var(--bad-fg)" stroke-width="2.5" fill="none"/>
<rect class="svg-box-hi" x="644" y="60" width="250" height="58" rx="3"/>
<text class="svg-ink" x="769" y="84" font-size="14.5" font-weight="650" text-anchor="middle">Kunnskapsbasen</text>
<text class="svg-muted" x="769" y="102" font-size="12" text-anchor="middle">neste kjøring starter her</text>
<text class="svg-muted" x="450" y="208" font-size="12.5" text-anchor="middle">porten er lukket som utgangspunkt — det som ikke er dømt, kommer ikke inn</text>
<defs>
<marker id="m9" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path d="M0 0 L8 4 L0 8 z" class="svg-accent"/></marker>
<marker id="m9m" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path d="M0 0 L8 4 L0 8 z" class="svg-muted"/></marker>
</defs>
</svg>
</div>
<p class="say">Når du godkjenner eller avviser et utfall, forsvinner ikke vurderingen i et referat. En port skriver den inn i kunnskapsbasen — og porten er lukket for alt annet: rå maskinoutput slipper aldri inn.</p>
<p class="say dim">I eksempelkjøringen: første kjøring hentet 0 tidligere dommer for kandidaten. Etter én godkjenning og ett driftsnotat i innboksen hentet neste kjøring 3 — og begge de målte realiseringsgradene (0,79 og 0,66) var på plass i dens hypotesegrunnlag.</p>
</div>
</section>
<!-- ============ 11 · VERDIEN ============ -->
<section class="slide" data-part="Verdien">
<div class="inner">
<div class="kicker">Verdien</div>
<h2>Hva det gir, i størrelsesorden</h2>
<div class="fig">
<svg viewBox="0 0 900 212" role="img" aria-label="Fire søyler: kostlinja, den avviste påstanden, det beregnede taket og den validerte besparelsen">
<text class="svg-muted" x="250" y="30" font-size="13" text-anchor="end">kostlinja tiltaket rører</text>
<rect x="260" y="8" width="560" height="34" fill="var(--surface-2)" stroke="var(--line)" stroke-width="1.5"/>
<text class="svg-ink" x="812" y="30" font-size="13" font-weight="650" text-anchor="end">4 386 150 kr</text>
<text class="svg-muted" x="250" y="80" font-size="13" text-anchor="end">først påstått — avvist</text>
<rect x="260" y="58" width="268" height="34" fill="var(--bad-bg)" stroke="var(--bad-fg)" stroke-width="1.5"/>
<text x="536" y="80" font-size="13" font-weight="650" fill="var(--bad-fg)">2 100 000 kr</text>
<text class="svg-muted" x="250" y="130" font-size="13" text-anchor="end">taket regnekoden fant</text>
<rect x="260" y="108" width="226" height="34" fill="var(--warn-bg)" stroke="var(--accent)" stroke-width="1.5"/>
<text x="494" y="130" font-size="13" font-weight="650" fill="var(--warn-fg)">1 769 915 kr</text>
<text class="svg-muted" x="250" y="180" font-size="13" text-anchor="end">validert besparelse</text>
<rect x="260" y="158" width="57" height="34" fill="var(--ok-bg)" stroke="var(--ok-fg)" stroke-width="1.5"/>
<text x="325" y="180" font-size="13" font-weight="650" fill="var(--ok-fg)">445 500 kr — 10,2 % av linja</text>
<text class="svg-muted" x="450" y="208" font-size="12.5" text-anchor="middle">tallene er fra eksempelkjøringen — utskriften er byte-låst fasit i repoet</text>
</svg>
</div>
<p class="say">Dette er <strong>ett</strong> prosjekt og <strong>én</strong> kostlinje. Verdien av rammeverket er ikke ett enkelt tall, men tre egenskaper: letingen blir nesten gratis per prosjekt, bare etterprøvbare tall når fram til beslutning, og hver dom gjør neste leting bedre.</p>
<p class="say dim">Størrelsesorden, ikke løfte: hva din portefølje rommer, vet ingen før letingen er gjort. Det nye er at selve letingen er blitt billig.</p>
</div>
</section>
<!-- ============ 12 · STATUS ============ -->
<section class="slide" data-part="Status">
<div class="inner">
<div class="kicker">Status</div>
<h2>Hva som er bevist — og hva som ikke er</h2>
<div class="cards">
<div class="card good">
<span class="t">Bevist — målt, ikke ment</span>
<ul class="plist">
<li><strong>Rammeverket har kjørt mot en levende språkmodell</strong> — én kjøring 14. august 2026. Modellen svarte i den formen systemet bestiller, og regneporten avgjorde.</li>
<li>Hele sløyfa kjører ende til ende offline, og læringen når fram på begge tidsskalaer. Utskriften er byte-låst som fasit i repoet.</li>
<li>Regneporten feller reelt: 2&nbsp;100&nbsp;000-påstanden avvises og 445&nbsp;500 valideres — i hver kjøring, hver gang.</li>
<li>869 automatiske tester grønne (5 hoppes over), målt 14. august 2026.</li>
<li>Installasjonen er målt, ikke antatt: dokumentert kommando, 65 pakker, exit 0.</li>
</ul>
</div>
<div class="card risk">
<span class="t">Ikke bevist</span>
<ul class="plist">
<li>Ingen levende modell har fått et forslag <strong>gjennom</strong> regneporten. Den ene kjøringen ble avvist: <code>claimed saving 34500 exceeds P90 feasible 11488</code> — utfordreren godkjente resonnementet, regneporten avviste tallene.</li>
<li>Modellen fant opp en kostnadslinje som ikke finnes i kunnskapsbasen. Porten stoppet forslaget — men på beløpet, ikke på at linja var oppdiktet.</li>
<li>Om en ekte modell foreslår godt og dømmer klokt, er fortsatt et åpent spørsmål. Ingen målt gevinst fra en virkelig portefølje ennå.</li>
</ul>
</div>
</div>
<p class="say dim" style="margin-top:18px">Det som mangler, er nøyaktig det en første pilot finnes for å måle. Grunnmuren er bygget for at den målingen skal kunne stoles på.</p>
<p class="foot-note">Demo-tallene ellers i presentasjonen er byte-låst fasit fra en skriptet kjøring (<code>tests/golden/demo-transcript.stdout</code>). Tallene fra den levende kjøringen hører bare til denne siden — målt 14. august 2026 mot <code>gpt-4.1-mini</code>, forbruk 15&nbsp;306 tokens.</p>
</div>
</section>
<div class="bar-ui">
<span>← → for å bla · F for fullskjerm</span>
<span class="where" id="where"></span>
<span class="nav"><button class="btn" id="prev"></button><span class="counter" id="count"></span><button class="btn" id="next"></button></span>
</div>
<script>
(function () {
var slides = Array.prototype.slice.call(document.querySelectorAll('.slide'));
var i = 0;
var count = document.getElementById('count');
var where = document.getElementById('where');
var prog = document.getElementById('prog');
function show(n) {
i = Math.max(0, Math.min(slides.length - 1, n));
slides.forEach(function (s, k) { s.classList.toggle('active', k === i); });
count.textContent = (i + 1) + ' / ' + slides.length;
where.textContent = slides[i].getAttribute('data-part') || '';
prog.style.width = ((i + 1) / slides.length * 100) + '%';
try { location.hash = String(i + 1); } catch (e) {}
}
function go(d) { show(i + d); }
document.addEventListener('keydown', function (e) {
var k = e.key;
if (k === 'ArrowRight' || k === 'ArrowDown' || k === 'PageDown' || k === ' ' || k === 'Enter') { go(1); e.preventDefault(); }
else if (k === 'ArrowLeft' || k === 'ArrowUp' || k === 'PageUp' || k === 'Backspace') { go(-1); e.preventDefault(); }
else if (k === 'Home') { show(0); e.preventDefault(); }
else if (k === 'End') { show(slides.length - 1); e.preventDefault(); }
else if (k === 'f' || k === 'F') {
if (document.fullscreenElement) { document.exitFullscreen(); }
else if (document.documentElement.requestFullscreen) { document.documentElement.requestFullscreen(); }
e.preventDefault();
}
});
document.getElementById('next').addEventListener('click', function () { go(1); });
document.getElementById('prev').addEventListener('click', function () { go(-1); });
var start = null;
document.addEventListener('touchstart', function (e) { start = e.changedTouches[0].clientX; }, {passive: true});
document.addEventListener('touchend', function (e) {
if (start === null) return;
var dx = e.changedTouches[0].clientX - start;
if (Math.abs(dx) > 55) { go(dx < 0 ? 1 : -1); }
start = null;
}, {passive: true});
var h = parseInt((location.hash || '').replace('#', ''), 10);
show(isNaN(h) ? 0 : h - 1);
})();
</script>

View file

@ -0,0 +1,623 @@
# Kunnskapsbasen for én konkret kjøring
Dette dokumentet er for **fagpersonen** som skal planlegge en kjøring mot et konkret prosjekt, og
for **den tekniske personen** som skal bygge kunnskapsbasen den kjøringen leser. Den første jobben
er alltid den samme: sette sammen en kunnskapsbase som gir riktig kontekst for *nettopp denne*
kjøringen. Det er den jobben som beskrives her — fra bestillingen til en base som er klar til å
kjøres.
Tre dokumenter står rundt dette:
- [knowledge-base-recipe.md](knowledge-base-recipe.md) *(engelsk)* beskriver **prosessen**: de to
rollene, de fire fasene (inventar → skjelett → frø → iterer) og den ærlige forventningen om at en
god base tar 12 uker. Det gjentas ikke her. Dette dokumentet svarer på det oppskriften ikke
svarer på: *hvordan går man fra en gitt oppgave til en riktig sammensatt base?*
- [bestille-en-kjoring.md](bestille-en-kjoring.md) beskriver oppdragsfila — det som kommer **inn**
til kjøringen. Dette dokumentet møter den i den andre enden: basen oppdraget skal leses mot.
- [ekspert-svar.md](ekspert-svar.md) beskriver dommen — det som kommer **ut**, og som gjør neste
base bedre.
> **Merkekonvensjon.** Hver teknisk påstand i dokumentet er enten **VERIFISERT** (med filen,
> linjen eller kommandoen som viser det — samlet i [verifiseringsloggen](#8-verifiseringslogg)
> nederst) eller **ANTATT** (en tolkning eller en anbefaling som ikke følger av noe målt).
> Konvensjoner som bare er observert i eksempelbasene, ikke normert i spesifikasjonen, er merket
> **KONVENSJON**. Ingenting her er en påstand om at en levende modell ville resonnert godt over
> basen; det er en påstand om hva basen må inneholde for at kjøringen skal kunne starte, lese
> riktig og dømme mot ekte tall.
## Det korte svaret
**1. Hvilke kategorier trengs?** Fire, og de skiller seg på *hva de følger*:
| Kategori | Følger | Hvem eier den |
|---|---|---|
| **Prosjektlaget** | prosjektet / anlegget | prosjekteier og driftsorganisasjon |
| **Faglaget** | fagområdet (veglys, tunnel, bygg …) | fagmiljøet |
| **Erfaringslaget** | organisasjonen, over tid | fagekspertene som avgir dommer |
| **Kjøringslaget** | denne ene bestillingen | bestilleren |
Det er **én kunnskapsbase per prosjekt** (VERIFISERT: `shared/method-spec.md` §2). Prosjekt-,
fag- og erfaringslaget ligger *i* basen. Kjøringslaget ligger *ved siden av* — det er instruksen
for én kjøring og lagres aldri som kunnskap. Faglaget deles logisk på tvers av prosjekter i samme
fagområde, men **kopieres fysisk inn i hver base** — se [§2](#2-kategoriene-hva-følger-hva).
**2. Hvilke innholdstyper?** Seks markdown-typer og to tallfiler. Tabellen i
[§4](#4-innholdstypene) sier for hver av dem hvem som eier den, hvilken form fagpersonen leverer
den i, hva loopen bruker den til, og hva som skjer hvis den mangler. Den viktigste raden er
**`cost-baseline.json`**: mangler den, starter kjøringen uten et ord — og validatoren dømmer da bare
mot tall forslaget selv oppga (VERIFISERT ved kjøring, [§4.1](#41-den-skarpeste-mangelen-cost-baselinejson)).
**3. Hvordan ser det ut for et veiprosjekt?** [§5](#5-veiprosjektet-fylkesveg-sør-fra-bestilling-til-kjøreklar-base)
går gjennom en veglysportefølje langs fylkesveg, fra oppdragsfila til en base som består
kjøreklar-sjekken. Basen det ender i finnes og kjører (VERIFISERT: `shared/examples/veglys-fv-soer/`
er den demoen bruker).
## 1. Hva kjøringen leser, og hvorfor det avgjør hva basen må inneholde
Hva basen må inneholde er ikke en smakssak. Det følger av hvordan loopen leser den.
**Kjøringen navigerer, den søker ikke.** Den starter i `index.md`, følger lenkene derfra, og
leser hver fil den når. Det som står i brødteksten til `index.md` kommer **først og ordrett** inn
i det agentene leser; deretter kommer hver lenket fil som en egen seksjon `## <type>: <tittel>`
med hele brødteksten. Det finnes ingen stikkordssøk og ingen «relevante utdrag» — en fil leses
helt, eller ikke i det hele tatt (VERIFISERT: `shared/method-spec.md` §3 Steg 1;
`src/portfolio_optimiser/okf.py` `bundle_context`). Tre konsekvenser for den som bygger basen:
- **En fil uten lenke fra `index.md` finnes ikke** for kjøringen. Det gjelder også en fil som
ligger i riktig mappe med riktig type (VERIFISERT: `shared/ingest-spec.md` §2: «a generated
concept file without an index link is unreachable»).
- **En lenke ut av basen følges aldri.** Målet må ligge inne i basens egen mappe; en lenke til en
nabobase eller et delt fagbibliotek hoppes stille over, uten feilmelding (VERIFISERT:
method-spec §3 Steg 1, «a target resolving outside the bundle is skipped»). Det er grunnen til
at faglaget kopieres inn, ikke lenkes til.
- **Mengde er ikke kvalitet.** Alt som lenkes leses i sin helhet, så ti sider støy koster like mye
oppmerksomhet som ti sider substans. Hold filene stramme (ANTATT: en anbefaling, ikke et krav i
koden).
**Én type holdes alltid utenfor lesekonteksten: `type: verdict`.** Tidligere dommer kommer aldri
inn som vanlig tekst. De hentes for seg, nøklet på *kandidaten*, og legges foran hypoteseprompten
som «tidligere dommer om lignende tiltak» (VERIFISERT: method-spec §3 Steg 1 «Experience fold»;
`okf.py` `context_files` ekskluderer typen). Kandidaten de nøkles på leses fra
`validator-input.json` — ikke fra bestillingen, og ikke fra hva agentene ender med å foreslå
(VERIFISERT: `src/portfolio_optimiser/run.py:634-638`). Det er derfor den fila er påkrevd.
**Ved siden av markdown-filene ligger to tallfiler.** `validator-input.json` må finnes — uten den
nekter kjøringen å starte (VERIFISERT ved kjøring, [§4.1](#41-den-skarpeste-mangelen-cost-baselinejson)).
`cost-baseline.json` er valgfri, og det er nettopp problemet: uten den starter kjøringen som om alt
var i orden.
**Målt på veglys-basen:** mappa har 9 filer. Én er `index.md`, én er dommen, to er tallfiler —
og **5** er det kjøringen faktisk navigerer inn som kontekst (VERIFISERT:
`tests/golden/demo-transcript.stdout` linje 13, «navigerte konseptfiler (5)»).
> **Teknisk note.** Formatet basen bygges i heter Open Knowledge Format (OKF); i koden og
> spesifikasjonene kalles basen en *bundle*. Hver fil har en frontmatter (`---`-blokk) der
> `type:` er det ene påkrevde feltet. Koden leser svært få av de andre feltene: `title` (til
> seksjonsoverskriften og prosjektnavnet), `dimension` (når kjøringen er avgrenset til én
> kostakse), og på dom-filer `decision`, `description`, `realization_rate`,
> `expected_actual_saving_nok` samt den valgfrie nøkkel-trioen `affected_codes` /
> `measure_type` / `claimed_saving_nok`. Felt som `resource`, `measure_id`, `tags`, `gap_source`
> og `okf_version` i eksemplene leses **ikke** av koden — de er dokumentasjon for mennesker
> (VERIFISERT: `grep` over `src/portfolio_optimiser/` gir null treff på dem). De er likevel verdt
> å skrive: de gjør basen lesbar i Obsidian/VS Code, som er lesegrensesnittet i dag.
## 2. Kategoriene: hva følger hva
| Kategori | Innhold | Følger | Gjenbruk på tvers av prosjekter |
|---|---|---|---|
| **Prosjektlaget** | `index.md` · `type: project` · `type: hypothesis` · `validator-input.json` · `cost-baseline.json` | prosjektet | Nei. Skrives per prosjekt, fra prosjektets egne tall. |
| **Faglaget** | `type: methodology` · `type: reference` | fagområdet | Logisk ja, fysisk nei: **kopieres inn** i hver base og tilpasses. |
| **Erfaringslaget** | `type: verdict` (frø-dommer og promoterte dommer) · dom-innboksen utenfor basen | organisasjonen, over tid | Ja, gjennom dommene: en dom om et lignende tiltak i et annet prosjekt hentes inn når kandidatene ligner strukturelt. |
| **Kjøringslaget** | oppdragsfila (`--mandate`) · dimensjonsavgrensning (`--dimension-config`) · eksterne tjenester (`--mcp-config`) · tak (runder/tokens) | denne ene bestillingen | Nei. Instruks for én kjøring; «den lagrer seg ikke som kunnskap» ([bestille-en-kjoring.md](bestille-en-kjoring.md)). |
**Deles metode- og litteraturlaget på tvers?** Svaret er todelt, og begge halvdeler er målt.
*Logisk* er det samme fagstoff: alle tre eksempelbasene (kontorbygg, veglys, tunnel) bærer en
`metode-ipmvp-a.md` og en `kilder-*.md`, og alle tre bygger på samme M&V-rammeverk (IPMVP Option
A). *Fysisk* er det tre ulike filer: 40, 81 og 98 linjer, med hver sin tittel — «for veglys — og
hvorfor de andre opsjonene er stengt», «for tunnelstyring — anlegget måler inngangssignalet, ikke
energien» (VERIFISERT: `wc -l` + `diff` over de tre). Begge veiprosjekt-basene sier det selv i
`index.md`: «metode- og kildelaget er **materialisert inn her**, ikke lenket på tvers av bundler».
Grunnen er teknisk og ufravikelig: navigasjonen følger aldri en lenke ut av basen
([§1](#1-hva-kjøringen-leser-og-hvorfor-det-avgjør-hva-basen-må-inneholde)). Men det er også
faglig riktig: metoden *for veglys* er ikke metoden *for tunnel*. I veglys er ex-post stengt fordi
anlegget mangler måler; i tunnel er ex-ante stengt fordi anlegget ble bygget før noen målte
(VERIFISERT: de to `index.md`-filene). En delt fil ville måttet si begge deler og dermed ingen av
dem.
Praktisk betyr det (ANTATT, anbefaling): fagmiljøet eier en **mal** per fagområde; hver base får
en **tilpasset kopi**; når malen endres, er det en kjent jobb å gå gjennom kopiene. To kopier
drifter — det er prisen, og den skal være uttalt, ikke skjult.
**Prosjektlaget er det som byttes ut.** Begge veiprosjekt-basene er bygget med fiktivt prosjektlag
og ekte litteraturlag, og sier selv hvordan de er ment brukt: «en produksjons-deployer erstatter
prosjektlaget med en ekte kunnskapsbase og beholder litteraturlaget» (VERIFISERT: begge
`index.md`). Det er nøyaktig kategoriskillet over, skrevet fra eksemplenes side.
**Erfaringslaget er reservert.** Ingen automatisk kilde kan skrive en `type: verdict`-fil inn i
basen; den eneste veien dit er en promotering av en dom et menneske har godkjent, eller en
håndskrevet frø-dom (VERIFISERT: `shared/ingest-spec.md` §3, «The verdict layer is RESERVED»).
Grunnen står i spesifikasjonen: hver dom-fil i basen leses som en *godkjent* dom, så en
datakilde som kunne skrive dem ville sprøytet maskingenererte «godkjenninger» rett inn i
læringen.
### Hvem eier hva
| Rolle | Leverer | Leverer ikke |
|---|---|---|
| **Prosjekteier / drift** | prosjektlaget: anleggsdata, kostlinjer, rammer, kandidat-tiltak | fag-metoden, dommene |
| **Fagmiljøet** | faglaget: målemetode, verifisert litteratur, kjente gap-mekanismer | prosjekttall |
| **Fageksperten(e)** | dommer — frø-dommer før første kjøring, innboks-dommer etter | innhold i basen direkte (de skriver innboksen; systemet leser) |
| **Bestilleren** | oppdragsfila: hensikt, tilnærminger, suksesskriterium | tall i basen; bestillingen styrer hva som vurderes, aldri hva som godkjennes |
| **Den tekniske personen** | bygger basen fra det de andre leverer: markdown, lenker, tallfilene, ev. ingest-manifest | fagvurderinger |
Fagpersonene leverer **filer i sine egne formater** — regneark, notater, PDF-er — aldri JSON eller
skjema ([knowledge-base-recipe.md](knowledge-base-recipe.md), «Who does what»). Oversettelsen til
basens format er den tekniske personens jobb, og i dag er den manuell
([§6](#6-ærlighetsdelen-hva-som-ikke-finnes-i-dag)).
## 3. Avgjørelsesdelen: spørsmålene du stiller fagpersonen
Hvert spørsmål avgjør én konkret del av basen. Still dem i denne rekkefølgen; svaret på de fire
første avgjør om kjøringen i det hele tatt kan starte.
**1. Hvilket prosjekt eller anlegg gjelder det, og hva skal det hete?**
Svaret blir prosjekt-ID-en. Den må være identisk tre steder — kommandolinjen,
`validator-input.json` og `cost-baseline.json` — ellers nektes kjøringen (VERIFISERT:
`run.py` `_project_from_bundle`, «bundle project_id … != requested»). Velg en ID uten mellomrom og
æøå; eksemplene bruker formen `VEGLYS-FV-SOER` (KONVENSJON).
*Hvis svaret er «flere anlegg»:* én base per prosjekt. En portefølje er flere baser, kjørt i
porteføljemodus.
**2. Hvilke kostlinjer kan tiltakene komme til å treffe — og har vi faktiske tall for dem?**
Svaret blir `cost-baseline.json`: én linje per kostkode, med mengde og enhetspris. Hver kostkode
et forslag viser til må finnes her, og mengden og prisen forslaget oppgir må ligge innenfor
toleransen (`BASELINE_TOLERANCE_DEFAULT = 0.05`, altså 5 %) rundt baselinens verdi — ellers
avvises forslaget (VERIFISERT: `src/portfolio_optimiser/validator.py` `_reconcile_against_baseline`).
Derfor må fila dekke *alle* linjene bestillingens tilnærminger kan treffe, ikke bare den ene
kandidaten som er projisert.
*Hvis svaret er «vi har ikke tallene»:* kjøringen går likevel, og det er faren — se
[§4.1](#41-den-skarpeste-mangelen-cost-baselinejson). Skaff tallene, eller skriv inn i bestillingens
suksesskriterium at kjøringen er uforankret, så ingen leser et VALIDATED-utfall som mer enn det
er (ANTATT: en arbeidsregel, ikke noe koden håndhever).
**3. Hvilket tiltak er «det ene» vi projiserer?**
Basen holder én kandidat i `validator-input.json`: tiltaket, kostlinjene det virker på, den
modellerte besparelsen og usikkerhetsbåndet. Denne kandidaten er **nøkkelen til tidligere
dommer** — det er dens kostkoder, tiltakstype og beløpsstørrelse som avgjør hvilke dommer fra
tidligere kjøringer som hentes inn (VERIFISERT: method-spec §3 Steg 1, rangeringsformelen;
`run.py:634-638`). Den er ikke det som valideres i kjøringen — det er forslaget agentene lager
(VERIFISERT: `generate.py:491` validerer kandidaten fra modellen).
*Hvis fagpersonen vil ha flere tiltak vurdert:* det går gjennom bestillingen (spørsmål 5), og
hvert tiltak bør ha sin egen `type: hypothesis`-fil i basen. Men nøkkelen til tidligere dommer er
fortsatt den ene projiserte kandidaten — se
[§6](#6-ærlighetsdelen-hva-som-ikke-finnes-i-dag).
**4. Hvilke harde rammer gjelder?**
Minstekrav som setter et gulv ingen besparelse kan gå under, ting som ikke kan endres, budsjett-
og anskaffelsesrammer. Svaret skrives inn i `type: project`-fila. Veglys-eksempelet har fire:
lystekniske minstekrav, vedlikeholdsfaktoren, at nattslukking ikke kan antas, og at tiltak
vurderes inne i porteføljen (VERIFISERT: `shared/examples/veglys-fv-soer/veglys-fv-soer.md`,
«Rammer»). Agentene leser dem som tekst; koden håndhever dem ikke (ANTATT: det følger av at
koden bare leser `title` fra fila, men er ikke målt mot en levende modell).
**5. Hvilke tilnærminger vil du ha vurdert, og hvorfor?**
Svaret blir oppdragsfila — se [bestille-en-kjoring.md](bestille-en-kjoring.md). `description`-feltet
mates ordrett inn til modellen; det er der fagkunnskapen om *hvorfor* tiltaket er verdt å prøve
gjør en forskjell. Hver tilnærming bør ha et motstykke i basen: en `type: hypothesis`-fil med
parametere, modellert besparelse og kjent usikkerhet (KONVENSJON: begge veiprosjekt-basene har
én hypothesis-fil per kandidat-tiltak; koden krever det ikke).
**6. Hvordan måles og verifiseres en besparelse i dette faget?**
Svaret blir `type: methodology`-fila. Den forteller agentene *hvorfor* modellert og faktisk
besparelse kan sprike, og hvilken måleopsjon som er åpen for dette anlegget. For veglys er svaret
«IPMVP Option A, ved eliminasjon» fordi anlegget mangler måler (VERIFISERT: veglys
`metode-ipmvp-a.md`).
*Hvis fagmiljøet har en mal:* kopier og tilpass. Tilpasningen er ikke pynt — den delen som
forklarer hvilke opsjoner som er *stengt for dette anlegget* er prosjektspesifikk.
**7. Hva vet litteraturen om gapet mellom modellert og faktisk besparelse her?**
Svaret blir `type: reference`-fila. Skill skarpt mellom det som er målt i vårt eget land/regime
og det som er lånt fra andre program — veglys-eksempelet deler fila i «Del A — norsk materiale»
og «Del B — lånt materiale», og sier hvorfor: «Å blande de to ville gjort et lånt tall til en
norsk måling» (VERIFISERT: `kilder-veglys-realisering.md`).
*Hvis svaret er «det finnes ingen norsk måling»:* skriv det. Et navngitt evidenshull er innhold;
et oppdiktet tall er forurensning.
**8. Finnes det tidligere erfaring med lignende tiltak — en dom noen faktisk har avgitt?**
Svaret blir en frø-dom: en `type: verdict`-fil med `decision`, en `description` som bærer
begrunnelsen, og der det finnes, `realization_rate` og `expected_actual_saving_nok`. Det er disse
feltene som når neste hypotese (VERIFISERT: `verdicts.py` `_verdict_rationale`).
*Hvis svaret er nei:* det er lovlig å starte med tom erfaring. Demoen starter slik — «fersk
kunnskapsbase, ingen tidligere dommer» (VERIFISERT: golden-transkriptet linje 11 og 14). Den
første ekte dommen kommer etter første kjøring, via innboksen.
**9. Skal kjøringen avgrenses til én kostakse?**
Hvis bestillingen gjelder for eksempel *energi* og ikke *vedlikehold*, settes en
dimensjonsavgrensning (`--dimension-config`). Da leser agentene bare filer som er merket med den
dimensjonen eller er umerket, og et forslag utenfor aksen avvises (VERIFISERT: `run.py:517-519`,
`728-740`; `okf.py` `bundle_context`). Filene merkes med `dimension:` i frontmatter.
*Hvis svaret er nei:* la det være. Ingen av eksempelbasene bruker `dimension:` (VERIFISERT:
`grep` gir null treff under `shared/examples/`).
**10. Ligger kildene som data — regneark, database, tjeneste — eller som dokumenter?**
Tabelldata kan hentes inn automatisk med et **ingest-manifest**: én JSON-fil som kobler en kilde
(CSV-katalog, SQL-database, HTTP-endepunkt eller MCP-tjeneste) til basen, og skriver én
markdown-fil per uttrekk (`ingest-<id>.md`) med provenance-stempel og lenke i `index.md`
(VERIFISERT: `shared/ingest-spec.md` §2, §4§7). Manifestet kan aldri skrive en dom-fil, og det
kan aldri gi seg selv nettverkstilgang — den er et kjøreargument (VERIFISERT: §3, §8).
*Det ingest IKKE gjør:* skrive `validator-input.json` eller `cost-baseline.json`. Begge er
håndarbeid (VERIFISERT: [extending.md](extending.md), «An ingested bundle is not yet a runnable
bundle»). Dokumenter i fri form (PDF, notat) oversettes manuelt til markdown i dag.
**11. Hvem skal avgi dommen etterpå — og etter hvilke kostkoder?**
Svaret blir en innboks-mappe (`--verdict-dir`) eksperten skriver i, og eventuelt en rutingtabell
som sier hvilken ekspert som får hvilke forslag, etter kostkode-prefiks (VERIFISERT:
`src/portfolio_optimiser/hitl.py`, `route`). Innboksen skal være en annen mappe enn utboksen
kjøringen skriver til — de har motsatt eierskap ([ekspert-svar.md](ekspert-svar.md)).
## 4. Innholdstypene
| Innhold | Eies av | Fagpersonen leverer | Brukes i loopen til | Hvis den mangler |
|---|---|---|---|---|
| `index.md` | teknisk person, tekst fra prosjekteier | et avsnitt om hva prosjektet er og hvorfor dette fagområdet er valgt | brødteksten er det **første** agentene leser, ordrett; lenkene bestemmer hva som ellers leses | kjøringen nektes — «a bundle has no entry point without it» (VERIFISERT: method-spec §3 Steg 1) |
| `type: project` | prosjekteier / drift | anleggsregister, energibaseline, rammer — regneark og notat | kontekst: hva anlegget er, tallene det består av, gulvet ingen besparelse kan gå under | kjøringen starter; prosjektnavnet faller tilbake til ID-en (VERIFISERT: `run.py` `_project_from_bundle`); agentene resonnerer uten rammer (ANTATT) |
| `type: hypothesis` | prosjekteier + fagmiljø | kandidat-tiltak med parametere, modellert besparelse, hva som er usikkert, hva som er utledet | kontekst: forslagene agentene forbedrer i stedet for å finne opp; «Mapping til validatoren» forklarer tallfilene | kjøringen starter; forslagene står på oppdragsfilas `description` alene (ANTATT) |
| `type: methodology` | fagmiljøet | målemetode (M&V), hvilke opsjoner som er åpne og stengt for dette anlegget — notat eller mal | kontekst: hvorfor modellert ≠ faktisk, og hva som kan verifiseres etterpå | kjøringen starter; gap-resonnementet mangler i konteksten (ANTATT) |
| `type: reference` | fagmiljøet | verifisert litteratur med kilde og årstall; eget materiale skilt fra lånt | kontekst: tallgrunnlaget for realiseringsgrad og gap-mekanismer | kjøringen starter; en realiseringskorreksjon har ingenting å forankres i (ANTATT) |
| `type: verdict` (frø) | fageksperten | en tidligere dom: beslutning, begrunnelse, realiseringsgrad, forventet faktisk besparelse | **ikke** lest som kontekst; hentes som «tidligere dom» og legges foran hypoteseprompten | lovlig: første kjøring har null tidligere dommer (VERIFISERT: golden linje 14) |
| `validator-input.json` | teknisk person, tall fra prosjekteier | den ene projiserte kandidaten: kostlinjer, modellert besparelse, usikkerhetsbånd | prosjekt-ID-sjekk ved oppstart; nøkkelen tidligere dommer hentes med | **kjøringen nektes**, rc 1: «IR projection not found in bundle» (VERIFISERT ved kjøring, §4.1) |
| `cost-baseline.json` | prosjekteier / drift | prosjektets faktiske kostlinjer: kode → mengde × enhetspris | validatorens **steg 0**: hvert forslag avstemmes mot ekte linjer FØR noe annet regnes | **kjøringen starter, rc 0, uten melding** — gaten er uforankret (VERIFISERT ved kjøring, §4.1). Finnes fila men er feilformet: kjøringen nektes (VERIFISERT) |
| oppdragsfila | bestilleren | hensikt, tilnærminger med begrunnelse, suksesskriterium | hva som vurderes; kunngjøring før og oppgjør etter | kjøringen går uten bestilling: systemet foreslår selv; ingen kunngjøring, intet oppgjør per tilnærming |
| dom-innboksen | fageksperten | én JSON-fil per dom, etter kjøringen | merges inn før neste hypotese; lærer systemet noe | lovlig: ingen læring mellom kjøringene |
| dimensjonskonfig | bestilleren | hvilken kostakse kjøringen gjelder | avgrenser lesekontekst og avviser forslag utenfor aksen | lovlig: hele basen leses, ingen avvisning på akse |
| ingest-manifest | teknisk person | — (kobler en datakilde fagpersonen eier til basen) | skriver `ingest-*.md` med provenance før kjøringen | lovlig: alt er håndkuratert |
| MCP-konfig | bestiller + teknisk person | hvilke interne tjenester agentene får spørre, med navngitte verktøy | oppslag under debatten; alt som kan kontaktes står i kunngjøringen | lovlig: null nettverkskall (VERIFISERT: [bestille-en-kjoring.md](bestille-en-kjoring.md)) |
De fire typene `project`, `hypothesis`, `methodology` og `reference` er **KONVENSJON**: de står
i alle tre eksempelbasene, men ingen av de to spesifikasjonene nevner dem (VERIFISERT: `grep`
over `shared/method-spec.md` og `shared/ingest-spec.md` gir null treff). Spesifikasjonen normerer
bare at `type` er påkrevd, at `index.md` er inngangen, og at `verdict` er reservert. En base kan
bruke andre typenavn; de renderes som `## <type>: <tittel>` uansett. Hold dere til de fire likevel
— lesbarhet på tvers av baser er verdt mer enn friheten (ANTATT).
### 4.1 Den skarpeste mangelen: `cost-baseline.json`
Før denne fila fantes resonnerte hver del av validatoren bare om tall forslaget selv oppga, så
«an internally consistent hallucination passes» (VERIFISERT: `README.md`, «How it is set up»).
Fila forankrer gaten i prosjektets ekte kostlinjer: hvert forslag avstemmes mot dem i et steg 0
*før* løseren, og to ting avvises — en kostkode prosjektet ikke har, og en ekte kode med mengde
eller enhetspris utenfor 5 % av baselinens verdi (VERIFISERT: `validator.py:154-190`, `:210-214`).
Men fila er **valgfri** på bundle-stien, og fraværet er stille. Målt 2026-08-21 med
`--live-dry-run` på fire kopier av veglys-basen, samme kommando, samme oppdragsfil:
| Variant | Utfall | rc | Melding |
|---|---|---|---|
| intakt | `LIVE-DRY-RUN OK` | 0 | — |
| uten `validator-input.json` | nektet | 1 | `live-dry-run refused: IR projection not found in bundle: 'validator-input.json'` |
| **uten `cost-baseline.json`** | **`LIVE-DRY-RUN OK`** | **0** | **`Cost baseline: NONE in the bundle — this run is un-anchored: …`** |
| korrupt `cost-baseline.json` (`quantity: -1`, `unit_cost` mangler) | nektet | 1 | `live-dry-run refused: 2 validation errors for CostBaseline …` |
Kjeden som gir det utfallet: `okf.load_optional_cost_baseline` returnerer `None` når fila mangler
(VERIFISERT: `okf.py:323-335`), `run.py:516` tar imot `None`, og `validate_proposal` hopper over
steg 0 når baselinen er `None` (VERIFISERT: `validator.py:213`). Det er med vilje — en base skrevet
før fila fantes skal fortsatt kjøre byte-identisk (VERIFISERT: `CLAUDE.md`, S4.0-raden).
**Fraværet er ikke lenger stille (2026-08-21).** Kjøringen sier det nå på to måter, og begge
stammer fra den *samme* oppslagsverdien inne i kjøringen — ikke fra en ny lesing av basen:
- **Et strukturert felt:** `provenance.cost_baseline_anchored` er `true`/`false`, og følger med
inn i utboksens `{run_id}-proposal.json`, som dumper hele stempelet (VERIFISERT:
`tests/test_baseline_visibility_loadbearing.py`, armene (a) og (d)).
- **Én linje på stdout:** `Cost baseline: NONE in the bundle — this run is un-anchored: …`,
skrevet av tørrkjøringen, av den fulle enkeltkjøringen og per prosjekt i porteføljemodus. Er
basen forankret, skrives **ingen linje i det hele tatt** — en linje for noe kjøringen ikke har
utelates, samme regel som resten av kunngjøringen følger (VERIFISERT: kjørt 2026-08-21 mot to
kopier av veglys-basen; intakt kopi er byte-uendret, kopi uten fila bærer linja).
Ankeringen er fortsatt **valgfri** — en base skrevet før fila fantes kjører uendret. Dette er
synlighet, ikke en ny nekt. Demoen har sin egen, norske formulering
(«KUNNSKAPSBASE: … — uten kostbaseline», `simulation.py:791-802`): to formuleringer med vilje, fordi
demoen beskriver hva *basen erklærer* før kjøringen, mens linja over rapporterer hva *kjøringen
gjorde*.
**Konsekvensen for planleggingen:** spørsmål 2 i [§3](#3-avgjørelsesdelen-spørsmålene-du-stiller-fagpersonen)
er det eneste spørsmålet der et «vet ikke» ikke stopper noe — og derfor det eneste der svaret må
dokumenteres utenfor systemet. En base uten `cost-baseline.json` bør ikke kalles kjøreklar av
noen som vet hva fila gjør (ANTATT: en arbeidsregel; koden lar deg kjøre).
## 5. Veiprosjektet Fylkesveg Sør: fra bestilling til kjøreklar base
Eksempelet følger en veglysportefølje langs fylkesveg. Basen det ender i er
`shared/examples/veglys-fv-soer/`, som er sjekket inn, kjører i demoen og er målt med
kjøreklar-sjekken under. Prosjektlaget i den basen er **fiktivt** — porteføljen finnes ikke —
mens litteraturlaget er ekte og kildebelagt (VERIFISERT: basens `index.md`). Det gjør den til et
godt eksempel på nøyaktig det skillet [§2](#2-kategoriene-hva-følger-hva) handler om: et ekte
prosjekt bytter ut prosjektlaget og beholder resten.
### 5.1 Bestillingen
En driftsleder i fylkeskommunen vil vite hva LED-utskifting gir på de eldste strekningene, og om
styring oppå det er verdt noe. Oppdragsfila (VERIFISERT: akseptert av kjøringen, kunngjøringen
under er dens faktiske utskrift):
```json
{
"objective": "Redusere energikostnaden i veglysporteføljen Fylkesveg Sør uten å gå under lystekniske minstekrav, med tiltak som kan bestilles i 2027.",
"approaches": [
{
"id": "led-trinn-1",
"label": "LED-utskifting av de 2 500 eldste HPS-punktene",
"description": "Drift melder at armaturene på de eldste strekningene er fra før 2005 og byttes hyppig; vi vil vite hva ren armaturutskifting gir før styring vurderes."
},
{
"id": "adaptiv-styring",
"label": "Adaptiv styring på de LED-utskiftede punktene",
"description": "Håndbok V124 tillater MF 0,85; vi tror nye anlegg overdimensjoneres og at marginen kan hentes ut med dimming, men har ingen måling."
}
],
"allow_own_proposals": true,
"success_criteria": "Minst ett tiltak som passerer validatoren og som driftsavdelingen kan stå inne for."
}
```
Bestillingen er den første målingen av basen: hver tilnærming nevner ting basen må kunne svare
på — armaturalder, vedlikeholdsfaktor, lystekniske minstekrav, fravær av måling.
### 5.2 Spørsmålene, besvart for dette prosjektet
| # | Spørsmål | Svar for Fylkesveg Sør | Lander i |
|---|---|---|---|
| 1 | Prosjekt-ID | `VEGLYS-FV-SOER` — samme streng på kommandolinjen og i begge tallfiler | alle tre |
| 2 | Kostlinjer med ekte tall | én linje: porteføljens årlige energikostnad, `ENERGI-VEGLYS-EL`, 4 386 150 kWh à 1,00 NOK. Investeringskostnad **bevisst utelatt** — ingen kilde gir NOK per lyspunkt | `cost-baseline.json` |
| 3 | Den ene projiserte kandidaten | LED-utskifting trinn 1 (2 500 punkter, 114 → 70 W), modellert 445 500 NOK/år | `validator-input.json` |
| 4 | Harde rammer | lystekniske minstekrav (1,0 cd/m², 5 lx), MF ≤ 0,85, nattslukking kan ikke antas, tiltak vurderes inne i porteføljen | `veglys-fv-soer.md` |
| 5 | Tilnærminger | de to i oppdragsfila, pluss systemets egne | oppdragsfila + to `hypothesis`-filer |
| 6 | Målemetode | IPMVP Option A, ved eliminasjon: umålt anlegg stenger B, C og D | `metode-ipmvp-a.md` |
| 7 | Litteratur om gapet | norsk: baseline, regelverk og *årsaken* til at gapet ikke kan ses (mangler måler). Lånt: selve realiseringsgraden (amerikansk programlitteratur, 0,81) | `kilder-veglys-realisering.md` |
| 8 | Tidligere erfaring | én frø-dom: godkjent med realiseringskorreksjon, rate 0,81, **merket som lån** | `verdict-veglys-fro.md` |
| 9 | Avgrensning til kostakse | nei — porteføljen har én kostlinje | — |
| 10 | Kilder som data | nei — anleggsregisteret er levert som tall i et notat; alt er håndkuratert | — |
| 11 | Hvem dømmer | fylkets egen energirådgiver, etter kjøringen, via innboksen | `--verdict-dir` |
(Alle svar i kolonnen «Svar» er VERIFISERT mot filene i `shared/examples/veglys-fv-soer/`; kolonnen
«Lander i» er VERIFISERT mot filnavnene der.)
### 5.3 Hva fagpersonene leverer
| Leveranse | Fra | Form de leverer i | Blir til |
|---|---|---|---|
| Anleggsregister: antall lyspunkter, armaturtype, installert effekt | drift | uttrekk fra anleggsdatabasen, regneark | `type: project` (energibaseline) + raden i `cost-baseline.json` |
| Brenntimer og energipris | drift / økonomi | tabellverdi (Håndbok V124) + fakturagrunnlag | samme; prisbåndet i `validator-input.json` |
| Kravgrunnlag: lystekniske minstekrav, vedlikeholdsfaktor | fagmiljø vegbelysning | henvisning til NMFV og Håndbok V124 | «Rammer» i `type: project` |
| Kandidat-tiltak med parametere | drift + fagmiljø | notat: før/etter-effekt, antall, hva som er utledet | to `type: hypothesis`-filer |
| M&V-praksis for umålte anlegg | fagmiljø | mal for IPMVP, tilpasset | `type: methodology` |
| Litteratur om realiseringsgap, med kilde | fagmiljø | kildeliste med URL og årstall, merket norsk/lånt | `type: reference` |
| Tidligere vurdering av LED på småveg | energirådgiver | kort notat: «forvent ~80 % av modellert, fordi …» | `type: verdict` (frø) |
Leveranseformene er ANTATT — de er det en slik leveranse rimelig ser ut som, ikke noe
eksempelbasen dokumenterer. Det som er VERIFISERT er hva hver leveranse *blir til*.
### 5.4 Basen som bygges
```
veglys-fv-soer/
├── index.md type: index inngangen; lenker til alt under
├── veglys-fv-soer.md type: project porteføljen, energibaselinen, rammene
├── tiltak-led-utskifting.md type: hypothesis kandidat 1 — den som er projisert
├── tiltak-adaptiv-styring.md type: hypothesis kandidat 2 — svakere kildebelagt, og merket slik
├── metode-ipmvp-a.md type: methodology Option A, og hvorfor de andre er stengt
├── kilder-veglys-realisering.md type: reference Del A norsk / Del B lånt
├── verdict-veglys-fro.md type: verdict frø-dommen — holdes ute av lesekonteksten
├── validator-input.json den projiserte kandidaten
└── cost-baseline.json prosjektets ene kostlinje
```
(VERIFISERT: `ls shared/examples/veglys-fv-soer/` og `grep '^type:'` over filene.)
`index.md` gjør to jobber i denne basen. Den første er navigasjon: seks lenker, én per fil, med
type og én setning hver. Den andre er å si høyt hva som er fiktivt og hva som er ekte, og
*hvorfor* domenet er valgt — at realiseringsgraden i norsk veglys er «strukturelt usynlig» fordi
anlegget mangler måler. Begge deler går ordrett inn som det første agentene leser.
### 5.5 De to tallfilene — skrevet fra samme linje
Hele basens tallgrunnlag er én linje aritmetikk:
> 9 500 lyspunkter × 114 W × 4 050 t/år ÷ 1 000 = **4 386 150 kWh/år** à 1,00 NOK = 4 386 150 NOK/år
`cost-baseline.json` bærer den som `ENERGI-VEGLYS-EL: {quantity: 4386150, unit_cost: 1.0}`.
`validator-input.json` bærer **nøyaktig samme** kode, mengde og pris i `affected_items`, pluss den
modellerte besparelsen for trinn 1 (2 500 × 44 W × 4 050 t ÷ 1 000 = 445 500 kWh ≈ 445 500 NOK)
og prisbåndet 0,701,40 NOK/kWh til risikosimuleringen (VERIFISERT: begge filene). De to er ikke
«innenfor toleranse» — de er identiske, fordi begge er skrevet fra samme linje. Det er slik
toleransen på 5 % skal lukkes: ved konstruksjon, ikke ved avstemming etterpå.
**Én beslutning i mappingen er verdt å lære av:** `affected_items` er *hele porteføljens*
energikostnad, ikke de 2 500 berørte punktenes eget forbruk. Hadde det vært det siste, ville
besparelsen vært 38,6 % av linjen — over validatorens 30 %-tak — og det riktige forslaget blitt
avvist av en gate som målte feil størrelse (VERIFISERT: `tiltak-led-utskifting.md`, «Mapping til
validatoren»). Kostlinjen skal være den linjen tiltaket *virker på* i regnskapet.
### 5.6 Frø-dommen
```yaml
type: verdict
decision: approved_with_adjustment
realization_rate: 0.81
modelled_saving_nok: 445500
expected_actual_saving_nok: 360855
description: "… brenntimene er et nasjonalt tabellanslag, ikke en målt kurve, og anlegget
mangler måler — så avviket kan ikke oppdages i drift. Forventet faktisk besparelse settes til
81 % av modellert, lånt fra belysnings-programlitteratur og merket som lån. …"
provenance: "frø — AI-forfattet. Realiseringsgraden er LÅNT … Det finnes INGEN norsk ex-post-måling
for veglys. Erstattes av ekte HITL i produksjon."
```
(Utdrag; VERIFISERT: `verdict-veglys-fro.md`.) Det som når neste hypotese er `description` pluss
`[realiseringsgrad=0.81; forventet_faktisk_NOK=360855]` (VERIFISERT: `verdicts.py`
`_verdict_rationale`). `provenance`-feltet leses ikke av koden — men det er det som gjør at en
fagperson som åpner basen ser at dommen er et frø og raten et lån. I et ekte prosjekt erstattes
denne fila av energirådgiverens egen vurdering, i samme form.
### 5.7 Kjøreklar-sjekken
Det finnes ingen egen «valider basen»-kommando
([§6](#6-ærlighetsdelen-hva-som-ikke-finnes-i-dag)). Det nærmeste er en tørrkjøring med
bestillingen på plass:
```bash
uv run python -m portfolio_optimiser.run VEGLYS-FV-SOER \
--docs-dir shared/examples/veglys-fv-soer \
--bundle-dir shared/examples/veglys-fv-soer \
--mandate oppdrag.json \
--live-dry-run
```
Målt 2026-08-21 (VERIFISERT, rc 0):
```
Run mandate for VEGLYS-FV-SOER
Objective: Redusere energikostnaden i veglysporteføljen Fylkesveg Sør uten å gå under lystekniske minstekrav, med tiltak som kan bestilles i 2027.
Evaluates: 2 expert-proposed approach(es) + the system's own proposals
1. led-trinn-1 — LED-utskifting av de 2 500 eldste HPS-punktene
2. adaptiv-styring — Adaptiv styring på de LED-utskiftede punktene
Stops at: 3 rounds / 100000 tokens
Contacts: no external services
Success: Minst ett tiltak som passerer validatoren og som driftsavdelingen kan stå inne for.
VEGLYS-FV-SOER: LIVE-DRY-RUN OK (profile=local, models={'proposer': 'qwen3:4b', 'checker': 'qwen3:4b'}, max_rounds=3, max_tokens=100000, top_k=3) — ingen modellkall gjort (stoppet før første debate.run)
```
**Hva `OK` beviser:** basen åpner — `index.md` finnes, `validator-input.json` finnes og bærer
riktig prosjekt-ID, `cost-baseline.json` *hvis den finnes* er velformet, bestillingen er gyldig,
modellene er oppløst, takene er satt, og ingen ekstern tjeneste vil bli kontaktet. Alt dette
skjer før kuttet, uten et eneste modellkall (VERIFISERT: `run.py:513-516` ligger før
dry-run-returen på `:565-588`).
**Hva `OK` sier fra om:** en lenke kjøringen ikke klarte å følge. Tørrkjøringen skriver da én
blokk under `OK`-linja — én linje per lenke, med filen lenken sto i, lenketeksten ordrett, og
hvorfor: `missing` (målet ligger inne i basen, men det er ingen lesbar fil der — nesten alltid en
skrivefeil i lenken) eller `outside-bundle` (målet peker ut av basen). Er alle lenkene fulgt,
skrives ingen linje i det hele tatt. Målt på en base med begge feilene:
```
Knowledge base: 2 cross-link(s) NOT followed — the agents never read the document(s) behind them:
- index.md -> metodikk-som-aldri-ble-skrevet.md (missing)
- index.md -> ../nabobase/index.md (outside-bundle)
```
Selve toleransen er uendret: en brutt lenke stopper ikke kjøringen (OKF SPEC §4 krever det). Det
som er nytt er at den ikke lenger er taus — før dette var «dokumentet ble aldri skrevet» og «lenken
til det var feil» umulig å skille utenfra. Den samme blokka skrives på en full kjøring.
**Hva `OK` ikke beviser:** at `cost-baseline.json` *finnes* (se [§4.1](#41-den-skarpeste-mangelen-cost-baselinejson)
— sjekk det med `ls`), at filene faktisk er lenket fra `index.md` (en fil ingen lenker til er
unåbar, og da finnes det ingen lenke å rapportere), eller at innholdet er godt. `--docs-dir` må oppgis, men leses ikke på bundle-stien (VERIFISERT:
`run.py:513-530` bruker bare `bundle_dir`; `:1504` krever flagget likevel). Pek den på samme
mappe.
**Det offline ende-til-ende-beviset på denne basen** er demoen:
`uv run python -m portfolio_optimiser.simulation`. Den kjører nøyaktig `veglys-fv-soer`, skriver
«KUNNSKAPSBASE: veglys-fv-soer — kostbaseline erklært (ENERGI-VEGLYS-EL 4386150 x 1)», navigerer
de fem konseptfilene, og viser at en dom avgitt etter kjøring A når kjøring B (VERIFISERT:
`tests/golden/demo-transcript.stdout`, linjene 7, 13 og 50). Agent-svarene i demoen er
skriptede; den beviser dataflyten, ikke modellens dømmekraft.
### 5.8 Etter kjøringen
Kjør med `--outbox-dir` og `--run-id`, så legger kjøringen igjen én artefakt per vurdert
tilnærming. `hitl pending` viser hva som venter på dom; energirådgiveren skriver dommen som
JSON-fil i innboksen etter [ekspert-svar.md](ekspert-svar.md). Neste kjøring med `--verdict-dir`
leser den inn før hypotesen formes. Godkjennes den for promotering, blir den en ny
`type: verdict`-fil i basen — og basen er blitt én versjon klokere.
## 6. Ærlighetsdelen: hva som ikke finnes i dag
Ingenting under er en feil i koden. Det er grensen for hva som er bygget, og det skal stå her
så dokumentet ikke lover mer enn det som kan leveres.
- **Bundle-fabrikken er ikke bygget** (operatørbeslutning D-G / T0). Det finnes ingen verktøy
som tar fagpersonens regneark og notater og lager en base. Oversettelsen til markdown,
lenkene i `index.md` og begge tallfilene er den tekniske personens håndarbeid (VERIFISERT:
[knowledge-base-recipe.md](knowledge-base-recipe.md), «Future / factory-dependent»).
- **Fri-format-oversettelse av dommer finnes ikke.** Frø-dommer skrives direkte i det strenge
formatet (VERIFISERT: samme sted). Den tiltenkte flyten — eksperten leverer fri tekst, en
oversetter lager den strukturerte dommen, som alltid peker tilbake på originalen — er
beskrevet, ikke bygget.
- **Ingest har ingen kommandolinje.** `materialize` er et Python-API (VERIFISERT: ingen
`__main__` i `src/portfolio_optimiser/ingest.py`), og «no bundle has yet been materialized from
a live source» (VERIFISERT: `README.md`). Det som er målt er fil/CSV, SQL, HTTP og MCP mot
innsjekkede fixtures.
- **Tidligere dommer hentes én gang per kjøring, for den projiserte kandidaten** — ikke per
tilnærming i bestillingen (VERIFISERT: `run.py:634-638`; én `expel_query` for hele
kjøringen). En bestilling med to tilnærminger mot to ulike kostlinjer får tidligere dommer
hentet for den kandidaten `validator-input.json` beskriver.
- **Det finnes ingen «er basen klar?»-kommando.** `--live-dry-run` beviser at basen *åpner*, sier
fra hvis `cost-baseline.json` mangler, og lister lenkene den ikke klarte å følge
([§5.7](#57-kjøreklar-sjekken)) — men ingenting om innholdets kvalitet, og ingenting om filer
ingen lenker til (de er unåbare, så det finnes ingen brutt lenke å rapportere). `preflight`
sjekker Azure-konfigurasjon, ikke basen (VERIFISERT: `preflight.py` docstring).
- **Uforankret kjøring er synlig, men ikke summert.** Feltet og linja finnes per kjøring
([§4.1](#41-den-skarpeste-mangelen-cost-baselinejson)); det finnes ingen rapport som teller opp
hvor mange kjøringer i et porteføljepass som gikk uforankret.
- **Eksempelbasene er ikke ekte prosjekter.** Prosjektlaget er fiktivt; realiseringsgraden i alle
tre frø-dommene er lånt fra utenlandsk programlitteratur fordi ingen norsk ex-post-måling
finnes (VERIFISERT: `provenance`-feltet i de tre dom-filene).
- **De fire innholdstypene er konvensjon**, ikke spesifikasjon
([§4](#4-innholdstypene)).
- **`docs/extending.md` er utdatert på ett punkt:** den sier at ingen eksempelbase shipper
`cost-baseline.json`. Det var sant da den ble skrevet (2026-08-05); begge veiprosjekt-basene har
fått fila siden (VERIFISERT: `ls`, begge datert 2026-08-09). Rettelsen er ikke gjort her, for
den hører hjemme i det dokumentet.
- **12 uker.** Oppskriften sier det, og ingenting i dette dokumentet korter det ned. Det som
står her er hva ukene skal brukes til.
## 7. Punktet der utforskningen konkluderte med kode
Dokumentet pekte opprinnelig på ett punkt der det ikke holdt å beskrive: en kjøring som går uten
`cost-baseline.json` burde si det — i kunngjøringen, i `provenance`, eller begge. Demoen gjorde det
allerede; kjørestien gjorde det ikke.
**Det er nå bygget (2026-08-21), med begge deler:** et strukturert `cost_baseline_anchored`-felt på
provenance-stempelet, og én linje på stdout når kjøringen er uforankret — se
[§4.1](#41-den-skarpeste-mangelen-cost-baselinejson). Ankeringen forble valgfri, og
golden-transkriptet er byte-uendret, fordi demoen kjører en base som *har* fila og linja derfor
ikke fyrer der.
## 8. Verifiseringslogg
| Påstand | Kilde | Status |
|---|---|---|
| Én base per prosjekt | `shared/method-spec.md` §2 («one curated … knowledge bundle per project») | VERIFISERT |
| Navigasjon fra `index.md`, flat rendering `## type: tittel`, verdict ekskludert | `shared/method-spec.md` §3 Steg 1; `src/portfolio_optimiser/okf.py` `bundle_context`, `context_files` | VERIFISERT |
| Lenke ut av basen hoppes stille over | method-spec §3 Steg 1 | VERIFISERT |
| Fil uten index-lenke er unåbar | `shared/ingest-spec.md` §2 | VERIFISERT |
| Verdict-laget reservert; ingest kan ikke skrive det | ingest-spec §3, §4 (`okf_type` MUST NOT be `verdict`) | VERIFISERT |
| Tidligere dommer nøkles på `validator-input.json`-kandidaten, én gang per kjøring | `run.py:634-638`; `verdicts.py` `bundle_candidate_features` | VERIFISERT |
| Det som valideres er modellens forslag, ikke `validator-input.json` | `generate.py:491` | VERIFISERT |
| Seks `type:`-verdier i drift: index 3 · project 4 · hypothesis 5 · methodology 3 · reference 3 · verdict 3 | `grep -h '^type:' shared/examples/*/*.md \| sort \| uniq -c` (2026-08-21) | VERIFISERT |
| De fire ikke-reserverte typene er ikke nevnt i noen spesifikasjon | `grep` over `shared/method-spec.md` og `shared/ingest-spec.md` — null treff | VERIFISERT |
| Metode-filene er tre ulike filer (40 / 81 / 98 linjer), materialisert per base | `wc -l`, `diff`; begge `index.md` («materialisert inn her») | VERIFISERT |
| Koden leser ikke `resource`, `measure_id`, `gap_source`, `context_key`, `okf_version`, `tags` | `grep` over `src/portfolio_optimiser/*.py` — null treff | VERIFISERT |
| `dimension:` brukes av ingen eksempelbase | `grep -rn '^dimension:' shared/examples/` — null treff | VERIFISERT |
| Prosjekt-ID må stemme mellom kommandolinje og `validator-input.json` | `run.py` `_project_from_bundle` | VERIFISERT |
| Prosjektnavn leses fra `type: project`-filas `title`, ellers ID | samme | VERIFISERT |
| Steg 0 avviser ukjent kode og avvik > 5 % | `validator.py:154-190`; `BASELINE_TOLERANCE_DEFAULT = 0.05` | VERIFISERT |
| Manglende `cost-baseline.json``None` → steg 0 hoppes over | `okf.py:323-335`; `run.py:516`; `validator.py:213` | VERIFISERT |
| Fire tørrkjøringer: intakt rc 0 · uten IR rc 1 · uten baseline rc 0 uten melding · korrupt baseline rc 1 | kjørt 2026-08-21 på kopier i scratchpad, kommandoen i §5.7 | VERIFISERT |
| Ingen artefakt bærer forankret/uforankret | `grep baseline src/portfolio_optimiser/provenance.py src/portfolio_optimiser/outbox.py` — null treff | VERIFISERT |
| Demoen printer forankringsstatus | `simulation.py:791-802`; golden linje 7 | VERIFISERT |
| `--live-dry-run` navigerer basen og laster begge tallfiler før kuttet | `run.py:513-516` vs `:565-588` | VERIFISERT |
| `--docs-dir` påkrevd men ulest på bundle-stien | `run.py:513-530`, `:1504` | VERIFISERT |
| Demoen kjører `veglys-fv-soer`, navigerer 5 konseptfiler, henter 0 så 3 dommer | `tests/golden/demo-transcript.stdout` linjene 7, 13, 14, 50 | VERIFISERT |
| Frø-rationale = `description` + `[realiseringsgrad=…; forventet_faktisk_NOK=…]` | `verdicts.py` `_verdict_rationale` | VERIFISERT |
| Dom-nøkkel-trioen: alle tre felt eller ingen | `verdicts.py` `_features_from_verdict_frontmatter`; `README.md` | VERIFISERT |
| Veglys-tallene: 9 500 × 114 W × 4 050 t = 4 386 150 kWh; 445 500 NOK modellert; 10,2 % av total; 38,6 % av berørte punkter | `veglys-fv-soer.md`, `tiltak-led-utskifting.md`, begge JSON-filer | VERIFISERT |
| Frø-dommen: rate 0,81, forventet 360 855, lånt | `verdict-veglys-fro.md` frontmatter | VERIFISERT |
| Fabrikk og fri-format-oversettelse ikke bygget | `docs/knowledge-base-recipe.md`; `docs/plan/2026-07-14-revisjonspakke-DF-DI.md` §3 | VERIFISERT |
| Ingest har ingen CLI; ingen base materialisert fra levende kilde | `grep __main__ src/portfolio_optimiser/ingest.py` — null treff; `README.md` «How it is set up» | VERIFISERT |
| Ingest skriver ikke `validator-input.json` / `cost-baseline.json` | `docs/extending.md`, «Legg til en ingest-kilde» | VERIFISERT |
| `docs/extending.md` sier ingen eksempelbase shipper `cost-baseline.json` | samme dokument; motbevist av `ls shared/examples/{veglys-fv-soer,tunnel-hauglia}/` | VERIFISERT (utdatert) |
| Leveranseformene i §5.3; anbefalingene merket ANTATT | — | ANTATT |

View file

@ -69,7 +69,7 @@ nøkkel/nettverk · mypy/ruff grønne.
## I3 — D7-speil: filkatalog/CSV **[GATET: annet repo]** ## I3 — D7-speil: filkatalog/CSV **[GATET: annet repo]**
**Mål:** Samme kontrakt konsumert uendret fra commons i søskenrepoet **Mål:** Samme kontrakt konsumert uendret fra commons i søskenrepoet
(`/Users/ktg/repos/portfolio-optimiser-claude`) — beviser at ingest-spec-en faktisk er delt. (`../portfolio-optimiser-claude`) — beviser at ingest-spec-en faktisk er delt.
**Innhold:** forankres i søskenets STATE + egen `/trekbrief`. Commons-pull der; egen impl mot **Innhold:** forankres i søskenets STATE + egen `/trekbrief`. Commons-pull der; egen impl mot
samme spec; speiltester av I2s load-bearing-sett; søskenets framework-guard utvides samme spec; speiltester av I2s load-bearing-sett; søskenets framework-guard utvides

View file

@ -37,8 +37,8 @@
|---|---|---|---| |---|---|---|---|
| A1 | `portfolio-optimiser/src/portfolio_optimiser/okf.py` | Py, 202 l | les + skriv + validér | | A1 | `portfolio-optimiser/src/portfolio_optimiser/okf.py` | Py, 202 l | les + skriv + validér |
| A2 | `portfolio-optimiser-claude/src/portfolio_optimiser_claude/okf.py` | Py, 134 l | **kun les** (mangler skrive-halvdelen/Steg 8-primitiver) | | A2 | `portfolio-optimiser-claude/src/portfolio_optimiser_claude/okf.py` | Py, 134 l | **kun les** (mangler skrive-halvdelen/Steg 8-primitiver) |
| B1 | `ktg-plugin-marketplace/okr/` (lib+scripts `.mjs`) | JS | index-generering + validering | | B1 | `plugin-marketplace/okr/` (lib+scripts `.mjs`) | JS | index-generering + validering |
| B2 | `ktg-plugin-marketplace/linkedin-studio/scripts/brain/` | TS | emit + conformance-test | | B2 | `plugin-marketplace/linkedin-studio/scripts/brain/` | TS | emit + conformance-test |
| C1 | `claude-code-llm-wiki/tools/wiki_ingest/bundle.py` | Py, 436 l | OKF-produsent (ingest→bundle) | | C1 | `claude-code-llm-wiki/tools/wiki_ingest/bundle.py` | Py, 436 l | OKF-produsent (ingest→bundle) |
| C2 | `llm-ingestion-pipeline-security/src/llm_ingestion_guard/okf.py` | Py, 579 l | defensiv validator, **reject-by-default** | | C2 | `llm-ingestion-pipeline-security/src/llm_ingestion_guard/okf.py` | Py, 579 l | defensiv validator, **reject-by-default** |

View file

@ -25,7 +25,7 @@ kodelokasjoner (revisjonspakke §0.2, verifisert mot ground truth 2026-07-14):
| A2 | `portfolio-optimiser-claude/.../okf.py` | 134 l | **kun les** — mangler skrive-halvdelen | | A2 | `portfolio-optimiser-claude/.../okf.py` | 134 l | **kun les** — mangler skrive-halvdelen |
| C1 | `claude-code-llm-wiki/tools/wiki_ingest/bundle.py` | 436 l | OKF-produsent (ingest→bundle) | | C1 | `claude-code-llm-wiki/tools/wiki_ingest/bundle.py` | 436 l | OKF-produsent (ingest→bundle) |
| C2 | `llm-ingestion-pipeline-security/.../okf.py` | 579 l | defensiv validator, reject-by-default | | C2 | `llm-ingestion-pipeline-security/.../okf.py` | 579 l | defensiv validator, reject-by-default |
| B1/B2 | `ktg-plugin-marketplace/` (`.mjs`/`.ts`) | JS/TS | index-generering + conformance | | B1/B2 | `plugin-marketplace/` (`.mjs`/`.ts`) | JS/TS | index-generering + conformance |
≥4 uavhengige Python-`parse_frontmatter` + 2 i JS. Konsekvensene (F-INT-2): duplisert ≥4 uavhengige Python-`parse_frontmatter` + 2 i JS. Konsekvensene (F-INT-2): duplisert
vedlikehold, og at Claude-søskenet (A2) **mangler skrive-halvdelen** A1 har — så det ikke kan vedlikehold, og at Claude-søskenet (A2) **mangler skrive-halvdelen** A1 har — så det ikke kan
@ -93,7 +93,7 @@ UNDER det D7-sammenligningen måler, så delt kode her forurenser ikke sammenlig
mangler i dag** (Steg 8-primitiver). Dette er hovedgevinsten for D-Gs «minst like godt»-mål. mangler i dag** (Steg 8-primitiver). Dette er hovedgevinsten for D-Gs «minst like godt»-mål.
3. **`claude-code-llm-wiki` (C1)** → senere; produsent-stien (ingest→bundle) legges på 3. **`claude-code-llm-wiki` (C1)** → senere; produsent-stien (ingest→bundle) legges på
`okf-core` + fabrikken. `okf-core` + fabrikken.
4. **`ktg-plugin-marketplace` (B1/B2)** → forblir JS/TS (kan ikke dele Python-kode), men 4. **`plugin-marketplace` (B1/B2)** → forblir JS/TS (kan ikke dele Python-kode), men
konsumerer **formatprøven** som delt data. konsumerer **formatprøven** som delt data.
5. **`llm-ingestion-guard` (C2)** → migreres IKKE; forblir eget repo og trekkes inn som 5. **`llm-ingestion-guard` (C2)** → migreres IKKE; forblir eget repo og trekkes inn som
**avhengighet** i fabrikkens sikkerhetsvask. **avhengighet** i fabrikkens sikkerhetsvask.

View file

@ -1,5 +1,18 @@
# Inkluderingsplan — `llm-ingestion-guard` som write-time persist-gate # Inkluderingsplan — `llm-ingestion-guard` som write-time persist-gate
> **⚠️ UTDATERT PÅ TO PUNKTER — REALISERT 2026-08-09 av
> [innholdsgate-planen](2026-08-09-innholdsgate-og-aerlighet.md) (P2/S1.b).** Dokumentet beholdes
> som kartleggingen den var; disse to påstandene skal IKKE leses som gjeldende:
>
> 1. **«Guard-modenhet: `v0.2` (alpha)» er FEIL** (målt 2026-08-09: syv publiserte tagger, siste
> `v0.3.4`, `dependencies = []`). Det premisset gjorde adopsjonen urimelig risikabel og er
> grunnen til at innholdsgate-planen ble skrevet. Adoptert pin: `v0.3.4`.
> 2. **Gate-plasseringen i denne planen er FEIL.** Den plasserer gaten i `ingest.materialize`,
> «repoets ene skrivepunkt». Målt er `materialize` en ren delegasjon til det pinnede
> `llm_ingestion_okf`-bibliotekets `materialize_bundle`, som stager i minnet og skriver selv —
> ingen callback mellom staging og disk. Gaten bor derfor i `materialize_gated`
> (kopi → materialiser → skann → publiser eller avvis), ikke der denne planen sier.
>
> **Status:** PLAN (ikke implementert). Denne økta KARTLA repoets untrusted-ingest og la denne > **Status:** PLAN (ikke implementert). Denne økta KARTLA repoets untrusted-ingest og la denne
> planen; guarden er IKKE wiret inn ennå (per adopsjons-briefens §7: «kun planlegg og oppdater > planen; guarden er IKKE wiret inn ennå (per adopsjons-briefens §7: «kun planlegg og oppdater
> planene»). Utløst av konsument-inkluderings-prompten fra `llm-ingestion-pipeline-security`. > planene»). Utløst av konsument-inkluderings-prompten fra `llm-ingestion-pipeline-security`.

View file

@ -0,0 +1,136 @@
# Demo-uke — alle åtte steg live torsdag 13. august 2026
> **Grunnlag:** `2026-08-06-intensjons-qa.md` (påstander A1C8, funn G1G6, beslutninger O1O4).
> Rammene A/B/C fra briefen står. Denne planen legger til dagsrekkefølge, den ene kodejobben,
> og et go/no-go-punkt som gjør uka robust mot at innholdet ikke kommer.
## 0. Låst — ikke gjenåpne
| | Beslutning |
|---|---|
| **A** | «Live» = sanntids-gjennomgang, skriptet under panseret. Preflight/Foundry er UTE av kritisk sti. |
| **B** | MAF-siden alene. Søsken-repoet er parkert. |
| **C** | Det realistiske eksemplet bygges i commons, i en egen tab. |
| **O1** | Ferdigbygd eksempel. Fabrikken (D-G/T0 `okf-toolkit`) er eksplisitt utsatt. |
| **O2** | Steg 5 bygges og vises live. |
| **O3** | Bestilt commons 2026-08-06 med frist 11. august; reserve planlagt. |
| **O4** | README oppdateres ETTER demoen (14.15. august), ikke før. |
Bestillingen er levert: `20260806T112037Z-2648131757-from-portfolio-optimiser.md`.
## 1. Hva demoen ÆRLIG er — formuleringen er avtalt på forhånd
D-I setter publiserings-påstanden til **nivå 2**: realistisk case, modellerte tall, aldri salgsspråk
over beleggsnivået. Tre ting sies høyt i demoen, ikke i en fotnote:
1. **Agent-svarene er skriptet.** Dette beviser dataflyten, den deterministiske ryggraden og at
læringssløyfa lukkes — ikke at en levende modell ville produsert nettopp dette forslaget.
(Banneret sier det allerede i dag; det skal stå, ikke pyntes bort.)
2. **Innholdet er håndkuratert, ikke fabrikkert.** Den besluttede demo-stien (D-H pkt. 4) går via en
bundle-fabrikk som ikke er bygget. Vi viser «last ned → kjør», men et menneske lagde innholdet.
3. **Tallene er modellerte, ikke målte.** Ingen pilot har validert dem i drift.
Dette er ikke en unnskyldning som svekker demoen — det er selve grunnregelen repoet er bygget på
(A5: koden får ikke påstå mer enn den gjør). En demo som overselger bryter med det den demonstrerer.
## 2. De åtte stegene → hva som faktisk vises
`RunResult` bærer allerede `retrieved`, `debate_output`, `checker_verdict`, `outcome`
(`ValidatedProposal | Rejection`), `verdict` og `coverage`. Presentasjonen er ~30 `print`-linjer
(`simulation.py:295326`). Derfor er sju av åtte steg **presentasjonsarbeid**, og ett er ekte bygg:
| Steg (`method-spec` §3) | Vises som | Status i dag | Arbeid |
|---|---|---|---|
| 1 — Forstå konteksten | navigerte filer + ExpeL-folden (`retrieved`) | markør-linja antyder det | `print` |
| 2 — Hypotese | forslaget med parametere | data finnes, printes ikke | `print` |
| 3 — Debatt (maker-checker) | begge deltakere + `checker_verdict` | checker printes | `print` |
| 4 — Valider / falsifiser | validator-linja med P90 | **printes** ✔ | — |
| 5 — Forbedre, informert og bundet | avvisning → korrigert forslag | **bygget 7. aug** ✔ | — |
| 6 — Forkast eller foreslå | typet `outcome` | data finnes, printes ikke | `print` |
| 7 — Svar på tilbakemelding | persona-dom + fil-innboksen | dommen printes | `print` |
| 8 — Promoter godkjent kunnskap | promotert fil + index-lenke | **printes** ✔ | — |
**Steg 5 er den eneste ekte kodejobben.** `generate_via_llm` forbruker den mellomliggende
avvisningen internt (`last`) og returnerer bare sluttresultatet — og i dagens demo-kjøring
validerer forslaget på FØRSTE forsøk, så forbedringsløkka trigges aldri. Det kreves to ting:
en søm som slipper avvisnings-historikken ut, og et nytt skriptet forløp der forslaget først
blir avvist og deretter korrigert.
## 3. Dagsplan
**Fredag 7. august — steg 5-sømmen (den ene kodejobben). ✔ GJORT.**
Slipp avvisnings-historikken ut av `generate_via_llm` uten å endre løkkas tak (`max_attempts` +
`meter.tick_round` står urørt — «forbedre til god nok» uten tak er forbudt). Nytt skriptet
avvis-så-korriger-forløp. Ny load-bearing-test: RØD når sømmen kobles fra. Ligger først i uka
med vilje — det er den eneste jobben som kan overraske, og den har fem dagers slakk bak seg.
> **Utfall:** den åpne beslutningen ble **egen returtype**`generate_via_llm` returnerer
> `GenerationResult(outcome, refinements)`, og `RunResult.refinements` bærer den ut av kjøringen.
> En returverdi kan ikke bli stille tapt slik en out-parameter kan, og mypy tvinger hvert kallsted
> til å ta stilling. `refinements` bærer KUN avvisninger som faktisk ble matet tilbake (den siste
> avvisningen ved uttømt budsjett ER `outcome`). Simuleringens proposer overklager 250 000 NOK, som
> den deterministiske validatoren felt mot P90 = 90 000; det korrigerte forslaget på 30 000
> validerer. Fire mutasjoner målt røde mot hele suiten, med kontroll.
**Lørdag 8. søndag 9. august — presentasjonslaget.**
De fem `print`-tilleggene over, formet som én lesbar gjennomgang med steg-nummer i margen.
Ingen ny logikk. Målet er at en tilhører kan følge hvert steg uten at du forklarer hva de ser på.
**Mandag 10. august — gjør manuset innholds-drevet.**
`ScriptedChatClient` tar en `reply_selector` over `(prompt_blob, role)` — den ser altså prompten
og KAN nøkle svaret på kandidaten i stedet for å ha ett hardkodet svar. Bygg det **nå, før
innholdet kommer**: da er et nytt prosjekt en data-oppføring, ikke et nytt manus skrevet for hånd
under tidspress. Dette er ukas viktigste risikoreduksjon.
**Tirsdag 11. august — GO/NO-GO på innholdet.**
Er commons-leveransen hentbar? `git subtree pull --prefix=shared commons main --squash`, så
kjør. **JA:** pek `simulate_learning_loop` på den nye bundelen, utvid selector-dataene, mål
rendret kontekst-størrelse per bundle. **NEI:** lås mikro-eksemplet som demo-innhold og si det i
ærlighets-avsnittet. Beslutningen tas tirsdag, ikke onsdag kveld.
**Onsdag 12. august — generalprøve, så fryse.**
Kjør hele gjennomgangen to ganger. Identisk output begge ganger (determinisme er et poeng, ikke en
detalj). Full suite grønn. Etter generalprøven: ingen endringer i kjørestien.
**Torsdag 13. august — demo.**
**Fredag 14. lørdag 15. august — README (O4).** Nivå-2-påstanden løftes ETTER at beviset finnes.
## 4. Risiko
| Risiko | Utslag | Tiltak |
|---|---|---|
| Commons rekker ikke 11. august | Tynt innhold | Reserve låst tirsdag; bestillingen sier eksplisitt at det er en reserve, ikke en krise |
| Nytt innhold krever nytt manus | 2 dager håndarbeid under press | Innholds-drevet `reply_selector` bygges mandag, FØR innholdet kommer |
| Prompten sprenges av stor bundle | Token-tak slår inn midt i demoen | Bestillingen er størrelses-kappet på målt grunnlag (~25 000 tegn/bundle); mål på nytt tirsdag |
| Steg 5-sømmen tar lengre tid | Ett steg mangler | Ligger fredag; fallback er testbevis-varianten (vurdert og valgt bort, men den finnes) |
| `docs/presentasjon-*.html` eies av annen sesjon | Konflikt | Røres aldri; alltid eksplisitt filliste ved `git add` |
## 5. Verifisering
Konkrete kriterier, ikke «sjekk at det virker»:
1. `uv run python -m portfolio_optimiser.simulation` → exit 0, og outputen har **én merket linje per
steg 18**. Verifiseres med `... | grep -cE "^ *Steg [1-8]"` → 8.
2. Steg 5 er synlig som **to** forslag: ett avvist med grunn, ett korrigert som validerer.
Verifiseres ved at outputen inneholder både en `REJECTED`- og en `VALIDATED`-linje for samme kandidat.
3. Ny load-bearing-test for steg 5-sømmen blir **RØD** når sømmen kobles fra. Måles mot HELE suiten,
med kontroll, restaurert fra scratchpad-kopi + `shasum -c`.
4. `uv run pytest -q` grønn (baseline i dag: 759 kollektert, 755 passed / 4 skipped).
5. `uv run ruff check .` + `uv run mypy src` rene.
6. Generalprøve onsdag: to kjøringer, **byte-identisk** output (`diff <(kjøring1) <(kjøring2)` tom).
7. Ved commons-leveranse: rendret kontekst per bundle måles med `okf.bundle_context` og skal ligge
under ~25 000 tegn. Over det → bruk færre bundles, ikke større prompt.
8. `shared/examples/bygg-energi-mikro/` og `nav-golden-*` er **uendret** etter subtree-pull
(`git diff --stat` på de stiene → tomt). Goldenene er load-bearing.
## 6. Nøkkelantakelser som skal testes, ikke antas
- **«Sju av åtte steg er ren presentasjon.»** Testes fredag/lørdag: hvis et av de fem `print`-tilleggene
viser seg å kreve ny logikk, er det samme klasse funn som C7 og skal meldes med en gang.
- **«`reply_selector` kan nøkle på kandidaten.»** Signaturen er `(prompt_blob, role) -> str`, så
prompten er tilgjengelig — men at kandidaten er entydig identifiserbar i blobben er ikke verifisert.
Testes mandag, på mikro-eksemplet, før innholdet kommer.
- **«Ny bundle plugges inn som parameter.»** `simulate_learning_loop(bundle_dir, work)` tar katalogen
som argument (`simulation.py:183`, kalt `:293`) — verifisert i dag. Det som IKKE er verifisert, er at
en bundle med flere kandidater kjører gjennom uendret; det er S3.2-stien, og den testes tirsdag.

View file

@ -0,0 +1,311 @@
# Intensjons-QA — forståelsen av repoet, lagt fram som korrigerbare påstander
> **Hva dette er.** Operatøren stoppet planleggingen 2026-08-06 fordi briefen til demo-uken var
> skrevet av en økt som *leste seg til* intensjonen fra dokumenter andre økter hadde skrevet — og to
> av rammene ble snudd av primærkildene innenfor én samtale. Denne økten leste primærkildene selv og
> legger forståelsen fram som **påstander med kilde**, slik at det koster operatøren én linje å rette
> hver enkelt.
>
> **Slik brukes den:** svar med nummer. «A3 er feil — det er slik: …». Alt du ikke kommenterer,
> regnes som bekreftet og blir grunnlag for planen.
>
> **Ingen påstand her er hentet fra `STATE.md` eller fra hukommelse.** Hver påstand står med kilden,
> og hvert tall med kommandoen som produserte det.
## 0. Kilder lest i sin helhet, og målinger gjort
Lest i sin helhet i dag: `docs/plan/2026-06-26-maalbilde-agentic-loop.md` ·
`shared/method-spec.md` · `shared/ingest-spec.md` · `shared/CONCEPT.md` ·
`docs/plan/2026-07-03-sammenligningsprotokoll.md` · `docs/research/2026-06-23-prior-art-platform.md`
§11§15 · `docs/plan/2026-08-06-v1-inventory.local.md` · `docs/plan/2026-08-06-v1-demo-brief.local.md`.
Lest strukturelt (overskrifter + de avsnittene påstandene nedenfor hviler på):
`docs/review-2026-07.md` · `docs/plan/2026-07-10-sesjonsplan-fase2-6.md` · `docs/extending.md`.
Målinger kjørt i denne økten, på HEAD `520e741`:
```
$ uv run pytest -q --collect-only | tail -1
759 tests collected in 0.49s # = inventarets 755 passed + 4 skipped ✔
$ uv run python -m portfolio_optimiser.simulation # exit 0, offline, uten nøkkel/nettverk ✔
# output identisk med inventarets gjengivelse
$ ls ~/repos | grep -iE "okf|toolkit|commons|portfolio"
_okf-interim _okf-upstream llm-ingestion-okf llm-ingestion-pipeline-security
portfolio-optimiser portfolio-optimiser-claude portfolio-optimiser-commons
# MERK: ingen okf-toolkit (se G2)
$ grep -ci -- "<begrep>" shared/method-spec.md shared/ingest-spec.md
mandate 0/0 · notify 0/0 · ledger 0/0 · "value report" 0/0 · "cost simulation" 0/0 ·
dimension 0/0 · "portfolio budget" 0/0 · concurren 0/0 · preflight 0/0 # se G5
```
To inventar-tall er altså stikkprøvet uavhengig (test-antall, simuleringen), og begge holdt.
---
## A. Intensjonen — hva repoet skal være
**A1. Metoden er produktet, ikke koden.** Leveransen er en metode: sverm av agenter genererer
kandidat-tiltak → obligatorisk deterministisk validator avgjør tallene → fageksperter dømmer via
HITL → systemet lærer av dommene. *(målbilde §1; `method-spec` §1)*
**A2. Problemet er *inne i* hvert prosjekt, aldri på tvers.** Porteføljen er uavhengige prosjekter;
å behandle porteføljen som ett system er eksplisitt anti-scope. *(målbilde §1; research §15.5 A8;
README «Not a portfolio-level reallocator»)*
**A3. Differensiatoren er læringssløyfa, og den krever et domene med lærings-overflate.** Energi
ble valgt fordi det finnes et ekte gap mellom det *beregnbare* (modellert besparelse) og det
*ekspert-kjennbare* (realiseringsgapet). FinOps ble forkastet som «for deterministisk» — der ville
sløyfa blitt dekorativ. *(målbilde §10 D-DOMENE; `CONCEPT.md` «Konkret»)*
**A4. Tillitsankeret er at agentene aldri får avgjøre verdien.** Validatoren er den ene
endpoint-frie dommeren, og den er obligatorisk og blokkerende — aldri en anbefalt plugin.
*(målbilde §6; `method-spec` §3 Steg 4; research §15.5 A5)*
**A5. Formålet er operatørens læring, men kvalitetskravet er publiseringsklart.** Grunnregelen er
at koden ikke får påstå mer enn den gjør — en docstring som lover læring over en usammenkoblet
sløyfe er i seg selv en defekt. *(målbilde §1; `method-spec` §1 «Honesty rule (unwaivable)»)*
**A6. Sluttleveransen på programnivå er en *rettferdig sammenligning* av to stacker på identisk
metode og identisk datagrunnlag — men hver implementasjon skal også stå selvstendig som brukbar.**
*(målbilde §1; `sammenligningsprotokoll` §1§2)*
**A7. Liveness-asymmetrien er en låst programbeslutning, ikke et kostnadsvalg som kan revurderes.**
MAF-siden kjøres ALDRI mot ekte modell; dens bevis er et skriptet offline-bevis av plumbing,
deterministisk ryggrad og lukket læringssløyfe. Claude-siden kjører ÉN minimal ekte API-kjøring som
programmets eneste genuine modell-atferdsbevis. Erklæringen gjengis **ordrett** i sluttrapporten.
*(`sammenligningsprotokoll` §3 — dette er kilden som snudde min forrige ramme)*
**A8. Rammeverket er rent teknisk.** Deployeren eier DPIA/ROS/behandlingsformål; vi bygger kun de
tekniske forutsetningene (lokal-only default, provenance, ingen stille egress) + disclaimer.
*(målbilde §6; `method-spec` §1 «Boundary»; `ingest-spec` §1)*
**A9. Det delte eksemplet er felles og bygges én gang.** OKF-bundles + golden-suite +
ekspert-persona er delt kjerne mellom de to repoene; `shared/` her er pull-only subtree av
`portfolio-optimiser-commons`. *(målbilde §8§9; CLAUDE.md konvensjoner)*
---
## B. Prosessen — hvordan arbeidet faktisk styres
**B1. Det finnes to nivåer, med vilje.** Program-nivå = målbildet (definerer «ferdig», endres
sjelden). Fase-nivå = én Voyage-syklus per inkrement (`/trekbrief → /trekresearch → /trekplan →
/trekexecute → /trekreview`), der `/trekreview` er anti-drift-mekanismen. *(målbilde §0)*
**B2. Spec-ene er normative og skrevet for å kunne implementeres «from this spec alone».** De er
framework-nøytrale ved regel, håndhevet av guard-tester. Det er dét som gjør en rettferdig
sammenligning mulig i det hele tatt. *(`method-spec` topptekst + §11; `ingest-spec` topptekst)*
**B3. «Ferdig» er definert som load-bearing-tester, ikke som features.** En søm regnes ikke som
bygget før en test **feiler** når sømmen kobles fra — «grønn-men-død» er den navngitte fellen.
*(målbilde §6§7; `method-spec` §11 med 12 påkrevde rød-betingelser)*
**B4. Ingen ubegrenset løkke, noe sted, og tak kreves ved oppstart.** All konfig valideres fail-fast
FØR noen modellklient konstrueres. *(målbilde §6; `method-spec` §8 + §10)*
**B5. Kostnadsdisiplin er en designramme, ikke en preferanse.** Utvikling skjer offline/lokalt;
M1M3 er de eneste stegene som koster penger, krever tenant eller krever et menneske.
*(`sesjonsplan-fase2-6` §4 + §5-fotnote; CLAUDE.md)*
**B6. Metoden skal kunne konsumeres av andre som en Agent Skill, og ekspert-personaen ER en slik
skill.** `shared/skills/expert-reviewer/` er den ene delte artefakten begge stacker instansierer
reviewer-en fra; `shared/` forblir ren data. *(`method-spec` §4.3; CLAUDE.md)*
**B7. Datatilgangen har én arkitektonisk konsekvens som binder alt annet: data når modellen KUN
via OKF-bundles.** Ingen query-tid-retrieval mot bundelen, ingen RAG i kjørestien. Konnektorene
lever derfor i et deterministisk ingest-steg FØR optimalisereren. *(`ingest-spec` §1§2;
`method-spec` §3 Steg 1)*
---
## C. Hvilke features som faktisk finnes (målt, ikke antatt)
**C1. Alle åtte steg er wiret, og målbildets egen «ferdig»-definisjon er oppfylt for løkka.** De fem
load-bearing-testene målbilde §7 krever (steg 1, 3/4, 5, 7, 8) finnes som egne filer og suiten er
grønn (759 kollektert, 755 passed / 4 skipped). *(målbilde §7; inventaret + min egen måling)*
**C2. Simuleringen er det primære metode-beviset, den kjører, og den er allerede demo-formet.**
`uv run python -m portfolio_optimiser.simulation` kjører offline til exit 0 og viser to kjøringer
adskilt av en promotering, der markøren `realiseringsgrad=0.79` er `False` i Run A og `True` i Run B.
*(målt i dag; `simulation.py`-docstring)*
**C3. Ingest-laget er reelt, men implementasjonen bor i et ANNET repo.** `llm-ingestion-okf` er en
deklarert runtime-dep («the shared implementation of `shared/ingest-spec.md`»), og dette repoets
`ingest.py` (202 linjer) er konsument-sømmen over den. Fire golden-ekstraksjoner finnes lokalt
(`file`, `sql`, `http`, `mcp`). *(`pyproject.toml`; `examples/ingest-golden-*`)*
**C4. Repoet er vesentlig større enn de åtte stegene.** 31 moduler / 7871 linjer i `src/`, 88
testfiler, 42 load-bearing-filer — mot `method-spec` §11s 12 påkrevde sømmer. Overskuddet er ekte
kapasiteter: mandat (`mandate.py`), outbox (`outbox.py`), hovedbok (`ledger.py`), verdirapport
(`value_report.py`), kostnadssimulering (`costsim.py`), semantisk henting (`semretrieval.py`),
dimensjons-gating (`dimension.py`), HITL-ruting (`hitl.py`), varsling (`notify.py`),
portefølje-budsjett (`budget.py`), concurrent fan-out, Foundry-preflight (`preflight.py`),
MCP-verktøy i kjørestien (`mcp_tools.py`). *(inventaret; docstring-måling i denne økten)*
**C5. Det finnes ingen konsoll-kommando.** `[project.scripts]` er tom/fraværende i `pyproject.toml`
— alt kjøres som `uv run python -m …`. Målbilde §11 pkt. 7 fører `[project.scripts]` opp som
release-hygiene, og det er ikke gjort. *(målt: `grep -n scripts pyproject.toml` → ingen treff)*
**C6. Eksempelet som finnes i dag er mikro, og hele det delte laget er lite.** `shared/` har 45
filer, hvorav eksempel-bundelen `bygg-energi-mikro` er 8 filer (én kandidat, ett frø-verdict).
Simuleringen kjører dessuten i en temp-katalog, ikke på noe som ligger igjen i repoet.
*(målt; inventaret pkt. 2)*
**C7. Steg 2 og 6 kan gjøres synlige fra data som allerede finnes; steg 5 kan det IKKE.** Hele
presentasjonen er ~30 `print`-linjer (`simulation.py:295326`) over et `RunResult` som bærer
`outcome` (typet `ValidatedProposal | Rejection`), `verdict`, `retrieved`, `debate_output`,
`checker_verdict` og `coverage`. Men `generate_via_llm` returnerer bare sluttresultatet — den
mellomliggende avvisningen (`last`) forbrukes internt og forlater aldri funksjonen. **Og i demoen i
dag validerer forslaget på FØRSTE forsøk**, så forbedrings-løkka trigges ikke i det hele tatt.
Å vise steg 5 krever derfor både en ny søm og et nytt skriptet avvis-så-korriger-forløp — ikke
bare en `print`. *(målt i `simulation.py`, `run.py:115133`, `generate.py:134196`)*
**C8. Kosmetikken i inventaret er bekreftet.** To `GroupChatOrchestrator reached max_rounds=3;
forcing completion.`-linjer og to MAF `ExperimentalWarning`-linjer skrives FØR banneret.
*(målt i dag, identisk med inventaret)*
---
## D. Fem gap i bildet briefen hviler på — det QA-en fant
Rammene A/B/C i briefen står. Det som var galt, var **bildet under dem**.
**G1. Intensjonen har en andre akse som ikke sto på listen over primærkilder.** STATE pekte på fem
kilder (målbilde, research §15, `method-spec`, sammenligningsprotokoll, `ingest-spec`). Ingen av dem
nevner roadmap-aksen — og det er DEN de fleste modulene i C4 kommer fra:
`docs/review-2026-07.md` (uavhengig kryssmodell-review, funn F1F14 langs fire akser) og
`docs/plan/2026-07-10-sesjonsplan-fase2-6.md` (S2.0S5.4, beslutningene D-AD-I, milepælene M1M3,
roadmap C/D/E). Målt kobling: 20 S-numre, 18 av dem med treff i `src/`+`tests/`. **Konsekvens:** en
plan skrevet fra de fem kildene alene ville beskrevet et repo med åtte steg, og ikke gjenkjent to
tredeler av det som står der.
**G2. Den besluttede demo-stien er fabrikk-avhengig, og fabrikken finnes ikke.** D-H (BESLUTTET
2026-07-14) definerer demo-stien ordrett: «klon → unzip energi-eksempel i bundle-innboks → fabrikk
bygger → hele sløyfa kjører». Fabrikken er D-G/T0 — et eget repo med arbeidstittel `okf-toolkit`,
som per måling **ikke eksisterer** (`ls ~/repos`). Sesjonsplanen sier eksplisitt at
fabrikk-avhengige deler av D-F/D-H — «realistisk energi-innhold via fabrikken, ekspert-dom-
oversettelse, **demo-sti**, M3-pilot» — er blokkert til T0 finnes. Briefens krav «enhver som laster
ned repoet skal kunne kjøre nøyaktig det samme» ER den demo-stien. **Konsekvens:** planen må velge
eksplisitt — ferdigbygd bundle sjekket inn i commons (fabrikk ute av 13.-august-stien), eller
akseptere at stien er blokkert. Briefen min nevnte T0 ikke med ett ord.
**G3. Innholdsmodellen for det realistiske eksemplet er allerede besluttet.** D-F (BESLUTTET
2026-07-14, fasit i `2026-07-14-revisjonspakke-DF-DI.md` §1): kunnskapstyper (tiltaksmønstre /
erfaringsnotater / faglige råd) **alle med påkrevd kildebelegg**, kun lesestoff for
forslagsstilleren, validatorens regler urørt, streng separasjon fra dommene (bibliotek via
`bundle_context`, korreksjoner KUN via ExpeL-folden), delt dimensjonsbibliotek materialisert inn i
hver bundle via ingest-mønsteret, trinnvis lesing. Samme beslutning sier at **realistisk
energi-innhold er «egen senere innholds-produksjonsjobb»** — altså en allerede planlagt jobb i
programmet, ikke ny scope. **Konsekvens:** bestillingen til commons skal referere D-F, ikke definere
en innholdsmodell på nytt. En bestilling som finner opp sin egen struktur ville satt commons i
konflikt med sin egen beslutning.
**G4. Demoen er programmets nivå-2-bevis, med et forhåndsavtalt ærlighetstak.** D-I (BESLUTTET
2026-07-14): publiserings-påstanden er **nivå 2** — realistisk case, modellerte tall, aldri
salgsspråk over beleggsnivået; nivå 3 (ekte pilot) er en åpen invitasjon. Og: «README oppdateres
FØRST når nivå-2-beviset finnes». **Konsekvens:** 13. august er ikke bare en presentasjon, det er
milepælen som utløser en README-oppdatering — og formuleringene i demoen er bundet av et tak som
allerede er avtalt. Det gjør ærlighets-leveransen i ramme A til et *eksisterende* krav, ikke et nytt
påfunn.
**G5. Den delte spec-en dekker løkka + ingest — ingenting av C4-overskuddet.** Målt: `method-spec`
og `ingest-spec` nevner *ikke* mandat, varsling, hovedbok, verdirapport, kostnadssimulering,
dimensjon, portefølje-budsjett, concurrency eller preflight (0 treff hver). **Konsekvens for A6:**
sammenligningen er en sammenligning av den **spec-ede kjernen**, ikke av dette repoet. Et søsken
bygget «from spec alone» ville ikke hatt disse. Det er ikke nødvendigvis feil — men det er en
påstand om sammenligningens rekkevidde som må stå eksplisitt i sluttrapporten, og D7-speilings-
gjelden (CLAUDE.md: S2.7, S3.2, S4.0, (p), (a)/(i), `mandate.py`, A5, B4 — søskenet har ikke svart)
er den samme saken sett fra andre siden.
**G6 (mindre, men styrer en formulering).** S3.5 er i praksis ikke bygget: eneste treff i kode er en
kommentar i `hitl.py:169` om en «minimal MVP stand-in for the S3.5 dimension catalog», og S3.5 er
gated på et commons-amendment som per CLAUDE.md aldri kom. Dimensjonskatalogen er altså et
stand-in, ikke den besluttede delte modellen. *(målt: `grep -rn S3.5 src/ tests/`)*
---
## E. Det jeg IKKE kunne verifisere — ingen hull fylt med antakelser
1. **CHANGELOG-ens `[0.1.0]`** — ~30 tekniske påstander, allerede publisert, ikke uavhengig
kontrollert av noen. Jeg har ikke gått gjennom dem i denne økten.
2. **Commons' egen tilstand og beslutningskø.** Jeg ser bare de 45 filene subtree-en har hentet, og
`shared/docs/plan/` viser at commons har en aktiv egen beslutnings-prosess (ti plan-dokumenter,
nyeste 2026-08-02). Om commons har kapasitet 7.13. august, vet jeg ikke — og jeg kan ikke se det
herfra.
3. **Søsken-repoets faktiske tilstand.** `portfolio-optimiser-claude` eksisterer som katalog; hva
som er bygget der, har jeg ikke undersøkt (ramme B parkerer det uansett).
4. **Om `_okf-interim` / `_okf-upstream` er forløpere til T0** eller noe annet. Jeg har ikke sett i
dem — de ligger utenfor dette repoet.
5. **Realiserings-kostnaden av C7** (ny søm for steg 5) er ikke estimert; det hører i planen.
---
## F. Hva denne QA-en endrer for planleggingsøkten
Ikke rammene — de står. Men fire ting planen nå må gjøre som briefen ikke ba om:
1. **Behandle T0/fabrikken eksplisitt (G2):** velg ferdigbygd bundle, og si at fabrikk-stien er
utsatt — ellers planlegges det mot en blokkert avhengighet.
2. **Bestillingen til commons refererer D-F (G3)** i stedet for å definere innhold på nytt.
3. **Steg 5 er ikke presentasjonslag (C7):** ny søm + nytt skriptet forløp. Steg 2 og 6 er
presentasjon.
4. **Nivå-2-taket (G4)** blir demoens ærlighets-ramme, og README-oppdateringen er en konsekvens som
skal med i planen — eller eksplisitt utsettes.
---
## G. Operatørens svar (2026-08-06) — de fire beslutningene planen bygger på
Påstandene A1C8 ble lagt fram og **ingen ble korrigert** → alle 20 står som bekreftet, slik
dokumentets egen regel sier. Det operatøren derimot avgjorde, var de fire valgene QA-en avdekket:
**O1 (svar på G2) — FERDIGBYGD EKSEMPEL.** Energi-eksemplet bygges for hånd og sjekkes inn i
commons. **Fabrikk-stien (D-G/T0 `okf-toolkit`) er eksplisitt utsatt** og er ute av 13.-august-stien.
Demoen viser fortsatt «last ned → kjør» — men innholdet er kuratert, ikke fabrikkert, og det er dét
som skal sies høyt. Konsekvens: D-Hs demo-sti pkt. 4 er **delvis oppfylt** i denne demoen, og
avviket hører i ærlighets-avsnittet, ikke i en fotnote.
**O2 (svar på C7) — STEG 5 BYGGES OG VISES LIVE.** Ny søm som slipper den mellomliggende
avvisningen ut av `generate_via_llm`, pluss et nytt skriptet avvis-så-korriger-forløp i
simuleringen. Estimert én økt. Begrunnelsen er fristen selv: kravet er ALLE ÅTTE STEG, og et steg
som bare omtales er ikke vist. Testbevis-varianten og den muntlige varianten ble vurdert og valgt bort.
**O3 (svar på E2) — BESTILL NÅ MED FRIST 11. AUGUST, OG PLANLEGG RESERVE.** Bestillingen sendes til
commons i dag via `coord-send`; planen får et fall-tilbake her (kjør demoen på `bygg-energi-mikro`)
som utløses hvis eksemplet ikke er hentbart 11. august. Ingen jobber i commons' repo fra denne taben.
**O4 (svar på G4) — README OPPDATERES ETTER DEMOEN (14.15. august).** Rekkefølgen D-I krever holdes:
beviset først, påstanden etterpå. Planen reserverer tid, men README røres ikke før torsdag.
### Målinger gjort etter beslutningene (grunnlag for bestillingens størrelse)
```
$ uv run python -c "okf.navigate_bundle('shared/examples/bygg-energi-mikro') → bundle_context"
context_files: 4 · chars: 12005 · ≈ 3001 tokens
kilder-realiseringsgap.md (reference) 3519 · bygg-kontor-nord.md (project) 1594
tiltak-led-retrofit.md (hypothesis) 3007 · metode-ipmvp-a.md (methodology) 1759
```
To konsekvenser bestillingen MÅ bære, begge målt og ingen av dem valgfrie:
- **`bundle_context` rendrer HELE brødteksten i hver navigert fil** (`okf.py:206233`), og
**trinnvis sammendrags-lesing (D-F pkt. 6) er ikke bygget.** D-F pkt. 7s fulle ambisjon —
1530 tiltak i ett bibliotek — ville derfor gitt 4090 000 tegn (1022 k tokens) inn i HVER
hypotese-prompt, under harde token-tak. `dimension`-filteret hjelper ikke, siden alt her er
samme dimensjon. Bestillingen må derfor være **størrelses-kappet**, og det fulle biblioteket
forblir den «egne senere innholds-produksjonsjobben» D-F pkt. 7 selv navngir.
- **D-F pkt. 4 løser dette strukturelt:** delt bibliotek *materialiseres inn i hver
prosjekt-bundle*. Flere prosjekter = flere bundles, ikke én stor. Det holder per-kjøring-
konteksten nede OG gir porteføljestien (bølger, budsjett, hovedbok) noe ekte å kjøre på.
**Innplasserings-funn (godt nytt for planen):** `simulate_learning_loop(bundle_dir, …)`
(`simulation.py:183`, kalt `:293`) tar bundle-katalogen som **parameter** og kopierer den til en
arbeidskatalog. Et nytt eksempel plugges altså inn i en eksisterende søm. **Men** de skriptede
svarene er skrevet mot LED-caset, så nytt innhold krever nytt manus — den koblingen er planens
egentlige kostnad ved O1, ikke selve bundle-innlesingen.
### Fortsatt ubesvart (ikke blokkerende — anbefaling gjelder ved taushet)
- Dette dokumentet til den offentlige speilingen? **Anbefaling: nei** (intern analyse).
- Tracking av `.claude/projects/`? **Anbefaling: nei** (uendret fra tidligere økter).

View file

@ -0,0 +1,146 @@
# Prompt: planrevisjon før Go — vurder seks innsigelser, endre planen
> **Til:** en fersk sesjon på **Fable 5 / xhigh**, i `portfolio-optimiser`.
> **Fra:** Opus-5-sesjon 2026-08-07, som gjennomgikk planen på operatørens spørsmål
> «er planen god nok til å bli gjennomført med høy kvalitet til torsdag?».
> **Lim inn alt under streken som første melding.**
---
Du skal **vurdere seks innsigelser mot ukeplanen, avgjøre hver enkelt, og endre planen deretter.**
Du bygger ingen produksjonskode i denne økten. Produktet er dømmekraft + redigerte plandokumenter.
## Rammer som ikke er dine å endre
- **Frist: live demo av alle åtte steg torsdag 13. august. Frys onsdag 12.** Fristen flyttes ikke.
- **O1O4 og A/B/C står** (`docs/plan/2026-08-06-demo-uke-plan.md` §0). Du gjenåpner dem ikke.
- **Aldri arbeid i et annet repo.** commons er pull-only; `coord-send` er mekanismen.
- **`docs/presentasjon-portfolio-optimiser.html` eies av en annen sesjon.** Rør den aldri,
`git add -A` aldri.
- **Scope-vakt:** du foreslår planendringer og skriver dem inn i plandokumentene. Du starter ikke
implementeringen av dem.
## Denne økten kjører uten advisor — derfor denne regelen
En Fable-økt kan ikke bruke advisor. Kompenser i arbeidet: **hvert tall og hvert faktapremiss du
bruker skal produseres av en kommando du faktisk kjører i denne økten.** Skriv kommandoen og
resultatet inn i planen ved siden av påstanden. Du får en liste med belegg under — den er *påstander
fra en annen sesjon*, altså premisser, ikke fakta. **Verifiser hver enkelt selv før du handler på
den.** Én av innsigelsene under er nettopp at planen bygger på en for smalt målt kommando; ikke gjenta
den feilen i din egen revisjon.
## Les først (primærkilder, i denne rekkefølgen)
1. `STATE.md` — «👉 NESTE»-blokka er sannheten om hvor vi står
2. `docs/plan/2026-08-09-egnethetsreview-plan.md`**hoveddokumentet du skal endre** (§0 to spor,
§1 funn, §2 P1P4, §4 belegg, §5 kalender)
3. `docs/plan/2026-08-06-demo-uke-plan.md` — eier kjørestien + de åtte verifiseringskriteriene (§5)
4. `docs/plan/2026-08-09-innholdsgate-og-aerlighet.md` — Spor B / P2
## Kontekst: hva som ER bra i planen (ikke riv det ned)
Målt i dag (2026-08-07): `uv run pytest -q`**766 passed / 4 skipped på 110 s**. Grunnpremisset
«v1 = den målte kjernen» holder. Planen har konkrete verifiseringskriterier framfor «sjekk at det
virker», en nedgraderingskolonne per post, en beleggstabell, to-spors-delingen, og front-lasting av
alt som ikke krever nytt innhold. Innsigelsene under er justeringer av rekkefølge og ett målefeil-
funn — ikke en underkjenning av planen.
---
## Innsigelse 1 (viktigst) — Funn 1 er målt for smalt, og én følgesetning er feil
**Planens påstand.** §1 Funn 1 + §4 rad 3: «ingen bundle i repoet har `cost-baseline.json`», målt
med `ls shared/examples/bygg-energi-mikro/`. Og §1 linje 78: «reserven (mikro-eksemplet) **kan aldri
få fila**», som er grunnen til at NO-GO-tilfellet trenger sin egen ærlige setning.
**Belegg som motsier den (verifiser selv):**
| Kommando | Resultat |
|---|---|
| `find . -name 'cost-baseline.json' -not -path './.git/*'` | `src/portfolio_optimiser/data/bundles/bygg-energi-baseline-mikro/cost-baseline.json` |
| `cat` på den fila | gyldig S4.0-format: `project_id` + `items{code:{quantity,unit_cost}}` |
| `grep -n BASELINE_BUNDLE tests/test_s40_cost_baseline_loadbearing.py` | `:42` — fixturen er alt i bruk, seks mutasjoner målt røde |
| `sed -n '505,525p' src/portfolio_optimiser/run.py` | `:516` `baseline = okf.load_optional_cost_baseline(bundle_dir)` — bundle-stien er wiret |
| `grep -n 'bundle_dir\|copytree' src/portfolio_optimiser/simulation.py` | `:280` argument, `:316` `shutil.copytree`, `:492` default — bundelen er en parameter, og den kopieres før kjøring |
**Innsigelsen.** Beleggskommandoen så på ÉN katalog under `shared/examples/`. Repoet shipper en
fungerende, format-definerende kostbaseline-bundle, og kjørestien leser den. Den ekte hindringen er
ikke at fila er umulig å skaffe — det er at `shared/` er **pull-only subtree** og at kriterium 8
krever goldenene byte-uendret. Det er en *plasserings*-begrensning, ikke en umulighet: en repo-lokal
demo-bundle, eller et kopier-og-utvid-steg før kjøring, gir en forankret kjøring uten å røre commons.
**Hvorfor det betyr noe for uka.** Slik planen står, møter S4.0-forankringen ekte innhold for
**første gang tirsdag 11.** — to dager før demo, én dag før frys — og planen sier selv at et avvik
over 5 % da feller BÅDE det overdrevne og det korrigerte forslaget på scenen. Ukas største enkeltrisiko
er plassert sist. Den kan flyttes til helgen for lav kostnad.
**Foreslått endring (din avgjørelse).** Nytt punkt i P4-forskuddet (helg): kjør hele demoløpet mot en
lokalt forankret bundle, inkludert 10 %-avviks-prøven som i dag ligger i P3. Da blir tirsdag en
*re-måling mot nytt innhold* i stedet for en førstegangskjøring. Korriger samtidig §1 Funn 1, §1 linje
78 og §4 rad 3 til det som faktisk er målt.
**Vurder mot:** koster dette en ekstra økt vi ikke har? Kolliderer en repo-lokal demo-bundle med
`_default_bundle_dir()`-sømmen (`simulation.py:48-51`, `PORTFOLIO_SHARED_ROOT`) eller med kriterium 6
(byte-identisk stdout)? Er en syntetisk forankring godt nok bevis, eller flytter den bare
usikkerheten? Avvis innsigelsen hvis svaret er nei — men avvis den med en kommando.
## Innsigelse 2 — Rekkefølgedefekt: stderr-demping vs. stderr-pinning
P4 pkt. 2 pinner de fire kjente stderr-linjene som fasit i **helgen**. §0 Spor 2 lister demping av de
samme fire linjene som «valgfritt før frys» — altså **onsdag**. Demping etter pinning ugyldiggjør
pinnet på frysedagen. **Foreslått endring:** ta demping-beslutningen i helgen, før pinningen, og skriv
den inn som en beslutning (ja/nei), ikke som et valgfritt tillegg sent i uka.
## Innsigelse 3 — `[project.scripts]` ligger på frysedagen, men endrer install-flaten
§0 S1.c legger `[project.scripts]` inn onsdag kveld, etter at P4 pkt. 1 (fresh-clone-kriteriet) er
målt. `grep -n scripts pyproject.toml` → 0 treff i dag. En ny entry point endrer det `uv sync` /
install produserer, så «last ned → kjør»-beviset må strengt tatt måles på nytt etter frysen — som er
selvmotsigende. **Foreslått endring:** flytt `[project.scripts]` til P4-forskuddet i helgen, slik at
onsdag kun er versjonssynk (fire steder) + CHANGELOG + tag.
## Innsigelse 4 — Tirsdagens subtree-pull mangler avbruddssti
Kriterium 8 sjekker at goldenene er uendret etter pull, men ingen sted står det hva som skjer hvis
pullen gjør suiten rød dagen før frys. **Foreslått endring:** skriv inn pre-pull-hash-notering, en
eksplisitt revert-regel, og et klokkeslett på tirsdag der NO-GO utløses uten videre diskusjon.
## Innsigelse 5 — Ingen demo-runbook er allokert
Det som skal SIES torsdag ligger spredt over fire steder: demo-uke-plan §1 (tre ærlighets-punkter),
innholdsgate-plan §5, P4 pkt. 4 (to ferdigskrevne setninger), og §0 Spor 2 (muntlig mandat-setning).
Ingen post i planen produserer ÉN side operatøren kan følge på scenen — kjøresekvens, hva som sies
hvor, og hva som gjøres hvis kjøringen feiler live. Golden-transkriptet fra P4 pkt. 3 er den naturlige
aborten, men det står ikke som abortsti noe sted. **Foreslått endring:** egen post, ~0,25 økt,
produseres VED frysen onsdag så den matcher frosset output.
## Innsigelse 6 (lav) — datohygiene svekker beleggstabellen
Plandokumentet heter `2026-08-09-…`, STATE-loggen daterer økter til 2026-08-09, og §4 sier «målt
2026-08-09 på HEAD `c96ef90`» — men `git log -6 --format='%h %ad' --date=short` gir **2026-08-06** for
`c96ef90`, og i dag er **2026-08-07**. Datoene ligger 23 dager fram i tid. Ikke en økt verdt, men en
beleggstabell mister etterprøvbarhet når datoen ikke stemmer med commit-datoen. **Foreslått endring:**
korriger datoene i §4 og i STATE-loggen; la filnavnet stå (omdøping koster lenker) med en note.
---
## Det du skal levere
1. **En avgjørelse per innsigelse: TAS INN / AVVISES / ENDRES TIL <x>** — hver med kommandoen du
kjørte for å avgjøre den. Avvisning er et fullt legitimt utfall; en avvisning uten måling er ikke.
2. **`docs/plan/2026-08-09-egnethetsreview-plan.md` redigert** — §1/§4 korrigert der målingen krever
det, §2 P3/P4 og §5-kalenderen omorganisert etter avgjørelsene, §0-økt-regnskapet oppdatert hvis
summen endres.
3. **Et oppdatert økt-regnskap mot kalenderen fre 7. ons 12.** Si eksplisitt om totalen fortsatt går
opp, og hva som er første kutt hvis den ikke gjør det (nedgraderingskolonnen er alt skrevet — bruk
den, ikke finn opp en ny).
4. **`STATE.md`s «👉 NESTE»-blokk overskrevet** med den reviderte første handlingen for i dag (fredag),
inkludert board-linja og route-linja.
5. **Commit** med `docs(plan):`-prefiks. Ikke `feat:` (den krever staget README + CLAUDE.md).
`git add` i eget Bash-kall, aldri sammen med `git commit`. Eksplisitt filliste — aldri `-A`.
## Kriteriet på at denne økten lyktes
Planen som ligger der etterpå har **ukas største måletekniske risiko tidligst, ikke sist**, og hver
påstand i §4 er produsert av en kommando som er kjørt i dag. Hvis du konkluderer med at planen skal
stå uendret, er det et gyldig utfall — men da skal §4 bære målingene som viser hvorfor.

View file

@ -0,0 +1,705 @@
# Egnethetsreview → plan — funnene gjort varige, én økt av gangen
> **Hva dette er.** Fable-reviewen (2026-08-09, kjørt fra
> `2026-08-09-fable-egnethetsreview-prompt.md`) leverte funnene sine i et chat-transkript som
> forsvinner. Dette dokumentet er den varige formen: hvert funn i klarspråk, med belegget som
> produserte det, rangert som en plan `STATE.md` peker inn i økt for økt. Chatten kan glemmes —
> alt som trengs står her.
>
> **Slik brukes den:** `STATE.md`s «👉 NESTE»-blokk peker alltid på det ØVERSTE ulukkede
> P-punktet her. En økt lukker et punkt → setter ✔ med commit-hash HER → overskriver STATE.md →
> neste økt leser videre. **Demo-uke-planen (`2026-08-06-demo-uke-plan.md`) og innholdsgate-planen
> (`2026-08-09-innholdsgate-og-aerlighet.md`) BESTÅR uendret** — denne planen føyer review-funnene
> inn i samme løp og flytter ingenting som er besluttet (O1O4 står).
>
> **Voyage:** småjobber (≤1 økt, kjent sti) kjøres direkte med `/tdd`-disiplin. Full
> Voyage-syklus (`/trekbrief → /trekplan → /trekexecute → /trekreview`) brukes der et punkt er
> merket **[Voyage]** — punktene med reell design-usikkerhet eller ≥2 økters bygging.
>
> **Revidert 2026-08-07 (planrevisjonen — seks innsigelser målt og avgjort, se §6):** ukas største
> måletekniske risiko (S4.0-forankringen) er flyttet fra tirsdag til helgen, stderr-beslutningen tas
> FØR pinningen, `[project.scripts]` er flyttet ut av frysedagen, tirsdag har fått abortsti, og
> demoen har fått en runbook-post (P4.5). **Datonote (I6):** filnavnets `2026-08-09` beholdes
> (omdøping koster lenker), men øktene dokumentet daterer til 08-08/08-09 ble committet
> **2026-08-06** (målt 08-07: `git show -s --format='%h %ad' --date=short c96ef90 0eb0f3d 295e966
> 688ee24 e93e921 d6f3359` → alle `2026-08-06`).
## 0. TO SPOR (operatørbeslutning 2026-08-09 — overordner rekkefølgen under)
Operatøren har delt uka i to spor. P-punktene under består, men hører nå hjemme slik:
### SPOR 1 — KOMPLETT VERSJON 1, klar til torsdag 13. (inkludert andre repo)
v1 = den **målte kjernen** (alle åtte steg wiret, 42 load-bearing-testfiler, 766/770 grønn —
inventaret i `2026-08-06-v1-inventory.local.md`) + to lukkinger + release-kuttet:
| Post | Innhold | Kost | Nedgradering hvis tid/kvote ryker |
|---|---|---|---|
| S1.a = **P1** | Steg 7-innboksen inn i demoløpet | 1 økt | ærlig etikett (0,1 økt) |
| S1.b = **P2** | Innholdsgaten (Spor B) **✔ (2026-08-09, `c255662`)** — alle seks kriterier grønne; 801 → 810 tester. **JA-varianten** i ærlighets-teksten gjelder dermed. | 12 økter | NEI-varianten i ærlighets-teksten (ferdigskrevet) — **ikke i bruk** |
| S1.c (ny) | **Release-kuttet:** versjon `1.0.0` synket FIRE steder (`pyproject`/`__init__`/`uv.lock`/`test_smoke`) + CHANGELOG (`[Unreleased]` → 1.0.0; Steg-5-API-endringen står alt der) + tag `v1.0.0` ETTER grønn generalprøve. **⚠️ «PÅ BEGGE REMOTES» ER FELT (2026-08-10) — TAGGEN GÅR TIL `origin` ALENE.** `open/`-speilet mangler 26 commits (**53 filer / 6087 innsettelser**, målt: `open/main` = `520e741` = `v0.1.0`), så en tag DER er ikke en tag-operasjon men en **offentlig publisering** — og eksponerings-gjennomgangen viste at den krever **seks plandokument-beslutninger**: `2026-08-06-intensjons-qa` + `2026-08-06-demo-uke-plan` (åpen sak, stående anbefaling NEI) pluss FIRE aldri vurdert (`2026-08-07-planrevisjon-prompt`, `2026-08-09-egnethetsreview-plan`, `2026-08-09-fable-egnethetsreview-prompt`, `2026-08-09-innholdsgate-og-aerlighet`). Seks publiseringsbeslutninger på frysedagen er nøyaktig det denne planen forbyr. **Speilet synkes i P5-vinduet**, med READMEen som forklarer commitene — kode publisert foran sin README leser som forlatt. **Onsdag er dermed ETT trekk: CHANGELOG-stempel + `git tag -a v1.0.0 -m "<ordrett fra runbookens §5 punkt 10>"` + `git push origin v1.0.0`.** **`-m` er PÅKREVD, ikke pynt** — uten den er kommandoen ikke kjørbar (målt: `GIT_EDITOR=true``fatal: no tag message?`, exit 128, ingen tag; uten `GIT_EDITOR` åpner den en editor). **Taggen er ANNOTERT (tir 11. økt 14, målt):** repoets eneste andre tag er det (`git cat-file -t v0.1.0``tag`, melding `v0.1.0 — first tagged release`), så en lettvekts `v1.0.0` ville gjort hovedreleasen til den eneste taggen uten forfatter, dato eller melding. **Meldingsteksten står literalt ÉTT sted — runbookens §5 punkt 10** — så den verken improviseres på enveis-dagen eller får en andre kopi å drifte fra. Formen er tørrkjørt i eget engangs-repo; ingen av de tre bekreftelses-kommandoene i punkt 11 endrer forventning av `-a`. *(`[project.scripts]` er flyttet videre til P4 pkt. 5 i HELGEN (I3): en ny entry point endrer install-flaten, så den må ligge FØR fresh-clone-målingen — aldri på frysedagen.)* **SYNK + CHANGELOG ✔ (2026-08-10, `a41272d`) — forskuttert fra ons kveld; TAGGEN gjenstår, frys-gatet.** Overskriften står bevisst på `[Unreleased]`: STATE hjemler innholdet nå, ikke release-stempelet, og et forhåndsdatert `[1.0.0] - <dato>` ville påstått en hendelse som ikke har skjedd. Ingen test leser CHANGELOG (målt), så paret er ikke maskin-gatet. Tag-dagen = ETT trekk: døp om overskriften, stemple dato, tagge. **«ETT trekk» er MÅLT (2026-08-10), ikke antatt:** `grep -c '^## \[Unreleased\]$' CHANGELOG.md`**1** (verbatim og unik; kun to `## `-overskrifter, og `## [0.1.0] - 2026-08-06` er historikk), og **ingen link-refs** nederst (`grep -nE '^\[.+\]:'` → tomt) som måtte følges med. Bar en dato, eller stod overskriften to steder, ville «det ene trekket» blitt improvisasjon på frysedagen. **Re-locken ble gatet, ikke antatt:** `uv lock` kjørt EKSPLISITT og diffet FØR noen test, ellers ville neste `uv run` re-locket usynlig mot en range-dep mens de to `ExperimentalWarning`-linjene er byte-pinnet i stderr-goldenen. Diffen = kun versjonslinja. **Målt at fire ER alle:** ingen README-badge; CHANGELOG-ens `[0.1.0]` er historikk. | 0,50,75 økt (ons kveld) | tag fredag i stedet — aldri tag før grønn generalprøve |
**Andre repo — hva som faktisk MÅ gjøres der (målt, ikke antatt):**
- **commons:** energieksemplet MED `cost-baseline.json` — ALLEREDE bestilt (frist tir 11.,
coord `20260806T112037Z…`); GO/NO-GO tirsdag står. Reserven (mikro) gjør at v1 ALDRI
blokkeres av commons: **rammeverket er v1, innholdet er data.**
- **llm-ingestion-okf:** ingenting — pinnet `v0.3.2` gjennom v1 (målt at den holder).
- **guard-repoet:** ingenting — `v0.3.4` finnes, stdlib-only; wiringen skjer HER (S1.b).
- **po-claude:** PARKERT (ramme B) — v1 er MAF-siden alene, og det SIES i release-notatet.
**Ingen ny coord-melding trengs.** Alt andre-repo-arbeid er enten bestilt eller ikke nødvendig.
**Flagget konflikt (operatøren eier den):** O4 sier README-løftet kommer ETTER demoen (frelør).
Anbefaling: tag `v1.0.0` onsdag med nøktern README-status; nivå-2-påstanden inn i README fre/lør
som besluttet. Beviset først, påstanden etterpå — det gjelder også taggen.
**EKSPLISITT UTE av v1 (sies i release-notatet, ikke i en fotnote):** CLI-porteføljetak (P6),
commons-amendmentene (P7), metode-skillen (P8), bestiller-flaten (åpen beslutning i STATE),
bundle-fabrikken (O1), søsken-repoet (parkert), okf `v0.4.0`+.
### SPOR 2 — KOMPLETT OG OVERBEVISENDE DEMO av alle åtte steg
= **P3** (GO-dag + re-måling av kostnads-sjekken) + **P4** (forankret prøvekjøring +
fresh-clone-/stderr-/golden-kriterier + to ærlighets-setninger) + **P4.5** (demo-runbook —
SKREVET man 10., FYLLES UT ved frysen; se P4.5-blokka) + generalprøven + én **muntlig** mandat-setning (bestillingsflyten er svaret
på «kan vi styre hva som analyseres?» — `docs/bestille-en-kjoring.md`).
**✔ SETNINGEN ER SKREVET (man 10. økt 7, `c7a57d8`)** — den sto her som en *plassholder for en
beslutning*, ikke som en beslutning: ingen steder fantes ordlyden. Nå formulert i runbookens §2,
forankret i bestillingsdokumentet: bestillingen styrer hva som **vurderes**, aldri hva som
**godkjennes** — validatoren gjelder uendret, og avvisningen kommer tilbake med begrunnelsen.
**Stderr-dempingen er BESLUTTET (I2, 2026-08-07): JA — utføres i HELGEN som del av
P4-forskuddet, FØR stderr-pinningen** (demping etter pinning ville ugyldiggjort fasiten på
frysedagen). Målt 08-07: stderr er SEKS linjer — de fire støylinjene (to `ExperimentalWarning` +
to «forcing completion») pluss blanklinje + `arbeidskopi:`-linja, som er bevisst
ikke-deterministisk (`simulation.py:531-534`) og IKKE skal dempes. Dempingen rører ikke stdout
(kriterium 6). Faller den: NEI-fallback = pin de fire linjene som fasit, med
`arbeidskopi:`-linja normalisert på prefiks.
**Økt-regnskap for uka i dette repoet (revidert 08-07):** S1.a (1) + S1.b (12) +
S1.c (0,50,75) + P3 (0,25) + P4/generalprøve — utvidet med forankret prøvekjøring,
10 %-prøven, `[project.scripts]` og dempingen (0,751,25) + P4.5 runbook (0,25)
**3,755,5 økter** på seks dager (fre 7.ons 12., ny full ukeskvote). Totalen går opp med
slakk i normal drift (12 økter/dag). **Første kutt hvis den ikke gjør det: S1.b →
NEI-varianten i ærlighets-teksten** (nedgraderingskolonnen — ferdigskrevet, frigjør 12 økter,
og Spor B er ikke en frys-betingelse). Deretter gjelder nedgraderingskolonnen rad for rad —
v1 forblir ærlig komplett på hvert nivå, den blir aldri stille ufullstendig.
## 1. Funnene i klarspråk
Reviewen fant ingen brann i koden: suiten er grønn (766 passed / 4 skipped), demo-outputen er
byte-identisk over to kjøringer, og alle åtte steg har sin merkede linje. Det den fant, er fire
steder der **demoen lover mer enn den viser**, og noen hull i sikkerhetsnettet rundt torsdag.
**Funn 1 — kostnads-sjekken har aldri kjørt på ekte innhold.**
Validatoren KAN avstemme et forslag mot prosjektets faktiske kostnadstall (S4.0), men sjekken
aktiveres bare når kunnskapsbasen shipper `cost-baseline.json` — og **ingen demo-bundle under
`shared/examples/` har den fila** (målt, §4 rad 3). *(Korrigert 08-07, I1: den opprinnelige
formuleringen «ingen bundle i repoet» var målt for smalt — repoet shipper en fungerende,
format-definerende baseline-bundle i `src/portfolio_optimiser/data/bundles/bygg-energi-baseline-mikro/`,
i bruk av S4.0-testens målte mutasjoner, og kjørestien leser fila: `run.py:516`.)* I dagens demo
regner validatoren derfor kun på tall forslaget selv oppgir. Bestillingen til commons krever fila
(bra) — men slik planen sto, ville sjekken møtt ekte innhold FØRSTE gang på leveransedagen; avviker
manus-tallene mer enn 5 % fra de leverte kostnadstallene, avvises BÅDE det overdrevne og det
korrigerte forslaget på scenen. **Derfor er forankringen flyttet til helgen (P4 pkt. 0):** reserven
kan ikke få fila I `shared/` (pull-only subtree + kriterium 8 krever goldenene byte-uendret), men
det er en *plasserings*-begrensning, ikke en umulighet — demoen kjører uansett på en KOPI av
bundelen (`simulation.py:316`), så en repo-lokal kopier-og-utvid-variant gir en forankret kjøring
uten å røre commons. NO-GO-setningen i P4 pkt. 4 er omskrevet tilsvarende.
**Funn 2 — demoen sier «fil-innboks for ekspertdommer», men bruker den ikke.**
Steg 7-linja i demoen sier «lang fil-løkke». I virkeligheten leveres dommen som et
funksjonsargument — `simulate_learning_loop` kaller `run_project` uten `verdict_dir`
(§4 rad 4). Fil-innboksen finnes og er testet (`test_step7_async_loop_loadbearing.py`), men
demoen kjører den ikke. Dette er samme klasse som Steg 5 var før 7. august: **et steg som
omtales er ikke vist.**
**Funn 3 — «last ned og kjør» er aldri testet fra en fersk nedlasting.**
Ingen av de åtte demo-kriteriene kjører fra en ren klone. `uv.lock` finnes, så beviset er én
kommandosekvens — og den fanger miljøavhengigheter (`PORTFOLIO_SHARED_ROOT` re-peker
kunnskapsbasen!), utrackede filer og lokal `.venv`-drift.
**Funn 4 — små presisjonshull i det som sies og vises.**
Kjøring A kalles «fersk kunnskapsbase, ingen tidligere dommer», men bundelen shipper ett
dom-frø, og Kjøring B viser «2 dommer» der bare én kom fra sløyfa — én muntlig setning retter
det. Stderr-støyen (to warnings + to «forcing completion») er det første publikum ser.
**Etter demoen (feature-settet):**
- **Funn 5:** porteføljekjøring fra kommandolinja har ikke noe samlet token-tak — taket finnes i
biblioteket (`PortfolioMeter`, seks målte mutasjoner), men `main()` kobler det aldri på; koden
sier det selv (§4 rad 6).
- **Funn 6:** spec-gjelden mot commons/søskenet vokser — seks lokale semantikk-beslutninger er
uspeilet (D7-speiling ÅPEN i CLAUDE.md), og «rettferdig sammenligning» (A6) blir mindre sann
for hver av dem.
- **Funn 7:** «metoden som Agent Skill» er en CLAUDE.md-konvensjon uten realisering — kun
ekspert-personaen finnes (§4 rad 5).
- **Funn 8:** `[project.scripts]` mangler (release-hygiene, målbilde §11 pkt. 7).
- **Null-funn:** bytte av orkestrering (Sequential/Handoff/graf-laget/checkpointing) skal IKKE
gjøres — målt: debatten er i praksis en fast sekvens og Group Chat beholdes av byttekost-grunner,
gevinsten er kosmetisk, kostnaden er re-verifisering av hele offline-beviskjeden (§4 rad 78).
## 2. Plan FØR demoen (P1P4, i utførelsesrekkefølge)
### P1 — Steg 7-innboksen kobles på i demoen ✔ (2026-08-09)
**[1 økt · Opus 5/high · TDD direkte · MÅ lande før onsdags-frysen]**
Rute persona-dommen gjennom en faktisk `verdict_dir`-katalog i `simulate_learning_loop`
sømmene finnes allerede (`run_project(verdict_dir=…)` + `write_verdict` er offentlig primitiv).
Ny load-bearing-test: detach innboks-lesingen → markør-/Steg 7-linja endres → RØD. Kriterium 6
(byte-identisk stdout) måles på nytt etterpå.
**Faller den på tid:** minimumsvarianten = ærlig etikett i `_run_trace_lines` («dom levert
direkte her; fil-innboksen er samme søm, bevist i test») + én muntlig setning. 0,1 økt.
**UTFØRT — men IKKE slik punktet var formulert, og forskjellen er bærende.** Å rute
*persona-dommen* gjennom innboksen ville gitt ÉN markør på to veier: Steg 7 (innboks) og Steg 8
(promotering) ender begge i Run B's hypotese-prompt, så hver av dem kunne båret markøren alene —
og `test_simulation_loadbearing.py`s promoterings-assert ville stått GRØNN med promoteringen
detached. Punktet ville altså gjort en eksisterende load-bearing test vakuøs for å lukke seg selv.
Utført i stedet: en ANDRE dom, med sin egen markør (`realiseringsgrad=0.66`), skrives som fil med
`write_verdict` MELLOM kjøringene, og Run B får `verdict_dir=`. `simulate_learning_loop` raiser
`ValueError` hvis de to markørene er like. 766 → 769 grønne (773 kollektert).
- **Kriterium 6 re-målt:** stdout byte-identisk over to kjøringer (`diff` tomt). Stderr uendret
6 linjer. *(Lærdom: første stderr-måling ga 62 linjer — `2>&1 >/dev/null` under zsh MULTIOS
blander stdout inn. Formen som holder er `>/dev/null 2>fil`. Målefeilen, ikke koden.)*
- **Mutasjoner MÅLT mot hele suiten (~110 s hver), fire røde + grønn kontroll:** detach
`verdict_dir=` (2 røde) · la Run B lese en TOM mappe mens fila fortsatt skrives (2 røde) ·
markør = `realization_rate: 0.82`, målt til stede i dom-frøet (1 rød) · markør =
`energy performance gap`, målt til stede i en navigert konseptfil (1 rød) · godartet omdøping av
innboks-katalogen (grønn kontroll).
- **Ærlighets-grense funnet UNDER målingen:** de to siste mutasjonene felte
kausalitets-asserten (markøren finnes i bundelen), IKKE Run A-kontrollen — fordi
genererings-prompten bærer `Project: {id} - {name}` + debatt-outputen, ikke bundle-konteksten.
Run A-kontrollen kan altså ikke alene fange en bundle-tilstedeværende markør; det er
kausalitets-asserten som lukker det hullet. Paret holder, men det er verdt å vite hvilken av
dem som faktisk bærer hvilken egenskap.
- **Lærdom, samme klasse som 08-06:** første markør-mutasjon satte `realiseringsgrad=0.82` og gikk
GRØNN — bundelen bærer `realization_rate: 0.82`, ikke den strengen. Mutasjonen endret ingen
betingelse og beviste ingenting. En mutasjon må måles mot hva fila FAKTISK inneholder, ikke mot
hva STATE kaller verdien.
### P2 — Spor B: innholdsgaten ✔ (2026-08-09, `c255662`)
**[12 økter · Opus 5/high · TDD direkte · følger innholdsgate-planen §3§4 uendret]**
Reviewens skjerpelse, ellers ingen endring: §4-beslutning 2 (avvisning per dokument eller per
bundle?) tas FØR bygging, og innholdsgate-planens kriterium 5 (byte-uendret demo-stdout) måles
ETTER wiring. Målt frys-sikker: verken `run.py` eller `simulation.py` importerer `ingest`.
### P3 — GO/NO-GO + kostnads-sjekken (nå RE-måling, med abortsti) ✔ (2026-08-09, `02ddc67`) — **GO**
**UTFØRT SØNDAG 09.08, ikke tirsdag 11.** Rekkefølge-avviket er uttalt i STATE: commons leverte to
døgn før fristen, og et golden-transkript (P4 pkt. 3) pinnet mot RESERVEN ville blitt ugyldig i det
øyeblikk `main()` pekte på levert bundle. Å pulle først gjør pkt. 3 én gang i stedet for to.
**Abortstien ble fulgt i rekkefølge.** Pre-pull-hash `7acd331` notert FØR pull (verifisert med
`git rev-parse`, ikke lest fra STATE). Pull av `002f000`+`27cdce9`. **Kriterium 8 grønt, målt to
uavhengige veier:** `git diff --stat` på begge nav-golden-katalogene → tomt, OG `shasum -c` mot et
pre-pull-manifest → 15/15 OK. Full suite etter pull: 785 passed / 4 skipped — identisk med før.
Ingen reset, ingen NO-GO. Pullen var ren tilføyelse pluss ÉN endret fil (`ingest-spec.md`, se
åpent punkt under).
**(b) Retningen er snudd.** `_CANDIDATES` har nå en `VEGLYS-FV-SOER`-oppføring hvis kostlinjer er
skrevet FRA `shared/examples/veglys-fv-soer/cost-baseline.json`; `baseline_from_scripted_candidate`
brukes IKKE på denne stien (`main()` leser levert fil via `okf.load_optional_cost_baseline`). Det
korrigerte svaret ER den leverte IR-projeksjonen ordrett — inkludert `assumptions`-bandet, så
Monte Carlo-en er ekte og ikke degenerert.
**(4) Overdrivelsen: `2 100 000`.** Målt fraværende fra bundelen (også som `2 100 000`/`2.100.000`),
og over BEGGE terskler — målt P90 er **1 769 915** (commons' anslag var ~1 770 000; vår seedede MC er
fasit, og de traff). `600 000`/`900 000` ville klarert gaten.
**(a) Demo-kriterium 1+2 mot NY bundle:** åtte steg-linjer står; hypotese #1 REJECTED
(`2100000 exceeds P90 feasible 1769915`) og den korrigerte VALIDATED (`445500`) — samme kandidat,
og det er P90-stagen som feller, ikke stage 0. Begge læringsveiene lukkes også på levert innhold
(Steg 8-markøren og Steg 7-innboksmarkøren når begge Kjøring B's prompt; Kjøring A har ingen).
**(c) 10 %-prøven mot levert innhold** er `test_a_deviating_delivered_baseline_forkaster_the_run_before_the_solver`:
levert baseline avviket 10 % → FORKASTET i stage 0 med avstemmings-grunnen, ikke P90-grunnen.
**Måling felte en defekt reserven skjulte:** `:g` slår over i eksponentform ved 7. signifikante
siffer, så den leverte baselinen printet `4.38615e+06`. Reservens `300000` har seks siffer og nådde
aldri overgangen — syntetiske tall skjulte den, levert innhold avslørte den på FØRSTE kjøring.
`_num` erstatter `:g` begge steder.
**Load-bearing MÅLT mot hele suiten, fem mutasjoner alle røde + grønn kontroll:** detach
main-wiringen · reverter `_num` til `:g` · drift registeret ETT siffer (`4386151` — innenfor
5 %-toleransen, og fanget av **ingenting i 792 tester** bortsett fra den nye) · sett `flip_key` til
et token som finnes i bundelen · detach forankringen på bundle-stien. 785 → 793.
**Abortstien står fortsatt:** reserven har sin registeroppføring, og `materialize_anchored_bundle`
er urørt — NO-GO er tre linjer i `main()`.
<details><summary>Planteksten slik den sto før utførelse</summary>
**[0,25 økt, del av tirsdagsøkta · Opus 5/medium]**
Som demo-uke-planen — men etter planrevisjonen 08-07 (I1) er dette en **re-måling mot nytt
innhold, ikke førstegangskjøring**: S4.0-forankringen og 10 %-avviks-prøven kjøres første gang i
HELGEN mot den lokalt forankrede reserven (P4 pkt. 0). Tirsdag: (a) kjør demo-kriterium 1+2
(åtte steg-linjer; REJECTED- og VALIDATED-linje for samme kandidat) mot den NYE bundelen;
(b) skriv manus-registerets tall FRA den leverte `cost-baseline.json`, aldri ved siden av den;
(c) gjenta 10 %-prøven mot levert innhold (mekanismen er alt bevist — dette måler INNHOLDET):
bevisst avvik → FORKASTET; korrigert → FORESLÅTT.
**Abortsti (I4, ufravikelig rekkefølge) — ⚠️ HISTORISK per man 10. økt 6: BEGGE pullene er landet
(innholdet søn 09., personaen man 10.), ingen NO-GO utløst, og 18:00-fristen under er utløpt i
betydningen «ikke lenger noe å rekke». Regelen står som mønster, ikke som en levende instruks.
Én måling er verdt å ta med videre: `git reset --hard` BLOKKERES av hooken — `--keep` slipper:**
(1) FØR pull: noter `git rev-parse HEAD` i STATE;
(2) pull → kriterium 8 (`git diff --stat` på goldens → tomt) + full suite; (3) rødt utfall →
`git reset --hard <pre-pull-hash>` (squash-pullen er lokale commits uten push — resetten fjerner
dem helt) og NO-GO er UTLØST — **senest kl. 18:00 tirsdag, uten videre diskusjon**: reserven ER
demoinnholdet (den forankrede varianten fra helgen), ærlighets-setningen per P4 pkt. 4. NO-GO er
et planlagt utfall, ikke en krise — generalprøve nr. 0 mandag har allerede verifisert det.
</details>
### P4 — Forankret prøvekjøring + fire kriterier + to setninger ✔ (2026-08-10, `d306929`)
**[0,751,25 økt · Opus 5/lowmedium · alt under bygges/måles mot mikro-reserven i helgen — se §5]**
**LUKKET man 10. (økt 5).** Punktene 05 falt 08-09; generalprøve nr. 0 (08-10) re-målte alt mot
**levert** innhold — unntatt **pkt. 1 (fersk klon)**, som ikke inngår i prøven (den måler
arbeidskopien). Pkt. 1 var derfor målt på `ab7f45a`, **elleve commits tilbake**, og re-målingen ble
kjørt 08-10 på `c9787cf` — se UTFØRT-tillegget under pkt. 1. **Onsdagens rad sa «P4 re-målt mot
valgt innhold» uten å si hva det var; det var dette, og det er gjort.** Frysesekvensen starter
dermed på generalprøve ×2.
0. **Forankret prøvekjøring ✔ (2026-08-09)** — se UTFØRT-blokka under punkt 5.
**(NY 08-07, I1 — ukas største måletekniske risiko, nå TIDLIGST):**
lag en repo-lokal kopier-og-utvid-bundle — mikro-reservens innhold + `cost-baseline.json` i
S4.0-formatet fixturen definerer (`project_id` + `items{code:{quantity,unit_cost}}`), med
baseline-tall avledet FRA manus-registerets tall (samme disiplin som P3 b, speilvendt). Kjør
HELE demoløpet mot den: `simulate_learning_loop` tar `bundle_dir` som argument og kopierer den
(`simulation.py:280/:316`), og `run.py:516` leser fila. Inkluder 10 %-avviks-prøven (flyttet hit
fra P3): bevisst avvik → FORKASTET i stage 0; korrigert → FORESLÅTT. Ligger utenfor `shared/`
kriterium 8 urørt; kriterium 6 er selv-identitet og påvirkes ikke. Ved NO-GO tirsdag kjører
demoen denne varianten (call-site-valg i `main()` — samme søm GO-utfallet uansett bruker).
**Faller den:** fallback = uforankret reserve + den gamle NO-GO-setningen — ingenting tapt mot
planen slik den sto før revisjonen.
1. **Fresh-clone-kriterium ✔ (2026-08-09, `ab7f45a`)** — se UTFØRT-blokka under punkt 5.
2. **Stderr: FØRST beslutningen, SÅ pinningen (I2) ✔ (2026-08-09, `ab7f45a`)** — beslutningen er
tatt og målt; se UTFØRT-blokka under punkt 5. **Konsekvens for pinningen (pkt. 3):** fasiten kan
IKKE være literal — de to gjenstående `ExperimentalWarning`-linjene bærer en absolutt sti inn i
`.venv/…/site-packages`, som er ulik i fersk klon og arbeidskopi (målt). Normaliser på BEGGE:
site-packages-stien OG `po-sim-`-suffikset. Pinnet stderr er da fire linjer, ikke to.
3. **Golden-transkript ✔ (2026-08-09)** — se UTFØRT-blokka under punkt 4.
*(Planteksten:)* sjekk inn demo-outputen som fasit-fil og diff mot den — selvidentitet
(kriterium 6) fanger ikke-determinisme, men ikke regresjon mellom onsdag og torsdag.
**Transkriptet er også demoens abortsti** (gjort eksplisitt i P4.5).
4. **To ferdigskrevne setninger ✔ (2026-08-09)** inn i ærlighets-teksten (mønsteret fra innholdsgate-planen §5).
NO-GO-varianten er OMSKREVET etter I1: «kostnads-forankringen er aktiv også i reserve-eksemplet,
men kostnadstallene der er syntetiske — avledet av manuset, ikke levert av et fagmiljø»
(fallback hvis pkt. 0 faller: den gamle setningen «kostnads-forankringen er ikke aktiv i
reserve-eksemplet»). Frø-setningen står: «én av de to tidligere dommene i Kjøring B fulgte med
eksempelet — den andre er den demoen lærte».
5. **`[project.scripts]` (flyttet HIT fra S1.c, I3) ✔ (2026-08-09, `ab7f45a`)** — se UTFØRT-blokka
rett under.
**UTFØRT — punkt 3 og 4 (2026-08-09). 793 → 801 passed / 4 skipped.** Punktene ble gjort i ÉN økt
og i denne rekkefølgen fordi pkt. 4 endrer stdout: en fasit pinnet før den ville vært foreldet i
samme økt. Testene ble skrevet FØR begge (målt rød: pkt. 3 på manglende fasit-fil, pkt. 4 på
manglende `_verdict_origin_line`), og fasiten ble generert til slutt — ETTER at P3-kriteriene var
re-verifisert mot den nye outputen (åtte steg-linjer; `REJECTED` på P90-stagen og `VALIDATED 445500`
for samme kandidat; begge markører `False` i Kjøring A og `True` i B; kostbaselinen erklært).
*Punkt 3:* `tests/golden/demo-transcript.stdout` er ORDRETT (ingen normalisering, ingen toleranse) —
det gjør fila brukbar som abortsti, siden den ER det operatøren ville sett. `…​.stderr` normaliserer
nøyaktig de to spannene som ble målt miljø-avhengige: `site-packages`-prefikset og temp-katalogen bak
`(arbeidskopi: …)`. `po-sim-`-prefikset holdes SYNLIG — det tilhører programmet, ikke miljøet — mens
`TMPDIR`-rota og suffikset maskeres. Pinnet stderr = fire linjer, som pkt. 2 forutsa. Bredden på
maskeringen er selv under test: `test_normalisation_does_not_mask_a_new_warning` mater en syntetisk
EKSTRA linje gjennom samme normaliserer og krever at den slutter å matche.
*Punkt 4 — den forhåndsskrevne frø-setningen var FEIL, og målingen fanget det.* Planen sa «én av de
to tidligere dommene i Kjøring B fulgte med eksempelet». Målt mot levert VEGLYS-bundle henter
Kjøring B **tre**: den frøsatte (`verdict-veglys-fro.md`), Steg-8-promoteringen og Steg-7-innboksen —
altså **én fulgte med, to er demoens egne, én per tidsskala**. Setningen ville vært en falsk påstand
sagt på scenen om et tall som står printet linja over. Splitten er derfor **avledet**
(`_verdict_origin_line`), ikke skrevet ned: en håndskrevet «én av tre» er den andre kopien som
drifter (samme regel som punkt 0), og ville blitt sagt uendret etter at en framtidig bundle shipper
en andre frøsatt dom. GO-varianten av provenans-setningen sto allerede live som `_VEGLYS_PROVENANCE`
siden P3; den var IKKE positivt asserted noe sted (anker-testen utelukker bare reservens setning), så
den er nå pinnet mot fasiten.
**Load-bearing MÅLT mot HELE suiten (~123 s hver), fem mutasjoner alle røde + grønn kontroll:**
ett byte i en stdout-linje (`FORSTÅ``FORSTA`) · detach rund-taks-dempingen i `main()` ·
over-normaliser stderr (drop advarsels-linjene) **med fasiten regenerert under den** — begge
likhets-testene forble GRØNNE, kun kontrollen felte den, som er hele grunnen til at kontrollen finnes ·
literal splitt i stedet for avledet · detach frø-setningens print. **To målinger er verdt å merke:**
byte-mutasjonen og detach-mutasjonen ble fanget av **kun** golden-testen — 800 andre tester merket
ingenting, som er nøyaktig gapet kriterium 6 ikke dekker; og den literale splitten ble fanget av
**kun** skille-testen (golden-testen forble grønn, siden literalen printer identisk tekst for den
leverte bundelen).
**UTFØRT — punkt 5, 2 og 1 (2026-08-09, `ab7f45a`). 775 → 785 passed / 4 skipped.**
*Punkt 5:* to konsoll-kommandoer — `portfolio-optimiser` (`run:main`) og `portfolio-optimiser-demo`
(`simulation:main`). **Bevisst to av fem `main()`:** `costsim`/`hitl`/`preflight` beholder
`-m`-formen; hvert navn her er et navn frysen må bære. Testen leser den INSTALLERTE distribusjonens
metadata, ikke TOML-en — en `[project.scripts]`-linje som aldri er `uv sync`-et er en påstand, ikke
en kommando. Målt: stdout er byte-identisk mellom `uv run portfolio-optimiser-demo` og
`uv run python -m portfolio_optimiser.simulation`.
*Punkt 2 — den åpne beslutningen, avgjort ved måling:* **rund-taks-linjene dempes, de to
`ExperimentalWarning`-linjene gjør det ikke.** Loggeren er lest ut av MAFs kilde
(`logger.warning` i `_base_group_chat_orchestrator`), ikke gjettet; filteret er nøklet på MELDINGEN
og installeres i `main()`, aldri ved import. **Hvorfor de to andre ikke dempes:** de fyrer mens
`portfolio_optimiser/__init__.py` importerer `run``agent_framework` — alltid FØR `simulation` sin
egen importblokk, under BEGGE kjøreformer. Å dempe dem ville krevd et warnings-filter inne i
bibliotekpakken, altså at rammeverket bestemmer hva MAF får si til enhver konsument. En wrapper bak
konsoll-kommandoen ble avvist av en andre grunn: de to kjøreformene ville da skrevet ULIK stderr, og
en byte-fasit ville pinnet kommandoen i stedet for programmet. **stderr 6 → 4 linjer.**
*Første implementasjon ble FJERNET etter måling:* en scoped mute rundt `simulation`s egen
`agent_framework`-import kan aldri fyre (pakken har allerede importert den) — en grønn-men-død søm.
*Punkt 1 — fresh-clone, målt mot `ab7f45a`:* klon fra `origin``uv sync` → `uv run
portfolio-optimiser-demo`. **stdout byte-identisk** med arbeidskopien; stderr identisk normalisert
på site-packages-sti + `po-sim-`-suffiks. READMEens egen verifikasjon kjørt i klonen: **785 passed /
4 skipped**. *(Merk: `open/`-speilet står fortsatt på `520e7412` = `v0.1.0`. **Korrigert 2026-08-10:
publisering dit er IKKE S1.c onsdag — den er FELT og flyttet til P5-vinduet; se S1.c-raden i §0.**
Uansett ikke en del av denne målingen.)*
**RE-MÅLT 2026-08-10 på `c9787cf` (økt 5) — og det var ikke en formalitet.** Målingen over sto på
`ab7f45a`, **elleve commits tilbake**, altså FØR commons-subtree-pullen, FØR P3/GO (demoen kjørte
ennå ikke levert bundle), FØR det pinnede transkriptet, FØR innholdsgaten (**som gjorde guarden til
en deklarert runtime-dep**) og FØR versjonssynken (**som endret `uv.lock`**). En ny runtime-dep og
en endret låsefil er nøyaktig det en fersk installasjon feller — og planens eget prinsipp (§5) sier
at slikt legges FØR tirsdag, ikke på frysedagen. *Målt, hver påstand av sin egen kommando:* klon fra
`origin` → HEAD = `c9787cf` · `uv sync` exit 0 og **`git status --short` tomt** (ingen stille
re-lock i et ferskt miljø — den faren S1.c gatet, nå bekreftet utenfor arbeidskopien) ·
`uv run pytest -q`**810 passed / 4 skipped** (126,72 s) · `ruff` rent + `mypy src` 31 filer ·
`PYTHONIOENCODING=utf-8 uv run portfolio-optimiser-demo` → exit 0, og `diff` mot
`tests/golden/demo-transcript.stdout` **tomt** · 61 stdout- / 4 stderr-linjer · K1 distinkt = **8**.
*(Amendert man 10. økt 6 — HOLDBARHETEN sagt eksplisitt, siden dette er samme premiss-klasse som
re-målingen selv felte: persona-pullen (`71b7b66`+`d0e8bb0`) landet ETTER `c9787cf`, så tallene over
står på et tre som er to commits gammelt. **Målingen står likevel, og grunnen er målt:** deltaet er
prosa i `shared/` + en regenerert fasit, med `git diff --stat``pyproject.toml` og `uv.lock`
**tomt** — altså ingen ny dep og ingen endret låsefil, som er nøyaktig de to tingene en fersk
installasjon feller. Onsdagens generalprøve ×2 er bekreftelsen; en tredje fersk klon er den ikke
verdt.)*
**Load-bearing MÅLT mot HELE suiten, fem mutasjoner alle røde + grønn kontroll:** fjern
`[project.scripts]` · typo i target · detach `main()`-kallet · la filteret droppe alt · installer
filteret ved import. **Typo-mutasjonen felte en TEST:** resolve-asserten resolverte det FORVENTEDE
targetet mot seg selv; den leser nå det distribusjonen faktisk installerer. Fjerde gang på fem økter
at mutasjonsmålingen feller testen, ikke koden.
**UTFØRT — punkt 0 (2026-08-09).** `materialize_anchored_bundle` kopierer reserven og legger til
`cost-baseline.json` utenfor `shared/`; `main()` kjører den varianten, og hele demoløpet er kjørt
mot den. **Baselinen avledes i KODE fra manus-registeret** (`baseline_from_scripted_candidate`),
ikke skrevet ved siden av det — på GO-dagen snus retningen (punkt b i P3), og en håndskrevet kopi
ville vært den andre kilden som drifter. Begge skriptede svar må oppgi samme kostlinjer, ellers
`ValueError`: var de ulike, ville hypotese #1 blitt avvist av stage 0 istedenfor av P90, og demoens
REJECTED-linje kommet fra en annen mekanisme enn den den forteller om.
**10 %-prøven, målt:** bevisst avvik (baseline ×1,10) → `FORKASTET — quantity 300000 for cost code
'ENERGI-TOTAL-EL' is outside the 5.0% tolerance around the baseline quantity 330000`, i stage 0,
FØR løseren. Korrigert (manus = baseline) → `FORESLÅTT — LED-retrofit av kontorbelysning: 30000 NOK`.
Kontroll: hypotese #1 avvises fortsatt av P90-stagen, så demo-kriterium 2 viser samme mekanisme som
før. Suite 769 → 775 passed / 4 skipped.
**Kriterium 6 re-målt:** stdout byte-identisk mellom to kjøringer; stderr uendret 6 linjer. Eneste
diff mot uforankret demo er den nye fire-linjers KUNNSKAPSBASE-blokka — **alt annet er
byte-identisk, og det er selve problemet:** forankringen er usynlig, derfor må den printes, og
derfor er `provenance` et påkrevd argument (kallstedet som velger bundelen er det eneste som vet
hvor tallene kom fra). Punkt 4s NO-GO-setning står nå ordrett på skjermen.
**Målingen felte TESTEN først.** Første form av entry-point-testen asserterte
`"kostbaseline erklært" in stdout` — men den uforankrede grenen sa «ingen kostbaseline erklært»,
som INNEHOLDER strengen; og `ENERGI-TOTAL-EL` står allerede i Steg 2-linja. Detach-mutasjonen gikk
GRØNN. De to grenene deler nå ingen ordlyd, og asserten navngir hele linja. Fem mutasjoner røde +
grønn kontroll (`tests/test_anchored_reserve_loadbearing.py`).
**GO-dagens endring er ÉN blokk i `main()`:** `bundle = materialize_anchored_bundle(...)` +
`provenance = _RESERVE_PROVENANCE` byttes mot den leverte bundelens sti og dens egen provenans.
Kjørestien er uendret — `run.py` leser baselinen fra hvilken som helst bundle-katalog.
### P4.5 — Demo-runbook: ÉN side operatøren følger på scenen (NY 08-07, I5) ✔ SKREVET (man 10.), §6 TILFØYD (tir 11.), FYLLES UT ons 12.
**[0,25 økt · Opus 5/low · ~~produseres VED frysen onsdag~~ — se amendementet rett under]**
**AMENDERT man 10. økt 7 (`c7a57d8`): runbooken er SKREVET, og onsdag FYLLER DEN UT.** Gaten
«produseres VED frysen» ble lest på nytt mot sin egen begrunnelse — *«så den matcher frosset
output»*. Det gater **hashen X og verbatim output-utdrag**, ikke forfatter-dømmekraften. Og
onsdagsradens egen regel sier at dagen skal **måle og utføre, ikke avgjøre**: en runbook skrevet fra
bunnen på en enveis-dag under tidspress er nøyaktig det den regelen forbyr. Delingen er derfor
bevisst to-trinns:
- **Skrevet mandag:** kjøresekvensen, hva som sies ved hver skjermlinje (forankret i linjenumre i
det pinnede transkriptet), ærlighets-avsnittet i JA-varianten, abortstien, forventede spørsmål —
og **mandat-setningen, som fantes ingen steder som tekst** (Spor 2 sa bare «én muntlig setning»;
en udraftet setning til en live demo er ikke en beslutning som er tatt).
- **Fylles onsdag:** **to** målte felt (X, prøve-tidspunkt) — se §5-amendementet tir 11. økt 12 for
hvorfor tag-bekreftelsen IKKE er et tredje felt.
- **Tilføyd tirsdag 11. (økt 10) — §6, torsdagens pre-flight.** Runbooken hadde ingen sjekk
operatøren kjører **før** han går på. Mekanismen fantes allerede: golden-transkriptet ble sjekket
inn nettopp for å fange *«regresjon mellom onsdag og torsdag»* (pkt. 3 over). Men i runbooken sto
kommandoen under overskriften «*Hvis du vil vise at outputen er den frosne*» — altså som et
show-element **under** demoen, og kjørt der oppdager den regresjonen samtidig med publikum.
Samme defektklasse som frys-gaten (×1 → ×2) og CHANGELOG-datoen (lest på feil HEAD):
**kommandoen var riktig, tidspunktet var det ikke.** §6 flytter den til før rommet fylles og
legger til et tag-anker. **Ankeret er IDENTITET (`git describe --tags --exact-match HEAD`
`v1.0.0`), ikke frys-gatens diff-med-unntak** — og den forskjellen ble felt i review: de to
unntakene (`docs/`, `CHANGELOG.md`) var begrunnet i at *onsdagen* skriver nøyaktig dem. Torsdag
skriver ingenting, så arvet dit ville de gjort gaten **fail-OPEN mot den ene skriveren vi vet er
aktiv** — den parallelle sesjonen som eier `docs/presentasjon-portfolio-optimiser.html`. Samme
klasse som da frys-gaten selv ble snudd fra positiv liste til eksklusjonsform.
**Målt fail-closed begge veier:** `no tag exactly matches '<sha>'` (exit 128) når HEAD ikke er
tagget, `bad revision` når taggen ikke finnes. Feiler den, er den en BESKJED: `git diff --stat
v1.0.0..HEAD` **uten unntak** viser hva som landet — kjøresti-filer = abort til §3, kun `docs/` =
demoen upåvirket, men da vitende. Steg 1 er likeledes en REGEL, ikke et øyeblikksbilde
(`--untracked-files=no` → TOMT), så den fremmede HTML-fila ikke lærer operatøren å ignorere gaten.
Kommandoen for selve outputen står fortsatt kun ÉN gang i dokumentet (§6 peker på §1-blokka), så
det er ikke laget en andre kopi å drifte fra. Ingen ny placeholder: utfyllings-gaten står uendret
på 3. Tatt tirsdag med vilje — onsdagen skal måle og utføre, ikke avgjøre.
- **Amendert tirsdag 11. (økt 12) — §0s tag-felt og §5s haker var SIRKULÆRE, og begge landet på
torsdag.** Tag-feltet i §0 hentet sin verdi fra `git tag -l v1.0.0` **etter push**, altså etter
§5s siste punkt — mens §5s punkt 6 krever at utfyllings-gaten er **tom**, altså før. Punkt 6 var
dermed gatet på informasjon som først finnes etter punkt 10, og ingen av utveiene holdt: fylt
ærlig krever den en commit **etter** taggen (da står HEAD ikke lenger på `Z`, og torsdagens §6
steg 2 — `git describe --tags --exact-match HEAD``v1.0.0` — er **rød på demo-morgenen**), og
ufylt bryter den gaten i punkt 6. Sekvensen modellerer heller ikke en tredje commit: STATE og
frys-blokka sier `X → Y → Z → tag`. **Tvillingen ble funnet ved å lese videre:** §5s haker settes
i selve fila, og punkt 710 skjer *etter* runbook-commiten `Y` — målt gir det
` M docs/plan/2026-08-12-demo-runbook.md`, altså rød §6 steg 1, eller en commit etter taggen,
altså rød §6 steg 2. Samme motsigelse, samme to gater. **Løsningen bevarer identiteten** (§6 steg
2 var dyr å vinne — den er den eneste gaten som fanger bevegelse over natta): §0 mistet
tag-raden, §5 fikk et ellevte punkt som **bekrefter taggen der den settes** (`git tag -l` ·
`git ls-remote --tags origin` · `git describe --tags --exact-match HEAD`, det siste = torsdagens
anker kjørt et døgn tidlig), og §5s ingress sier at hakene aldri settes i fila. Ingenting skrives
etter taggen. **Målt:** utfyllings-gaten **3 → 2** (begge gjenværende felt er kjennbare før `Y`) ·
§5 ti → **elleve** punkter · §0-tabellen 4 pipes per rad · **§1 og §2 byte-urørt** (`shasum` likt
før/etter — økt 9s 67 målinger er gjort mot de bytene, og §6 peker på §1 nettopp for å slippe en
andre kopi). Sjuende defekt i denne dokumentfamilien på sju økter, og fjerde gang klassen er
*«kommandoen var riktig, tidspunktet var det ikke»*.
- **En ÅTTENDE falt ut av gjennomlesningen, og den er økt 11s egen bom:** §5 punkt 9 sa «**Steg 2**
målte en tilstand som ikke lenger finnes», mens første frys-gate-kjøring er **punkt 5**.
Verifisert mot `818b55a`: da linja ble skrevet var frysen **TO** kommandoer og gaten var nr. 2 —
økt 11 gjorde den til tre, men grep-passen den økta lette etter strengen «to kommandoer», så en
referanse formulert som «Steg 2» slapp forbi. Økt 11 konkluderte eksplisitt at *«ingen
kryssreferanse pekte på frys-blokkas gamle nummerering»*; **det var én.** Nå forankret i §5s EGEN
nummerering, som ikke kan drifte med frys-blokkas telling. **Alle 20 numeriske kryssreferanser i
runbooken deretter sveipet** (§5s punkt 6/10/11 · §6s steg 1/2/3 · §2s «punkt 2 i åpningen» ·
planens «pkt. 3» = golden-transkriptet) — ingen flere.
- **En NIENDE, funnet i review etter første commit, og den var ukas alvorligste:** `Y` og `Z`
navngis fem steder i §5 — men **ingen av de elleve punktene opprettet dem**. Punkt 6 sa «Runbooken
fylt ut», punkt 7 «CHANGELOG-overskriften stemplet»; ingen sa `git commit`, og punkt 8
*forutsatte* at `Z` fantes. Målt mot gate-definisjonene: frys-gaten unntar BÅDE `docs/` og
`CHANGELOG.md`, så to ucommitterte endringer passerer punkt 5 og 9 **stille**; punkt 11s anker
passerer også, fordi HEAD da fortsatt er `X` og `X` **er** det taggede; **`v1.0.0` ville blitt
tagget med `## [Unreleased]` fortsatt i CHANGELOG** — en RELEASE-defekt, ikke bare en
gate-defekt; og først torsdagens §6 steg 1 ville ropt, foran demoen. Rettet som klausuler PÅ
punkt 6 og 7, ikke som nye punkter — commiten er det som gjør handlingen varig, og å skille dem
er nøyaktig defekten. Fortsatt elleve punkter. **Klassen er ny for uka:** ikke «feil tidspunkt»,
men *«handlingen var riktig, steget som gjør den varig var implisitt»* — den overlevde både
gjennomlesningen og propagerings-passen, fordi begge lette etter tall og referanser, ikke fravær.
**Utfyllingen er gjort til et SJEKKET steg, ikke et husket:** **to** greppbare placeholders (tre til
og med tir 11. økt 11 — se amendementet over), og grep-en er **selv-sikker** — mønsteret
`'<<[A-ZÆØÅ-]*>>'` matcher ikke sin egen tekst (målt: 2 treff, ingen av dem kommandolinjene i
dokumentet). Et uutfylt felt er samme drift-klasse som plan-radene økt 4,
5 og 6 hver for seg fant. `docs/` er unntatt frys-gaten, så utfyllingen tripper ingenting.
**Vedlegget feller tre STATE-premisser:** de tre «scene-kosmetiske» punktene er målt mot det
pinnede transkriptet og **ingen er synlige**`23700 NOK/aar` klippes bort (rationale 389 tegn,
beløpet ca. tegn 370, klipp på 300 → `pga. overes…`; `grep -c`**0**), `0.82` hører til
bygg-goldenen (`grep -c`**0**), og `ekspert-svar.md` leses ikke av demoen. Torsdagen slipper tre
setninger den var fortalt at den måtte bære. *(Planteksten under står uendret som opphav.)*
Det som skal SIES torsdag ligger i dag på fire steder: demo-uke-planen §1 (tre ærlighets-punkter),
innholdsgate-planen §5 (opplesnings-avsnittet), P4 pkt. 4 (to setninger) og §0 Spor 2 (muntlig
mandat-setning). Runbooken samler dem på én side: kjøresekvens (kommandoen + forventede
steg-linjer), hva som sies hvor, og **abortstien**: feiler live-kjøringen, vis golden-transkriptet
fra P4 pkt. 3 og si høyt at det er gårsdagens frosne kjøring. Sti:
`docs/plan/2026-08-12-demo-runbook.md` (datert sti — utenfor `_LIVE_DOCS`-gaten). Runbooken er
lesestoff, ikke kjøresti — den kan skrives etter frysen uten å røre den.
## 3. Plan ETTER demoen (P5P10, i verdirekkefølge)
### P5 — README + nivå-2-påstanden (O4, fre 14.lør 15.) ☐
**[1 økt · Opus 5/medium]** Allerede besluttet (O4). Reviewens tillegg: G5-forbeholdet skal stå
i teksten som løftes — sammenligningen mot søskenet gjelder den spec-ede kjernen, ikke hele
dette repoet (mandat/hovedbok/portefølje-budsjett m.m. er utenfor spec-ene, målt 0 treff).
### P6 — Globalt token-tak inn i CLI-en ☐
**[1 økt · Opus 5/high · TDD direkte]** `--budget`-dør i `main()`: `PortfolioMeter` +
`read_spend`/`write_spend`-wiring + `BudgetRefused` inn i except-tuplen (TRAP-kommentaren i
`run.py` sier selv at den ikke fanges i dag). Feller: CLI-test med spend-fil nær taket +
`--portfolio` → strukturert refusal; detach flagget → rød.
### P7 — Amendment-pakken til commons — ÉN samlet bestilling ☐
**[1 økt · Fable 5/high (spec-review er formen) · leveres via coord-send, ALDRI arbeid i commons]**
D-A-restene samlet i én tekst: F2/F3-validator-semantikken, S3.2-seedingregelen,
S4.0-baseline-formatet, (p)-kvantiseringen, Steg-5-returtypen. Feller: amendmentet gir
spec-tester/goldens som binder semantikken på tvers av stackene — i dag kan søskenet følge
spec-en korrekt og likevel divergere fra dette repoet.
### P8 — Metoden som Agent Skill (B6) ☐ **[Voyage]**
**[/trekbrief først; bestilling til commons + liten konsum-søm her]** Målt: kun
`shared/skills/expert-reviewer/SKILL.md` finnes. Innholdet eies av commons (bestilling som
tekst); konsum-sømmen her er liten (MAF `SkillsProvider` er experimental — pin versjon).
[Voyage] fordi formen har reell design-usikkerhet — hva av metoden som skal være skript vs.
referanse er ikke avgjort.
### P9 — Småting ☐
**[0,5 økt samlet · Opus 5/low]** Kapabilitetskartets to korreksjoner (topologi-notatet fra
funn «null»: debatten er en fast sekvens, Group Chat beholdes av byttekost; checkpointing-raden
nedgraderes fra «ADOPT (later)» til «NEI med begrunnelse» — pass-nivå-gjenopptakelse er allerede
levert via spend-fila). *(`[project.scripts]` er flyttet inn i v1-release-kuttet, §0 S1.c.)*
### P10 — Eksplisitt NULL (ingen økt) ✔
Ingen Sequential-swap, ingen Handoff, ingen graf-adopsjon, ingen checkpointing. Står her så
ingen senere økt «oppdager» dem på nytt. Falsifisering av selve null-beslutningen: forsvinner
«forcing completion»-linjene en dag uten bytte, var topologi-analysen feil.
## 4. Belegg (kommandoene bak påstandene — RE-MÅLT 2026-08-07 på HEAD `bb3df79`; opprinnelig måling 2026-08-06, se datonoten øverst)
| # | Påstand | Kommando → resultat |
|---|---|---|
| 1 | Suiten grønn | `uv run pytest -q` → 766 passed / 4 skipped (107 s) — re-målt 08-07 |
| 2 | Demo deterministisk + 8 steg | to kjøringer 08-07, stdout adskilt fra stderr: `diff` → tom; `grep -cE "^ *Steg [1-8]"` → 8. Stderr målt: **6 linjer** — 2 `ExperimentalWarning` + 2 «forcing completion» + blanklinje + ikke-deterministisk `arbeidskopi:`-linje (bevisst, `simulation.py:531-534`) |
| 3 | **(KORRIGERT 08-07, I1)** Ingen bundle under `shared/examples/` shipper kostbaseline — men REPOET gjør, og kjørestien leser den | `ls shared/examples/bygg-energi-mikro/` → 8 filer, ingen baseline (re-målt, står — men var målt for SMALT: én katalog). `find . -name 'cost-baseline.json' -not -path './.git/*'``src/portfolio_optimiser/data/bundles/bygg-energi-baseline-mikro/cost-baseline.json` (gyldig S4.0-format, merket SYNTHETIC); i bruk: `grep -n BASELINE_BUNDLE tests/test_s40_cost_baseline_loadbearing.py``:42/:162/:242`; kjørestien: `run.py:516` `load_optional_cost_baseline`; bundelen er kopiert parameter: `simulation.py:280/:316` |
| 4 | Steg 7 vises uten fil-innboksen | re-målt 08-07: `run_project` kalles uten `verdict_dir` (`simulation.py:328-338/:356-366`); dommen er argumentet `verdict_input` (`:322`); etiketten «lang fil-løkke» står i `:478` |
| 5 | Metode-skill finnes ikke | `find shared -name "SKILL.md"` → kun `expert-reviewer` (re-målt 08-07) |
| 6 | CLI-porteføljen uten pass-tak | `run.py:1679-1686` (re-lest 08-07): budget-stop-armen «currently UNREACHABLE from here»; TRAP-kommentar: `BudgetRefused` (RuntimeError) utenfor except-tuplen |
| 7 | Debatten er en fast sekvens | `workflow.py:99-108` (re-lest 08-07): round-robin-selector, terminerings-nett = `max_rounds*2+1` = 7 > 3 dispatcher → fyrer aldri; «forcing completion» ×2 målt i dagens stderr |
| 8 | MAF-alternativene gir ikke gevinst | re-målt 08-07: installert `_group_chat.py:145-155` har round-robin kun som docstring-eksempel; `inspect.signature(SequentialBuilder.__init__)``checkpoint_storage` direkte. *(Handoff = modelldreven ruting: 08-06-introspeksjonen, ikke re-målt)* |
| 9 | `[project.scripts]` mangler — bygges i HELGEN (P4 pkt. 5, I3) | `grep -n scripts pyproject.toml` → 0 treff (re-målt 08-07) |
| 10 | Spor B er frys-sikker | `grep -nE '^(from\|import).*ingest' src/portfolio_optimiser/{run,simulation}.py` → 0 treff (re-målt 08-07, skarpere grep — treffene som finnes er kommentarer/hjelpetekst, ingen import) |
| 11 | (NY 08-07) Begge baseline-loaderne finnes | `grep -n 'def load_optional_cost_baseline\|def load_cost_baseline' src/portfolio_optimiser/okf.py``:305` + `:323` |
| 12 | (NY 08-07, I6) Daterings-avvik | `git show -s --format='%h %ad' --date=short c96ef90 0eb0f3d 295e966 688ee24 e93e921 d6f3359` → ALLE `2026-08-06`; `date` → 2026-08-07 (fredag); 13. aug = torsdag |
## 5. Ukens kalender (hvor punktene lander)
**Justert fre 7. aug (operatør): arbeid starter I DAG, helg inkludert, ny full ukeskvote —
full sti er hovedsporet, nedgraderingene i §0 er forsikring.** Prinsippet bak fordelingen:
alt som IKKE krever det nye commons-innholdet gjøres FØR tirsdag, så tirsdag/onsdag er tynne
og risikoen ligger tidlig med slakk bak seg.
| Dag | Innhold |
|---|---|
| fre 7. | **Planrevisjonen (I1I6)** ✔ + **P1/S1.a**: Steg 7-innboksen inn i demoløpet (økt 1) |
| lør 8.søn 9. | **P2/S1.b**: innholdsgaten (økt 2, evt. 3) + **P4-forskuddet UTVIDET** (0,751,25 økt), i denne rekkefølgen: forankret prøvekjøring + 10 %-prøven (pkt. 0, I1) **✔ 08-09** → `[project.scripts]` (pkt. 5, I3) → stderr-demping FØR pinning (pkt. 2, I2) → fresh-clone (pkt. 1) → golden-transkript-mekanikk (pkt. 3) → de to ærlighets-setningene (pkt. 4) — alt bygges og måles mot den FORANKREDE mikro-reserven NÅ |
| man 10. | **SYV ØKTER, alle ✔.** (1) **Generalprøve nr. 0** — kjørt mot **LEVERT VEGLYS-FV-SOER, ikke reserven**: P3 falt to døgn før fristen, så prøven målte demoinnholdet selv. Se UTFØRT-blokka under tabellen. (2) **S1.c synk + CHANGELOG** (`a41272d`) — forskuttert fra ons kveld; TAGGEN gjenstår, frys-gatet. (3) **`open/`-beslutningen TATT** — taggen til `origin` ALENE; speilet til P5-vinduet (se S1.c-raden i §0). (4) **Planen gjort sann** (`c9787cf`) — to `open/`-instrukser felt, P2 lukket, kalenderen rettet. (5) **Frysedagens to udefinerte steg lukket****P4 ✔** (fersk klon re-målt på `c9787cf`, elleve commits etter forrige måling) og **FRYS gjort kjørbar** (frys-blokka under). (6) **Persona-pullen landet** (`71b7b66`+`d0e8bb0`) — commons svarte to døgn før fristen, så tirsdagens eneste punkt ble tatt mandag; fasiten re-målt mot en prediksjon skrevet FØR pullen. **Ingenting kodemessig gjenstår før onsdag, og tirsdagen er tom.** (7) **P4.5-runbooken SKREVET** (`c7a57d8`) — beslutnings-innholdet forskuttert fra onsdag (gaten dekker målingene, ikke forfatterskapet), inkludert **mandat-setningen som aldri fantes som tekst**; onsdag fyller ~~tre~~ **to** målte felt (tag-feltet felt tir 11. økt 12 — det var sirkulært). Vedlegget felte tre «scene-kosmetiske» STATE-premisser: ingen av dem er synlige på skjermen (målt). **Frysen ble VURDERT flyttet fram og bevisst IKKE flyttet** — onsdag er en dato-beslutning på en enveis-handling, og risikoen den ville hedget er retirert av økt 6 (samme kjøresti målt grønn; `d0e8bb0..HEAD` er dokumenter alene). |
| tir 11. | **IKKE TOM — SEKS ØKTER (8, 9, 10, 11, 12, 13), alle måling/dokument, null kodeendring.** Frysen ble VURDERT flyttet fram og bevisst IKKE flyttet: **et frysevindu er en forpliktelse, ikke slakk** — å tagge tirsdag ville forbudt kjørestien et døgn lenger og gjort ethvert onsdagsfunn til en `v1.0.1`-beslutning på demo-aften. Tirsdagen er verdt mer som **lovlig-fiks-dag**. (8) generalprøve som PRØVE, alt grønt på `818b55a`; X bevisst IKKE notert; pre-flight mot stale tag ren. (9) runbookens §2 målt mot fasiten — 67 påstander + hver kommando kjørt, null feil; §1s stderr felte én defekt (`/tmp/po-sim-…` kan aldri vises, `TMPDIR` = `/var/folders/…/T/`). (10) **runbookens §6 — torsdagens pre-flight — tilføyd** (se P4.5). (11) **to en-linjes herdinger av onsdagen/torsdagen:** §5 manglet en **arbeidstre-sjekk ved X** (frys-gaten er commit-til-commit, prøven leser treet — en ucommittet `src/`-endring ville gjort X til en beskrivelse av noe som aldri ble prøvd, usynlig for BEGGE gate-kjøringene), og **§3s abortsti brukte relativ sti** til fasit-fila, altså feilet av nøyaktig den «feil katalog»-årsaken prosaen selv navngir (målt). Frysen er nå **tre** kommandoer. (12) **§0s tag-felt var SIRKULÆRT og §5s haker var tvillingen** — feltet kunne først fylles etter §5s siste punkt, mens punkt 6 krever at gaten er tom; hakene settes i fila, og punkt 710 skjer etter runbook-commiten `Y`. Begge utveier gjorde en av **torsdagens** to gater rød (ucommittet → §6 steg 1; commit etter taggen → §6 steg 2s identitet). Tag-raden fjernet, ellevte §5-punkt bekrefter taggen der den settes, hakene forlot fila. Se P4.5-amendementet. (13) **§5s punkt 5 var en gate som ikke kunne feile** — `<X>` **er** HEAD der, så `<X>..HEAD` er tom per konstruksjon (målt med en endret `src/`-fil i treet: fortsatt tom). Den målte altså ingen tilstand, mens økt 7s begrunnelse sa «en tilstand som ikke lenger finnes». Punktet beholdt (fjerning ville renummerert elleve punkter og brutt fem kryssreferanser på frys-eve), men gitt en **andre arm** mot fast hash `c255662` → ikke tomt (6 filer), som beviser at kommandoen kan diskriminere før punkt 9 hviler på at den er tom — uten den ville en ødelagt pathspec gitt grønt på feil grunnlag. Punkt 9s tilbakereferanse og §5s ingress rettet i samme pass. Se frys-blokkas økt-13-amendement. *(Raden sa «TOM — GÅ RETT PÅ ONSDAG»; det var sant da den ble skrevet man 10. og sluttet å være det samme uke. Samme drift-klasse som radene økt 4, 5 og 6 hver for seg fant.)* *(Amendert man 10. økt 6: tirsdagens ENESTE åpne punkt var commons-svaret på persona-formuleringen — «i kontorbygg» på et veglys-prosjekt, printet ordrett i Steg 7. Svaret kom **to døgn før fristen** og ble tatt samme dag: subtree pull av commons `73136eb` → «i tilsvarende anlegg», `marker` byte-uendret (`71b7b66`), fasiten re-målt (`d0e8bb0`). Abortstien I4 ble aldri utløst — men den ble verifisert KJØRBAR først: `git reset --hard` blokkeres av hooken, **`--keep` slipper**. Rød-settet etter pullen var NØYAKTIG én test, det pinnede transkriptet; goldenene (kriterium 8) uendret; `pyproject.toml`/`uv.lock` urørt. Denne raden instruerte om arbeid som var utført — samme drift-klasse som økt 4 felte.)* |
| ons 12. | **FRYSESEKVENSEN, i denne rekkefølgen. P4 ER LUKKET (man 10., økt 5 — fersk-klon-målingen var det eneste som gjensto, og den er kjørt på `c9787cf`), så sekvensen starter på prøven, ikke på en udefinert re-måling:** **generalprøve ×2** (bruk **distinkt**-tellingen `grep -oE "^ *Steg [1-8]" \| tr -d ' ' \| sort -u \| wc -l`**8**; linje-tellingen gir 9, se avviket under tabellen) → **FRYS****P4.5: FYLL UT demo-runbooken** (`docs/plan/2026-08-12-demo-runbook.md` — den er SKREVET man 10. økt 7, `c7a57d8`; onsdag setter inn **to** målte felt (X + prøve-tidspunkt) og følger §5s **elleve** punkter i terminalen — **hakene settes ALDRI i fila**, og taggen bekreftes i punkt 11, ikke som et felt i §0; begge deler ville krevd en skriving etter taggen og gjort torsdagens identitets-anker rødt (tir 11. økt 12). Utfyllings-gaten: `grep -n '<<[A-ZÆØÅ-]*>>' docs/plan/2026-08-12-demo-runbook.md` → TOMT) → **S1.c-TAGGEN sist** (CHANGELOG-stempel + `git tag -a v1.0.0 -m "<ordrett fra runbookens §5 punkt 10>"`**ANNOTERT**, som `v0.1.0`; `-m` er PÅKREVD (uten den: exit 128, ingen tag), og meldingsteksten står literalt i §5 punkt 10 og skal ikke skrives på nytt her — + push til **`origin` ALENE** — synk + CHANGELOG-innhold er alt gjort man 10.; `[project.scripts]` lå i helgen, I3). **Punkt 11s bekreftelse tåler nå en rate-limitet `origin`** (tir 11. økt 14, målt): tom utskrift er tvetydig, og exit-koden er det som skiller «taggen mangler» (0) fra «kom ikke fram» (128). **Alle beslutninger er tatt på forhånd — onsdag skal MÅLE og UTFØRE, ikke avgjøre.** **FRYSEN ER TRE PUNKTER, ikke en holdning — se frys-blokka under tabellen** (rent tre → noter X → gaten; arbeidstre-sjekken tilføyd tir 11. økt 11, fordi gaten er commit-til-commit og prøven leser treet). Punkt 3 er **to armer** siden økt 13 — kjøringen mot `<X>` er tom per konstruksjon og beviser ingenting alene; diskriminerings-armen mot `c255662` er den som gjør den siste gate-kjøringen meningsfull. |
| tor 13. | **DEMO = v1 vises** (runbooken i hånda). **FØRST runbookens §6 — pre-flighten, før noen er i rommet:** rent tre (`git status --short --untracked-files=no` → TOMT) · tag-ankeret `git describe --tags --exact-match HEAD`**`v1.0.0`** (IDENTITET, ikke frys-gatens diff-med-unntak — de unntakene er onsdagens, og arvet hit ville de vært fail-open mot den parallelle `docs/`-sesjonen) · golden-diffen TOM. Feiler ankeret: les `git diff --stat v1.0.0..HEAD` UTEN unntak — kjøresti-filer = §3, kun `docs/` = upåvirket men vitende. Golden-diff ikke tom = §3, vis fasit-fila; aldri debugging på scenen. *(Tilføyd tir 11. — goldenen ble bygget for å fange regresjon onsdag→torsdag, men kommandoen sto som et show-element UNDER demoen; kjørt der oppdager den regresjonen samtidig med publikum.)* |
| fre 14.lør 15. | **P5**: README (O4) |
| deretter | **P6 → P7 → P8 → P9**, ett punkt per økt; STATE.md peker på øverste åpne |
**FRYSEN, OPERASJONELT (skrevet 2026-08-10, økt 5 — den var UDEFINERT, og en udefinert frys er
nettopp en beslutning tatt på frysedagen).** «Frys» går igjen gjennom hele planverket — **40 treff i
åtte plandokumenter, målt** — og er ett eneste sted forsøkt definert: demo-uke-planen linje 93,
*«etter generalprøven: ingen endringer i kjørestien»* — en **regel**, ikke en handling. Det gjorde
ett av onsdagens fire steg innholdsløst.
Problemet er konkret: sekvensen er generalprøve ×2 (commit **X**) → runbook (commit Y) →
CHANGELOG-stempel + tag (commit **Z**), så **taggen lander på Z mens prøven målte X**. At Y og Z bare
er dokumenter er sant i dag, men sto ingen steder som noe onsdagen SJEKKER. Tre kommandoer:
1. **Rent tre — FØR X noteres**`git status --short --untracked-files=no` → TOMT.
*(TILFØYD tir 11. økt 11 (advisor-review). Gaten i punkt 3 er `git diff <X>..HEAD`
**commit-til-commit** — mens generalprøven kjører fra **arbeidstreet**. En ucommittet endring i
`src/` ved prøvetidspunktet gjør **X** til en beskrivelse av noe som aldri ble prøvd, og BEGGE
kjøringene av gaten står tomme: de kan ikke se den. Verre om endringen er dét som gjør prøven
grønn — da er den taggede koden rød, som er nøyaktig hullet kriterium 6 ikke dekker. Samme
argument som ga §6 steg 1 sin plass (økt 10): **rent tre er en REGEL, ikke et øyeblikksbilde**
det gjelder identisk ved X. Samme kommando, tredje tidspunkt, egen jobb.)*
2. **Noter X** rett etter grønn generalprøve ×2 — `git rev-parse HEAD` — og skriv hashen inn i
runbooken (P4.5). *(Amendert man 10. økt 6: setningen sa «X er ikke mandagens HEAD — lander
tirsdagens persona-pull, flytter X seg». **Pullen ER landet**, `d0e8bb0`. X måles altså fra
`d0e8bb0` og framover — men den skal fortsatt LESES av `git rev-parse HEAD` etter grønn prøve,
aldri skrives av her: en hash notert i en plan er et premiss, ikke en måling.)*
3. **Før taggen, kjør frys-gaten:**
```
git diff --stat <X>..HEAD -- . ':(exclude)docs/' ':(exclude)CHANGELOG.md' # → TOMT
```
Tomt = alt mellom prøven og taggen er dokumenter, altså **er det prøvde treet det taggede treet**.
**Ikke tomt = IKKE tag** — kjør generalprøven om igjen på en ny X.
**AMENDERT man 10. økt 7 (advisor-review): gaten kjøres TO GANGER — rett etter X, og ÉN GANG TIL
rett før `git tag`.** Sekvensen er X → Y (runbook) → Z (CHANGELOG-stempel), så en gate kjørt kun
ved X måler en tilstand som ikke lenger finnes når taggen settes. Kommandoen er den samme; det er
*tidspunktet* som gjør den til et bevis om det TAGGEDE treet i stedet for om et mellomsteg.
Samme review flyttet CHANGELOG-datoen fra veggklokka til `git log -1 --format=%cs`.
**AMENDERT tir 11. økt 8 (advisor-review): datoen må RE-LESES etter at Z finnes.** Formuleringen
«leses av commiten som tagges» beskrev ikke sekvensen den står i: når kommandoen kjøres, står HEAD
**Y** — og Y blir aldri tagget. Verdien skrives så inn i **Z**. Det holder når Y og Z lander
samme dag (også ved en samlet skli til torsdag morgen, som var tilfellet setningen påberopte seg),
men brekker ved midnatt **mellom** Y og Z: da bærer den taggede commiten gårsdagens stempel.
Retteslen er ett re-lesningssteg i runbookens §5 — `git log -1 --format=%cs` én gang til ETTER
commit av Z og FØR `git tag`, med `--amend` ved avvik. Samme klasse som gaten over: kommandoen var
riktig, *tidspunktet* var det ikke.
**AMENDERT tir 11. økt 13 (målt): kjøringen ved X er tom PER KONSTRUKSJON, ikke bare foreldet.**
Økt 7s formulering over — «måler en tilstand som ikke lenger finnes» — er for snill. Ved den
første kjøringen **er** `<X>` HEAD (punkt 2 leste den nettopp, og ingenting er committet siden),
`<X>..HEAD` er tom uansett hva treet inneholder. **Målt** med en endret `src/`-fil liggende i
treet: gaten sto fortsatt tom. Den måler altså ingen tilstand — den er en **lime-inn-sjekk** av
hashen (`fatal: bad revision` hvis den er feil). Frysen selv bæres av punkt 1 (rent tre) og
kjøringen etter Y og Z; de to er tilstrekkelige, og den første kjøringen er strukturelt overflødig.
Å la en gate som bare kan bli grønn stå udeklarert er nøyaktig det repoet forbyr i testene sine
(«en gate som bare kan bli grønn beviser ingenting»), og en operatør som ser den grønn ved X kan
under tidspress lese den siste kjøringen som en gjentakelse.
**Retteslen er ikke å fjerne den** — det ville renummerert §5s elleve punkter og brutt fem
kryssreferanser på frys-eve — **men å gi den en arm den kan feile på.** Runbookens punkt 5 kjører
nå gaten mot en fast historisk hash i tillegg:
```
git diff --stat c255662..HEAD -- . ':(exclude)docs/' ':(exclude)CHANGELOG.md' # → IKKE tomt (målt: 6 filer)
```
Den beviser at kommandoen *kan* diskriminere **før** den siste kjøringen hviler på at den er tom.
Uten den ville en feilskrevet `':(exclude)…'` eller en quoting som ikke overlevde skallet
(MULTIOS-lærdommen, 08-09) gitt en grønn gate på feil grunnlag — og et utestet tre tagget.
`c255662` ligger fast bak både Y og Z, så armen forblir ikke-tom uansett hvor HEAD står onsdag.
**Gaten LISTER HVA SOM ER UNNTATT, ikke hva den vokter — den feiler LUKKET.** Første form listet
kjørestien positivt (`src/ tests/ shared/ pyproject.toml uv.lock`), og en slik gate er blind for alt
den ikke rakk å regne opp: **målt på `1522e2a^..1522e2a` rapporterer inklusjonsformen to filer og
slipper `README.md` OG `CLAUDE.md` rett igjennom, mens eksklusjonsformen tar alle fire.** På en
enveis-dag er det feil vei å feile — en ny fil skal trippe gaten, ikke passere den fordi ingen
forutså den. De to unntakene er nøyaktig det onsdagen SKAL skrive: runbooken (`docs/`) og
**`CHANGELOG.md`, som stemples i samme trekk som taggen** — en gate som dekket den kunne aldri blitt
grønn. (`STATE.md` er gitignorert og kan ikke dukke opp i en diff.)
**Gaten er MÅLT at den diskriminerer (08-10), ikke antatt** — en gate som bare kan bli grønn beviser
ingenting: `c255662..HEAD` (treet generalprøve nr. 0 faktisk kjørte på → HEAD) gir **fire filer**
(`pyproject.toml` · `__init__.py` · `test_smoke.py` · `uv.lock`) — versjonssynken som landet ETTER
prøven, altså akkurat den klassen gaten finnes for — mens `a41272d..HEAD` gir tomt. Begge målt på
BEGGE former, med samme svar; det er kun README/CLAUDE-vinduet over som skiller dem.
**UTFØRT — GENERALPRØVE NR. 0 (2026-08-10). BESTÅTT, ingen kodeendring.** Ingen fil i repoet ble
rørt av prøven; den er ren måling. Planen forutsatte at prøven kjørte mot den forankrede reserven —
den kjørte mot **levert VEGLYS-FV-SOER**, fordi P3 falt 08-09. Det er en STRENGERE prøve enn
planlagt (reserven validerer mekanikk, aldri presentasjon — P3s lærdom 2), og reserve-stien er
fortsatt målt: den lever i suiten som `test_anchored_reserve_loadbearing.py`, ikke som demoens
call-site-valg.
*Målt, hver påstand av sin egen kommando:*
- **Golden-diff (selve prøven):** `diff -u tests/golden/demo-transcript.stdout <kjøring>`**tomt**,
exit 0. Målt for BEGGE kjøringene, ikke bare den første.
- **K6 selv-identitet:** to kjøringer, `diff` på stdout **tomt**. Eneste stderr-diff er
`po-sim-y6x0kxp_``po-sim-rfbjsl86` — nøyaktig det spannet pkt. 3-normaliseringen dekker, og
prefikset står synlig i begge, som pkt. 3 krever.
- **K1 åtte steg:** alle åtte distinkte (`Steg1``Steg8`). **Tellingen er 9, ikke 8** — se avviket under.
- **K2:** `hypotese #1: REJECTED (claimed saving 2100000 exceeds P90 feasible 1769915)` +
`etter forbedring: VALIDATED (påstått 445500 <= P90 1769915)` — samme kandidat, P90-stagen.
- **K4:** `uv run pytest -q`**810 passed / 4 skipped** (814 kollektert) på 122,52 s. Uendret fra 08-09.
- **K5:** `ruff check .`*All checks passed*; `mypy src`*no issues found in 31 source files*.
- **K8:** `git diff --stat``shared/examples/bygg-energi-mikro/` + `nav-golden-*`**tomt**.
- **Exit 0**, stdout 61 linjer, stderr 4 linjer — de fire pkt. 2/3 forutsa.
**ÉTT AVVIK, og det er kriteriets BOKSTAV, ikke demoen.** Demo-uke-planen §5 pkt. 1 sier
`grep -cE "^ *Steg [1-8]"`**8**; målt gir den **9**. Årsaken er P1/S1.a: Steg 7 har nå TO merkede
linjer (kort løkke i kjøringen + `Steg 7 (lang løkke) — EN EKSPERT LEGGER EN DOM I INNBOKSEN`), fordi
de to tidsskalaene ble skilt og hver fikk sin markør. Kriteriets INTENSJON — «én merket linje per
steg 18», dvs. at ingen steg mangler — er oppfylt: `sort -u` gir Steg1…Steg8, åtte distinkte.
Tellingen `= 8` var skrevet 08-06, før Steg 7 fikk to linjer. **Kriteriet oppdateres ikke her** — det
bor i demo-uke-planen, og en telling justert i samme økt som den feiler er ikke lenger en gate.
Onsdagens generalprøve ×2 skal bruke **distinkt-tellingen** (`sort -u` → 8), ikke linje-tellingen.
## 6. Planrevisjonen 2026-08-07 — seks innsigelser, avgjørelser med belegg
Kjørt på Fable 5/xhigh uten advisor; hvert premiss fra innsigelses-prompten er derfor re-målt i
økta med egne kommandoer (§4 + kolonnen her) — ingen tall er gjenbrukt.
| # | Innsigelse | Avgjørelse | Nøkkelmåling (kjørt 08-07) |
|---|---|---|---|
| I1 | Funn 1 målt for smalt; «reserven kan aldri få fila» feil som formulert | **TAS INN** — forankret prøvekjøring + 10 %-prøve flyttet til helgen (P4 pkt. 0); §1/§4 korrigert | `find . -name 'cost-baseline.json'` → fixturen finnes (gyldig S4.0-format); `run.py:516` wirer loaderen; `simulation.py:280/:316` — bundelen er kopiert parameter |
| I2 | Demping (ons) etter pinning (helg) ugyldiggjør fasiten | **TAS INN, SKJERPET** — beslutning JA tatt nå; demping i helgen FØR pinning; målt at stderr uansett trenger definert form | to sim-kjøringer: stderr = **6** linjer, ikke 4 — `arbeidskopi:`-linja er ikke-deterministisk (`simulation.py:531-534`) |
| I3 | `[project.scripts]` på frysedagen endrer install-flaten ETTER fresh-clone-målingen | **TAS INN** — flyttet til P4 pkt. 5 (helg), FØR fresh-clone; S1.c = synk + CHANGELOG + tag | `grep -n scripts pyproject.toml` → 0 treff |
| I4 | Tirsdagens pull mangler avbruddssti | **TAS INN** — pre-pull-hash + reset-regel + NO-GO senest kl. 18:00 skrevet inn i P3 og §5 | kriterium 8 finnes (demo-uke-plan §5 pkt. 8, lest 08-07), men ingen revert-regel sto i noe dokument |
| I5 | Ingen demo-runbook allokert | **TAS INN** — ny post P4.5 (0,25 økt, VED frysen); golden-transkriptet gjort eksplisitt til abortsti | de fire spredte kildene bekreftet (demo-uke-plan §1, innholdsgate §5, P4 pkt. 4, §0 Spor 2 — lest 08-07) |
| I6 | Datoene ligger 23 dager fram i tid | **TAS INN** — §4 re-datert til faktisk måling; STATE-loggen korrigert; filnavnet står med datonote | `git show -s --format='%h %ad'` → alle refererte commits `2026-08-06`; `date` → 2026-08-07 (fredag) |
**Avvist: ingen.** I1s mot-spørsmål ble målt, ikke antatt: ingen kollisjon med
`_default_bundle_dir()`/`PORTFOLIO_SHARED_ROOT` (bundelen er et funksjonsargument, og demoen
kjører på en kopi — `simulation.py:280/:316/:492`), ingen kollisjon med kriterium 6
(selv-identitet: en forankret kjøring diffes mot seg selv) eller kriterium 8 (den lokale bundelen
ligger utenfor `shared/`), og syntetisk forankring beviser MEKANISMEN live — tall-ærligheten
dekkes allerede av nivå 2-regelen (demo-uke-planen §1 pkt. 3) og fixture-notatet «SYNTHETIC».
Kostnaden (~0,250,5 økt ekstra i helgen) er dekket av ny full ukeskvote og kjøpes tilbake ved at
tirsdag/onsdag blir tynnere.

View file

@ -0,0 +1,142 @@
# Fable 5 — egnethetsreview: feature-sett, demo, og hva ukeskvoten skal kjøpe
> **Hva dette er.** En ferdig prompt å lime inn i en Fable 5-økt. Den ligger i repoet fordi en
> prompt som bare finnes i en samtale forsvinner ved `/clear`.
>
> **Hvorfor Fable.** Produktet er dømmekraft — stort bilde, review, prioritering — ikke
> implementering. Det er oppgaveformen der Fable er sterkest, og der en feil ikke stopper noe; den
> blir bare stående og styrer en uke.
>
> **Kjøres i denne taben** (`~/repos/portfolio-optimiser`), etter `/exit`:
> `claude --model fable --effort xhigh`
>
> **Merk før du starter:** Fable kjører uten advisor (godtar kun Fable-advisor, ikke valgbar i
> CC 2.1.220). Den uavhengige kontrollen finnes ikke. Derfor bærer prompten disiplinen selv.
---
```
Du reviewer et repo du ikke har skrevet: ~/repos/portfolio-optimiser (Python, bygget på
Microsoft Agent Framework). Mandat: EGNETHET, adversarisk. Du skriver INGEN kode og
endrer INGEN fil. Produktet ditt er dømmekraft og prioritering.
KONTEKST DU MÅ HA:
Torsdag 13. august er det live demo av alle åtte steg i metoden. Onsdag 12. fryses
kjørestien. Operatøren har sagt at BÅDE et fullstendig feature-sett OG demoen er
viktige, og at ukeskvoten skal prioritere nettopp disse jobbene. Derfor er ikke en
funnliste nok — du skal levere en RANGERT plan der hvert punkt har en kostnad.
REGEL NR. 1 — DEN VIKTIGSTE:
Hvert tall du oppgir skal være produsert av en kommando, og kommandoen skal stå ved
siden av tallet. Du kjører UTEN advisor: det finnes ingen uavhengig kontroll på deg.
Der du ikke kan måle noe, skriv "ikke verifisert". Aldri fyll et hull med en gjetning.
Premisser i STATE.md og i plandokumenter er PREMISSER, ikke fakta — repoet har målt
minst tre av dem feil, senest 2026-08-09 ("guarden er v0.2 alpha" var v0.3.4).
GRENSE: du skriver ALDRI i et annet repo. Funn som hører hjemme i
llm-ingestion-okf, llm-ingestion-pipeline-security eller portfolio-optimiser-commons
leveres som tekst operatøren kan sende videre. portfolio-optimiser-claude er PARKERT.
LES, i denne rekkefølgen:
docs/qa/2026-08-06-intensjons-qa.md hva repoet ER ment å være (20 påstander)
docs/research/2026-06-24-maf-capability-map.md hva av MAF som ER vurdert, og hvordan
docs/review-2026-07.md F14 — Magentic-vurderingen
docs/plan/2026-08-06-demo-uke-plan.md demoen, åtte kriterier
docs/plan/2026-08-09-innholdsgate-og-aerlighet.md innholdsgaten, fire åpne beslutninger
CLAUDE.md invariantene
src/portfolio_optimiser/workflow.py debatten
src/portfolio_optimiser/run.py kjørestien + porteføljens fan-out
src/portfolio_optimiser/ingest.py Door A
MÅL SELV — ikke stol på kartet eller på meg:
uv run python -c "import agent_framework.orchestrations as o; \
print(sorted(n for n in dir(o) if not n.startswith('_')))"
uv run python -c "import importlib.metadata as m; \
print(m.version('agent-framework-core'), m.version('agent-framework-orchestrations'))"
grep -rn "asyncio.gather\|GroupChatBuilder\|WorkflowBuilder" src/
uv run pytest -q
uv run python -m portfolio_optimiser.simulation
MÅLTE UTGANGSPUNKT (2026-08-09 — verifiser stikkprøvevis, ikke blindt):
- To agenter i debatten (proposer + checker), ikke tre. Bygget ferskt per kjøring.
- Vi bruker ÉN MAF-orkestrering: GroupChatBuilder. Ubrukt: SequentialBuilder,
ConcurrentBuilder, HandoffBuilder, MagenticBuilder, og hele graf-laget
(WorkflowBuilder/Executor/Edge/SwitchCase/checkpointing/sub-workflows/WorkflowViz).
- Porteføljens fan-out er håndrullet asyncio.gather, ikke ConcurrentBuilder.
- Kapabilitetskartet er organisert etter BEHOV (token-tak, minne, verktøy, MCP,
skills, modell-map) — seks seksjoner, ingen sammenligner orkestrerings-TOPOLOGIER.
- Kartet er IKKE utdatert på versjon: skrevet mot core 1.9.0 / orchestrations 1.0.0,
som er det som er installert i dag.
- Magentic ER vurdert (Spike B footgun + review F14). Handoff og Sequential er nevnt
i henholdsvis to og ett dokument. Graf-laget er aldri holdt opp mot metoden.
- Door A skriver uten innholds-skanning i dag; guarden (v0.3.4, stdlib-only) er
planlagt wiret, ikke wiret. Bundle-fabrikken okf-toolkit finnes ikke (utsatt, O1).
- Demoens kjøresti importerer IKKE ingest.
DEL 1 — FEATURE-SETTET (hovedsporet)
F1. Group Chat maker-checker ble valgt tidlig og kun falsifisert mot Magentic. Hold
metoden (method-spec §3, åtte steg) opp mot HandoffBuilder, SequentialBuilder og
graf-laget. Er Group Chat fortsatt riktig? Svar med MEKANISME, ikke preferanse:
hvilket steg ville blitt bedre, og hva ville det kostet i determinisme?
F2. Steg 5-løkka (informert forbedring) er i dag en for-løkke med tak. I graf-laget
er dette en betinget kant (SwitchCase på validator-utfall). Ville grafen gitt oss
noe vi ikke har — eller bare en avhengighet vi ikke trenger?
F3. Porteføljens fan-out er håndrullet fordi Spike B fant at en gjenbrukt workflow
lekker tråd-state. Er håndrullingen fortsatt BEGRUNNET, eller ble den en vane
etter at footgun-en var forstått?
F4. Budsjett-stopp er i dag fail-fast uten gjenopptakelse. MAF har checkpointing.
Er "stopp og gjenoppta et porteføljepass" en feature dette produktet trenger?
F5. Hva i MAF 1.9.0 løser et problem vi har LØST SELV, som kapabilitetskartet ikke
fanget fordi kartet spurte etter behov og ikke etter topologi?
F6. Motsatt vei — og dette er det farligste: hva i intensjons-QA-ens 20 påstander
realiserer koden IKKE, og som ingen test ville fanget? Et feature-sett måles mot
intensjonen, ikke mot rammeverket.
DEL 2 — DEMOEN
D1. Hvilket av de åtte stegene er svakest som DEMO — der en tilhører ikke vil forstå
hva de ser, eller vil tro noe sterkere enn det som faktisk vises?
D2. Hvor påstår gjennomgangen mer enn koden gjør? Grunnregelen (A5) er at koden ikke
får påstå mer enn den gjør, og den binder presentatøren også. Kjent og bevisst:
agent-svarene er skriptet, innholdet er kuratert, tallene er modellerte — det er
premisset, ikke funn. Se etter det som IKKE sies høyt. Vurder særlig om
ærlighets-teksten i 2026-08-09-planen §5 faktisk dekker ingest- og fabrikk-gapet.
D3. Er det noe i kjørestien som ikke er deterministisk, og som kan se annerledes ut
på scenen enn på generalprøven?
D4. Er de åtte kriteriene i demo-uke-planen §5 tilstrekkelige? Hvilket kriterium
mangler for at onsdagens frys skal være trygg?
DEL 3 — DET OPERATØREN FAKTISK SKAL BRUKE KVOTEN PÅ
Dette er leveransen, ikke et vedlegg. Lag én rangert liste over alt du fant, der
hvert punkt har:
- hva som er galt, med fil:linje eller kommando-output som belegg
- MEKANISME: hvorfor det betyr noe, ikke at det "bør fikses"
- tidsmerking, hardt:
[FØR TORSDAG] kan lukkes uten å røre kjørestien som fryses onsdag
[ETTER DEMOEN] krever bygging; skal IKKE gjøres nå
- kostnadsanslag i ØKTER (ikke timer), og hvilken modell/effort formen krever
- hva som blir FALSIFISERT hvis punktet gjøres — hvis ingenting kan bli rødt,
er punktet en mening, ikke et funn
Sorter etter verdi per økt, ikke etter alvorlighetsgrad. Si eksplisitt hvilke tre
punkter du ville tatt først hvis kvoten holdt til nøyaktig tre økter, og hvorfor
akkurat de tre.
FORBUDT:
- Ros. Repoet trenger ikke bekreftelse; det trenger motstand.
- Å foreslå refaktorering som [FØR TORSDAG].
- Å foreslå at feature-arbeid tas før demoen. Demoen er en hard frist; feature-
settet er ikke. Hvis du mener det motsatte, si det som en eksplisitt anbefaling
med konsekvensen stavet ut — ikke smugle det inn i en prioritering.
- Å telle noe uten å vise kommandoen.
```
---
## Etterpå
Funnene hører hjemme i STATE.md's NESTE-blokk (det som skal gjøres) og i git (det som ble
avgjort). Ikke la reviewen bli et dokument ingen handler på — det er nøyaktig det som skjedde med
`app-creator`- og `app-factory`-reviewene fra 10. juli, som fortsatt står uratifiserte.

View file

@ -0,0 +1,206 @@
# Innholdsgate og ærlighet — plan fram til demoen 13. august
> ## ✅ SPOR B LANDET 2026-08-09 — alle seks kriterier grønne
>
> `materialize_gated` i `src/portfolio_optimiser/ingest.py`; gate-pin `v0.3.4`.
> **801 → 810 tester.** Fem mutasjoner målt røde mot HELE suiten + grønn kontroll.
>
> **§3s premiss var FEIL, og målingen felte det før byggingen.** Planen plasserer gaten i
> `ingest.materialize`, «repoets ENE skrivepunkt på Door A». Målt er `materialize` en ren
> delegasjon til det pinnede `llm_ingestion_okf` v0.3.2s `materialize_bundle`, som stager i
> minnet og utfører sin egen disk-fase. **Ingen callback finnes mellom de to** — en gate plassert
> der kunne bare kjørt ETTER at bytene hadde landet, altså en opprydding, ikke en gate.
> Sømmen ble i stedet: **kopier bundelen → materialiser inn i kopien → skann det som ble
> generert → publiser eller forkast.**
>
> **Kopien er bærende, ikke bekvemmelighet.** Bibliotekets §3 eierskaps-skann, kollisjons-gaten mot
> kuratert innhold og §6 index-merge leser ALLE den EKSISTERENDE bundelen. Staging i en tom
> temp-katalog ville mistet alle tre og publisert en bundle uten kuraterte naboer og deres
> index-lenker — et datatapsbug forkledd som en sikkerhetsfiks. Mutasjon 4 beviser det.
>
> ### De fire beslutningene i §4, tatt og målt
>
> | # | Beslutning | Utfall | Begrunnelse |
> |---|---|---|---|
> | 1 | Policy-preset | **Ingen av dem**`Origin.EXTERNAL` + `Channel.AUTOMATIC` | `import_bundle` tar ikke et preset; `trust_for` utleder policy fra origin alene. `PRESET_USER_UPLOAD` bærer `quarantine_default=True` (opplastings-semantikk Door A ikke har); `PRESET_TRUSTED_SOURCE` gir en tier en manifest-navngitt ekstern kilde ikke har fortjent |
> | 2 | Fail-closed, per dokument eller per bundle | **Utfall per BUNDLE, diagnostikk per DOKUMENT** | `materialize_bundle` er allerede alt-eller-ingenting per manifest — den sletter hver stemplet fil og regenererer `index.md`. Delvis publisering ville etterlatt en bundle + index som svarer til INTET manifest. Planens motargument («ett av femti avvist gir en halv bundle») er argumentet FOR: per-bundle gir ingen halv bundle. `import_bundle` itererer likevel forbi første avvisning, så visibiliteten taper ingenting |
> | 3 | Hvor havner `Report` | **`log.md` (OKF §7), ALDRI konsept-frontmatter** | Konseptbytene er det pinnede bibliotekets, og fire golden-suiter pinner dem. Et gate-felt i frontmatter ville brutt alle fire. `test_gated_output_is_byte_identical_to_ungated_materialize` er vakten som holder det ute |
> | 4 | `mypy --strict` over sømmen | **Override OG adapter**, som planen krevde | Override alene gjør sømmen type-BLIND, ikke type-sikker: alt ankommer som `Any`. `_stamp_line` + koersering i `verdicts` stopper `Any` ved grensen |
>
> ### Kriterier (§6)
>
> 1. `uv sync` grønn; `llm_ingestion_guard.__version__`**`0.3.4`** ✅
> 2. Load-bearing-test grønn; **fem** mutasjoner røde (planen krevde tre) + kontroll ✅
> 3. `uv run pytest -q` grønn, antall **økt 801 → 810**
> 4. `ruff check` + `ruff format --check` + `mypy src` rene ✅
> 5. **Demo-stdout BYTE-IDENTISK med `tests/golden/demo-transcript.stdout`** ✅ — gaten lekket ikke
> inn i det som fryses. Målt både av golden-testen i suiten og ved eksplisitt kjøring
> 6. `git diff --stat shared/` tomt ✅
>
> ### Mutasjons-manifest (alle mot HELE suiten, ~120 s hver)
>
> | # | Mutasjon | Utfall |
> |---|---|---|
> | 1 | Detach gate-kallet (`if False`) | 🔴 4 tester |
> | 2 | Gaten fyrer ETTER publisering (advarsel, ikke gate) | 🔴 3 tester |
> | 3 | `Origin.INTERNAL` som default | 🔴 2 tester |
> | 4 | Staging i TOM katalog i stedet for kopi | 🔴 1 test — og KUN den; 809 andre merket ingenting |
> | 5 | Rapporter kun FØRSTE avviste dokument | 🔴 1 test |
> | — | **Kontroll: urørt kode** | 🟢 810 passed / 4 skipped |
>
> **Lærdom — mutasjon 3 avslørte en vakuøs test, og rettelsen kom av å måle.** Første kjøring av
> mutasjon 3 felte KUN logg-testen; alle tre avvisnings-testene forble GRØNNE. Årsak: en hard
> injeksjon scorer `fail_secure` under BEGGE trust-tierene, så beslutning 1 så dekket ut uten å
> være testet. Målingen fant båndet der tieren faktisk avgjør — et høy-entropi-innhold gir
> `quarantine_review` under EXTERNAL og `warn` under INTERNAL — og
> `test_external_origin_refuses_what_internal_would_publish` (med trusted-kontroll) ble skrevet
> mot nøyaktig det. Uten den re-målingen ville §4-beslutning 1 stått ubevist bak en grønn suite.
>
> **Lærdom 2 — den lave dispositionen er `warn`, ikke `allow`.** Et RENT konsept scorer `warn`;
> `Disposition` er `warn < quarantine_review < fail_secure`, og noe `allow` finnes ikke. En gate
> skrevet mot `== allow` ville avvist hvert dokument som noensinne ingestes. Kontroll-testen er
> det som fanger den klassen.
>
> **§5-ærlighetsavsnittet: bruk JA-varianten.** Gaten er landet, så setningen som leses på scenen
> er «ingest-veien skanner nå innholdet før det skrives, med en egen sikkerhetskomponent».
> P1 og P3 (håndkuratert bundle, ingen generisk fabrikk) er UENDRET ærlighets-punkter.
> **Utløser.** Demoen viser «last ned → kjør». Sier vi, eller antyder vi, at man kan peke systemet
> mot sine egne kilder og bygge en kunnskapsbase, påstår vi noe koden ikke bærer. Det er A5-brudd i
> muntlig form — koden får ikke påstå mer enn den gjør, og det gjelder også presentatøren.
>
> **Kjører parallelt med demo-uke-planen** (`2026-08-06-demo-uke-plan.md`), ikke i stedet for den.
> Den planen eier kjørestien og fryser den onsdag 12. Denne eier Door A, som ikke er på kjørestien.
## 0. Målte premisser (verifisert 2026-08-09, ikke gjenbrukt fra eldre dokument)
| Påstand | Målt | Kommando |
|---|---|---|
| Guarden er «v0.2 alpha» | **FEIL**`v0.3.4`, syv publiserte tagger | `git -C ~/repos/llm-ingestion-pipeline-security tag` |
| Guarden er tung å adoptere | **FEIL**`dependencies = []`, stdlib-only | `pyproject.toml` i guard-repoet |
| Vår okf-pin drar inn guarden | **NEI**`v0.3.2` har `dependencies = []` | `git show v0.3.2:pyproject.toml` |
| Vi må vente på release-nummer-striden | **NEI** — den gjelder utgivelsen *etter* `v0.3.4` | guard-repoets STATE |
| Door A er på demoens kjøresti | **NEI**`simulation.py` importerer ikke `ingest` | `grep "^from portfolio_optimiser" simulation.py` |
| Ingesten skanner innhold i dag | **NEI**`ingest.py:20-25` sier det selv | modul-docstring |
Den første raden er grunnen til at denne planen finnes: inkluderingsplanen fra 16. juli
(`2026-07-16-llm-ingestion-guard-inclusion.md`) sier «v0.2 (alpha)», og det premisset gjorde
adopsjonen urimelig risikabel. Målt er den tre mindre-versjoner nyere og uten avhengigheter.
**Den planen skal merkes som utdatert på dette punktet, ikke slettes.**
## 1. De tre påstandene, presist — og hvilke som kan lukkes
Formuleringen «tre ting som ikke holder» var upresis. Målt er de:
| # | Påstand | Status | Til torsdag? |
|---|---|---|---|
| P1 | Bundelen i demoen ble laget av pipelinen | **Nei** — den er håndkuratert i commons | **Ærlighet.** Kan ikke bygges bort |
| P2 | Ingesten skanner innhold før skriving | **Nei**`materialize` skriver det den får | **JA — bygges** |
| P3 | Det finnes en generisk bundle-fabrikk (`okf-toolkit`, T0) | **Nei** — eget repo, finnes ikke, utsatt ved O1 | **Ærlighet.** Nytt repo på fire dager er utelukket |
**To av tre lukkes med kode, én med en setning.** Å forsøke P3 før torsdag ville brutt scope-vaktet
(O1 er en tatt beslutning) og satt demoen i spill for en gevinst ingen tilhører etterspør.
## 2. To spor, med vilje adskilt
**Spor A — demoen.** Eies av demo-uke-planen. Fryses onsdag 12. Rører ikke `ingest.py`.
**Spor B — innholdsgaten.** Denne planen. Rører KUN `ingest.py` + `pyproject.toml` + ny test.
Adskillelsen er ikke kosmetisk: `simulation.py` importerer ikke `ingest` (målt), så Spor B kan
lande etter frysen uten å røre det som fryses. Faller Spor B, står demoen uendret — den mister kun
retten til å si at gaten finnes, som er nøyaktig statusen i dag.
## 3. Dagsplan
**Søndag 9. / mandag 10. — Spor B, byggingen.**
Wire `llm_ingestion_guard` inn i `ingest.materialize` (`ingest.py:167`), som er repoets ENE
skrivepunkt på Door A. Fire beslutninger må tas, ikke antas — de er listet i §4.
**Tirsdag 11. — GO/NO-GO på commons (Spor A, uendret).**
Spor B rører ikke commons; de to konkurrerer ikke om samme fil.
**Onsdag 12. — generalprøve + frys (Spor A).**
Spor B skal være committet FØR frysen hvis den skal nevnes i demoen, men den er ikke en
frys-betingelse. Er den ikke ferdig: si status som den er, ikke som den var planlagt.
**Torsdag 13. — demo.** Ærlighets-avsnittet leses opp som skrevet i §5.
## 4. Byggingen — fire beslutninger som må måles, ikke antas
1. **Hvilken policy-preset?** Guarden eksporterer `PRESET_TRUSTED_SOURCE` og `PRESET_USER_UPLOAD`.
Door A henter fra eksterne kilder oppgitt i et manifest — det er ikke det samme som en fil en
bruker laster opp, og ikke det samme som en kilde vi eier. **Les begge presetene i installerte
bytes før du velger.** Feil preset gir enten en gate som slipper alt gjennom eller en som gjør
ingest ubrukelig.
2. **Fail-closed eller flagg-og-skriv?** Repoets presedens er entydig — `promote_verdict` er
fail-closed, `write_concept_file` validerer og reparerer aldri. **Forslag: avvist dokument
skrives IKKE**, og `materialize` raiser en typet feil på linje med `IngestStampError`. Motargument
som må avvises eksplisitt før valget: en bundle der ett av femti dokumenter avvises blir en halv
bundle. Avgjør om avvisning er per dokument eller per bundle.
3. **Hvor havner guardens `Report`?** Provenance er en invariant her. Funnene skal ikke kastes —
de hører i `ingest_manifest`, ved siden av eierskaps-stempelet. Avgjør felt-navn og form.
4. **`mypy --strict` over sømmen.** Guarden shipper ingen `py.typed`, så symbolene ankommer som
`Any`. okf løser det med en `[[tool.mypy.overrides]]`-blokk (`ignore_missing_imports = true`) OG
en adapter som koerserer hver verdi til konkret type. **Kopier begge deler** — kun overriden ville
gjort `--strict` meningsløs på vår side av sømmen.
**Avhengigheten pinnes som okf gjør det**, git-kilde + tagg:
`llm-ingestion-guard = { git = "https://git.fromaitochitta.com/open/llm-ingestion-pipeline-security.git", rev = "v0.3.4" }`.
Aldri en flytende ref på en sikkerhetskomponent.
**Load-bearing-krav (ufravikelig).** En test som bare er grønn beviser ingenting. Testen skal:
- mate `materialize` et fikstur-dokument med kjent injeksjons-innhold, og kreve at det IKKE skrives;
- ha en kontroll: et rent dokument som skrives uendret (ellers kan ikke testen skille «gaten virker»
fra «ingenting skrives»);
- måles RØD ved minst tre mutasjoner: detach gate-kallet · gjør avvisningen til en advarsel ·
bytt til en preset som slipper alt gjennom.
- Måles mot HELE suiten, med restaurering fra scratchpad + `shasum -c`.
## 5. Ærlighets-avsnittet — teksten som faktisk sies på scenen
Erstatter ingenting i demo-uke-planens §1; **kommer i tillegg**, og leses opp:
> «To ting om innholdet dere ser. For det første: denne kunnskapsbasen er laget for hånd, ikke
> produsert av systemet. Det finnes en ingest-vei som henter eksterne kilder inn i formatet, men
> eksempelet her gikk ikke gjennom den, og den generiske fabrikken som skulle laget slike baser er
> ikke bygget — den er bevisst utsatt.
> For det andre, om sikkerhet: [HVIS SPOR B ER LANDET] ingest-veien skanner nå innholdet før det
> skrives, med en egen sikkerhetskomponent, slik at forgiftet kildeinnhold ikke havner i basen.
> [HVIS IKKE] ingest-veien skriver i dag det den får, uten innholds-skanning. Gaten er planlagt og
> komponenten finnes, men den er ikke koblet inn ennå. Peker dere dette mot upålitelige kilder i
> dag, er det deres ansvar, ikke systemets.»
De to variantene er skrevet på forhånd med vilje: valget mellom dem er en **observasjon** onsdag, ikke
en vurdering torsdag morgen.
## 6. Verifisering — konkrete kriterier
1. `uv sync` grønn med guarden som deklarert dep; `uv run python -c "import llm_ingestion_guard as g; print(g.__version__)"``0.3.4`.
2. Ny load-bearing-test grønn; **tre mutasjoner målt røde** mot hele suiten, med kontroll.
3. `uv run pytest -q` grønn — og **antallet skal ha økt**, ikke bare være grønt.
4. `uv run ruff check .` + `uv run mypy src` rene, med `--strict` fortsatt meningsfull over sømmen
(adapter, ikke bare override).
5. **Demoens kjøresti byte-uendret:** `uv run python -m portfolio_optimiser.simulation` gir fortsatt
åtte merkede steg og identisk stdout over to kjøringer. Dette er Spor B's viktigste kriterium —
det beviser at gaten ikke lekket inn i det som fryses.
6. `git diff --stat``shared/examples/` → tomt. Goldenene er load-bearing.
## 7. Hva denne planen IKKE gjør
- **Bygger ikke `okf-toolkit` (T0/D-G).** O1 står. Håndteres av §5.
- **Adopterer ikke okf `v0.4.0+`.** Vi står på `v0.3.2` gjennom demoen; v0.4.0+ gjør guarden til
hard runtime-dep, og den koblingen tas etter demoen, ikke før.
- **Rører ikke commons.** Den har sin egen frist 11. august og sin egen økt.
- **Rører ikke `docs/presentasjon-portfolio-optimiser.html`** — eies av en annen sesjon.
- **Wirer ikke guarden på bundle-LESE-stien.** Gaten sitter ved skriving (Door A). En bundle som
allerede ligger i repoet er kuratert av et menneske; å skanne den ved lesing er en annen
beslutning med en annen begrunnelse, og den er ikke tatt.
## 8. Risiko
| Risiko | Utslag | Tiltak |
|---|---|---|
| Preset-valget viser seg feil under bygging | Gate som slipper alt, eller blokkerer alt | §4.1: les begge i installerte bytes FØR valg; testen har både positiv og kontroll |
| Guarden avviser demo-bundelen ved en feil | Panikk onsdag | Gaten sitter kun på Door A; demo-bundelen leses, ikke ingestes. Kriterium 5 fanger lekkasje |
| Spor B tar lengre tid enn ventet | Ett løfte mindre i demoen | §5 har ferdigskrevet NEI-variant; ingen improvisasjon på scenen |
| `mypy --strict` blir meningsløs over sømmen | Stille typetap | §4.4: adapter OG override, som okf gjorde |

View file

@ -0,0 +1,394 @@
# Demo-runbook — torsdag 13. august 2026
> **Én side å ha i hånda på scenen.** Kjøresekvens · hva som sies hvor · abortstien.
>
> **Status:** beslutnings-innholdet er skrevet **mandag 10. august** (økt 7) — alt som er kjennbart
> uten frysen. **Onsdag 12. fyller ut de målte feltene** og krysser av nederst. Runbooken er
> lesestoff, ikke kjøresti: den ligger under `docs/`, som er unntatt frys-gaten, så den kan skrives
> og fylles ut uten å røre det frosne treet.
>
> **Onsdagens utfyllings-sjekk (kjør FØR demoen):**
> ```
> grep -n '<<[A-ZÆØÅ-]*>>' docs/plan/2026-08-12-demo-runbook.md # → TOMT
> ```
> Ikke tomt = runbooken er et utkast, ikke en runbook. Et uutfylt felt er samme drift-klasse som
> plan-radene økt 4, 5 og 6 hver for seg fant: et dokument som instruerer om noe som ikke stemmer.
---
## 0. Målte felt — fylles onsdag
| Felt | Verdi | Hvor det kommer fra |
|---|---|---|
| Frosset commit **X** | `777b9f7225cc1d4d4d9d45a1d3bdc7033ebaeb1a` | `git rev-parse HEAD` rett etter grønn generalprøve ×2 |
| Generalprøve ×2 | 2026-08-12 20:15 | tidspunktet begge kjøringene var grønne |
**Det finnes med vilje ingen rad for taggen, og fraværet er bærende.** Et tag-felt kunne først
fylles ut *etter* §5s punkt 10 (push) — men §5s punkt 6 krever at utfyllings-gaten er **tom**, altså
før. Å fylle det ærlig ville krevd en commit **etter** taggen, og da står HEAD på den commiten mens
taggen står på `Z`: torsdagens §6 steg 2 (`git describe --tags --exact-match HEAD``v1.0.0`)
ville vært **rød på demo-morgenen**, av den ene gaten som måler identitet. Å la feltet stå ufylt
bryter gaten i stedet. Taggen bekreftes derfor **der den settes** (§5 punkt 11, tre kommandoer i
terminalen) og måles på nytt torsdag av §6 steg 2 — den er en handling som verifiseres, aldri et
tall som avskrives hit.
Alt annet i dette dokumentet er målt mandag 10. august mot det pinnede transkriptet
(`tests/golden/demo-transcript.stdout`, 61 linjer) og står uendret.
---
## 1. Kjøresekvensen
**Kommandoen — kjør denne, ingen andre flagg:**
```
uv run python -m portfolio_optimiser.simulation
```
Forventet: **exit 0**, **61 linjer stdout**, **4 linjer stderr**.
Stderr er fire linjer og skal være der: to `ExperimentalWarning` fra MAF (Skills + MemoryStore),
en blanklinje, og `(arbeidskopi: <systemets temp-katalog>/po-sim-…)`.
**Katalogen varierer, prefikset gjør ikke det.** På macOS er temp-katalogen `/var/folders/…/T/`,
**ikke** `/tmp/` — så linja på skjermen blir lang og stygg, og det er riktig. `po-sim-` er den delen
som er konstant, fordi programmet setter den (`mkdtemp(prefix="po-sim-")`); resten tilhører miljøet.
Fasiten maskerer nøyaktig dette skillet. En lang `/var/folders/…`-sti er altså **ikke** et tegn på
at noe er galt, og ingen grunn til å gå til abortstien.
De to advarslene dempes **ikke** med vilje — de
fyrer mens biblioteket importeres, og å dempe dem ville betydd at rammeverket bestemmer hva MAF
får si til enhver konsument. Rund-tak-linjene («forcing completion») er dempet og skal **ikke** vises.
**Hvis du vil vise at outputen er den frosne:**
```
uv run python -m portfolio_optimiser.simulation > /tmp/demo.out 2>/dev/null
diff /tmp/demo.out tests/golden/demo-transcript.stdout # → TOMT
```
*(Konsoll-kommandoen `portfolio-optimiser-demo` gir byte-identisk stdout og virker like godt.
`-m`-formen står her fordi den ikke antar noe om PATH. `PYTHONIOENCODING=utf-8` er pinnet i
testene; i en vanlig UTF-8-terminal trengs den ikke.)*
---
## 2. Hva som sies — i rekkefølge, forankret i skjermlinjene
Linjenumrene under er linjene i det pinnede transkriptet, så du kan finne igjen stedet uten å lete.
### Åpningen (skjermlinje 15, banneret står allerede der)
Banneret sier det selv. Les det, ikke pynt det bort, og legg til de **tre ærlighets-punktene**
(demo-uke-planen §1 — nivå-2-påstanden, D-I):
1. **Agent-svarene er skriptet.** Dette beviser dataflyten, den deterministiske ryggraden og at
læringssløyfa lukkes — ikke at en levende modell ville produsert nettopp dette forslaget.
2. **Innholdet er håndkuratert, ikke fabrikkert.** Et menneske lagde kunnskapsbasen.
3. **Tallene er modellerte, ikke målte.** Ingen pilot har validert dem i drift.
> Dette er ikke en unnskyldning som svekker demoen — det er grunnregelen repoet er bygget på
> (A5: koden får ikke påstå mer enn den gjør). En demo som overselger bryter med det den demonstrerer.
### Ærlighets-avsnittet — leses opp ORDRETT (innholdsgate-planen §5, **JA-varianten**)
Spor B landet 2026-08-09, så det er JA-varianten som gjelder. NEI-varianten brukes **ikke**.
> «To ting om innholdet dere ser. For det første: denne kunnskapsbasen er laget for hånd, ikke
> produsert av systemet. Det finnes en ingest-vei som henter eksterne kilder inn i formatet, men
> eksempelet her gikk ikke gjennom den, og den generiske fabrikken som skulle laget slike baser er
> ikke bygget — den er bevisst utsatt.
> For det andre, om sikkerhet: ingest-veien skanner nå innholdet før det skrives, med en egen
> sikkerhetskomponent, slik at forgiftet kildeinnhold ikke havner i basen.»
*(Forbeholdet hvis noen graver: gaten er **opt-in**`materialize_gated` er den gatede inngangen,
`materialize` er bevisst ugatet, og en kaller som vil ha gaten ber om den ved navn. Det står slik i
CHANGELOG-ens Security-oppføring. Si det hvis det spørres; ikke som en fotnote i opplesningen.)*
### Kunnskapsbasen (skjermlinje 79)
Linje 9 **er** ærlighets-setningen om provenans — den står allerede på skjermen og skal ikke sies
på nytt: *«tallene er levert i kunnskapsbasen — utledet av fagkilder (Håndbok V124, NMFV), ikke av
demo-manuset»*. Pek på den. Poenget er at kostbaselinen er **erklært**, og at validatorens stage 0
avstemmer forslagets kostlinjer mot den **før** løseren.
### Kjøring A, steg for steg (skjermlinje 1133)
| Skjerm | Steg | Det ene poenget |
|---|---|---|
| 1215 | 1 — Forstå konteksten | fem konseptfiler ble **navigert**, ikke søkt opp som tekstbiter; **0** tidligere dommer, og markøren er `False` — det er kontrollen som gjør Kjøring B beviselig |
| 1619 | 2 — Hypotese | forslaget med parametere og kostlinjer; påstått **2 100 000 NOK** |
| 2022 | 3 — Debatt | to deltakere; checkeren gater **resonnementet** og sier `VERDICT=APPROVE` |
| 2324 | 4 — Valider | **REJECTED** — 2 100 000 overstiger P90 feasible **1 769 915**. Dette er hele poenget: checkeren sa ja, tallene sa nei, og tallene vinner |
| 2527 | 5 — Forbedre | grunnen mates tilbake i neste forsøk, **bundet** av `max_attempts`**VALIDATED 445 500** |
| 2829 | 6 — Forkast eller foreslå | typet utfall forlater kjøringen (`validator=validated, checker=approve`) |
| 3033 | 7 — Tilbakemelding (kort løkke) | ekspert-personaen godkjenner **med realiseringskorreksjon** (0.79) |
**Setningen som bærer demoen** (skjermlinje 22 mot 24): *«Checkeren godkjente resonnementet. Den
deterministiske validatoren avviste tallet. To uavhengige falsifiserere, og det er den som regner
som blokkerer.»*
### Mellom kjøringene (skjermlinje 3546)
To **uavhengige** tilbakemeldings-veier, én per tidsskala — og de bærer hver sin markør nettopp
for at ingen av dem skal kunne ta æren for den andre:
- **Steg 7, lang løkke** (3741): en ekspert legger en fil i innboksen **etter** kjøringen
(`realiseringsgrad=0.66`). Rollene byttes aldri: systemet **leser** mappa, eksperten **skriver**
den. Dager kan gå.
- **Steg 8, promotering** (4346): den godkjente dommen løftes inn i wikien (`realiseringsgrad=0.79`).
Gaten er **fail-closed** — kun en godkjent dom promoteres, rå agent-output aldri.
### Kjøring B (skjermlinje 4854)
Kun det som **endret seg** vises. Begge markørene er `True`, og linje 51 sier hvor dommene kom fra:
**1 av 3 fulgte med kunnskapsbasen, de øvrige 2 er dem demoen lærte i denne økten**. Den splitten
er **regnet ut** av kjøringen, ikke skrevet ned — derfor stemmer den også når basen en dag shipper
flere dommer.
Utfallet er **det samme tiltaket til samme beløp**. Det er riktig og verdt å si høyt: læringen
endret ikke svaret her, den endret **grunnlaget** svaret ble formet på.
### Avslutningen (skjermlinje 5661)
Les blokken. Poenget er den siste setningen: **ingen av de to veiene gikk gjennom minnet** — begge
gikk gjennom fil.
### Mandat-setningen (MUNTLIG — ingenting av dette vises på skjermen)
Svaret på «kan vi styre hva som analyseres?»:
> «Ja. En kjøring kan bestilles med en oppdragsfil: du skriver hva kjøringen er til for, og hvilke
> tilnærminger du vil ha vurdert — hver av dem får sin egen vurdering og sin egen linje i oppgjøret,
> og systemet kan i tillegg foreslå sitt eget. Men bestillingen styrer hva som **vurderes**, aldri
> hva som **godkjennes**: validatoren gjelder uendret, så ber du om noe tallene ikke bærer, blir det
> avvist — og avvisningen kommer tilbake til deg med begrunnelsen. Det er ikke vist i denne demoen;
> det er et eget flagg (`--mandate`), og det er dokumentert.»
Dokumentet er [`docs/bestille-en-kjoring.md`](../bestille-en-kjoring.md). Søsterdokumentet for den
andre enden — å avgi dommen etterpå — er `docs/ekspert-svar.md`.
---
## 3. Abortstien
**Feiler live-kjøringen på scenen: ikke debug. Vis fila.**
```
cat tests/golden/demo-transcript.stdout
```
**Stien er absolutt med vilje.** Prosaen under peker på feil katalog som en sannsynlig årsak — og en
relativ sti ville feilet av nøyaktig den årsaken (målt: `cat: tests/golden/…: No such file or
directory`). En abortsti som deler failure-mode med det den aborterer fra, er ingen abortsti; den
gjør ett synlig problem til to.
Den fila **er** transkriptet — ordrett, uten normalisering — fra den frosne kjøringen. Si høyt hva
den er:
> «Dette er den frosne kjøringen fra onsdag, sjekket inn som fasit. Det dere ser er ikke en
> gjenfortelling, det er outputen ordrett — og at den er sjekket inn er grunnen til at jeg kan vise
> den nå.»
Ikke prøv å fikse noe under demoen. Miljøet er den sannsynlige årsaken (`uv sync` / feil katalog),
og feilsøking på scenen koster mer enn fila.
---
## 4. Forventede spørsmål
| Spørsmål | Svar |
|---|---|
| «Hvorfor viste Kjøring A **0** tidligere dommer når basen shipper ett dom-frø?» | Med vilje: Kjøring A kjøres mot en **tom** wiki. Uten den kontrollen kunne markøren i Kjøring B like gjerne kommet fra basen som fra læringen — kontrollen er det som gjør sløyfa beviselig, ikke bare påstått. |
| «Er dette en ekte LLM?» | Nei — agent-svarene er skriptet, og det står i banneret. Det som er ekte er dataflyten, den deterministiske validatoren og at læringen faktisk går gjennom fil. |
| «Hva om modellen hallusinerer et tall?» | Stage 0 avstemmer hver kostlinje mot prosjektets **erklærte** kostbaseline før løseren i det hele tatt kjører. Ukjent kostkode, eller en mengde/enhetspris utenfor toleransen, avvises — validering, aldri reparasjon. |
| «Kan den kjøre en hel portefølje?» | **Biblioteket** har porteføljekjøring med bølge-modell og et globalt token-tak. Kommandolinja eksponerer ikke taket, og **porteføljekjøring er ikke det denne demoen viser** — den kjører ett prosjekt. Ikke tilby en live demonstrasjon av porteføljestien; den er ikke prøvekjørt denne uka. |
| «Er den generiske fabrikken for kunnskapsbaser med?» | Nei, bevisst utsatt. Basen her er håndkuratert. Det er punkt 2 i åpningen. |
| «Hva med `open/`-speilet / hvor ligger koden?» | Taggen `v1.0.0` er satt på det private repoet. Det offentlige speilet synkes etter demoen, sammen med READMEen som forklarer commitene. |
---
## 5. Onsdag 12. — elleve punkter i rekkefølge (etter generalprøve ×2)
**Følges i terminalen. Hakene settes ALDRI i denne fila.** Punkt 6 er runbook-commiten `Y`, og alt
fra punkt 7 og ut skjer *etter* den. Et hake satt der havner enten ucommittet i treet — målt:
`git status --short --untracked-files=no` svarer da ` M docs/plan/2026-08-12-demo-runbook.md`, så
torsdagens §6 steg 1 blir rød — eller i en commit **etter** taggen, som gjør §6 steg 2s identitet
rød. Samme motsigelse som §0s manglende tag-rad, samme to gater. **Fila skrives i punkt 4 og punkt 6
— begge FØR runbook-commiten `Y` — og røres ikke etter den.** Det er *etterpå* som er den bærende
egenskapen, ikke antallet skrivinger: en hash notert i punkt 4 og først skrevet ned tre steg senere
er en hash båret i hodet gjennom tre gate-kjøringer. Bruk papir, en annen skjerm eller hukommelsen
til **hakene** — de er det eneste som ellers ville havnet i fila etter `Y`.
- [ ] Generalprøve ×2 grønn — golden-diff tom begge ganger
- [ ] K1: `grep -oE "^ *Steg [1-8]" <stdout> | tr -d ' ' | sort -u | wc -l` → **8**
*(linje-tellingen gir **9** — kjent og forventet: Steg 7 har to linjer, én per tidsskala.
Kjør kommandoen, ikke prosaen.)*
- [ ] **Rent tre FØR X noteres**`git status --short --untracked-files=no`**TOMT**.
Prøven du nettopp kjørte leste **arbeidstreet**; **X** er en **commit**. Frys-gaten under er
commit-til-commit og kan ikke se en ucommittet endring i `src/`**begge** kjøringene av den
ville stått tomme mens det taggede treet var noe annet enn det prøvde. Samme kommando som
§6 steg 1, tredje tidspunkt, egen jobb. Utrackede filer holdes utenfor av samme grunn som der:
`presentasjon-*.html` eies av en annen sesjon.
**Ikke tomt: commit eller forkast — og kjør så prøven OM IGJEN.** Ikke fristes til å committe
og gå videre: den commiten flytter HEAD, så **X** ville pekt på et tre prøven aldri så. Ved X
er ingenting redigert ennå — runbooken (Y) og CHANGELOG (Z) skrives *etter* dette punktet.
- [ ] **X** notert (`git rev-parse HEAD`) og skrevet inn i tabellen i §0
- [ ] **Frys-gaten prøvekjørt — BEGGE armer.**
```
git diff --stat <X>..HEAD -- . ':(exclude)docs/' ':(exclude)CHANGELOG.md' # → TOMT
git diff --stat c255662..HEAD -- . ':(exclude)docs/' ':(exclude)CHANGELOG.md' # → IKKE tomt
```
Den første armen er **tom, og kan ikke være annet**: `<X>` **er** HEAD her — punkt 4 leste den
nettopp, og ingenting er committet siden — så `<X>..HEAD` er tom uansett hva treet inneholder.
Målt med en endret `src/`-fil liggende i treet: gaten sto fortsatt tom. Den er altså en
**lime-inn-sjekk** av hashen (`fatal: bad revision` hvis du bommet), ikke en frys-sjekk.
Frysen selv måles av punkt 3 (rent tre) og punkt 9 (etter Y og Z).
**Den andre armen er den som gir punkt 9 mening** — den beviser at kommandoen *kan* bli
ikke-tom, før punkt 9 hviler på at den er tom. Målt: **6 filer**. `c255662` ligger fast bak
både Y og Z, så den forblir ikke-tom uansett hvor HEAD står i morgen.
**Er den andre armen også tom, er det pathspec-en som er ødelagt** — feilskrevet
`':(exclude)…'`, eller quoting som ikke overlevde skallet (repoets MULTIOS-lærdom: en kommando
som ser riktig ut kan lyve). Punkt 9 ville da vært grønn på feil grunnlag, og du ville tagget
et utestet tre. **Ikke gå videre før den andre armen er ikke-tom.**
**De to måtene armen kan svikte på ser IKKE like ut, og det er med vilje:** *stille tomt* =
pathspec-en er ødelagt (det er dette armen finnes for). *`fatal: bad revision`* = du bommet på
hashen — den er `c255662`, målt ikke-tom 08-11. En tom utskrift er altså aldri «feil hash»;
da hadde git ropt.
- [ ] Runbooken fylt ut (§0s to felt) + `grep -n '<<[A-ZÆØÅ-]*>>' docs/plan/2026-08-12-demo-runbook.md`
→ TOMT — **og så `git commit`. Den commiten ER `Y`.** Uten den er runbooken en ucommittet
endring resten av sekvensen ikke kan se: frys-gaten (punkt 5 og 9) ekskluderer `docs/`, så den
passerer **stille**; punkt 11s anker passerer også, fordi HEAD da fortsatt er `X` og `X` er det
taggede. Først torsdagens §6 steg 1 roper — foran demoen.
- [ ] CHANGELOG-overskriften stemplet — **datoen LESES, ikke skrevet på forhånd og ikke tatt fra
veggklokka:** `git log -1 --format=%cs` (HEAD står nå på `Y`) — **og så `git commit`. Den
commiten ER `Z`, og det er `Z` taggen settes på.** Uten den blir `v1.0.0` tagget med
`## [Unreleased]` fortsatt i CHANGELOG — frys-gaten unntar `CHANGELOG.md` og ser det ikke —
og punkt 8 ville sammenlignet datoen mot en fil som ikke er committet.
- [ ] **Datoen RE-LEST etter at stempel-commiten (Z) finnes, FØR taggen**`git log -1 --format=%cs`
på nytt → må være **IDENTISK** med den stemplede overskriften. Første lesning skjedde med HEAD
**Y** (runbook-commiten), og **Y blir aldri tagget**. Faller midnatt mellom Y og Z, stemplet
du gårsdagens dato inn i commiten som faktisk tagges. Avvik = `git commit --amend`
overskriften, les på nytt, ikke tag før de er like. *(Sklir HELE sekvensen til torsdag morgen,
er alt i orden — Y og Z er da samme dag. Det er bare midnatt MELLOM dem som brekker enigheten.)*
- [ ] **Frys-gaten kjøres ÉN GANG TIL, rett før taggen** — samme kommando som punkt 5s første arm.
**Dette er den første kjøringen som kan si noe.** Punkt 5s arm mot `<X>` var tom *per
konstruksjon* (X var HEAD da); her har runbook-commiten (Y) og CHANGELOG-stempelet (Z) flyttet
HEAD forbi X, så en `src/`-endring imellom ville dukket opp. Tom her = **det taggede treet er
beviselig det prøvde treet**. Ikke tomt = IKKE tag.
- [ ] `git tag -a v1.0.0 -m "v1.0.0 — first complete eight-step loop"` + `git push origin v1.0.0`
(**`origin` alene** — `open/` er P5-vinduet).
**Annotert (`-a`), ikke lettvekts.** Repoets eneste andre tag er annotert — målt:
`git cat-file -t v0.1.0``tag`, med tagger-header og meldingen `v0.1.0 — first tagged release`.
En lettvekts `v1.0.0` ville gjort hovedreleasen til den eneste taggen uten forfatter, dato eller
melding. **Meldingen står literalt her nettopp for at den ikke skal improviseres på en enveis-dag**
— den er *ikke* et felt i §0, og utfyllings-gaten forblir derfor på **2**.
Formen er tørrkjørt 08-11 (eget engangs-repo, ikke dette treet): `-a` med em-dash gir
`git tag -l``v1.0.0`, `git describe --tags --exact-match HEAD``v1.0.0`, filtrert
`ls-remote`**1 linje**, og em-dashen overlevde skallet.
- [ ] **Taggen bekreftet der den ble satt**`git tag -l v1.0.0``v1.0.0` · `git ls-remote --tags
origin v1.0.0` → **én linje** · `git describe --tags --exact-match HEAD` → `v1.0.0`.
Dette **er** tag-bekreftelsen; den skrives ikke inn i §0 (begrunnelsen står der). Den tredje
kommandoen er nøyaktig ankeret torsdagens §6 steg 2 leser — kjørt her koster den sekunder, og
en tag som landet på feil commit oppdages på **frysedagen** i stedet for på demo-morgenen.
Samme grunn som at §6 selv ble flyttet fra «under demoen» til «før rommet fylles».
**«Én linje» gjelder BEGGE tag-former**, så punkt 10s `-a` endrer ikke forventningen her.
Målt mot ekte `origin` 08-11: den annoterte `v0.1.0` gir **to** linjer *uten* filter
(`refs/tags/v0.1.0` + den peelede `refs/tags/v0.1.0^{}`), men **én** med `v0.1.0` som refspec —
`^{}` matcher ikke pattern-et. Kjør den derfor **med** taggnavnet, som over.
**⚠️ TOM UTSKRIFT ER TVETYDIG — LES EXIT-KODEN.** `origin` rate-limiter SSH på burst. Målt
08-11: de to første `ls-remote` gikk igjennom, de fire neste ga `Connection refused`, og porten
svarte igjen etter en pause — Forgejo-weben var oppe hele tiden (HTTP 303), så serveren var
aldri nede. Punkt 11 kjører to SSH-kall rett etter en push, altså nøyaktig et burst.
**Begge tilfellene gir null linjer på stdout** (målt): taggen mangler = **exit 0** og tom
stderr · kom ikke fram = **exit 128** og `ssh: connect to host … Connection refused` på stderr.
Et `| wc -l` alene kan altså ikke skille dem, og lest som «taggen landet ikke» er det en abort
på feil grunnlag — dagen etter at push-en faktisk lyktes. **Exit 128: vent til porten svarer
igjen, og bruk en probe — ikke en klokke:** `ssh -T git@git.fromaitochitta.com` → `Hi there,
<user>!`. Det er dette som kom tilbake da forbindelsen løsnet 08-11; **ingen fast ventetid er
målt**, så et tall her ville vært gjettet. Kjør så kommandoen på nytt. `git tag -l` er lokal og
svarer uansett; den skiller «tagget lokalt» fra «nådde origin» uten å røre nettverket.
**Ble PUSH-en selv rate-limitet (punkt 10), er retry både riktig og trygt** — målt: `git push`
av en uendret tag gir `Everything up-to-date`, exit 0. **Det som ALDRI skal gjøres er å FLYTTE
taggen** (`git tag -f` + force-push). Uten force er selv det fail-closed — målt: remote svarer
`! [rejected] … already exists`.
---
## 6. Torsdag 13., FØR noen er i rommet — tre kommandoer
Ingenting her vises fram. Dette er sjekken som gjør at en feil blir et ikke-problem i stedet for en
abortsti foran publikum, og den koster sekunder: demoen selv kjører på under 3 sekunder (målt).
**Hvorfor den finnes:** golden-transkriptet ble sjekket inn nettopp for å fange regresjon **mellom
onsdag og torsdag** — selv-identitet (kriterium 6) fanger ikke-determinisme, men ikke at noe flyttet
seg over natta (egnethetsreview-planen pkt. 3). Kommandoen som gjør det står allerede i §1, under
«Hvis du vil vise at outputen er den frosne». **Torsdag er den ikke valgfri, og den kjøres FØR du
går på — ikke som et show underveis.** Samme kommando, annet tidspunkt, helt annen jobb: kjørt på
scenen oppdager den regresjonen samtidig med publikum.
**1 — Rent tre**
```
git status --short --untracked-files=no
```
Forventet: **TOMT** (målt). Utrackede filer holdes bevisst utenfor: `docs/presentasjon-portfolio-optimiser.html`
eies av en annen sesjon, og en gate som roper på den lærer deg å ignorere gaten. Kommer det linjer
her, er det **sporet** innhold som har endret seg — les hva før du gjør noe annet.
**2 — Treet du kjører ER det taggede**
```
git describe --tags --exact-match HEAD
```
Forventet: **`v1.0.0`**. Torsdag skriver ingenting, så forventningen er **IDENTITET** — ikke
frys-gatens «tom modulo `docs/` og `CHANGELOG.md`». De to unntakene var begrunnet i at *onsdagen*
skriver nøyaktig dem; arvet hit ville de gjort gaten blind for den ene skriveren vi vet er aktiv i
repoet (den parallelle sesjonens `docs/`-fil).
**Feiler den, er det en BESKJED — ikke nødvendigvis en abort.** Målt: uten tag på HEAD sier git
`fatal: no tag exactly matches '<sha>'` (exit 128); finnes ikke taggen i det hele tatt, sier den
`fatal: bad revision` — da landet ikke onsdagens siste steg. Begge feiler høylytt; ingen av dem kan
forveksles med grønt. Les så hva som faktisk landet:
```
git diff --stat v1.0.0..HEAD
```
**Ingen unntak her, med vilje.** Rører det `src/`, `tests/`, `shared/`, `pyproject.toml` eller
`uv.lock`**ikke kjør demoen fra dette treet**, gå til §3. Er det bare `docs/`, er demoen
upåvirket — men da *vet* du det, i stedet for å ha hatt en gate som tidde.
**3 — Outputen er fortsatt den frosne**
Kjør blokka i §1 («Hvis du vil vise at outputen er den frosne»). Forventet: `diff` **TOMT**.
*(Skriver `uv` en `Resolved`/`Audited`/`Installed`-linje før programmet starter, er det miljø-sjekken
sin og ikke en feil. Målt på varmt miljø er `uv run` stille — stderr er de fire linjene §1 beskriver
— men en første kjøring for dagen kan si fra. Det er ingen grunn til abort.)*
**Er 3 ikke tom: ikke debug — gå til §3.** Forskjellen er at du vet det på forhånd, så «dette er den
frosne kjøringen, sjekket inn som fasit» blir en planlagt setning i stedet for en redning.
---
## Vedlegg — tre punkter som IKKE trenger en setning på scenen (målt 2026-08-10)
STATE bar tre «scene-kosmetiske» punkter. Alle tre er målt mot det pinnede transkriptet, og
**ingen av dem er synlige i demoen**:
1. **`23700 NOK/aar` når aldri skjermen.** Personaens `rationale` er 389 tegn og beløpet står helt
til slutt (ca. tegn 370); demoen klipper på **300** tegn, så linja ender på `pga. overes…`.
Målt: `grep -c "23700" tests/golden/demo-transcript.stdout`**0**. STATEs formulering
(«printes fortsatt ordrett») var et premiss, ikke en måling. Det trengs altså **ingen** setning
på scenen — kun hvis noen åpner selve persona-fila.
2. **`0.82` vs `0.79`.** `0.82` hører til bygg-eksempelets golden, ikke veglys-kjøringen.
Målt: `grep -c "0\.82" tests/golden/demo-transcript.stdout`**0**.
3. **`docs/ekspert-svar.md`** er en operatør-guide og leses ikke av demoen.
Punktene står fortsatt som post-demo-opprydding i sine egne spor; de er bare ikke noe torsdagen
må bære.

View file

@ -0,0 +1,652 @@
# MAF-utnyttelsesrevisjon U1U15 + plan for Magentic-basert utforskningssløyfe
> **Ordre** `20260823T125528Z-1876024635-from-.claude` · **Økt 53**, 2026-08-23 · Fable 5/xhigh uten
> advisor (operatør-overstyring). Planarbeid: ingen kode i `src/`, ingen tester. **Hvert tall i dette
> dokumentet kommer fra en kommando som ble kjørt denne økten** (vedlegg V1V3); alt som ikke ble
> målt er merket «ikke målt» eller «spike».
## 0. Sammendrag — de seks setningene som bærer resten
1. **Målingen:** av `.claude`s ni «null i src/»-påstander holder 7 ordrett; U5 og U11 trenger
presisering (artefaktet/sitatbæringen finnes, MAF-konstruktet gjør det ikke). Nevner: 32 filer.
2. **Magentic-status:** hele Learn-siden hentet — **ingen experimental-advarsel for Python**
(eneste «experimental» er et C#-pragma), og installert kode har ingen `@experimental`-markør
på Magentic. F14-sitatet «untested … outside of the original Magentic-One design» **står
fortsatt**. PyPI latest er 1.1.1 (Production/Stable), men bare 1.0.0/1.0.1 kjører på core 1.9.0.
3. **Designet:** Magentic legges **OVER** den normative 8-stegs-sløyfa, ikke inni Steg 3. Utforskningen
er en **mandat-former**: prompt + bundles (+ fagpersonens frø-hypoteser) → `Mandate`
`run_project(mandate=…)` **uendret**. Validatoren gater hvert tall i nøyaktig samme gate som i dag.
4. **Fire ting ble MÅLT på installert 1.0.0, ikke antatt:** en Magentic-workflow er single-use
(høylytt `RuntimeError`), men **builder og manager-instans blør** (4/4 kontaminering, worker
hoppet over, fabrikkert sluttsvar); plan-review-rundturen **virker**, men har **ingen
revise-cap**; `max_round_count` gir en **kanonisk streng, aldri et typet signal**;
`MagenticResetSignal` **nullstiller ikke** deltakernes sesjon (orphan-attributt i 1.0.0).
5. **U14 er ikke «én funksjon»:** `setup_observability` finnes ikke; inngangen er
`agent_framework.observability.configure_otel_providers`, og **`opentelemetry-sdk` er ikke
installert** — kallet krasjer med `ModuleNotFoundError`. U14 er en avhengighetsbeslutning.
6. **Rekkefølgen:** S0S6-spikes (1 økt) → U14 (1) → U4+U13 synkron (2) → U12 + asynkron U13 (1)
→ U15 som egen sammenligning (1). U1/U2/U5/U11 avvises med begrunnelse. Seks økter.
---
## A. U1U15-måling (nevner: 32 `.py`-filer i `src/`; kontroll: 117 i `tests/`, 6 i `spikes/`)
Metode: `grep -rEn --include='*.py' "<regex>" src` per kapabilitet, samme regex mot `tests/` og
`spikes/` som kjent-positiv-kontroll, og — for hver regex som ga 0 i alle tre — en tredje kjøring
mot installert MAF (`.venv/lib/python3.12/site-packages/agent_framework{,_orchestrations}`) som
beviser at spørringen KAN finne (Verifiseringsloven ansikt 4). Skript og rå output: vedlegg V1.
En uavhengig andre sveip (Opus-agent, 35 tokens, samme nevner) ga identiske nuller (V3).
| ID | Kapabilitet (§15.1) | src | tests | spikes | venv-kontroll | Status | Belegg / spørring |
|----|----|---:|---:|---:|---:|----|----|
| U1 | `ConcurrentBuilder` fan-out | 0 | 4 | 4 | — (tests/spikes finner) | **IKKE** — erstattet av håndrullet bølge | `run.py:1262` `await asyncio.gather(` (D-D). Regex `ConcurrentBuilder\|BuildConcurrent\|FanIn` |
| U2 | `WorkflowBuilder` graf | 0 | 0 | 0 | 21 filer | **IKKE** | Regex `WorkflowBuilder\|@executor\|class .*\(Executor\)\|WorkflowContext\|SwitchCase\|add_edge\|add_fan_out` |
| U3 | `GroupChatBuilder` debatt | 10 | 7 | 7 | — | **LANDET** | `workflow.py:24` import · `:104` `GroupChatBuilder(` · `:108` `termination_condition=` · `:113` `.with_max_rounds(max_rounds)` |
| U4 | Magentic | 0 | 4 | 9 | 4 filer | **IKKE** | tests-treff: `tests/spikes/test_b_footguns.py`, `tests/spikes/test_imports.py`, `tests/test_method_spec_loadbearing.py:40` (framework-navn-svarteliste for spec-prosa — ikke bruk). `.claude`s «kun spikes/b_footguns.py» er riktig for BRUK |
| U5 | Agent Skills (`SkillsProvider`) | 0 | 0 | 0 | 3 filer | **IKKE** som MAF-konstrukt; **DELVIS** som artefakt | `persona.py:3`/`:47` leser `shared/skills/expert-reviewer/SKILL.md` via EGEN `load_persona_example`, aldri `SkillsProvider`. Installert core: `@experimental(feature_id=ExperimentalFeature.SKILLS)``SkillsProvider` (`_skills.py:1718`) |
| U6 | MCP-tools | 6 | 6 | 0 | — | **LANDET** (opt-in) | `mcp_tools.py:42-43` import · `:154` `MCPStdioTool(` · `:170` `MCPStreamableHTTPTool(`. Ingest-sømmen bruker rå `mcp.ClientSession` (`ingest_mcp.py:144-179`) |
| U7 | Function tools | 4 | 10 | 0 | — | **LANDET** | `datasource.py:18` `from agent_framework import FunctionTool, tool` · `:65` · `:69` `@tool(` |
| U8 | Middleware | 16 | 20 | 0 | — | **LANDET** | `budget.py:27` · `:228` `class BudgetMiddleware(ChatMiddleware)` · `run.py:635/:645` |
| U9 | `ContextProvider` | 8 | 11 | 4 | — | **LANDET** | `verdicts.py:29` · `:319` `class ExpeLContextProvider(ContextProvider)` · `run.py:724/:843` |
| U10 | Vektorlager | 2 | 19 | 0 | — | **DELVIS** (egen, ikke MAF) | `semretrieval.py:355` `save_vector_store` · `:393` `load_vector_store` — numpy brute-force (D-C). 0 på `AzureAISearch\|cosmos\|qdrant\|redis\|VectorStore` |
| U11 | Citation-aware RAG | 26 | 35 | 0 | — | **DELVIS** (egen, med MAF-typer) | `provenance.py:18` `from agent_framework import Annotation, TextSpanRegion` · `:78` `to_annotations()` («display only, NOT the source of truth»); `datasource.py:1` «citation-bearing data source». 0 på `AIContextProvider\|TextSearchProvider\|agentic_retrieval\|KnowledgeAgent` |
| U12 | Checkpointing | 3 (kun prosa) | 0 | 0 | 14 filer | **FRAVALGT** — §A.1 | `run.py:22`, `workflow.py:14`, `:115`: «no checkpoint — research 01: durable resume is fragile». 0 kode-treff på `checkpoint_storage\|CheckpointStorage` |
| U13 | HITL-gates | 3 | 0 | 0 | — | **DELVIS, og UØVD** | `workflow.py:116` `builder.with_request_info(agents=[agents[-1]])` bak `enable_layer1_hitl=False` (`:78`, `run.py:524`; ingen CLI-flagg). ENESTE test: `tests/test_workflow.py:54` `test_layer1_hitl_option_builds` — bygger, kjører aldri gaten, sender aldri et svar. `run.py` awaiter `debate.run` til ferdig (`:684-686`) og leser aldri strømmen. 0 på `RequestInfoEvent\|approval_mode\|MagenticPlanReview` i src OG tests |
| U14 | OpenTelemetry | 0 | 0 | 1 | 4 filer | **IKKE** | Regex `opentelemetry\|setup_observability\|OBSERVABILITY\|get_tracer\|start_as_current_span`. `uv pip list`: KUN `opentelemetry-api 1.42.1` (transitiv) — ingen sdk, ingen exporter |
| U15 | Evaluering | 0 | 0 | 0 | 2 filer | **IKKE** | Regex `evaluate_workflow\|LocalEvaluator\|Evaluator\|_evaluation`. `evaluate_workflow` finnes (`_evaluation.py:1833`) under `@experimental(feature_id=ExperimentalFeature.EVALS)` (`:1832`); 20 `@experimental` i modulen |
Utenfor de 15 (samme kjøring): U16 `CompactionProvider` 0/0/0 (venv 4) · U17 `as_tool(` 0/0/0
(venv 1) · U18 `FoundryChatClient` **LANDET** `backends.py:150` · U19 Harness 0/0/0 (venv 1).
**Mot `.claude`s ni null-påstander:** 7 av 9 holder ordrett (U1, U2, U4, U12, U13, U14, U15). 2 av 9
trenger presisering: **U5** og **U11**. De 6 landet-påstandene holder (U3, U6, U7, U8, U9,
U10-delvis). Aliasing/re-eksport er utelukket: alle 14 `agent_framework`-importlinjer i `src/` er
listet i V3 og hver er et direkte navn; `from agent_framework_orchestrations import` → 0 treff
(importstien i bruk er `agent_framework.orchestrations`, `workflow.py:24`).
### A.1 U12 — fravalgt på et premiss som har råtnet, eller som holder?
Premisset i koden: «research 01: durable checkpoint resume is fragile» (`workflow.py:14`).
**«research 01» finnes ikke som dokument i repoet** (`grep -rn -i "research.01" docs/ STATE*.md`
→ 0). Nærmeste dokumenterte grunnlag: kapabilitetskartet
(`docs/research/2026-06-24-maf-capability-map.md:54`): «superstep-granular (not mid-LLM-call);
resume needs identical graph. Defer past MVP unless needed»; egnethetsplanen 08-09 P9/P10
(«pass-nivå-gjenopptakelse er levert via spend-fila; ingen checkpointing»); `docs/extending.md:315`
(«A run either completes or is re-run; the async verdict inbox is the resumable boundary»).
**Vurdering:** begrunnelsen holder **for dagens debatt** (sekunderminutter; re-kjøring er billigere
enn gjenopptakelse). Den holder **ikke** for målbildet: en utforskning som stopper i
`MagenticPlanReviewRequest` og venter på en fagperson i dager kan ikke holde en prosess i live, og
«re-kjør» kaster planen fagpersonen skulle revidere. **Premisset er ikke råttent — det er utenfor
sitt gyldighetsområde.** Det som ble MÅLT i installert core 1.9.0 denne økten (V2 §7): mekanismen
FINNES og dekker Magentic-tilstand — `FileCheckpointStorage` (`_checkpoint.py:239`), «restore then
send» (`run(responses=…, checkpoint_id=…)` eksplisitt tillatt, `_workflow.py:877`), pending
`request_info` re-emitteres ved resume (`_runner_context.py:414-426`), og `MagenticOrchestrator.
on_checkpoint_save` skriver kontekst (runde/stall/reset-tellere), ledgere og managerens hele sesjon
(`_magentic.py:1266-1284`, `:746`). «Skjør» er i dag konkret: base64-pickle av tilstand med
allow-list (`_checkpoint.py:242-250`), graf-signatur må matche (`_runner.py:275-279`), og
manager-state-feil ved lagring/gjenoppretting **svelges med warning** (`_magentic.py:1281-1282`,
`:1320-1321`). Ingen ende-til-ende-resume med pending plan review er kjørt → **spike S4** (§D).
---
## B. Magentic-status mot primærkilden (hele siden hentet 2026-08-23)
Kilde: `microsoft_docs_fetch` mot
`learn.microsoft.com/en-us/agent-framework/workflows/orchestrations/magentic` — hele siden.
1. **Ingen experimental-advarsel for Python.** Eneste «experimental» på siden: C#-blokken
`#pragma warning disable MAAIW001 // Magentic types are experimental` (.NET-diagnostikk).
`.claude`s fravær er **bekreftet**, ikke bare ikke-motbevist.
2. **F14-sitatet står ordrett:** «it is untested how well the Magentic orchestration will perform
outside of the original Magentic-One design», og tipset «consider using the Group Chat pattern
instead» for enklere koordinering. F14s VURDERING var riktig lest; operatøren har flyttet
PREMISSET (målbildet krever planlegging), ikke sitatet. G8 i §15.3 er halvt riktig: funksjonelle
workflows er fortsatt `ExperimentalFeature.FUNCTIONAL_WORKFLOWS`; Magentic er ikke markert.
3. **Installert kode:** `agent_framework/_feature_stage.py:43-65``ExperimentalFeature` har 13
medlemmer (DECLARATIVE_AGENTS, EVALS, FILE_HISTORY, FIDES, FOUNDRY_TOOLS, FOUNDRY_PREVIEW_TOOLS,
FUNCTIONAL_WORKFLOWS, HARNESS, MCP_LONG_RUNNING_TASKS, MCP_SKILLS, PROGRESSIVE_TOOLS, SKILLS,
TO_PROMPT_AGENT). **Magentic er ikke der**; `grep -n -i "experimental\|feature_stage"
agent_framework_orchestrations/*.py` → 0.
4. **PyPI** (`curl https://pypi.org/pypi/agent-framework-orchestrations/json`): latest **1.1.1**
(2026-08-21), `Development Status :: 5 - Production/Stable`, 26 utgivelser. Krav per versjon:
1.0.0/1.0.1 → core ≥1.9.0; 1.0.2/1.1.0 → ≥1.13.0; 1.1.1 → ≥1.15.0. Core latest 1.15.0 (21.08).
5. **Installert = pinnet:** `uv pip list` → orchestrations 1.0.0, core 1.9.0; `uv.lock:61-63` låser
1.0.0; `pyproject.toml:17` `>=1.0.0`, `:12` core `>=1.9.0,<2` (`tests/test_maf_version_guard.py`).
6. **Learn-siden beskriver LATEST API** (`manager_agent=`, `enable_plan_review=`,
`intermediate_output_from=`). Installert 1.0.0 har de samme konstruktør-navnene (V2 §2) — men
builder-docstringen i 1.0.0 beskriver et HITL-API (`MagenticHumanInterventionRequest`,
`with_human_input_on_stall`) som **ikke finnes i koden** (kun i docstrings, `_magentic.py:1388-1559`).
Designet i §C er skrevet mot det som er målt installert, aldri mot docstring eller Learn.
7. **Core 1.12.0-notatet (21.07, samme minutt som orchestrations 1.0.1):** «Prevent the Magentic
manager from duplicating conversation history (#6297)». Om 1.0.1 bærer fiksen er **ikke
verifisert** — det er spike S0.
---
## C. Design: den utforskende sløyfa
### C.0 Beslutningen — Magentic OVER sløyfa, ikke INNI Steg 3
Kjernespørsmålet: Magentic gir manageren frihet til å velge vei; validatoren gater utfallet. Er de
forenlige? **Ja — på én betingelse: utforskningen produserer KANDIDATER, aldri FORSLAG.**
Tre fakta avgjør formen:
1. **Metodespesifikasjonen er normativ og commons-eid** (`shared/method-spec.md`, pull-only). §3
Steg 3 KREVER en to-rolle maker-checker-debatt, rundetak og fersk tilstand per kjøring; Steg 2
krever «exactly one candidate measure» per proposer-kall; §8 «Never an unbounded loop, anywhere».
En Magentic-sløyfe som ERSTATTER Steg 3 bryter spec-en og krever commons-amendment FØR bygging —
og den ville flyttet validatoren INN i grafen. Sømkartet (V3) er entydig: alt som gjør et svar
etterprøvbart ligger UTENFOR workflow-grafen og ETTER `debate.run` — generering `run.py:748-759`,
validator `generate.py:491`, provenance `run.py:793`, checker-override `:816`, dimensjonsgate
`:826`, domfangst `:849`, outbox `:857`.
2. **`Mandate` finnes og ER «en prompt om hva som skal gjøres»** (`mandate.py:55-88`): `objective` +
`approaches[{id, label, description}]` + `allow_own_proposals` + `success_criteria`. En
`Approach.description` er «the expert's own prose reason … fed to the proposer VERBATIM»
(`mandate.py:44-49`, `generate.py:308-317`) — altså «hypoteser fagpersoner kommer med».
`_evaluate_mandate` (`run.py:292-344`) kjører hver approach gjennom `generate_via_llm`
`validate_proposal` under SAMME meter, og rapporterer `not_evaluated` for det budsjettet ikke
rakk (aldri utelatt). `announce` (`mandate.py:130-183`) sier høyt hva kjøringen skal gjøre FØR
første betalte kall — repoets eksisterende før-kjøring-menneskesjekk.
3. **Validatoren er ren og offline** (`validate_proposal`, `validator.py:194-260`; CBC via PuLP,
`_MC_SAMPLES = 512`, ingen `agent_framework`-import) og kan eksponeres som `FunctionTool` (U7)
uten å endre hva den er.
Derfor: **utforskningen er en MANDAT-FORMER.** Inn: operatørens prompt + tilgjengelige bundles
(+ valgfrie frø-approaches fra fagpersoner). Ut: en `Mandate` hvis `approaches` er hypotesene
utforskningen fant verdt å teste, med managerens begrunnelse som `description`, og `bundle_id` per
approach. Deretter `run_project(bundle_dir=…, mandate=…)` **uendret**: debatt (Steg 3) → generering
(Steg 2) → deterministisk validator (Steg 4, blokkerende, stempler `validator_decision`) →
checker-gate → outbox/HITL (Steg 68). Ingen linje i den kjeden endres. Manageren velger VEI i et
rom som ikke kan skrive til outbox eller wiki; validatoren gater hvert TALL som forlater rommet, i
samme gate som i dag.
**Tre nivåer av garanti (uttalt, så ingen leser mer inn i det enn det er):**
| Nivå | Hva | Hvor | Stempel? |
|---|---|---|---|
| 1 | `quick_validate`-verktøyet i utforskningen | samme `validate_proposal`, samme baseline, kalt av hypothesiser-agenten | **NEI** — rådgivende tall i en utforskningslogg, aldri provenance |
| 2 | Pipelinen per approach | `run.py:748` `_evaluate``validate_proposal` | **JA**`ProvenanceStamp.validator_decision` |
| 3 | Skriverettigheter | kun pipelinen når outbox (`outbox.write_outbox`) og wiki (`promote_verdict`, gated) | utforskningens sluttsvar er RÅ (`{run_id}-exploration.json`), aldri et forslag |
Ærlighetsregelen (§1) følger direkte: managerens «final answer» presenteres ALDRI som et resultat.
Resultatet er det `_evaluate_mandate` returnerer.
### C.1 Manager + deltakersett (mot installert 1.0.0-API, V2 §23)
| Rolle | Type | Gjør | Verktøy / kanal |
|---|---|---|---|
| **manager** | `MagenticBuilder(manager_agent_factory=…)``StandardMagenticManager(agent=…)` med EGNE ledger-prompter (konstruktør-parametre `task_ledger_*_prompt`, `progress_ledger_prompt`, `final_answer_prompt`) | planlegger, velger neste taler, fører progress ledger, replanlegger | ingen — men **`BudgetMiddleware` PÅ manager-agenten** (C.2) |
| **navigator** | `Agent` | leser bundles PROGRESSIVT (§3 Steg 1) og svarer med sitater | `list_bundles()` · `read_bundle(id)``okf.navigate_bundle` + `Bundle.index_summary()`/`bundle_context` · `read_file(id, path)` innenfor `retrieval.safe_resolve` |
| **hypothesiser** | `Agent` | former én kandidat om gangen som IR-JSON (gjenbruker `generate._build_messages` + `proposal_response_format()`) | `quick_validate(ir_json)``{decision, reason, p10, p50, p90}` (nivå 1) |
| **expert_liaison** | menneske/persona | svarer på spørsmål, reviderer retning | plan review (`MagenticPlanReviewRequest.approve()/.revise(feedback)`) — og, etter spike S3b, `AgentApprovalExecutor` som deltaker for svar MIDT i kjøringen |
**Hvorfor `StandardMagenticManager`, ikke egen subklasse:** egen manager = egen ledger-parsing = egen
kopi av Magentic-One-logikken (kø-(p): den andre kopien drifter). Prompt-overstyring dekker behovet
(norsk domene, IR-disiplin, «aldri oppgi et tall uten `quick_validate`»). **Målt grunn til å
overvåke:** manageren parser ledgeren med en håndrullet JSON-skraper (`_extract_json`,
`_magentic.py:406-449`, `ast.literal_eval`-fallback), 3 forsøk (`:588-590`, `:710-723`) → `RuntimeError`;
`MagenticProgressLedgerItem.answer` er ikke typesjekket per felt (`:300-302`). Egen manager FØRST når en
målt mangel krever det.
**Hvorfor `manager_agent_factory=` og ikke `manager_agent=`:** målt E4 (V2 §6) — `manager_agent=`
konstruerer manageren eagerly (`:1683`) og gir SAMME instans til hvert `build()` (`:1729-1730`);
factory-varianten kalles per build (`:1732-1738`).
### C.2 Progress ledger som styringssignal, koblet mot `budget.py`
Ledgeren (`_magentic.py:310-314`, navn verifisert): `is_request_satisfied` / `is_in_loop` /
`is_progress_being_made` / `next_speaker` / `instruction_or_question`. Stall-regelen (`:1109-1118`):
`not is_progress_being_made or is_in_loop``stall_count += 1`, ellers **dekrementer** (ikke
nullstill); `stall_count > max_stall_count` (strengt `>`) → `_reset_and_replan` (`:1149`): kontekst
nullstilles, `reset_count += 1`, replan, plan review på nytt med `is_stalled=True` hvis aktivert.
To signaler, to vakter, aldri slått sammen (S3.4-invarianten):
| Signal | Kilde | Vakt | Utfall |
|---|---|---|---|
| **Semantisk** — stall | ledger | `max_stall_count` → reset+replan → `max_reset_count` | `exploration_stop="stalled"` — strukturert; IKKE krasj, IKKE suksess |
| **Ressurs** — tokens | `BudgetMiddleware` (`budget.py:228`) på HVER agent **inkl. manager** | pre-call-guard `:242-244` | `BudgetExceeded(kind="tokens")` → 429-kanalen |
| **Ressurs** — runder | Magentic `max_round_count` (= `Budget.max_rounds`) | intern (`:1238`) | **oversettes** av vårt lag til `BudgetExceeded(kind="exploration_rounds", limit, observed)` |
**Målt grunn til oversettelsen (E5, V2 §4):** `max_round_count`/`max_reset_count` gir **ingen
exception** — en kanonisk assistent-melding «Workflow terminated due to reaching maximum round
count.» + `_terminated=True` (`:1222-1256`), uten `prepare_final_answer`. Ved transporten er det
uskillbart fra suksess. Vårt lag leser derfor `round_count` fra `MagenticContext` (eksponert via
`PROGRESS_LEDGER_UPDATED`-eventene, ett per runde) og `orchestrator._terminated`, og produserer det
typede stoppet SELV — slik `BudgetExceeded`/429 alt gjør for debatten. Strengen pinnes mot den
installerte konstanten (`ROUND_CAP_LOGGER`-presedensen), aldri fri-tekst-matchet.
**`tick_round` er IKKE koblet til orkestreringsrunder i dag** (sømkart: to kallere, begge i
`generate.py:390/:455`; `run.py:632` gir meteret `max(max_rounds*4, 4)` for GENERERINGS-forsøk).
Utforskningen får derfor sin egen `Budget` (tokens + runder) i en egen `TokenMeter`, og `run_project`
sitt meter er urørt. Stall-telleren er IKKE en budsjettvakt og kobles ikke inn i `TokenMeter`.
Hver runde logges (ledger + `next_speaker` + `instruction_or_question`) til
`{run_id}-exploration.json`, så en stoppet utforskning er lesbar uansett hvilken vakt som fyrte.
**⚠ Spike S2:** manageren er et `Agent` (`StandardMagenticManager(agent=…)`, `:528`; ett
`create_session()` i `__init__`, `:572`; alle kall via `agent.run(…, session=…)`, `:601`), så
agent-nivå `ChatMiddleware` SKAL fyre — men det er **ikke målt** for manager-stien, og en feil her
gjør «harde token-tak» til en løgn for den mest pratsomme deltakeren. Kontroll: bar
`BaseChatClient` no-op-er middleware (`simulation.py:373-375`) — spiken bruker `ScriptedChatClient`.
### C.3 Grensene — B4 bygges ikke bort
| Dagens | Magentic (installert) | Regel |
|---|---|---|
| `with_max_rounds(max_rounds)` (`workflow.py:113`) | `max_round_count` (ctor-kwarg; default `None` = ubegrenset, `:542`) | PÅKREVD, positiv, fail-fast (G1/B4/A4) |
| — | `max_stall_count` (default 3, `:540`) | PÅKREVD, positiv — ingen arv av defaulten |
| — | `max_reset_count` (default `None`, `:541`) | PÅKREVD, ≥0 |
| termination-safety-net (`:108`) | manageren avslutter ved `is_request_satisfied` (`:1104-1107`) **eller** ved ukjent `next_speaker` (`:1128-1131` — stille sluttsvar!) | rundetaket ER nettet; ukjent taler logges som avvik |
| — | plan review: **ingen revise-cap, revises telles ikke som runder** (målt E6: 2 manager-kall per revise, 0 ledger-kall) | NY `max_plan_revisions` i kontrakten; hver revise `tick_round()`-es |
`ExplorationContract` (pydantic, fail-fast, speiler `contracts.TerminationContract`): `max_rounds`,
`max_tokens`, `max_stall_count`, `max_reset_count`, `max_plan_revisions`, `enable_plan_review`.
Ingen defaults: en utforskning uten uttalte grenser nekter å starte.
### C.4 B7 — tilstandsisolasjon. MÅLT (V2 §6; skript i V1)
| Eksperiment | Resultat | Konsekvens |
|---|---|---|
| **E1** én bygget workflow, to `.run()` | run 2 → `RuntimeError: This Magentic workflow has already been completed…` (`_magentic.py:914-918`), **0 modellkall** | single-use — STERKERE enn GroupChat 1.9.0s stille tomme kjøringer [2,0,0]; kan ikke korrumpere, for den kan ikke kjøre |
| **E2** to workflows, ÉN delt `StandardMagenticManager` | run 2s manager-kall ser run 1s oppgave **4/4**; meldingstall vokser 2→…→23→**26**→39 over kjøringsgrensen; run 2s første ledger svarte `satisfied=True` og **workeren ble aldri kalt (0)** — et fabrikkert sluttsvar | ikke bare kost: **en plausibel fasit produsert av null arbeid** |
| **E3** kontroll, fersk manager per build | **0/5** kontaminering, 5 kall inkl. `ledger_UNSAT` (workeren KALT) | 5-vs-4-kallformen er diskriminatoren |
| **E4** én `MagenticBuilder`, to `.build()` | samme 4/4-blødning som E2 (`:1683`, `:1729-1730`) | «ny workflow» er ikke nok — **ny BUILDER per oppgave**, eller `manager_agent_factory=` |
| **E7** `MagenticResetSignal` | `_magentic.py:1366` skriver `self._agent_thread` — lest av INGENTING (1 treff repo-vidt; den levende er `AgentExecutor._session`, 7 treff). Målt: cache/full_conversation tømt, **sesjonsidentitet uendret** | stall-replan gir fersk manager-ledger men **gamle deltakere**; docstringens løfte (`:1341-1344`) holder ikke i 1.0.0 |
**Regel:** per utforskning **ny builder → ny build → én kjøring**, fersk klient per agent (som
`fresh_workflow`/`_default_factory` alt gjør, `run.py:436-440`). Stol aldri på stall-reset for å
tømme deltakerminne. `fresh_exploration_workflow()` speiler `fresh_workflow()` (`workflow.py:74`).
### C.5 HITL-spranget: fra JA/NEI til «be om svar, bruke svarene»
**Installert flyt (målt E6, V2 §5):** `with_plan_review()`/`enable_plan_review=True`
`ctx.request_info(MagenticPlanReviewRequest{plan, current_progress, is_stalled},
MagenticPlanReviewResponse)` (`:1041-1048`) → event `type="request_info"` med `request_id` →
kjøringen STOPPER uten output → `workflow.run(responses={request_id: req.approve()})` eller
`req.revise("…")` (`_workflow.py:899-908`) → ved revise: feedback inn i historikken, `manager.replan`,
`REPLANNED`-event, **ny review-forespørsel** (`:1014-1031`) → ved approve: løkka kjører.
Dette er målbildets «be om svar, bruke svarene og jobbe videre» ordrett — og det virker på 1.0.0.
**Ingen landingsplass i dag** (sømkart §4): `with_request_info` bygges men ingenting konsumerer
strømmen; `hitl.py` er et offline inspeksjonsverktøy (id-join outbox inbox, `hitl.py:159`), ikke
en forespørsel/svar-mekanisme. Koblingen mot den asynkrone køa **gjenbruker formen, ikke filene:**
| Eksisterende | Utforskningen |
|---|---|
| outbox `{run_id}-outcome.json` med `verdict_id` | **NY artefaktklasse** `{run_id}-plan-review.json` = `{request_id, plan, current_progress, is_stalled, checkpoint_id}` — samme RAW-lag, samme mappe, byte-deterministisk via `outbox._dump` |
| inbox `{id}.json` (`verdicts.write_verdict`) | `{request_id}.json` = `{decision: approve\|revise, feedback}` — eksperten SKRIVER, systemet LESER (§3 Steg 7-rolledelingen) |
| `hitl.py pending` (id-join) | samme join over den nye klassen; `python -m portfolio_optimiser.hitl pending` viser begge |
| `FeedbackContract.decision ∈ {approved, rejected}` | **IKKE gjenbrukt** — en plan-revisjon er ikke en dom; eget lite pydantic-skjema |
| `simulation`-personaen (`load_persona_example`) | spiller eksperten SYNKRONT i demoen: `revise("Test også …")` én gang, så `approve()` |
To tidsskalaer, som Steg 7: **synkron** (persona/operatør i samme prosess — demoen og første
leveranse) og **asynkron** (fil-innboks over dager → krever U12: `checkpoint_id` i artefaktet,
`run(responses=…, checkpoint_id=…, checkpoint_storage=…)`). Det er DÉT som flytter U12 fra «NEI» til
«nå» — og spike S4 avgjør om 1.9.0 bærer det.
**Svar MIDT i kjøringen (ikke bare ved plan):** plan review er det ENESTE `request_info`
orkestratoren selv reiser (4 treff i `_magentic.py`, 0 i basen). Men `AgentApprovalExecutor`
(`_orchestration_request_info.py:168`, `propagate_request=True` `:201`) kan stå som DELTAKER:
når manageren velger `expert_liaison`, blir dens output en `request_info`, og mennesket svarer med
`AgentRequestInfoResponse.approve()`/`.from_strings(…)` (`:55-79`) — svaret blir deltakerens
output i historikken. **Ikke målt** → spike S3b. Lander den, er «stille spørsmål … be om svar»
dekket både ved plan og underveis.
### C.6 «Hypoteser fagpersoner kommer med» — tre inngangsdører, i kostnadsrekkefølge
1. **I mandatet (finnes):** `Approach{label, description}` → proposeren verbatim. Utforskningen tar
frø-approaches som INPUT og **MÅ bevare dem i output-mandatet** (en hypotese fagpersonen ba om
forsvinner aldri stille — `not_evaluated`-regelen anvendt på utforskning).
2. **I plan review (U13):** `revise("Test hypotesen at …")` → replan med teksten i task-ledgeren.
3. **Underveis (S3b):** `expert_liaison`-deltakeren — manageren SPØR, fagpersonen svarer, svaret
blir kontekst for neste runde.
4. **I kunnskapsbasen (senere, commons-sak):** `type: hypothesis`-konseptfil navigatoren finner.
Krever OKF-vokabular-beslutning hos commons — **ikke i denne planen**.
### C.7 OKF-bundles som inngang — hva manageren må vite
Manageren planlegger mot en **katalog**, aldri innholdet (§3 Steg 1). Per bundle: `id` ·
`Bundle.index_summary()` (`okf.py:154`) · kandidat-features (`verdicts.bundle_candidate_features`,
`:482`) · **om `cost-baseline.json` finnes** (`okf.load_optional_cost_baseline`, `:411`) — avgjørende:
en uforankret bundle kan ikke quick-valideres mot stage 0, og manageren skal vite hvilke hypoteser
som KAN testes deterministisk · antall `type: verdict`-filer (`Bundle.verdicts`). Navigatoren åpner
én bundle om gangen på managerens instruks. **Multi-bundle er NYTT:** `run_project` tar ÉN
`bundle_dir` (`run.py:514`; `bundle_dirs` → 0 treff); utforskningen tar `bundle_dirs`, hver
approach bærer `bundle_id`, og pipelinen kjøres per bundle som i dag (`run_portfolio`-formen).
### C.8 Modulplassering (for byggeøktene, ikke for denne)
`src/portfolio_optimiser/explore.py` (NY): `ExplorationContract`, `ExplorationResult{mandate,
ledger_log, stop, plan_reviews}`, `fresh_exploration_workflow(...)`, `explore(...)`. Importerer
`agent_framework.orchestrations`**aldri** fra `okf.py`/`mandate.py`/`hitl.py` (MAF-fri-guarden
`test_okf_is_maf_free`). Verktøyene (`quick_validate`, navigator-tools) bor i `explore.py` og
wrapper de rene modulene. `run.py` får ett nytt kallsted (`--explore "<prompt>"``explore()`
`run_project(mandate=…)`), `hosting.py` ett nytt whitelistet felt (`explore_prompt`), og
`simulation.py` ett nytt scenario. Demo-transkriptet (`tests/golden/demo-transcript.stdout`) er
**byte-uendret** fordi utforskningen er opt-in.
---
## D. Prioritert vei gjennom de ni gapene
### D.0 Versjonsbeslutningen (FØR noe bygges — én avgjørelse, ikke en glidning)
**Anbefaling: bygg mot 1.0.x (installert) i denne planen.** (1) core-bumpen 1.9→1.15 er seks
minor-versjoner over privat-API-premisser `test_maf_version_guard` vokter, og tre målte
1.9-egenskaper (`ExperimentalWarning`-paret, single-use GroupChat [2,0,0], middleware-no-op på bar
klient) må re-måles; (2) en bump er en egen beslutning, ikke et biprodukt av sløyfa; (3) **1.0.1 er et
patch-hopp på samme core** som kan tas gratis
hvis `uv lock --upgrade-package agent-framework-orchestrations` gir 1.0.1 og suiten er grønn — og
som KAN bære #6297 (§B pkt. 7) og/eller E7-fiksen. Det er spike S0. Bumpen til 1.1.x/core 1.15 er
en EGEN økt etter denne planen (bonus: `InvocationsHostServer` krever core ≥1.13) — ikke en forutsetning.
### D.1 Spikes (økt 54 — alle i `spikes/e_magentic.py` + `tests/spikes/`, aldri `src/`)
| # | Spørsmål | Måling | Felles hvis |
|---|---|---|---|
| S0 | Gir 1.0.1 samme API og fjerner den E2/E4/E7? | `uv lock --upgrade-package agent-framework-orchestrations``uv pip list`; re-kjør V1-skriptene `magentic_b7*.py` + `magentic_reset.py`; `uv run pytest -q` | E7 står → bli på 1.0.0, noter |
| S1 | B7 i repoets egen form | E1E4 + E7 portert til `spikes/` med `ScriptedChatClient` (ikke agentens `FakeClient`) | 4/4 på delt builder, 0/5 på fersk |
| S2 | Fyrer `BudgetMiddleware` på MANAGERENS kall? | manager-`Agent(middleware=[BudgetMiddleware(meter)])`, `Budget(max_tokens=1, …)`; assert `BudgetExceeded` propagerer ut av `workflow.run` OG at `meter.tokens > 0` etter ett kall | middleware fyrer ikke → egen klient-wrapper; plan-endring |
| S3 | Plan-review-rundtur i repoets form | E6 portert: request_info-event → `run(responses=…)` med `revise``approve`; tell manager-kall per revise (målt 2) | rundturen feiler på 1.0.0 |
| S3b | `AgentApprovalExecutor` som deltaker | svar injiseres via `from_strings("svar")`, assert svaret står i neste manager-prompt | ikke nåbar → kun plan review (dør 1+2) |
| S4 | Resume med pending plan review | `with_checkpointing(FileCheckpointStorage(tmp))`, kjør til review, **ny prosess** (subprosess), `run(responses=…, checkpoint_id=…, checkpoint_storage=…)`; assert `request_info` re-emittert + sluttsvar | feiler → U12 synkron-først, asynkron etter planen |
| S5 | `quick_validate`-latens | `validate_proposal` ×20 på `bygg-energi-baseline-mikro` med `time.perf_counter` | > 2 s/kall → budsjettér kall i kontrakten |
| S6 | OTEL gratis? | `uv add opentelemetry-sdk` i scratch-venv; `configure_otel_providers(enable_console_exporters=True)`; kjør demoen; tell spans | ingen `workflow.run`-span → U14 er større enn én økt |
### D.2 Rekkefølgen
| # | Hva | Gap | Load-bearing for målbildet? | Økt | Modell |
|---|---|---|---|---|---|
| 0 | **Spikes S0S6** | U4/U13/U12/U8/U14 | JA — hver er en antakelse planen ellers bygger blindt på | 54 | Opus 5/xhigh |
| 1 | **U14 OpenTelemetry**`opentelemetry-sdk` som deklarert dep, `configure_otel_providers` bak `PORTFOLIO_OTEL=…` (console default; OTLP KUN med eksplisitt `OTEL_EXPORTER_OTLP_ENDPOINT` — «ingen stille egress»), spans på DAGENS pipeline først; egne events for `PLAN_CREATED`/`REPLANNED`/`PROGRESS_LEDGER_UPDATED` (målt: `_magentic.py` emitterer null spans selv) | U14 | JA for «stole på» — og baselinen U15 måler mot | 55 | Opus 5/high |
| 2 | **U4 + U13 synkron**`explore.py` (C.8), `ExplorationContract`, mandat-former, `quick_validate`, navigator-tools, plan review med persona; `simulation`-scenario; `--explore` i CLI | U4, U13 | JA — dette ER målbildet | 5657 | Opus 5/xhigh |
| 3 | **U12 + asynkron U13**`FileCheckpointStorage`, `{run_id}-plan-review.json` + innboks-svar, `hitl.py pending` utvidet, `--resume <run_id>` | U12, U13 | JA for svar over dager — **betinget av S4** | 58 | Opus 5/xhigh |
| 4 | **U15** — EGEN sammenligning på golden-bundelen: utforskning vs. debatt på tre tall (validerte kandidater, tokens, runder), skrevet til `docs/` som måling | U15 | JA for «finner den bedre tiltak» — men MAF-evals AVVISES (eksperimentell i kode, «wrong shape», kapabilitetskart :58) | 59 | Opus 5/high |
| — | U1 `ConcurrentBuilder` | U1 | NEI — bølgemodellen er målt, bærer budsjett-reservasjon (S3.4); Concurrent har ingen runder å reservere mot | etter planen, om noensinne | — |
| — | U2 `WorkflowBuilder` | U2 | NEI — Magentic ER graf-laget; direkte adopsjon gir null ny kapabilitet | nei | — |
| — | U5 `SkillsProvider` | U5 | NEI — `ExperimentalFeature.SKILLS`; egen loader virker; commons eier innholdet | etter planen | — |
| — | U11 MAF RAG-providere | U11 | NEI — «navigate, never stuff» forbyr query-time retrieval; sitatbæring finnes | avvist med begrunnelse | — |
Seks økter (5459); faller S4, sparer pkt. 3 én økt.
### D.3 For og mot de fire kandidatene ordren navngir
**U4 + U13 tidlig — FOR:** selve målbildet; alt annet er instrumentering av status quo. **MOT:** hviler
på umålte antakelser (S2, S3b, S4) og på en `request_info`-sti repoet ALDRI har kjørt. Derfor spikes
først — ikke omvendt. **Plass #2 og #3.**
**U14 tidlig — FOR:** (1) repoets deploy-gate; (2) uten sporing er en utforskning en svart boks — og
Magentic-koden gir selv null spans, så ledger-eventene må vi logge; (3) LITE i kode: én funksjon +
env; (4) instrumentert FØR utforskningen gir U15 sin baseline. **MOT (målt):** det er **ikke gratis i
avhengigheter** — `opentelemetry-sdk` + exporter må deklareres (wheel-vekt, `uv sync`, handover-
pakka), og `ENABLE_INSTRUMENTATION` er default `True` (`observability.py:697`) så spans LAGES alt i
dag og kastes. OTLP-exporter er egress og må være opt-in bak eksplisitt endepunkt. **Plass #1,
betinget av S6.**
**U15 — FOR:** uten måling kan vi ikke påstå at sløyfa er bedre; ærlighetsregelen forbyr påstanden.
**MOT `evaluate_workflow`:** eksperimentell i kode (20 dekoratorer) og feil form (kvalitetsscore
offline, ikke domeneobjekt). **Konklusjon:** evaluering JA, som egen sammenligning; MAF-flaten
NEI inntil `EVALS` forlater `ExperimentalFeature`. **Plass #4** — den trenger noe å måle.
**U12 — FOR:** asynkron plan review over dager er umulig uten; mekanismen dekker Magentic-tilstand
(målt i kode, §A.1). **MOT:** ende-til-ende-resume på 1.9.0 er umålt, restore-feil svelges, og en
synkron persona-variant leverer målbildet i demoen uten den. **Konklusjon:** S4 avgjør; bygg i #3 hvis
grønn, ellers synkron-først og U12 etter planen, sammen med core-bumpen.
### D.4 Det som IKKE gjøres (uttalt)
- Ingen endring i `shared/method-spec.md`. Utforskningen er et lag OVER §3; en amendment
(«Step 0 — Explore, optional») foreslås til commons via coord (operatørbeslutning, §F) — planen
avhenger ikke av svaret.
- Ingen D7-speiling nå: po-claude er PARKERT (STATE); utforskningslaget meldes dit når parkeringen
oppheves, på linje med Steg-5-returtypen og demo-innboksen som også venter.
- Ingen erstatning av GroupChat-debatten (Steg 3 står). Ingen `open/`-push, versjonsbump, Azure.
- Ingen egen `MagenticManagerBase`-subklasse før en målt mangel krever det.
---
## E. Verifisering — testbare kriterier (kommandoer, ikke «sjekk at det virker»)
**Denne økten (planen selv):**
| # | Kriterium | Kommando → forventet |
|---|---|---|
| E1 | Nevneren er 32 | `find src -name '*.py' \| wc -l``32` |
| E2 | Hver null-spørring kan finne | `bash scratchpad/u_measure.sh` (V1) → `venv_files` ≥ 1 for hver av de 9 null-regexene |
| E3 | Magentic ikke kode-markert | `grep -c -i "experimental" .venv/lib/python3.12/site-packages/agent_framework_orchestrations/_magentic.py``0`; `grep -n "SKILLS\|EVALS" …/agent_framework/_feature_stage.py``:54`, `:64` |
| E4 | Installert = lås | `uv pip list \| grep orchestrations``1.0.0`; `sed -n '61,63p' uv.lock``version = "1.0.0"` |
| E5 | Single-use + builder-blødning reproduserbar | `uv run python scratchpad/magentic_b7.py``E1 run2 … RuntimeError`, `E2 VERDICT … 4/4`, `E3 VERDICT … 0/5` |
| E6 | Plan-review-rundtur reproduserbar | `uv run python scratchpad/magentic_b7b.py``E6 after APPROVE outputs: ['FINAL: …']`, `E5 outputs: ['Workflow terminated due to reaching maximum round count.']` |
| E7 | OTEL-sdk mangler | `.venv/bin/python -c "import opentelemetry.sdk"``ModuleNotFoundError` |
| E8 | Planen er dok-gate-klassifisert | `uv run pytest -q tests/test_doc_constant_sync_loadbearing.py` → grønn (datert sti = arkiv-klasse) |
| E9 | Treet urørt utenom planen | `git status --porcelain` → kun `docs/plan/2026-08-23-…md` + den fremmede `presentasjon`-HTML-en |
**Byggeøktene (kriterier som FELLES når sømmen detaches — load-bearing-metoden):**
| Leveranse | Rødt-først-kriterium |
|---|---|
| S1/B7 | test som deler builder mellom to utforskninger er RØD (4/4); `fresh_exploration_workflow` gjør den grønn (0/N) |
| S2/budsjett | `Budget(max_tokens=1)` på manager-agenten → `BudgetExceeded` ut av `explore()`; detach middleware på manageren → testen rød |
| C.2/runder | `max_rounds=1``BudgetExceeded(kind="exploration_rounds", limit=1, observed=1)`; detach oversettelsen → kjøringen «lykkes» med den kanoniske strengen → rød |
| C.3/kontrakt | `ExplorationContract(max_stall_count=None)``ValidationError` ved konstruksjon |
| C.5/plan review | persona-`revise` én gang → `REPLANNED` i loggen og `plan_reviews == 2`; `max_plan_revisions=1` + persona som alltid reviderer → typet stopp, aldri evig løkke |
| C.6/frø | frø-approach `id="fagperson-1"` finnes i output-`Mandate.approaches` uansett hva manageren fant; detach bevaringen → rød |
| C.0/nivå 3 | `explore()` skriver ingenting til outbox/wiki: `ls outbox` før/etter → kun `{run_id}-exploration.json`; `git status shared/` ren |
| U12/S4 | subprosess-resume: pending `request_info` re-emittert + sluttsvar; detach `checkpoint_storage` → rød |
| U14 | demoen under `PORTFOLIO_OTEL=console` → ≥1 `workflow.run`-span på stderr; uten variabelen → stdout byte-identisk med `tests/golden/demo-transcript.stdout` |
| Golden | `uv run pytest -q` grønn; `shasum tests/golden/demo-transcript.stdout` uendret |
---
## F. Nøkkelantakelser, risiko og åpne beslutninger
**Alle åtte radene er MÅLT i økt 54** (ordre `20260823T162224Z`, spikes S0S6). Måleapparatet er
`spikes/e_magentic.py` + `tests/spikes/test_e_magentic.py` (16 tester), kjørt mot orchestrations
**1.0.1** på core 1.9.0; hele suiten 920 passed / 5 skipped, golden-transkriptet uendret
(`ea8c534773acdbe41ae68f2c55724d69aaf8be4f`).
| # | Antakelse | Test | Status |
|---|---|---|---|
| A1 | Agent-nivå `ChatMiddleware` fyrer på managerens kall | S2 | **MÅLT GRØNN.** `BudgetMiddleware` på manager-agenten krediterer meteret (`meter.tokens == 8` etter ett kall) og stopper kjøringen. Detach-kontroll: uten middleware fullfører SAMME 1-token-budsjett. Planens budsjettgaranti står. |
| A2 | `BudgetExceeded` reist inni en Magentic-deltaker propagerer ut av `workflow.run` | S2 | **MÅLT GRØNN, og som repoets EGEN type** — ikke pakket i en `ExceptionGroup`: `isinstance(exc, BudgetExceeded)`, `kind="tokens"`, `limit=1`, `observed=8`. Trippelen kø-(y) leser er intakt, så 429-kanalen kan brukes uendret. |
| A3 | `request_info`-rundtur | S3 | **MÅLT GRØNN på 1.0.1.** Review stopper kjøringen uten output (manageren har da kun kalt `facts`+`plan`); `revise` koster nøyaktig 2 manager-kall (`facts_update`, `plan_update`), **null ledger-kall og null runder**, og **spør på nytt**; `approve` kjører løkka til sluttsvar. Bekrefter at `max_plan_revisions` MÅ inn i kontrakten — ellers er en alltid-reviderende ekspert et ubundet forbruk. |
| A4 | Resume med pending plan review i NY prosess | S4 | **MÅLT GRØNN.** Foreldreprosessen stopper på review og etterlater checkpoints; en subprosess (`spikes/e_magentic_resume.py`) som aldri så kjøringen svarer fra checkpointen alene og driver workflowen til sluttsvar. **Pris, ikke forutsett:** `FileCheckpointStorage` NEKTER å deserialisere `MagenticPlanReviewRequest`/`…Response` uten at begge navngis i `allowed_checkpoint_types` — uten det er checkpoint-fila uleselig og listingen TOM, altså en resume som feiler som et FRAVÆR. Begge prosesser må deklarere dem. |
| A5 | `AgentApprovalExecutor` som deltaker gir svar midt i kjøringen | S3b | **MÅLT GRØNN — dør 3 i C.6 står.** Ekspertens ord når både liaisonen og en senere manager-prompt. **To rundturer per menneskesvar:** `from_strings([svar])` mater svaret tilbake INN i liaisonen og gjenopptar IKKE manageren (målt: null manager-kall mellom de to forespørslene); først `approve()` sender liaisonens output videre. **Kostnad:** `AgentApprovalExecutor` er IKKE re-eksportert fra `agent_framework.orchestrations` (svartypen ER det) — døra koster i dag en privat-API-import. |
| A6 | Persona-manuset kan drive en Magentic-manager (ledger-JSON) offline | S1/S3 | **MÅLT GRØNN med repoets `ScriptedChatClient`** (ikke lenger en ad-hoc `FakeClient`). Ett forbehold funnet: selectoren får den SAMMENSLÅTTE prompten, så ett av fem manager-kall bærer to markører — rutingen må teste senere-stadium-markøren først. Og en `next_speaker` som ikke matcher en deltaker gir **stille sluttsvar uten at noen ble spurt** (`_magentic.py:1128-1131`), målt da liaison-spiken først het `worker`. |
| A7 | Utforskning som opt-in holder demo-transkriptet byte-uendret | `shasum` | **MÅLT uendret** gjennom hele økten (`ea8c534…`). |
| A8 | 1.0.1 er API-identisk med 1.0.0 | S0 | **MÅLT: API-identisk, ATFERD ikke.** Diffen er upstream-regresjonsfiksen #4371: `StandardMagenticManager.__init__` mistet `self._session = agent.create_session()`, og hvert manager-kall mynter nå en engangs-sesjon. **Konsekvens: E2 OG E4 er BORTE** (4/4 → **0/5** begge). E1 (single-use `RuntimeError`, null kall) og E7 (`MagenticResetSignal` skriver til orphan-attributtet `_agent_thread`, `:1369`, sesjonsidentitet uendret) står. Manager-sesjonen fjernet også fra checkpoint-state, konsistent med at manageren nå er tilstandsløs per kall. |
**Målt korreksjon til E-tabellen (U12/S4):** kriteriet «detach `checkpoint_storage` → rød» er
FEIL — å fjerne `checkpoint_storage=` fra `run()` lar HELE suiten stå grønn (920 passed), fordi
`.with_checkpointing(...)` på builderen allerede ga workflowen lageret. De to bærende punktene er
`checkpoint_id=` (fjernes → rød) og builderens `.with_checkpointing(...)` (fjernes → rød).
**Load-bearing MÅLT** (mot HELE suiten, grønn kontroll 920/5): `manager_keeps_persistent_session`
konstant `True` (2 røde — E2+E4 alene) · aldri fest `BudgetMiddleware` på manageren (3 røde, mens
detach-armen forblir grønn) · detach markør-registreringen (1 rød — S3b-positiven alene, kontrollen
grønn) · flipp `_route`-rekkefølgen så `pre-survey` testes først (4 røde) · resume uten
`checkpoint_id=` (1 rød) · builder uten `.with_checkpointing()` (1 rød) · tom
`_ALLOWED_CHECKPOINT_TYPES` (1 rød) · og den falsifiserte: resume uten `checkpoint_storage=` (**0
røde** — funnet over).
**S5 — `quick_validate`-latens: median 13,6 ms** over 20 kall på `bygg-energi-baseline-mikro`
(forankret baseline + assumption-bånd, så stage 0 + CBC + 512-sample Monte Carlo er alle med).
Langt under 2 s-terskelen: verktøyet kan kalles fritt i løkka, og kontrakten trenger ingen
egen latens-post. Båndet er med med vilje — uten det faller `_monte_carlo` tilbake på
`item.unit_cost`, alle draw blir identiske, og tallet ville underrapportert den ekte kostnaden.
**S6 — OTEL er gratis, men IKKE via `enable_console_exporters`.** I et scratch-venv pinnet til
samme stack (core 1.9.0 / orch 1.0.1 / `opentelemetry-sdk` 1.44.0; **ingenting lagt til
`pyproject.toml`**) gir `configure_otel_providers(enable_console_exporters=True)` **2
`workflow.run`-spans** — pluss `workflow.build`, `executor.process`, `edge_group.process`,
`message.send`, `invoke_agent`, `chat synthetic` — men de skrives til **stdout**, som ville
ødelagt golden-transkriptet. Med `exporters=[ConsoleSpanExporter(out=sys.stderr)]` kommer de 2
`workflow.run`-spanene på **stderr** og demoens stdout er **byte-identisk med fasiten** (samme
shasum). U14 er altså én økts arbeid — forutsatt at exporteren konstrueres eksplisitt mot stderr,
aldri via flagget.
**U14 er LANDET (økt 55, ordre `20260823T165757Z-6762213199`).** Operatøren tok alle fire
beslutningene som anbefalt 23.08, og bekreftet samme dag at **låsen på orchestrations 1.0.1 er
ENDELIG, ikke midlertidig** — spike-ordrens «revert hvis E7 står» er overstyrt av den senere
beslutningen, som betinget på grønn suite (920/5, målt) og ikke på E7. Levert:
* `opentelemetry-sdk>=1.42,<2` som DEKLARERT dependency (beslutning 2). Én ny pakke, ikke to:
`ConsoleSpanExporter` bor inne i sdk-en. `opentelemetry-api` fulgte med fra 1.42.1 til 1.44.0 —
målt uskadelig (golden `ea8c534…` uendret før og etter).
* `src/portfolio_optimiser/tracing.py``PORTFOLIO_OTEL` på truthiness, modusene `console`
(spans → stderr) og `otlp` (kun mot et navngitt endepunkt), `tracing_notice` som eneste renderer.
* Wiret i TRE inngangspunkter: `run.main`, `simulation.main`, `hosting.main`.
* **S6-forbeholdet er implementert som skrevet:** `ConsoleSpanExporter(out=…)`, aldri
`enable_console_exporters=True`. Målt: demoen under `PORTFOLIO_OTEL=console` gir `workflow.run`-
spans på stderr og stdout BYTE-IDENTISK med fasiten; uten variabelen null spans.
* **To ting E-tabellen ikke forutså, begge lukket:** (i) MAF bygger env-avledede OTLP-exportere
UBETINGET, så `console` NEKTER når en `OTEL_EXPORTER_OTLP_*_ENDPOINT` er satt — ellers var
«console» en usann påstand om hvor kjøringens innhold tok veien; (ii) `enable_console_exporters`
faller tilbake på `ENABLE_CONSOLE_EXPORTERS` i miljøet, så den pinnes eksplisitt `False` i begge
moduser. Begge er MÅLT som defektklasser, ikke antatt.
* **IKKE bygget, med grunn:** `PLAN_CREATED`/`REPLANNED`/`PROGRESS_LEDGER_UPDATED` — de hører til
sløyfa U4 bygger, og en emitter uten kallsted er en form gjettet i stedet for målt. De landes i
økt 5657, på sømmen U14 nettopp la. OTLP-exporter-PAKKENE er bevisst ikke deklarert (egress +
grpc/protobuf-vekt i et publisert wheel); uttalt ærlighets-grense i README/DEPLOY/env.template.
Suite etter U14: **943 passed / 5 skipped**, golden `ea8c534…` uendret, `mypy src` + `ruff` rene.
**Beslutninger som trenger operatøren (speiles i avslutningsblokken):**
1. **Versjon — AVGJORT 23.08: låsen står på 1.0.1, ENDELIG.** Bakgrunnen: ordrens «felles hvis» sa
at hvis E7 står i 1.0.1, revert til 1.0.0. E7 STÅR — men 1.0.1 fikset noe større enn det som ble
håpet (E2/E4, «en plausibel fasit produsert av null arbeid»). Operatøren aksepterte 23.08 at
gevinsten veier tyngre enn ordrens bokstav, og at betingelsen som gjelder er «grønn suite»
(målt 920/5), ikke E7. **Ikke midlertidig, og ikke å gjenåpne uten en ny måling.** Bumpen til
1.1.x / core 1.15 er fortsatt en EGEN økt etter denne planen.
2. **U14 som deklarert avhengighet — AVGJORT 23.08: ja.** Landet i økt 55; se «U14 er LANDET»
over. Kostnaden ble ÉN pakke (`opentelemetry-sdk`), ikke to.
3. **HITL — AVGJORT 23.08: synkron-først.** Merk at ordrens BEGRUNNELSE er utdatert mens
konklusjonen står: den sier «A4 er umålt og kun kode-lest», men A4 ble MÅLT GRØNN i økt 54, så
asynkron er teknisk mulig. Valget er dermed ren rekkefølge, ikke risiko — asynkron U13 forblir
pkt. 3 i rekkefølgen, betinget av S4 som nå er grønn.
4. **Commons-amendment «Step 0 — Explore» — AVGJORT 23.08: sendt.** `.claude` sendte forslaget til
`portfolio-optimiser-commons` via coord. Vi venter IKKE: planen slår selv fast at ingen amendment
er påkrevd, og «Step 0» er foreslått som VALGFRI. Kommer den, er den en tilføyelse.
---
## Vedlegg V1 — målingsskript og rå output (U1U19)
Skript (`scratchpad/u_measure.sh`, kjørt 2026-08-23; kopiert hit ordrett så målingen kan gjentas):
```bash
#!/bin/bash
cd "$(git rev-parse --show-toplevel)"
SRC_N=$(find src -name '*.py' | wc -l | tr -d ' ')
TST_N=$(find tests -name '*.py' | wc -l | tr -d ' ')
SPK_N=$(find spikes -name '*.py' | wc -l | tr -d ' ')
echo "DENOMINATOR src=$SRC_N tests=$TST_N spikes=$SPK_N"
m() { s=$(grep -rEn --include='*.py' "$2" src | wc -l | tr -d ' ')
t=$(grep -rEn --include='*.py' "$2" tests | wc -l | tr -d ' ')
k=$(grep -rEn --include='*.py' "$2" spikes | wc -l | tr -d ' ')
printf "%-4s %-60s src=%-4s tests=%-4s spikes=%s\n" "$1" "$2" "$s" "$t" "$k"
[ "$s" != "0" ] && grep -rEn --include='*.py' "$2" src | head -12; }
m U1 'ConcurrentBuilder|BuildConcurrent|FanIn'
m U1b 'asyncio\.gather|create_task\('
m U2 'WorkflowBuilder|@executor|class .*\(Executor\)|WorkflowContext|SwitchCase|add_edge|add_fan_out'
m U3 'GroupChatBuilder|with_max_rounds|termination_condition|RoundRobinGroupChatManager'
m U4 'Magentic'
m U5 'SkillsProvider|McpSkillsSource|AgentSkillsProvider|agent_framework.*skills'
m U6 'MCPStdioTool|MCPStreamableHTTPTool|MCPWebsocketTool|as_mcp_server'
m U7 'FunctionTool|ai_function|@tool|tools=\['
m U8 'Middleware|middleware='
m U9 'ContextProvider|HistoryProvider|context_providers='
m U10 'AzureAISearch|azure\.search|cosmos|qdrant|redis|VectorStore|vector_store|Qdrant|Chroma|chromadb'
m U11 'AIContextProvider|TextSearchProvider|citation|SourceName|SourceLink|agentic_retrieval|KnowledgeAgent'
m U12 'checkpoint|Checkpoint'
m U13 'approval_mode|request_info|RequestInfoEvent|RequestPort|MagenticPlanReview|ApprovalRequired'
m U14 'opentelemetry|setup_observability|OBSERVABILITY|get_tracer|start_as_current_span'
m U15 'evaluate_workflow|LocalEvaluator|Evaluator|_evaluation|agent_framework.*evaluation'
m U16 'CompactionProvider|SlidingWindow'
m U17 'as_tool\(|AsAIFunction'
m U18 'FoundryChatClient'
m U19 'HarnessAgent|ShellExecutor|FileMemoryStore|ToolApprovalAgent'
```
Rå tellinger: `DENOMINATOR src=32 tests=117 spikes=6` · U1 0/4/4 · U1b 1/5/0 · U2 0/0/0 · U3 10/7/7 ·
U4 0/4/9 · U5 0/0/0 · U5b(`SKILL\.md`) 1/5/0 · U6 6/6/0 · U6b(rå mcp) 7/6/0 · U7 4/10/0 · U8 16/20/0 ·
U9 8/11/4 · U10 2/19/0 · U10b(numpy/cosine) 49/102/1 · U11 26/35/0 · U12 3/0/0 · U13 3/0/0 ·
U14 0/0/1 · U15 0/0/0 · U16 0/0/0 · U17 0/0/0 · U18 7/8/0 · U19 0/0/0.
Kjent-positiv-kontroll mot installert MAF (`grep -rEl <regex> agent_framework agent_framework_orchestrations | wc -l`):
U2 → 21 · U5 → 3 · U14 → 4 · U15 → 2 · U16 → 4 · U17 → 1 · U19 → 1 · `checkpoint_storage|CheckpointStorage`
→ 14 · `MagenticBuilder|StandardMagenticManager` → 4.
B7-/HITL-målingsskriptene (`magentic_b7.py`, `magentic_b7b.py`, `magentic_reset.py`) ligger i
øktens scratchpad og porteres til `spikes/e_magentic.py` i økt 54 (S1/S3) — de committes ikke
herfra, fordi de bruker en ad-hoc `FakeClient` og ikke repoets `ScriptedChatClient`.
## Vedlegg V2 — installert Magentic-API (orchestrations 1.0.0, `_magentic.py`, 1802 linjer)
- **Eksport** (`agent_framework_orchestrations/__init__.py:43-62`): `MagenticBuilder`,
`MagenticContext`, `MagenticManagerBase`, `MagenticOrchestrator`, `MagenticOrchestratorEvent`/
`…EventType`, `MagenticPlanReviewRequest`/`…Response`, `MagenticProgressLedger`/`…Item`,
`MagenticResetSignal`, `StandardMagenticManager`, `MagenticAgentExecutor`. **Ikke i koden:**
`MagenticHumanIntervention*`, `with_human_input_on_stall` (kun docstrings).
- **`MagenticBuilder(*, participants, manager=|manager_factory=|manager_agent=|manager_agent_factory=
(nøyaktig én), task_ledger_*_prompt…, progress_ledger_prompt, final_answer_prompt,
max_stall_count=UNSET→3, max_reset_count=None, max_round_count=None, enable_plan_review=False,
checkpoint_storage=None, output_from, intermediate_output_from)`** (`:1396-1422`). Metoder:
`with_plan_review(enable=True)` (`:1516`), `with_checkpointing(storage)` (`:1564`), `build()`
(`:1772`; bidireksjonale kanter orkestrator↔deltaker `:1796-1797`). Grensene bor på MANAGEREN
(`:1693-1695`, lest tilbake `:905/:1115/:1240`); `manager=` + prompt/limit-argumenter → warning og
**stille ignorert** (`:1661-1677`). Ikke-agent-`Executor` som deltaker aksepteres (`:1498-1499`)
men får placeholder-beskrivelse (`_base_group_chat_orchestrator.py:115`).
- **`StandardMagenticManager(agent, …, max_stall_count=3, max_reset_count=None,
max_round_count=None, progress_ledger_retry_count=None→3)`** (`:528-544`). Én `AgentSession` i
`__init__` (`:572`), alle kall `agent.run(…, session=…)` (`:601`). Ledger-parsing: fri tekst +
`_extract_json` (`:406-449`), dataklasser ikke pydantic (`:288-333`).
- **Løkke:** runde-teller ×2 (`:1075-1076`), grense-sjekk FØR inkrement på `_round_index` (`:1238`);
stall `:1109-1118`; reset `:1149-1185`; **grense → kanonisk streng + `_terminated`, ingen
exception** (`:1222-1256`); sluttsvar `:1206-1220`; ukjent `next_speaker` → stille sluttsvar
(`:1128-1131`); single-use-guard `:914-918`.
- **Plan review:** request `:829-853` (`plan`, `current_progress`, `is_stalled`; `.approve()`,
`.revise(feedback)`), response `:800-826` (`review: list[Message]`, tom = approve `:1010`),
reises `:1041-1048`, svares `workflow.run(responses={id: …})` (`_workflow.py:899-908`; ukjent id
`ValueError` `:946`), revise-håndtering `:1014-1031` (ingen cap, ingen runde-telling).
Per-tur-HITL via `AgentApprovalExecutor` (`_orchestration_request_info.py:168`).
- **Checkpoint:** `CheckpointStorage`-protokoll `_checkpoint.py:119`, `InMemoryCheckpointStorage`
`:192`, `FileCheckpointStorage(storage_path, *, allowed_checkpoint_types)` `:239`;
`WorkflowCheckpoint.pending_request_info_events` `:81`; `run(checkpoint_id=, checkpoint_storage=)`
`_workflow.py:701-712`, `responses`+`checkpoint_id` tillatt `:877`; superstep-granularitet
`_runner.py:143-144`; re-emittering `_runner_context.py:414-426`; Magentic-state
`_magentic.py:1266-1284` + manager-sesjon `:746`; feil svelges `:1281-1282`/`:1320-1321`.
- **Observability:** `_magentic.py` → 0 spans. Runtime-spans `workflow.build`/`workflow.run`/
`executor.process`/`edge_group.process`/`message.send` (`observability.py:253-276`).
Inngang `configure_otel_providers(*, enable_sensitive_data, enable_console_exporters, exporters,
views, vs_code_extension_port, env_file_path, env_file_encoding)` (`:1168`), `enable_instrumentation`
(`:1132`); env `ENABLE_INSTRUMENTATION` (default True, `:697`), `ENABLE_SENSITIVE_DATA`,
`ENABLE_CONSOLE_EXPORTERS`, `OTEL_EXPORTER_OTLP_ENDPOINT`/`_PROTOCOL`/`_HEADERS`,
`OTEL_SERVICE_NAME`. Ikke re-eksportert fra `agent_framework` (`hasattr` → False).
`APPLICATIONINSIGHTS_CONNECTION_STRING` → ikke funnet. `opentelemetry-sdk`: **ikke installert**.
- **Evals/Skills:** `evaluate_workflow(*, workflow, workflow_result|queries, evaluators, …)`
(`_evaluation.py:1833`, `@experimental(EVALS)` `:1832`); `SkillsProvider(ContextProvider)`
(`_skills.py:1719`, `@experimental(SKILLS)` `:1718`; `from_paths` `:1861`).
## Vedlegg V3 — kommandoer kjørt (utvalg; fullstendige lister i øktens agent-transkripter)
```
find src -name '*.py' | sort | wc -l # 32
uv pip list | grep -i -E "agent.framework|^mcp |opentelemetry|azure-ai|anyio"
sed -n '61,75p' uv.lock ; grep -n "agent-framework" pyproject.toml
curl -s https://pypi.org/pypi/agent-framework-orchestrations/json # + /1.0.0 … /1.1.1/json
sed -n '1,80p' .venv/.../agent_framework/_feature_stage.py
grep -rn "@experimental|@preview|feature_stage(" .venv/.../agent_framework/*.py .../_workflows/*.py
grep -n -i "experimental|feature_stage" .venv/.../agent_framework_orchestrations/*.py # 0
bash scratchpad/u_measure.sh # V1
grep -rEl <9 null-regexer> .venv/.../agent_framework .venv/.../agent_framework_orchestrations | wc -l
grep -rn -i "research.01" STATE.md STATE-arkiv-2026-08-21.local.md docs/ # 0
grep -rn "enable_layer1_hitl|layer1" src tests ; grep -rEn 'request_info|RequestInfoEvent' tests # 0
grep -rn "agent_framework" src/portfolio_optimiser/*.py # 14 importlinjer
microsoft_docs_fetch learn.microsoft.com/en-us/agent-framework/workflows/orchestrations/magentic
uv run python scratchpad/magentic_b7.py ; … magentic_b7b.py ; … magentic_reset.py # E1E7
.venv/bin/python -c "import opentelemetry.sdk" # ModuleNotFoundError
```

View file

@ -0,0 +1,475 @@
<!doctype html>
<html lang="no">
<meta charset="utf-8">
<title>Slik bygger du kunnskapsbasen</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
:root { color-scheme: light; }
* { box-sizing: border-box; }
body { margin:0; background:#fff; color:#000;
font:17px/1.6 -apple-system, "Segoe UI", system-ui, sans-serif; }
.slide { display:none; min-height:100vh; padding:6vh 6vw 14vh; max-width:52rem; margin:0 auto; }
.slide.on { display:block; }
h1 { font-size:2.1rem; margin:0 0 .5em; line-height:1.2; }
h2 { font-size:1.5rem; margin:0 0 .9em; line-height:1.3; }
.kicker { color:#666; text-transform:uppercase; letter-spacing:.1em;
font-size:.72rem; margin:0 0 1.4em; }
.step { display:inline-block; border:2px solid #000; border-radius:4px;
padding:.05em .55em; font-weight:700; margin-right:.5em; }
p { margin:0 0 1em; }
ol, ul { padding-left:1.4em; margin:0 0 1em; }
li { margin:.5em 0; }
.lead { font-size:1.15rem; color:#444; }
table { border-collapse:collapse; width:100%; margin:1.2em 0; font-size:.94rem; }
th, td { border-bottom:1px solid #ddd; text-align:left; padding:.5em .6em; vertical-align:top; }
th { color:#666; font-weight:600; font-size:.78rem; text-transform:uppercase; letter-spacing:.04em; }
.done { border:2px solid #000; padding:.7em 1em; margin:1.4em 0; font-size:.95rem; }
.done b { display:block; font-size:.72rem; text-transform:uppercase; letter-spacing:.08em;
color:#666; margin-bottom:.25em; }
.note { border-left:3px solid #ccc; padding:.3em 0 .3em 1.1em; color:#444;
margin:1.4em 0; font-size:.95rem; }
figure { margin:1.6em 0; }
figure svg { width:100%; height:auto; display:block; }
figcaption { font-size:.82rem; color:#666; margin-top:.6em; text-align:center; }
nav { position:fixed; bottom:0; left:0; right:0; padding:.7em 6vw;
background:#fff; border-top:1px solid #ddd;
display:flex; gap:1em; align-items:center; font-size:.85rem; }
button { font:inherit; padding:.3em .9em; cursor:pointer; border:1px solid #bbb;
background:#fff; color:#000; border-radius:4px; }
#pos { color:#666; margin-left:auto; }
.d { fill:none; stroke:#000; stroke-width:2; }
.dt { fill:#000; font:13px -apple-system,"Segoe UI",system-ui,sans-serif; }
.dt-s { fill:#444; font:11px -apple-system,"Segoe UI",system-ui,sans-serif; }
.dim { stroke:#bbb; }
.dimt { fill:#999; font:12px -apple-system,"Segoe UI",system-ui,sans-serif; }
.fill { fill:#000; }
.hatch { fill:#eee; stroke:#000; stroke-width:2; }
</style>
<section class="slide on">
<p class="kicker">Optimaliseringsprosjekt</p>
<h1>Slik bygger du kunnskapsbasen</h1>
<p class="lead">Ni steg fra ingenting til en kunnskapsbase som kan kjøres. Hvert steg sier hvem
som gjør det, hva som leveres, og når du er ferdig med det.</p>
<p>Kunnskapsbasen er den lille, kuraterte samlingen systemet leser før det foreslår tiltak.
Uten den vet systemet ingenting om anlegget ditt.</p>
<figure>
<svg viewBox="0 0 720 118" role="img" aria-label="Ni steg fordelt på fire faser">
<rect class="d" x="8" y="26" width="150" height="52" rx="4"/>
<text class="dt" x="83" y="48" text-anchor="middle" font-weight="700">Ramme</text>
<text class="dt-s" x="83" y="66" text-anchor="middle">steg 13</text>
<path class="d" d="M158 52 h20"/><path class="fill" d="M186 52 l-10 -5 v10 z"/>
<rect class="d" x="186" y="26" width="150" height="52" rx="4"/>
<text class="dt" x="261" y="48" text-anchor="middle" font-weight="700">Innhold</text>
<text class="dt-s" x="261" y="66" text-anchor="middle">steg 46</text>
<path class="d" d="M336 52 h20"/><path class="fill" d="M364 52 l-10 -5 v10 z"/>
<rect class="d" x="364" y="26" width="150" height="52" rx="4"/>
<text class="dt" x="439" y="48" text-anchor="middle" font-weight="700">Bind sammen</text>
<text class="dt-s" x="439" y="66" text-anchor="middle">steg 78</text>
<path class="d" d="M514 52 h20"/><path class="fill" d="M542 52 l-10 -5 v10 z"/>
<rect class="d" x="542" y="26" width="170" height="52" rx="4"/>
<text class="dt" x="627" y="48" text-anchor="middle" font-weight="700">Kjør og lær</text>
<text class="dt-s" x="627" y="66" text-anchor="middle">steg 9</text>
<text class="dimt" x="360" y="108" text-anchor="middle">samlet: én til to uker, og det tunge er steg 2</text>
</svg>
</figure>
</section>
<section class="slide">
<p class="kicker">Før du starter</p>
<h2>To roller, og de kan ikke bytte plass</h2>
<figure>
<svg viewBox="0 0 720 165" role="img" aria-label="Fagpersonen eier innholdet, den tekniske personen eier formen">
<rect class="d" x="8" y="14" width="340" height="130" rx="4"/>
<text class="dt" x="178" y="42" text-anchor="middle" font-weight="700">FAGPERSONEN</text>
<path class="d dim" d="M40 54 h276"/>
<text class="dt-s" x="178" y="78" text-anchor="middle">eier innholdet og korrektheten</text>
<text class="dt-s" x="178" y="98" text-anchor="middle">tallene, rammene, tiltakene, dommene</text>
<text class="dt-s" x="178" y="126" text-anchor="middle">leverer i den formen de allerede jobber i</text>
<rect class="d" x="372" y="14" width="340" height="130" rx="4"/>
<text class="dt" x="542" y="42" text-anchor="middle" font-weight="700">DEN TEKNISKE PERSONEN</text>
<path class="d dim" d="M404 54 h276"/>
<text class="dt-s" x="542" y="78" text-anchor="middle">eier formen og strukturen</text>
<text class="dt-s" x="542" y="98" text-anchor="middle">oversetter til dokumenter og tall</text>
<text class="dt-s" x="542" y="126" text-anchor="middle">finner aldri opp et tall selv</text>
</svg>
</figure>
<p>Fagpersonen leverer regneark, notater og henvisninger — ikke ferdige dokumenter. Den tekniske
personen oversetter. <strong>Regelen som ikke kan brytes: den tekniske personen utleder aldri et
tall fagpersonen ikke har oppgitt.</strong> Mangler et tall, står det som mangler.</p>
<div class="note">Det finnes ingen automatikk som gjør denne jobben. Oversettelsen er håndarbeid,
og det er derfor det tar én til to uker.</div>
</section>
<section class="slide">
<p class="kicker">Ramme</p>
<h2><span class="step">1</span> Bestem hvilket anlegg det gjelder</h2>
<p>Én kunnskapsbase dekker <strong>ett</strong> prosjekt eller anlegg. Har du flere, blir det
flere baser — ikke én stor.</p>
<p><strong>Gjør dette:</strong> velg et navn på anlegget og skriv det ned ett sted. Navnet skal
brukes helt likt overalt siden, uten mellomrom og uten æ, ø og å.</p>
<table>
<tr><th>Hvem</th><th>Leverer</th></tr>
<tr><td>Prosjekteier</td><td>hvilket anlegg, og hva det skal hete</td></tr>
</table>
<div class="done"><b>Ferdig når</b>Navnet er bestemt, og alle er enige om at basen dekker akkurat
dette anlegget — ikke to naboanlegg «mens vi først er i gang».</div>
</section>
<section class="slide">
<p class="kicker">Ramme</p>
<h2><span class="step">2</span> Skaff de faktiske kostnadstallene</h2>
<p>Dette er det tunge steget, og det som avgjør om resten er verdt noe. Systemet skal senere
kontrollere hvert forslag mot ekte kostnadslinjer — mengde ganger enhetspris.</p>
<figure>
<svg viewBox="0 0 720 150" role="img" aria-label="Med kostnadstall avvises et oppdiktet forslag; uten dem passerer det">
<text class="dt" x="8" y="22" font-weight="700">MED ekte tall</text>
<rect class="d" x="8" y="32" width="120" height="40" rx="4"/>
<text class="dt-s" x="68" y="56" text-anchor="middle">Oppdiktet forslag</text>
<path class="d" d="M128 52 h30"/><path class="fill" d="M168 52 l-11 -5 v10 z"/>
<rect class="d" x="168" y="32" width="150" height="40" rx="4" stroke-width="3"/>
<text class="dt-s" x="243" y="56" text-anchor="middle">Avstemmes mot fasit</text>
<path class="d" d="M318 52 h30"/><path class="fill" d="M358 52 l-11 -5 v10 z"/>
<text class="dt" x="370" y="57" font-weight="700">AVVIST</text>
<text class="dt" x="8" y="112" font-weight="700">UTEN ekte tall</text>
<rect class="d" x="8" y="122" width="120" height="0" rx="4"/>
<rect class="d" x="8" y="102" width="120" height="40" rx="4"/>
<text class="dt-s" x="68" y="126" text-anchor="middle">Oppdiktet forslag</text>
<path class="d" d="M128 122 h30"/><path class="fill" d="M168 122 l-11 -5 v10 z"/>
<rect class="d dim" x="168" y="102" width="150" height="40" rx="4" stroke-dasharray="6 5"/>
<text class="dimt" x="243" y="126" text-anchor="middle">ingenting å avstemme mot</text>
<path class="d" d="M318 122 h30"/><path class="fill" d="M358 122 l-11 -5 v10 z"/>
<text class="dt" x="370" y="127" font-weight="700">GODKJENT</text>
<text class="dimt" x="440" y="127">— og kjøringen ser helt normal ut</text>
</svg>
</figure>
<p><strong>Gjør dette:</strong> list opp hver kostnadslinje tiltakene kan komme til å treffe, med
mengde og enhetspris. Ikke bare linjen til det tiltaket du tror på — alle linjene som er i spill.</p>
<p><strong>Ta ikke med tall du ikke har.</strong> Har dere ingen kilde på hva noe koster per enhet,
skal linjen utelates. En utledet verdi hører ikke hjemme her.</p>
<div class="done"><b>Ferdig når</b>Hver linje har en mengde og en enhetspris, og du kan si hvor
hvert tall kom fra. Får du ikke tak i tallene: skriv uttrykkelig at kjøringen blir uforankret, så
ingen leser et godkjent-resultat som mer enn det er.</div>
</section>
<section class="slide">
<p class="kicker">Ramme</p>
<h2><span class="step">3</span> Skriv ned hva som allerede er gjort</h2>
<p>Systemet vet ingenting om anlegget utover det basen sier. Er noe allerede bygget om, og det
ikke står noe sted, vil systemet foreslå det på nytt — og love en besparelse som allerede er tatt ut.</p>
<figure>
<svg viewBox="0 0 720 130" role="img" aria-label="Gjennomførte tiltak hører i anleggsbeskrivelsen, tidligere vurderinger i erfaringslaget">
<rect class="d" x="8" y="14" width="340" height="100" rx="4"/>
<text class="dt" x="178" y="42" text-anchor="middle" font-weight="700">Gjennomførte tiltak</text>
<text class="dt-s" x="178" y="66" text-anchor="middle">hva som faktisk er bygget om,</text>
<text class="dt-s" x="178" y="84" text-anchor="middle">når, og på hvor mye av anlegget</text>
<text class="dt-s" x="178" y="106" text-anchor="middle">→ inn i anleggsbeskrivelsen</text>
<rect class="d" x="372" y="14" width="340" height="100" rx="4"/>
<text class="dt" x="542" y="42" text-anchor="middle" font-weight="700">Tidligere vurderinger</text>
<text class="dt-s" x="542" y="66" text-anchor="middle">hva fagfolk mente om et forslag,</text>
<text class="dt-s" x="542" y="84" text-anchor="middle">og hvor mye som faktisk ble realisert</text>
<text class="dt-s" x="542" y="106" text-anchor="middle">→ inn i erfaringslaget (steg 7)</text>
</svg>
</figure>
<p><strong>Gjør dette:</strong> lag en kort liste over gjennomførte tiltak, og sørg for at
kostnadstallene fra steg 2 viser <em>dagens</em> situasjon — ikke situasjonen før forrige tiltak.</p>
<div class="done"><b>Ferdig når</b>Ingen kan peke på et tiltak som er gjennomført uten å stå i
basen, og kostnadstallene stemmer med hva anlegget bruker i dag.</div>
</section>
<section class="slide">
<p class="kicker">Innhold</p>
<h2><span class="step">4</span> Beskriv anlegget og rammene</h2>
<p>Ett kort dokument som svarer: hva er dette anlegget, hva består det av, og hva kan
<em>ikke</em> fravikes?</p>
<p><strong>Rammene er den viktigste halvdelen.</strong> Uten dem foreslår systemet tiltak som
bryter krav ingen har fortalt det om — og fagpersonen må bruke tid på å avvise det samme igjen
og igjen.</p>
<table>
<tr><th>Ta med</th><th>Eksempeltype</th></tr>
<tr><td>Hva anlegget består av</td><td>antall enheter, alder, tilstand, kapasitet</td></tr>
<tr><td>Dagens forbruk eller kostnad</td><td>gulvet ingen besparelse kan gå under</td></tr>
<tr><td>Krav som ikke kan fravikes</td><td>minstekrav, sikkerhetskrav, avtalefestede nivåer</td></tr>
<tr><td>Antakelser som ikke holder</td><td>«vi kan ikke forutsette at X er mulig her»</td></tr>
</table>
<div class="done"><b>Ferdig når</b>En fagperson som ikke kjenner anlegget kan lese dokumentet og
vite hva som er lov å foreslå.</div>
</section>
<section class="slide">
<p class="kicker">Innhold</p>
<h2><span class="step">5</span> Skriv tiltaksnotatene</h2>
<p>Ett kort notat per kandidat-tiltak. Systemet skal <em>forbedre</em> disse, ikke finne opp sine
egne fra bunnen — så jo mer konkrete de er, jo bedre blir svaret.</p>
<p><strong>Hvert notat skal ha:</strong> hva tiltaket går ut på, hvilke kostnadslinjer det virker
på, hvor mye det er beregnet å spare, hva som er usikkert, og hva som er utledet framfor målt.</p>
<p><strong>Trenger du hjelp til å finne kandidatene?</strong> For de fleste anleggstyper finnes
det et ganske stabilt sett tiltakstyper — utskifting til nyere teknologi, behovsstyring framfor
fast drift, tilstandsbasert vedlikehold framfor intervallbasert, og levetidsforlengelse framfor
utskifting. Fagmiljøet har listen for sin type; jobben er å avgjøre hvilke som er aktuelle her.</p>
<div class="note"><strong>Filteret som avgjør:</strong> et tiltak må kunne uttrykkes som en
kostnadslinje — en mengde ganger en enhetspris. Kan det ikke det, kan systemet foreslå det, men
ikke kontrollere det. Da er svaret verdt akkurat like mye som et vanlig godt råd.</div>
<div class="done"><b>Ferdig når</b>Hvert notat peker på kostnadslinjer som finnes i steg 2, og
det er tydelig merket hva som er målt og hva som er antatt.</div>
</section>
<section class="slide">
<p class="kicker">Innhold</p>
<h2><span class="step">6</span> Velg det ene tiltaket som skal regnes på</h2>
<p>Én kandidat får en egen, presis form som den deterministiske kontrollen kan konsumere:
kostnadslinjene den virker på, den beregnede besparelsen, og et usikkerhetsbånd.</p>
<p><strong>Denne kandidaten er også nøkkelen til tidligere erfaring</strong> — det er dens
kostnadslinjer og størrelsesorden som avgjør hvilke tidligere dommer systemet henter fram.</p>
<figure>
<svg viewBox="0 0 720 120" role="img" aria-label="Den regnede kandidaten og kostnadsgrunnlaget skrives fra samme regnestykke">
<rect class="d" x="200" y="8" width="320" height="40" rx="4" stroke-width="3"/>
<text class="dt" x="360" y="33" text-anchor="middle" font-weight="700">Ett regnestykke</text>
<path class="d" d="M300 48 C 300 68, 180 68, 180 82"/><path class="fill" d="M180 92 l-5 -11 h10 z"/>
<path class="d" d="M420 48 C 420 68, 540 68, 540 82"/><path class="fill" d="M540 92 l-5 -11 h10 z"/>
<rect class="d" x="50" y="92" width="260" height="24" rx="4"/>
<text class="dt-s" x="180" y="109" text-anchor="middle">Kostnadsgrunnlaget (steg 2)</text>
<rect class="d" x="410" y="92" width="260" height="24" rx="4"/>
<text class="dt-s" x="540" y="109" text-anchor="middle">Den regnede kandidaten</text>
</svg>
<figcaption>Samme kostnadslinje, samme mengde, samme enhetspris — skrevet ut fra ett regnestykke.</figcaption>
</figure>
<div class="done"><b>Ferdig når</b>Kandidaten og kostnadsgrunnlaget bærer nøyaktig samme tall for
de linjene de deler. Er de skrevet inn hver for seg, blir et lite avvik bare slurv — ikke et signal.</div>
</section>
<section class="slide">
<p class="kicker">Bind sammen</p>
<h2><span class="step">7</span> Legg inn metode, kilder og første erfaring</h2>
<p>Tre korte dokumenter som gjør forskjellen mellom et tall og et troverdig tall.</p>
<table>
<tr><th>Dokument</th><th>Hvem</th><th>Svarer på</th></tr>
<tr><td><strong>Metode</strong></td><td>fagmiljøet</td>
<td>hvordan skal effekten måles i etterkant — og hvilke metoder er stengt for dette anlegget?</td></tr>
<tr><td><strong>Kilder</strong></td><td>fagmiljøet</td>
<td>hva sier litteraturen om avviket mellom beregnet og faktisk effekt? Med årstall og opphav.</td></tr>
<tr><td><strong>Første erfaring</strong></td><td>fageksperten</td>
<td>hva pleier å skje? «Forvent rundt X prosent av beregnet, fordi …»</td></tr>
</table>
<p><strong>Skill eget materiale fra lånt.</strong> Har dere ingen egne målinger og må låne et tall
fra litteraturen, skal det stå uttrykkelig at det er lånt. Det er ikke en svakhet — det er det som
gjør at neste person kan vurdere om lånet holder.</p>
<div class="done"><b>Ferdig når</b>Hvert tall som ikke er målt hos dere, har en navngitt kilde
ved siden av seg.</div>
</section>
<section class="slide">
<p class="kicker">Bind sammen</p>
<h2><span class="step">8</span> Skriv innholdsfortegnelsen som binder alt</h2>
<p>Systemet <strong>navigerer</strong>, det søker ikke. Det starter i innholdsfortegnelsen, følger
lenkene derfra, og leser hvert dokument det når — helt, eller ikke i det hele tatt.</p>
<figure>
<svg viewBox="0 0 720 180" role="img" aria-label="Innholdsfortegnelsen lenker til dokumentene; et dokument uten lenke nås aldri">
<rect class="d" x="30" y="60" width="130" height="56" rx="4"/>
<text class="dt" x="95" y="83" text-anchor="middle">Innholds-</text>
<text class="dt" x="95" y="101" text-anchor="middle">fortegnelse</text>
<rect class="d" x="290" y="14" width="180" height="34" rx="4"/>
<text class="dt-s" x="380" y="35" text-anchor="middle">Anlegg og rammer</text>
<rect class="d" x="290" y="58" width="180" height="34" rx="4"/>
<text class="dt-s" x="380" y="79" text-anchor="middle">Tiltaksnotater</text>
<rect class="d" x="290" y="102" width="180" height="34" rx="4"/>
<text class="dt-s" x="380" y="123" text-anchor="middle">Metode og kilder</text>
<path class="d" d="M160 82 C 210 82, 230 31, 280 31"/><path class="fill" d="M290 31 l-11 -5 v10 z"/>
<path class="d" d="M160 88 h120"/><path class="fill" d="M290 75 l-11 -5 v10 z"/>
<path class="d" d="M160 94 C 210 94, 230 119, 280 119"/><path class="fill" d="M290 119 l-11 -5 v10 z"/>
<rect class="hatch" x="30" y="132" width="180" height="34" rx="4" stroke-dasharray="5 4"/>
<text class="dimt" x="120" y="153" text-anchor="middle">dokument uten lenke — finnes ikke</text>
<rect class="d dim" x="530" y="58" width="160" height="34" rx="4" stroke-dasharray="5 4"/>
<text class="dimt" x="610" y="79" text-anchor="middle">annen base</text>
<path class="d dim" d="M470 75 h44" stroke-dasharray="6 5"/>
<path class="d" d="M480 63 l22 24 M502 63 l-22 24" stroke-width="2.5"/>
<text class="dimt" x="610" y="112" text-anchor="middle">lenker ut følges aldri</text>
</svg>
</figure>
<p><strong>Gjør dette:</strong> skriv et kort avsnitt om hva prosjektet er og hvorfor dette
fagområdet er valgt — det er det aller første systemet leser — og lenk til hvert eneste dokument
du vil at det skal se.</p>
<div class="done"><b>Ferdig når</b>Hvert dokument du har laget er lenket. Et dokument uten lenke
eksisterer ikke, uansett hvor det ligger. Og trenger du fagstoff fra en annen base: kopier det
inn — lenker ut av basen følges aldri, men tørrkjøringen lister dem, så du ser at det skjedde.</div>
</section>
<section class="slide">
<p class="kicker">Kjør og lær</p>
<h2><span class="step">9</span> Sjekk at basen åpner, så kjør</h2>
<p>Kjør en tørrkjøring først. Den går hele veien fram til det første kallet mot språkmodellen og
stopper der — den koster ingenting, og kan kjøres hver gang basen endres.</p>
<table>
<tr><th>Tørrkjøringen beviser</th><th>Den beviser ikke</th></tr>
<tr><td>at basen åpner</td><td>at innholdet er godt</td></tr>
<tr><td>at navnet stemmer overalt</td><td>at forslagene blir kloke</td></tr>
<tr><td>at kostnadsgrunnlaget er lesbart</td><td>at alt du laget faktisk er lenket</td></tr>
<tr><td>hvilke lenker den ikke klarte å følge</td><td></td></tr>
<tr><td>at ingen ekstern tjeneste kontaktes</td><td></td></tr>
</table>
<p>Mangler kostnadsgrunnlaget, sier tørrkjøringen fra. Er en lenke brutt, stopper den fortsatt
ikke kjøringen — men den skrives ut, med dokumentet den pekte på og hvorfor den ikke ble fulgt
(<code>missing</code> = ingen fil der, <code>outside-bundle</code> = peker ut av basen). Er alle
lenker fulgt, står det ingenting. Den feilen den fortsatt ikke kan se, er dokumentet
<em>ingen</em> lenker til — da finnes det ingen lenke å rapportere.</p>
<div class="done"><b>Ferdig når</b>Tørrkjøringen er grønn, den lister ingen lenker den ikke fulgte,
og du har lest gjennom listen over dokumenter den faktisk fant.</div>
</section>
<section class="slide">
<p class="kicker">Kjør og lær</p>
<h2>Etter kjøringen: dommen tilbake i basen</h2>
<figure>
<svg viewBox="0 0 720 210" role="img" aria-label="Kjøring gir forslag, fagperson dømmer, dommen leses av neste kjøring">
<rect class="d" x="30" y="24" width="150" height="46" rx="4"/>
<text class="dt" x="105" y="52" text-anchor="middle">Kjøring</text>
<path class="d" d="M180 47 h64"/><path class="fill" d="M254 47 l-11 -5 v10 z"/>
<rect class="d" x="254" y="24" width="180" height="46" rx="4"/>
<text class="dt" x="344" y="44" text-anchor="middle">Forslag + kontroll</text>
<text class="dt-s" x="344" y="62" text-anchor="middle">ett per vurdert tilnærming</text>
<path class="d" d="M434 47 h64"/><path class="fill" d="M508 47 l-11 -5 v10 z"/>
<rect class="d" x="508" y="24" width="182" height="46" rx="4" stroke-width="3"/>
<text class="dt" x="599" y="44" text-anchor="middle" font-weight="700">Fagperson dømmer</text>
<text class="dt-s" x="599" y="62" text-anchor="middle">godkjent / avvist / korrigert</text>
<path class="d" d="M599 70 v42 H344"/><path class="fill" d="M344 112 l11 -5 v10 z"/>
<rect class="d" x="254" y="112" width="180" height="46" rx="4"/>
<text class="dt" x="344" y="140" text-anchor="middle">Innboks</text>
<path class="d" d="M254 135 H105 V76"/><path class="fill" d="M105 70 l-5 11 h10 z"/>
<text class="dt-s" x="118" y="105">leses før neste forslag</text>
<text class="dimt" x="360" y="192" text-anchor="middle">systemet leser innboksen — mennesket skriver den</text>
</svg>
</figure>
<p>Neste kjøring leser dommene <em>før</em> den former sitt forslag. Dommen påvirker altså det
neste forslaget, ikke bare arkivet. Godkjennes en dom for varig bruk, løftes den inn i basen.</p>
<div class="note"><strong>Regelen som ikke kan brytes:</strong> systemet leser innboksen,
mennesket skriver den. En kjøring lagrer aldri sin egen vurdering tilbake som kunnskap — ellers
ville systemet lært av seg selv.</div>
</section>
<section class="slide">
<p class="kicker">Underveis</p>
<h2>Må systemet spørre andre fagsystemer?</h2>
<p>Utgangspunktet er <strong>ingen integrasjon</strong>: uten konfigurasjon gjøres null
nettverkskall. Trenger du likevel data fra et annet system, er det to helt ulike veier — og valget
avgjøres av <em>når</em> svaret trengs.</p>
<table>
<tr><th></th><th>Hent på forhånd</th><th>Slå opp underveis</th></tr>
<tr><td>Når</td><td>før kjøringen</td><td>mens forslaget formes</td></tr>
<tr><td>Blir</td><td>et dokument i basen</td><td>et verktøy systemet kan kalle</td></tr>
<tr><td>Fordel</td><td>fagpersonen kan lese og korrigere det først</td><td>fanger opp noe som endrer seg</td></tr>
<tr><td>Krever</td><td>opphav og dato på dokumentet</td><td>uttrykkelig liste over tillatte kall</td></tr>
<tr><td>Nettverk under kjøring</td><td>null</td><td>ja, og alt navngis på forhånd</td></tr>
</table>
<p><strong>Velg «hent på forhånd» hvis du kan.</strong> Det er billigere, det kan kvalitetssikres
av et menneske, og det gjør at du kan si nøyaktig hva en kjøring har rørt.</p>
</section>
<section class="slide">
<p class="kicker">Sjekkliste</p>
<h2>Hvem leverer hva</h2>
<table>
<tr><th>Steg</th><th>Hvem</th><th>Leveranse</th></tr>
<tr><td>1</td><td>Prosjekteier</td><td>anlegg og navn</td></tr>
<tr><td><strong>2</strong></td><td><strong>Drift / økonomi</strong></td><td><strong>kostnadslinjer med mengde og enhetspris</strong></td></tr>
<tr><td>3</td><td>Drift</td><td>liste over allerede gjennomførte tiltak</td></tr>
<tr><td>4</td><td>Prosjekteier + fagmiljø</td><td>anleggsbeskrivelse og rammer</td></tr>
<tr><td>5</td><td>Drift + fagmiljø</td><td>kandidat-tiltak med parametere</td></tr>
<tr><td>6</td><td>Teknisk person</td><td>den ene kandidaten i presis form</td></tr>
<tr><td>7</td><td>Fagmiljø + fagekspert</td><td>metode, kilder, første erfaring</td></tr>
<tr><td>8</td><td>Teknisk person</td><td>innholdsfortegnelsen med alle lenker</td></tr>
<tr><td>9</td><td>Teknisk person</td><td>tørrkjøring, så kjøring</td></tr>
</table>
<p>Steg 2 er markert fordi det er det som stopper prosjekter. Alt annet kan skrives på en dag
hvis fagpersonene er tilgjengelige.</p>
</section>
<section class="slide">
<p class="kicker">Fallgruver</p>
<h2>De fem feilene som koster mest</h2>
<ol>
<li><strong>Ingen kostnadstall.</strong> Kjøringen går, resultatet ser normalt ut, og kontrollen
dømmer bare mot tall forslaget fant på selv. Dette er den dyreste feilen, fordi den er usynlig.</li>
<li><strong>Gjennomførte tiltak står ikke i basen.</strong> Systemet foreslår dem på nytt, og
besparelsen dobbelttelles.</li>
<li><strong>Dokumenter uten lenke.</strong> De leses aldri, og ingen feilmelding sier fra —
for det finnes ingen lenke å klage på. Er lenken der, men feil, sier tørrkjøringen nå fra;
er den aldri skrevet, er dokumentet usynlig. Symptomet er at forslagene virker uinformerte
uten at noen skjønner hvorfor.</li>
<li><strong>For mye materiale.</strong> Alt som lenkes leses i sin helhet. Ti sider støy koster
like mye oppmerksomhet som ti sider substans.</li>
<li><strong>Utledede tall.</strong> Et tall ingen kan peke på en kilde for, forurenser alt som
bygger på det. Mangler et tall, skal det stå som manglende.</li>
</ol>
</section>
<section class="slide">
<p class="kicker">Til slutt</p>
<h2>Hva dette lover — og ikke lover</h2>
<ul>
<li><strong>Én til to uker</strong> for en god base. Det tunge er ikke teknikken, men å få tak i
kostnadstallene og å få en fagperson til å skrive ned rammene sine.</li>
<li><strong>Det finnes ingen automatikk</strong> som lager basen av regnearkene deres. Det er
håndarbeid, og det er derfor det tar tid.</li>
<li><strong>Det finnes ingen «er basen klar»-knapp.</strong> Tørrkjøringen er det nærmeste: den
sier fra om kostnadsgrunnlaget mangler og lister lenkene den ikke klarte å følge, men den sier
ingenting om et dokument ingen har lenket til, og ingenting om innholdet er godt.</li>
<li><strong>Systemet dømmer ikke.</strong> Det foreslår og kontrollerer regnestykket.
Vurderingen av om tiltaket er klokt, gjør fagpersonen — og den vurderingen er det basen lærer av.</li>
</ul>
<div class="note">Basen blir bedre for hver kjøring, men bare hvis noen faktisk avgir dommen
etterpå. En base ingen dømmer imot, står stille.</div>
</section>
<nav>
<button id="prev">← Forrige</button>
<button id="next">Neste →</button>
<span id="pos"></span>
</nav>
<script>
const slides = document.querySelectorAll('.slide');
let i = 0;
function show(n) {
i = Math.max(0, Math.min(slides.length - 1, n));
slides.forEach((s, k) => s.classList.toggle('on', k === i));
document.getElementById('pos').textContent = (i + 1) + ' / ' + slides.length;
window.scrollTo(0, 0);
}
document.getElementById('prev').onclick = () => show(i - 1);
document.getElementById('next').onclick = () => show(i + 1);
document.addEventListener('keydown', e => {
if (e.key === 'ArrowRight' || e.key === 'PageDown' || e.key === ' ') show(i + 1);
if (e.key === 'ArrowLeft' || e.key === 'PageUp') show(i - 1);
});
show(0);
</script>
</html>

View file

@ -0,0 +1,609 @@
<!DOCTYPE html>
<html lang="no">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Hva løsningen trenger fra deg</title>
<style>
:root { color-scheme: light; }
* { box-sizing: border-box; }
body { margin:0; background:#fff; color:#000;
font:17px/1.6 -apple-system, "Segoe UI", system-ui, sans-serif; }
.slide { display:none; min-height:100vh; padding:6vh 6vw 14vh; max-width:52rem; margin:0 auto; }
.slide.on { display:block; }
h1 { font-size:2.1rem; margin:0 0 .5em; line-height:1.2; }
h2 { font-size:1.5rem; margin:0 0 .9em; line-height:1.3; }
h3 { font-size:1.02rem; margin:1.6em 0 .5em; }
.kicker { color:#666; text-transform:uppercase; letter-spacing:.1em;
font-size:.72rem; margin:0 0 1.4em; }
.step { display:inline-block; border:2px solid #000; border-radius:4px;
padding:.05em .55em; font-weight:700; margin-right:.5em; }
p { margin:0 0 1em; }
ol, ul { padding-left:1.4em; margin:0 0 1em; }
li { margin:.5em 0; }
.lead { font-size:1.15rem; color:#444; }
table { border-collapse:collapse; width:100%; margin:1.2em 0; font-size:.94rem; }
th, td { border-bottom:1px solid #ddd; text-align:left; padding:.5em .6em; vertical-align:top; }
th { color:#666; font-weight:600; font-size:.78rem; text-transform:uppercase; letter-spacing:.04em; }
.done { border:2px solid #000; padding:.7em 1em; margin:1.4em 0; font-size:.95rem; }
.done b { display:block; font-size:.72rem; text-transform:uppercase; letter-spacing:.08em;
color:#666; margin-bottom:.25em; }
.note { border-left:3px solid #ccc; padding:.3em 0 .3em 1.1em; color:#444;
margin:1.4em 0; font-size:.95rem; }
.ask { border-left:5px solid #000; padding:.2em 0 .2em 1.1em; margin:1.3em 0;
font-size:1.06rem; font-weight:600; }
code { font:.92em ui-monospace, SFMono-Regular, Menlo, monospace; }
figure { margin:1.6em 0; }
figure svg { width:100%; height:auto; display:block; }
figcaption { font-size:.82rem; color:#666; margin-top:.6em; text-align:center; }
nav { position:fixed; bottom:0; left:0; right:0; padding:.7em 6vw;
background:#fff; border-top:1px solid #ddd;
display:flex; gap:1em; align-items:center; font-size:.85rem; }
button { font:inherit; padding:.3em .9em; cursor:pointer; border:1px solid #bbb;
background:#fff; color:#000; border-radius:4px; }
#pos { color:#666; margin-left:auto; }
.d { fill:none; stroke:#000; stroke-width:2; }
.dt { fill:#000; font:13px -apple-system,"Segoe UI",system-ui,sans-serif; }
.dt-s { fill:#444; font:11px -apple-system,"Segoe UI",system-ui,sans-serif; }
.dim { stroke:#bbb; }
.dimt { fill:#999; font:12px -apple-system,"Segoe UI",system-ui,sans-serif; }
</style>
</head>
<body>
<!-- 1 -->
<section class="slide on">
<p class="kicker">Kostnadskutt i veg- og tunnelprosjekter</p>
<h1>Hva løsningen trenger fra deg</h1>
<p class="lead">Du er fagpersonen. Løsningen finner ingen besparelser uten det du vet —
og den kan ikke gjette seg til det.</p>
<p>Denne presentasjonen er de <b>sju spørsmålene</b> du blir stilt før en kjøring bestilles,
hva du skal svare, og hva du må skaffe på forhånd. Eksemplene er hentet fra
<b>utbedringsprosjekter på veg</b> og <b>tunnelprosjekter</b>.</p>
<table>
<tr><th>Spørsmål</th><th>Du leverer</th><th>Tid</th></tr>
<tr><td>1. Hvilke hypoteser skal vurderes?</td><td>36 tiltak du tror på, med begrunnelse</td><td>1 møte</td></tr>
<tr><td>2. Hva koster det i dag?</td><td>kostnadslinjer: mengde × enhetspris</td><td><b>dager til uker</b></td></tr>
<tr><td>3. Hvilke interne data finnes?</td><td>uttrekk og registre dere allerede har</td><td>timer</td></tr>
<tr><td>4. Hvilke eksterne data trengs?</td><td>normer, priser, erfaringstall — og hvilke <b>åpne API-er</b> som er relevante</td><td>timer</td></tr>
<tr><td>5. Hva kan ikke fravikes?</td><td>krav, minstenivåer, avtaler</td><td>1 møte</td></tr>
<tr><td>6. Hva er allerede gjort?</td><td>liste over gjennomførte tiltak</td><td>timer</td></tr>
<tr><td>7. Hva pleier å skje i praksis?</td><td>din erfaring med kalkyle vs. virkelighet</td><td>1 møte</td></tr>
</table>
<div class="note">Spørsmål 2 er det eneste som pleier å ta uker. Alt annet kan besvares
på en dag hvis du er tilgjengelig.</div>
</section>
<!-- 2 -->
<section class="slide">
<p class="kicker">Før spørsmålene</p>
<h2>Hva løsningen gjør — og hvorfor du blir spurt</h2>
<p>Løsningen leser en liten, kuratert kunnskapsbase om <b>ett prosjekt</b>, foreslår
kostnadsreduserende tiltak, og <b>kontrollerer regnestykket deterministisk</b> mot prosjektets
faktiske kostnadslinjer før noe forslag slipper ut.</p>
<figure>
<svg viewBox="0 0 720 150" role="img" aria-label="Fra din kunnskap til din dom">
<rect class="d" x="4" y="30" width="160" height="70" rx="4"/>
<text class="dt" x="84" y="58" text-anchor="middle">Din kunnskap</text>
<text class="dt-s" x="84" y="78" text-anchor="middle">tall, rammer, hypoteser</text>
<path class="d" d="M168 65 h44"/><path class="d" d="M204 58 l8 7 l-8 7"/>
<rect class="d" x="216" y="30" width="160" height="70" rx="4"/>
<text class="dt" x="296" y="58" text-anchor="middle">Forslag</text>
<text class="dt-s" x="296" y="78" text-anchor="middle">flere agenter, flere runder</text>
<path class="d" d="M380 65 h44"/><path class="d" d="M416 58 l8 7 l-8 7"/>
<rect class="d" x="428" y="30" width="130" height="70" rx="4"/>
<text class="dt" x="493" y="52" text-anchor="middle">Kontroll</text>
<text class="dt-s" x="493" y="70" text-anchor="middle">avstemmer mot</text>
<text class="dt-s" x="493" y="86" text-anchor="middle">ekte kostnadslinjer</text>
<path class="d" d="M562 65 h42"/><path class="d" d="M596 58 l8 7 l-8 7"/>
<rect class="d" x="608" y="30" width="108" height="70" rx="4"/>
<text class="dt" x="662" y="58" text-anchor="middle">Din dom</text>
<text class="dt-s" x="662" y="78" text-anchor="middle">ja / nei / justert</text>
<path class="dim" d="M662 104 v18 h-578 v-18" fill="none"/>
<path class="dim" d="M84 111 l-6 -8 M84 111 l6 -8" fill="none"/>
<text class="dimt" x="373" y="140" text-anchor="middle">dommen din går inn i neste kjøring</text>
</svg>
</figure>
<p><b>Kontrollen kan avgjøre om et tall er mulig. Den kan ikke avgjøre om tiltaket er klokt.</b>
Det gjør du, etterpå — og det er den vurderingen løsningen lærer av.</p>
<div class="note">Derfor er spørsmålene under ikke en kartlegging. De er de fire tingene
kontrollen ikke kan finne på egen hånd: <b>hva noe koster</b>, <b>hva som ikke er lov</b>,
<b>hva som allerede er gjort</b>, og <b>hva som pleier å skje i drift</b>.</div>
</section>
<!-- 3 -->
<section class="slide">
<p class="kicker">Før spørsmålene</p>
<h2>Din rolle, og hva du <em>ikke</em> skal gjøre</h2>
<table>
<tr><th></th><th>Du — fagpersonen</th><th>Den tekniske personen</th></tr>
<tr><td><b>Eier</b></td><td>innholdet og korrektheten</td><td>formen og strukturen</td></tr>
<tr><td><b>Leverer</b></td><td>tallene, rammene, tiltakene, dommene</td><td>oversettelsen til dokumenter og datafiler</td></tr>
<tr><td><b>Format</b></td><td>det du allerede jobber i: regneark, notat, uttrekk, PDF</td><td>markdown og JSON</td></tr>
<tr><td><b>Aldri</b></td><td>skriver systemfiler eller skjema</td><td>utleder et tall du ikke har oppgitt</td></tr>
</table>
<p><b>Du skal aldri levere ferdige dokumenter.</b> Lever et regneark, et notat, et skjermbilde
fra fagsystemet, en henvisning til en håndbok. Oversettelsen er ikke din jobb.</p>
<div class="done"><b>Regelen som ikke kan brytes</b>
Mangler et tall, står det som <b>manglende</b>. Det utledes ikke, og det rundes ikke av til
noe som «virker rimelig». Et tall ingen kan peke på en kilde for, forurenser alt som bygger på det.</div>
<div class="note">Det finnes ingen automatikk som lager kunnskapsbasen av regnearkene deres.
Oversettelsen er håndarbeid, og det er derfor forberedelsen tar én til to uker.</div>
</section>
<!-- 4 -->
<section class="slide">
<p class="kicker">Spørsmål 1 av 7</p>
<h2><span class="step">1</span> Hvilke hypoteser vil du at løsningen skal vurdere?</h2>
<div class="ask">«Hvis du fikk én uke til å lete etter penger i dette prosjektet —
hvor ville du sett først, og hvorfor?»</div>
<p>Løsningen <b>forbedrer</b> hypotesene dine framfor å finne opp sine egne fra bunnen. Jo mer
konkrete de er, jo bedre blir svaret. Den foreslår også sitt eget i tillegg — men dine går først.</p>
<p><b>Fire tiltakstyper dekker det meste.</b> Bruk dem som huskeliste, ikke som fasit:</p>
<table>
<tr><th>Tiltakstype</th><th>Utbedring på veg</th><th>Tunnel</th></tr>
<tr><td><b>Ny teknologi erstatter gammel</b></td>
<td>LED i veglys · nye rekkverkstyper med lengre levetid</td>
<td>LED-armaturer · frekvensstyrte vifter</td></tr>
<tr><td><b>Behovsstyring framfor fast drift</b></td>
<td>vinterdrift utløst av målestasjon/prognose framfor fast rode-utkalling</td>
<td>ventilasjon styrt på målt CO/NO₂ framfor fast drift · finere dimmetrinn på dagsonen</td></tr>
<tr><td><b>Tilstandsbasert framfor intervallbasert</b></td>
<td>dekkefornyelse etter målt spor og jevnhet framfor fast syklus · grøfterens etter tilstand</td>
<td>vask og renhold etter målt tilsmussing framfor fast frekvens</td></tr>
<tr><td><b>Levetidsforlengelse framfor utskifting</b></td>
<td>forsegling eller tynndekke framfor full reasfaltering · reparasjon framfor bytte av rekkverk</td>
<td>rehabilitering av eksisterende installasjon framfor full utskifting</td></tr>
</table>
<p><b>En femte som ofte glemmes:</b> <i>redusert omfang</i> — å utbedre mindre der tilstanden
ikke krever mer. Den er ofte den største, og den er alltid den vanskeligste å foreslå.</p>
<div class="done"><b>Ferdig når</b>
Du har 36 hypoteser, hver med én setning om <b>hvorfor</b> du tror på den. Begrunnelsen mates
ordrett inn til løsningen — det er der fagkunnskapen din faktisk gjør en forskjell.</div>
</section>
<!-- 5 -->
<section class="slide">
<p class="kicker">Spørsmål 1, fortsatt</p>
<h2>Filteret som avgjør om en hypotese er brukbar</h2>
<p>Et tiltak må kunne uttrykkes som en <b>kostnadslinje</b> — en mengde ganger en enhetspris.
Kan det ikke det, kan løsningen foreslå det, men <b>ikke kontrollere det</b>. Da er svaret verdt
akkurat like mye som et vanlig godt råd.</p>
<table>
<tr><th></th><th>Hypotese</th><th>Blir til</th></tr>
<tr><td></td><td>Færre vinterutkallinger med prognosestyring</td><td>antall utkallinger × kr per utkalling</td></tr>
<tr><td></td><td>Lengre intervall mellom tunnelvask</td><td>antall vask per år × kr per vask</td></tr>
<tr><td></td><td>Finere dimming av tunnelbelysningen</td><td>kWh per år × kr per kWh</td></tr>
<tr><td></td><td>Tynndekke framfor full reasfaltering</td><td>× kr per m²</td></tr>
<tr><td></td><td>«Bedre samhandling med entreprenøren»</td><td>ingen mengde, ingen enhetspris</td></tr>
<tr><td></td><td>«Tidligere involvering av fagressurser»</td><td>ingen mengde, ingen enhetspris</td></tr>
</table>
<p>De to nederste kan godt være riktige. De hører bare hjemme et annet sted enn her.</p>
<div class="done"><b>Gjør dette</b>
Skriv om hver hypotese til formen «<b>noe</b> ganger <b>en pris</b>». Klarer du det ikke, spør
deg selv hva som faktisk endrer seg i regnskapet — svaret er som regel mengden.</div>
</section>
<!-- 6 -->
<section class="slide">
<p class="kicker">Spørsmål 2 av 7 — det tunge</p>
<h2><span class="step">2</span> Hva koster dette i dag?</h2>
<div class="ask">«For hver hypotese: hvilken kostnadslinje treffer den, hva er mengden,
og hva er enhetsprisen — og hvor kommer tallet fra?»</div>
<p>Dette er det steget som stopper prosjekter. Uten ekte kostnadslinjer har kontrollen
<b>ingenting å avstemme mot</b>: et internt konsistent, oppdiktet forslag går rett gjennom, og
kjøringen ser helt normal ut.</p>
<table>
<tr><th>Prosjekttype</th><th>Typiske linjer</th><th>Formen</th></tr>
<tr><td rowspan="4"><b>Utbedring veg</b></td>
<td>dekkefornyelse</td><td>× kr/m²</td></tr>
<tr><td>vinterdrift</td><td>utkallinger/år × kr per utkalling, eller km × kr/km</td></tr>
<tr><td>veglys, energi</td><td>kWh/år × kr/kWh</td></tr>
<tr><td>grøfterens, kantklipp, rekkverk</td><td>løpemeter × kr/lm</td></tr>
<tr><td rowspan="4"><b>Tunnel</b></td>
<td>belysning, energi</td><td>kWh/år × kr/kWh</td></tr>
<tr><td>ventilasjon, energi</td><td>kWh/år × kr/kWh</td></tr>
<tr><td>vask og renhold</td><td>vask/år × kr per vask</td></tr>
<tr><td>utskifting av komponenter</td><td>antall × kr/stk</td></tr>
</table>
<p><b>Ta med alle linjene som er i spill</b> — ikke bare linjen til det tiltaket du tror mest på.
Et forslag som viser til en kostnadskode som ikke finnes i grunnlaget, blir avvist.</p>
<div class="done"><b>Ferdig når</b>
Hver linje har en mengde og en enhetspris, og du kan si <b>hvor hvert tall kom fra</b>.
Får du ikke tak i tallene: si det uttrykkelig, så ingen leser et godkjent-resultat som mer enn det er.</div>
<div class="note"><b>Vanligste fellene:</b> et tall som er fordelt fra en større post uten at det
står · et tall fra før forrige ombygging · en enhetspris uten årstall, som ikke kan prisjusteres ·
en investeringskostnad som dekker <em>hele</em> anlegget mens tiltaket bytter én del.</div>
</section>
<!-- 7 -->
<section class="slide">
<p class="kicker">Spørsmål 3 av 7</p>
<h2><span class="step">3</span> Hvilke interne data finnes — og hvem henter dem?</h2>
<div class="ask">«Hvilket system holder dette tallet i dag, og kan noen hente det ut for meg?»</div>
<p>Ikke spør om integrasjoner. Spør per tall. Fem kategorier dekker det meste:</p>
<table>
<tr><th>Kategori</th><th>Utbedring veg</th><th>Tunnel</th><th>Uten den</th></tr>
<tr><td><b>A. Kostnad og regnskap</b><br><span class="dimt">faktura, kalkyle, kontraktspriser</span></td>
<td>enhetspriser fra driftskontrakt, sluttkostnad fra tilsvarende prosjekt</td>
<td>energifaktura, priser fra siste elektroanbud</td>
<td><b>kontrollen er uforankret</b></td></tr>
<tr><td><b>B. Objekt og mengde</b><br><span class="dimt">hva anlegget består av</span></td>
<td>km veg, m² dekke, antall stikkrenner, meter rekkverk, alder og tilstand</td>
<td>antall armaturer og effekt, antall vifter og pumper, lengde, antall løp</td>
<td>ingen mengde å gange med</td></tr>
<tr><td><b>C. Bruk og driftsprofil</b><br><span class="dimt">hvor mye, hvor ofte, hvor lenge</span></td>
<td>ÅDT, antall vinterutkallinger, saltmengde, klippefrekvens</td>
<td>brenntimer, driftstimer vifter, vaskefrekvens, trafikkfordeling</td>
<td>årsforbruket kan ikke regnes</td></tr>
<tr><td><b>D. Historikk</b><br><span class="dimt">hva som er gjort</span></td>
<td>utførte dekkefornyelser med årstall og strekning</td>
<td>utskiftinger, oppgraderinger, rehabiliteringer med årstall</td>
<td>tiltak foreslås på nytt, gevinst dobbelttelles</td></tr>
<tr><td><b>E. Kontrakt og avtale</b><br><span class="dimt">hva som er bundet</span></td>
<td>driftskontraktens omfang og løpetid, opsjoner</td>
<td>serviceavtaler, garantiperioder</td>
<td>tiltak foreslås som ikke kan bestilles</td></tr>
</table>
<div class="done"><b>Gjør dette</b>
For hver kategori: skriv ned <b>hvilket system eller regneark</b> tallet ligger i, og
<b>hvem</b> som kan hente det ut. Et CSV-uttrekk eller et skjermbilde er nok — det trengs
ingen integrasjon for å komme i gang.</div>
</section>
<!-- 8 -->
<section class="slide">
<p class="kicker">Spørsmål 4 av 7</p>
<h2><span class="step">4</span> Hvilke eksterne data er nyttige?</h2>
<p>Eksterne data brukes til to ting: å <b>begrense</b> hva som er lov, og å <b>kalibrere</b> hva
som er realistisk. Fire kategorier:</p>
<p><b>Det aller meste av dette finnes allerede som åpne API-er.</b> Tilgang er sjelden problemet.
Å vite <em>hvilke</em> kilder som er relevante for akkurat dine tiltak, er det — og det er en
fagvurdering. Neste side er den jobben.</p>
<table>
<tr><th>Kategori</th><th>Hva det er</th><th>Hva det gjør i kjøringen</th></tr>
<tr><td><b>F. Normer og krav</b></td>
<td>håndbøker og vegnormaler som gjelder tiltaket — for tunnelbelysning
f.eks. Håndbok V124 og N500, med paragraf</td>
<td>setter gulvet ingen besparelse kan gå under; hindrer forslag som bryter krav</td></tr>
<tr><td><b>G. Priser og indekser</b></td>
<td>kraftpris og nettleie, prisindekser, markedspriser fra sammenlignbare anbud</td>
<td>gjør enhetsprisen etterprøvbar og prisjusterbar</td></tr>
<tr><td><b>H. Erfaringstall for realisering</b></td>
<td>litteratur og evalueringer om <b>gapet mellom beregnet og faktisk</b> effekt</td>
<td>korrigerer den beregnede besparelsen ned til det som pleier å komme ut</td></tr>
<tr><td><b>I. Produkt- og leverandørdata</b></td>
<td>effekt, levetid, garanti, dokumenterte ytelser</td>
<td>gir parametere til tiltaksnotatene i stedet for antakelser</td></tr>
</table>
<p><b>Kategori H er den som er vanskeligst å skaffe og som betyr mest.</b> Finnes det ingen norsk
måling på ditt område, er det helt i orden å låne et tall fra utenlandsk litteratur — men da skal
det stå uttrykkelig <b>at det er lånt</b>, og fra hva.</p>
<div class="done"><b>Ferdig når</b>
Hvert tall som ikke er målt hos dere, har en <b>navngitt kilde med årstall</b> ved siden av seg —
og det er tydelig merket hva som er eget materiale og hva som er lånt.</div>
</section>
<!-- 8b -->
<section class="slide">
<p class="kicker">Spørsmål 4, fortsatt — nesten det viktigste</p>
<h2>Åpne API-er og MCP-servere: jobben er å peke ut de riktige</h2>
<div class="ask">«Hvilke åpne datakilder bruker fagmiljøet ditt allerede —
og hvilke skulle du ønske du hadde hatt?»</div>
<p>Det tekniske er sjelden flaskehalsen. Det finnes ferdige MCP-servere for en rekke offentlige
norske datakilder, og et hvilket som helst åpent REST-API kan pakkes som én. <b>Det som mangler,
er noen som kan si hvilke kilder som er verdt å koble til.</b> Det er deg.</p>
<h3>Fem spørsmål per kandidatkilde</h3>
<table>
<tr><th>Spør</th><th>Hvorfor det avgjør</th></tr>
<tr><td><b>1.</b> Hvilket tall i regnestykket svarer den på?</td>
<td>En kilde som ikke treffer en kostnadslinje eller en parameter, tilfører støy — ikke presisjon.</td></tr>
<tr><td><b>2.</b> Er den autoritativ for nettopp det tallet?</td>
<td>Ville du sitert den i en rapport? Hvis ikke, skal den ikke ligge til grunn her heller.</td></tr>
<tr><td><b>3.</b> Hvor ofte endrer tallet seg?</td>
<td>Sjelden ⇒ hent på forhånd. I løpet av dager ⇒ argument for oppslag underveis.</td></tr>
<tr><td><b>4.</b> Hvem eier tilgangen, og koster den noe?</td>
<td>Åpent uten nøkkel · åpent med registrering · lukket og krever avtale. Tre helt ulike tidslinjer.</td></tr>
<tr><td><b>5.</b> Hvilken lisens har dataene?</td>
<td>Avgjør om resultatet kan deles videre, og med hvem.</td></tr>
</table>
<h3>To måter en kilde kommer inn — og de er ikke likeverdige</h3>
<table>
<tr><th></th><th>Hent på forhånd</th><th>Slå opp underveis (MCP)</th></tr>
<tr><td><b>Når</b></td><td>før kjøringen</td><td>mens forslaget formes</td></tr>
<tr><td><b>Blir</b></td><td>et dokument i kunnskapsbasen, med opphav og dato</td><td>et verktøy løsningen kan kalle selv</td></tr>
<tr><td><b>Fordel</b></td><td>du kan lese og korrigere dataene <em>først</em></td><td>fanger opp noe som endrer seg</td></tr>
<tr><td><b>Krever</b></td><td>at noen henter uttrekket</td><td>uttrykkelig liste over tillatte oppslag</td></tr>
<tr><td><b>Nettverk under kjøring</b></td><td>null</td><td>ja — og alt navngis på forhånd</td></tr>
</table>
<p><b>Velg «hent på forhånd» når du kan.</b> Det er billigere, det kan kvalitetssikres av et
menneske før det brukes, og det gjør at du etterpå kan si nøyaktig hva en kjøring har rørt.
Uten eksplisitt oppsett gjør en kjøring <b>null</b> nettverkskall.</p>
<div class="done"><b>Ferdig når</b>
Du har en navngitt liste: <b>kilde · hvilket tall den svarer på · hvor ofte det endrer seg ·
hvem som eier tilgangen</b>. Fem treffsikre kilder slår femti mulige.</div>
<div class="note"><b>Advarsel:</b> flere kilder gjør ikke svaret bedre av seg selv. Alt som kobles
til, blir lest. Ti kilder som ikke treffer et tall i regnestykket, koster like mye oppmerksomhet
som ti som gjør det.</div>
</section>
<!-- 9 -->
<section class="slide">
<p class="kicker">Spørsmål 5 av 7</p>
<h2><span class="step">5</span> Hva kan ikke fravikes?</h2>
<div class="ask">«Hvilke krav, nivåer og avtaler er det ingen besparelse som kan gå under —
og avviker noe av det hos dere?»</div>
<p>Rammene er den viktigste halvdelen av anleggsbeskrivelsen. Uten dem foreslår løsningen tiltak
som bryter krav ingen har fortalt den om, og du bruker tid på å avvise det samme igjen og igjen.</p>
<table>
<tr><th>Type ramme</th><th>Utbedring veg</th><th>Tunnel</th></tr>
<tr><td><b>Fagkrav med minstenivå</b></td>
<td>krav til friksjon, jevnhet, sporddybde, siktforhold</td>
<td>lystekniske minstekrav i sonene, luftkvalitetskrav, hysteresetid ved nivåendring</td></tr>
<tr><td><b>Sikkerhetskrav</b></td>
<td>rekkverksklasser, arbeidsvarsling</td>
<td>krav til nødbelysning, ventilasjon ved brann, redundans</td></tr>
<tr><td><b>Antakelser som ikke holder</b></td>
<td>«vi kan ikke forutsette at strekningen kan stenges»</td>
<td>«vi kan ikke forutsette nattstenging for arbeid»</td></tr>
<tr><td><b>Kontraktsbundet</b></td>
<td>driftskontraktens omfang ut avtaleperioden</td>
<td>serviceavtaler, garantibetingelser</td></tr>
<tr><td><b>Budsjett og anskaffelse</b></td>
<td colspan="2">hva som kan bestilles i hvilket år, terskelverdier</td></tr>
</table>
<div class="done"><b>Ferdig når</b>
En fagperson som <em>ikke</em> kjenner anlegget kan lese listen og vite hva som er lov å foreslå.
Er et krav strengere hos dere enn i normen — si det. Det er nøyaktig det du vet og normen ikke sier.</div>
</section>
<!-- 10 -->
<section class="slide">
<p class="kicker">Spørsmål 6 av 7</p>
<h2><span class="step">6</span> Hva er allerede gjort — og hva er allerede vurdert?</h2>
<div class="ask">«Hva er bygget om de siste årene, når, og på hvor stor del av anlegget?
Og har noen vurdert et av disse tiltakene før?»</div>
<p>Løsningen vet ingenting om anlegget utover det kunnskapsbasen sier. Står et gjennomført tiltak
ingen steder, blir det <b>foreslått på nytt</b> — med en besparelse som allerede er tatt ut.</p>
<table>
<tr><th>Du leverer</th><th>Hva det hindrer</th></tr>
<tr><td><b>Gjennomførte tiltak</b> — hva, når, på hvor mye av anlegget</td>
<td>dobbelttelling av en gevinst som allerede er hentet</td></tr>
<tr><td><b>Kostnadstall som viser dagens situasjon</b>, ikke situasjonen før forrige tiltak</td>
<td>at kontrollen avstemmer mot et grunnlag som ikke finnes lenger</td></tr>
<tr><td><b>Tidligere vurderinger</b> — hva fagfolk mente om et forslag, og hvorfor</td>
<td>at samme diskusjon tas om igjen fra null</td></tr>
<tr><td><b>Tiltak som ble forsøkt og ikke virket</b>, med begrunnelse</td>
<td>den dyreste gjentakelsen av alle</td></tr>
</table>
<div class="done"><b>Ferdig når</b>
Ingen i rommet kan peke på et gjennomført tiltak som ikke står i basen, og kostnadstallene
stemmer med det anlegget faktisk bruker i dag.</div>
</section>
<!-- 11 -->
<section class="slide">
<p class="kicker">Spørsmål 7 av 7 — det mest verdifulle</p>
<h2><span class="step">7</span> Hva pleier å skje mellom kalkyle og virkelighet?</h2>
<div class="ask">«Når dere har gjort noe slikt før — hvor mye av den beregnede besparelsen
kom faktisk ut? Og hva var det som spiste resten?»</div>
<p>Kontrollen kan avgjøre om et tall er <b>mulig</b>. Den kan ikke avgjøre om anlegget faktisk
leverer det. Der stopper regnestykket og din erfaring begynner — og det er den <b>eneste</b>
kunnskapen i hele prosessen som ikke kan hentes fra et system.</p>
<p><b>Et godt svar navngir mekanismen, ikke bare tallet.</b> Eksempel fra tunnelbelysning, der
tre kjente mekanismer trekker gevinsten ned:</p>
<table>
<tr><th>Mekanisme</th><th>Hvorfor den spiser gevinst</th></tr>
<tr><td>Påkrevd forsinkelse ved nivåendring</td>
<td>holder anlegget på det <em>høyere</em> nivået gjennom svingninger — asymmetrisk i energi</td></tr>
<tr><td>Den delen av tiltaket som ikke blir implementert</td>
<td>halve gevinsten kan ligge i en del som rutinemessig faller ut av leveransen</td></tr>
<tr><td>Kalibrering med sikkerhetsmargin</td>
<td>systematisk og ensrettet: ingen driftsorganisasjon justerer seg til for lite lys</td></tr>
</table>
<p>De samme spørsmålene på vegsiden: <i>Ble den nye driftsrutinen faktisk fulgt hele vinteren?
Ble tilstandsmålingene brukt til å styre, eller bare rapportert? Hvor mye av tynndekket måtte
gjøres om igjen innen tre år?</i></p>
<div class="done"><b>Ferdig når</b>
Du har sagt, med egne ord: «forvent rundt <b>X</b> prosent av det som er beregnet, fordi <b>Y</b>
Har du et tall fra et eget prosjekt — det er den enkeltleveransen som forbedrer basen mest.
Har du det ikke, si det: et navngitt kunnskapshull er innhold, et oppdiktet tall er forurensning.</div>
</section>
<!-- 12 -->
<section class="slide">
<p class="kicker">Kvalitet</p>
<h2>Fire krav til hvert tall du leverer</h2>
<table>
<tr><th>Krav</th><th>Hvorfor</th></tr>
<tr><td><b>1. Kilde.</b> Hvor kom tallet fra — system, faktura, håndbok, notat?</td>
<td>Et tall uten kilde kan ikke etterprøves, og da kan heller ikke resultatet det.</td></tr>
<tr><td><b>2. Årstall.</b> Hvilket år gjelder det for?</td>
<td>Et beløp uten årstall kan ikke prisjusteres. Da er det ubrukelig, uansett hvor riktig det var.</td></tr>
<tr><td><b>3. Målt eller antatt.</b> Er dette avlest, eller er det anslått?</td>
<td>Begge deler er brukbart. Å forveksle dem er ikke.</td></tr>
<tr><td><b>4. Omfang.</b> Hva dekker tallet — hele anlegget, eller den delen tiltaket treffer?</td>
<td>Feil omfang er den vanligste grunnen til at et riktig tiltak blir avvist.</td></tr>
</table>
<div class="done"><b>Den enkleste formen</b>
Én linje per tall: <code>hva · verdi · enhet · kilde · år · målt/antatt</code>.
Et regneark med de seks kolonnene er en fullgod leveranse.</div>
</section>
<!-- 13 -->
<section class="slide">
<p class="kicker">Fallgruver</p>
<h2>De fem feilene som koster mest</h2>
<ol>
<li><b>Ingen ekte kostnadstall.</b> Kjøringen går, resultatet ser normalt ut, og kontrollen
dømmer bare mot tall forslaget fant på selv. Den dyreste feilen, fordi den er usynlig.</li>
<li><b>Gjennomførte tiltak står ikke i basen.</b> De foreslås på nytt, og besparelsen
dobbelttelles.</li>
<li><b>Utledede tall.</b> Et tall ingen kan peke på en kilde for, forurenser alt som bygger
på det. Mangler et tall, skal det stå som manglende.</li>
<li><b>For mye materiale.</b> Alt som legges inn, leses i sin helhet. Ti sider støy koster like
mye oppmerksomhet som ti sider substans. Lever det som er relevant, ikke alt som finnes.</li>
<li><b>Ingen som dømmer etterpå.</b> Kjøringen produserer et forslag ingen svarer på, og
løsningen lærer ingenting. En base ingen dømmer imot, står stille.</li>
</ol>
</section>
<!-- 14 -->
<section class="slide">
<p class="kicker">Etterpå</p>
<h2>Din dom er produktet — ikke forslaget</h2>
<p>Etter kjøringen får du hvert vurderte tiltak tilbake, ett for ett, med kontrollens begrunnelse.
Du svarer én av tre ting:</p>
<table>
<tr><th>Svar</th><th>Når</th><th>Hva du skriver</th></tr>
<tr><td><b>Godkjent</b></td><td>tallet står seg som det er</td>
<td>kort. En lang begrunnelse for et enkelt ja gir bare støy.</td></tr>
<tr><td><b>Godkjent med korreksjon</b></td><td>regnestykket stemmer, men drift leverer mindre</td>
<td><b>det vanligste ekte svaret</b> — og det som bærer mest læring: hvor mye, og hvorfor.</td></tr>
<tr><td><b>Avvist</b></td><td>virkeligheten rundt tallet holder ikke</td>
<td>hvorfor. «Ikke gjennomførbart» lærer ingenting; «forutsetningen om X holder ikke her, fordi Y» gjør det.</td></tr>
</table>
<p><b>Skriv hvorfor, ikke hva.</b> Begrunnelsen er det eneste som bærer fagkunnskap videre til
neste kjøring — på dette prosjektet og på liknende prosjekter senere.</p>
<div class="note">Et avvist forslag er ikke en feilet kjøring. En avvisning med en god begrunnelse
er ofte mer verdt enn en godkjenning, fordi den lukker en retning for godt.</div>
</section>
<!-- 15 -->
<section class="slide">
<p class="kicker">Ta med denne</p>
<h2>Sjekkliste: dette skaffer du før kjøringen</h2>
<h3>Blokkerende — uten disse kjøres det ikke</h3>
<ul>
<li>Hvilket prosjekt eller anlegg det gjelder, med ett entydig navn</li>
<li>Kostnadslinjene tiltakene kan treffe: <b>mengde × enhetspris</b>, med kilde og årstall</li>
<li>36 hypoteser, hver med én setning om hvorfor</li>
<li>Liste over hva som allerede er gjennomført, med årstall</li>
<li>Kravene som ikke kan fravikes</li>
<li>Navnet på den som skal avgi dommen etterpå</li>
</ul>
<h3>Gjør svaret vesentlig bedre</h3>
<ul>
<li>Objekt- og mengdedata: antall, effekt, alder, tilstand</li>
<li>Driftsprofil: timer, frekvenser, ÅDT, utkallinger</li>
<li>Erfaringstall for realiseringsgrad — eget eller lånt, merket hvilket</li>
<li><b>Navngitte åpne datakilder</b>: hva hver av dem svarer på, og hvem som eier tilgangen</li>
<li>Produkt- og leverandørdata for de aktuelle tiltakene</li>
<li>Tidligere vurderinger, inkludert de som endte i nei</li>
</ul>
<h3>Avklares med den tekniske personen</h3>
<ul>
<li>Hvilke systemer tallene hentes fra, og hvem som henter dem</li>
<li>Om en kilde skal hentes på forhånd eller slås opp underveis</li>
<li>Om noe skal kontaktes under kjøring — og hvem som godkjenner det</li>
</ul>
<div class="done"><b>Realistisk tidsbruk</b>
Én til to uker. Det tunge er ikke teknikken — det er å få tak i kostnadstallene og å få skrevet
ned rammene.</div>
</section>
<nav>
<button id="prev">← Forrige</button>
<button id="next">Neste →</button>
<span id="pos"></span>
</nav>
<script>
const slides = document.querySelectorAll('.slide');
let i = 0;
function show(n) {
i = Math.max(0, Math.min(slides.length - 1, n));
slides.forEach((s, k) => s.classList.toggle('on', k === i));
document.getElementById('pos').textContent = (i + 1) + ' / ' + slides.length;
window.scrollTo(0, 0);
}
document.getElementById('prev').onclick = () => show(i - 1);
document.getElementById('next').onclick = () => show(i + 1);
document.addEventListener('keydown', e => {
if (e.key === 'ArrowRight' || e.key === 'PageDown' || e.key === ' ') show(i + 1);
if (e.key === 'ArrowLeft' || e.key === 'PageUp') show(i - 1);
});
show(0);
</script>
</body>
</html>

View file

@ -148,7 +148,7 @@ derfor re-kjøringen som et erklært protokollavvik — det absorberes ikke stil
kjøring 1 førsteklasses, jf. tabellen over. kjøring 1 førsteklasses, jf. tabellen over.
**Konfig-diff mellom feilet og vellykket kjøring (publisert):** **Konfig-diff mellom feilet og vellykket kjøring (publisert):**
`git -C /Users/ktg/repos/portfolio-optimiser-claude diff 0238507..7637c6f --stat` viser 7 filer `git -C ../portfolio-optimiser-claude diff 0238507..7637c6f --stat` viser 7 filer
endret, 253 innsettinger, 25 slettinger (`artifacts.py`, `loop.py`, `run_s10.py`, `sdk_client.py` endret, 253 innsettinger, 25 slettinger (`artifacts.py`, `loop.py`, `run_s10.py`, `sdk_client.py`
+ tre testfiler), samlet i fiks-commiten `7637c6f` «fix(run): S10 del 2 — post-mortem: + tre testfiler), samlet i fiks-commiten `7637c6f` «fix(run): S10 del 2 — post-mortem:
stopp-artefakt, SDK-isolasjon, raw-JSON-direktiv». Endringene er harness-fikser (SDK-isolasjon stopp-artefakt, SDK-isolasjon, raw-JSON-direktiv». Endringene er harness-fikser (SDK-isolasjon
@ -248,8 +248,8 @@ Klasser: **reproduce** = kommandoen kan kjøres på nytt og skal gi samme svar;
**recompute-from-artifact** = tallet avledes av committede artefakter (live-tall re-kjøres aldri, **recompute-from-artifact** = tallet avledes av committede artefakter (live-tall re-kjøres aldri,
jf. §4.2/§4.5); **disclosed-non-reproducible** = kilden er erklært og ikke-reproduserbar. jf. §4.2/§4.5); **disclosed-non-reproducible** = kilden er erklært og ikke-reproduserbar.
Kommandoer kjøres fra MAF-repoets rot (`/Users/ktg/repos/portfolio-optimiser`); søskenet er Kommandoer kjøres fra MAF-repoets rot (dette repoet); søskenet er sjekket ut som nabokatalog
`/Users/ktg/repos/portfolio-optimiser-claude`. (`../portfolio-optimiser-claude`).
| # | Påstand | Kommando / kilde | Klasse | | # | Påstand | Kommando / kilde | Klasse |
|---|---|---|---| |---|---|---|---|
@ -278,7 +278,7 @@ Kommandoer kjøres fra MAF-repoets rot (`/Users/ktg/repos/portfolio-optimiser`);
python3 - <<'EOF' python3 - <<'EOF'
import json, pathlib import json, pathlib
g = json.loads(pathlib.Path("shared/examples/bygg-energi-mikro/golden.json").read_text())["validator"] g = json.loads(pathlib.Path("shared/examples/bygg-energi-mikro/golden.json").read_text())["validator"]
r = json.loads(pathlib.Path("/Users/ktg/repos/portfolio-optimiser-claude/runs/s10/run_result.json").read_text())["outcome"] r = json.loads(pathlib.Path("../portfolio-optimiser-claude/runs/s10/run_result.json").read_text())["outcome"]
for k in ("p10", "p50", "p90"): for k in ("p10", "p50", "p90"):
assert g[k] == r[k], (k, g[k], r[k]) assert g[k] == r[k], (k, g[k], r[k])
print("OK: golden == run_result (bit-for-bit p10/p50/p90)") print("OK: golden == run_result (bit-for-bit p10/p50/p90)")

View file

@ -3,7 +3,7 @@
**Dato:** 2026-07-04 **Dato:** 2026-07-04
**Program:** ingest-konnektorer for portfolio-optimiser, avsluttende steg (I7). **Program:** ingest-konnektorer for portfolio-optimiser, avsluttende steg (I7).
**Omfang:** begge referanseimplementasjonene — MAF (dette repoet) og Claude Agent **Omfang:** begge referanseimplementasjonene — MAF (dette repoet) og Claude Agent
SDK-søsknet (D7, [`portfolio-optimiser-claude`](https://git.fromaitochitta.com/ktg/portfolio-optimiser-claude), SDK-søsknet (D7, [`portfolio-optimiser-claude`](https://git.fromaitochitta.com/open/portfolio-optimiser-claude),
sitert *read-only*). sitert *read-only*).
**Bindende føring:** ærlighets-regelen — [målbilde §0 + §11 «Stack-mapping (ærlighets-avgrenset)»](../plan/2026-07-03-maalbilde-ingest-lag.md). **Bindende føring:** ærlighets-regelen — [målbilde §0 + §11 «Stack-mapping (ærlighets-avgrenset)»](../plan/2026-07-03-maalbilde-ingest-lag.md).
Rapporten skiller **kapabilitet-implementert** fra **kjørt-mot-live**, og holder to «live»-akser Rapporten skiller **kapabilitet-implementert** fra **kjørt-mot-live**, og holder to «live»-akser

View file

@ -18,5 +18,26 @@ PORTFOLIO_LOCAL_API_KEY=ollama # local servers accept any
# --- AZURE / Foundry profile (targeted verification only — D6) --- # --- AZURE / Foundry profile (targeted verification only — D6) ---
# Deployment (model) names are tenant-specific and live in data/model_map.json (azure.*). # Deployment (model) names are tenant-specific and live in data/model_map.json (azure.*).
# Credential resolves via Azure DefaultAzureCredential (`az login` / managed identity). # The credential is chosen by the ENVIRONMENT and is not configured here:
# * developer host -> AzureCliCredential; constructing it acquires no token, so `az login`
# stays your own manual step (docs/2026-07-15-foundry-auth-recipe.md).
# * Foundry hosted agent -> ManagedIdentityCredential, selected when FOUNDRY_HOSTING_ENVIRONMENT
# holds a non-empty value. A container has no Azure CLI; the platform
# mints the agent a dedicated Entra identity at deploy time instead.
PORTFOLIO_FOUNDRY_PROJECT_ENDPOINT= # tenant-specific Foundry project endpoint (operator supplies) PORTFOLIO_FOUNDRY_PROJECT_ENDPOINT= # tenant-specific Foundry project endpoint (operator supplies)
# Hosted only: Foundry injects FOUNDRY_PROJECT_ENDPOINT itself, and the framework reads it as a
# fallback. The name above wins whenever it holds a non-empty value, so an explicit export is never
# shadowed by a platform default — and an exported-but-empty name falls through rather than masking.
# --- Tracing (U14) — OFF unless this variable holds a value ---
# Unset or empty: OpenTelemetry providers are NOT configured at all. Spans are still made (MAF's
# ENABLE_INSTRUMENTATION defaults to true) and simply discarded, so nothing can leave the process.
# console -> spans are written to STDERR. stdout is byte-identical to an untraced run, which is
# what keeps the pinned demo transcript usable. Refused if any OTEL_EXPORTER_OTLP_*
# endpoint is also set: those exporters are built unconditionally, so "console"
# would otherwise be a false statement about where the run's contents went.
# otlp -> spans are EXPORTED OVER THE NETWORK, and only to an endpoint you named below.
# Requires an OTLP exporter package, which this framework deliberately does NOT
# declare (`pip install opentelemetry-exporter-otlp-proto-grpc`).
# PORTFOLIO_OTEL=console
# OTEL_EXPORTER_OTLP_ENDPOINT= # required by PORTFOLIO_OTEL=otlp; egress — name it yourself

33
llms.txt Normal file
View file

@ -0,0 +1,33 @@
# portfolio-optimiser
> A generic, open Python framework on Microsoft Agent Framework (MAF) that finds cost savings
> inside each project of a portfolio of independent projects. A swarm of agents proposes candidate
> measures; a mandatory deterministic validator (solver + Monte Carlo) decides the numbers; domain
> experts judge the outcomes; and the system learns from their verdicts across runs. Solo-maintained,
> fork-and-own, MIT. Python >=3.10, installed from source with `uv`.
One start command. The clone is the documented path — the walkthrough in the README points at
files in the tree:
```bash
git clone https://git.fromaitochitta.com/open/portfolio-optimiser.git
cd portfolio-optimiser
uv sync
```
## Docs
- [README.md](README.md): what the framework is, an offline walkthrough of the whole chain (no API key, no network), and the task API.
- [shared/method-spec.md](shared/method-spec.md): the framework-neutral method specification the implementation is measured against.
- [docs/extending.md](docs/extending.md): the extension points — domains, data sources, ingest connectors — and what is deliberately unbuilt.
- [docs/knowledge-base-recipe.md](docs/knowledge-base-recipe.md): how to build a knowledge-base bundle the run path can navigate.
- [docs/kunnskapsbase-for-en-kjoring.md](docs/kunnskapsbase-for-en-kjoring.md): what one concrete run's knowledge base must contain (Norwegian).
- [docs/bestille-en-kjoring.md](docs/bestille-en-kjoring.md): what a requester supplies to order a run (Norwegian).
- [DEPLOY.md](DEPLOY.md): running the hosted entry point (`python main.py`) and the environment it needs.
- [CLAUDE.md](CLAUDE.md): the invariant ledger — each design decision, the measurement that forced it, and the test that turns red when it is undone.
## Optional
- [CHANGELOG.md](CHANGELOG.md): release history.
- [SECURITY.md](SECURITY.md): how to report a vulnerability, and what is supported.
- [CONTRIBUTING.md](CONTRIBUTING.md): issues welcome as signals; pull requests are not accepted.

14
main.py Normal file
View file

@ -0,0 +1,14 @@
"""Hosted-agent process entry (Fase 4d): the ONE file that starts the service.
Thin by design the runtime contract lives in ``portfolio_optimiser.hosting`` (packaged
and tested); this file is the scaffold-conventional ``main.py`` that ``python main.py``
starts, which is the single start command DEPLOY.md prints (14.08: the delivery is runnable
Python, and no image ``CMD`` exists to be a second copy of it). It is part of the git archive,
not of the wheel: a wheel consumer starts the same server with
``python -c "from portfolio_optimiser.hosting import main; main()"``.
"""
from portfolio_optimiser.hosting import main
if __name__ == "__main__":
main()

View file

@ -1,6 +1,6 @@
[project] [project]
name = "portfolio-optimiser" name = "portfolio-optimiser"
version = "0.1.0" version = "1.1.0"
description = "Generic framework on Microsoft Agent Framework for per-project cost-savings optimization" description = "Generic framework on Microsoft Agent Framework for per-project cost-savings optimization"
readme = "README.md" readme = "README.md"
requires-python = ">=3.10" requires-python = ">=3.10"
@ -28,13 +28,38 @@ dependencies = [
# `uv run mypy src` outright. Raise both floors together or not at all. # `uv run mypy src` outright. Raise both floors together or not at all.
"numpy>=2.0,<2.3", "numpy>=2.0,<2.3",
"llm-ingestion-okf", # Door A ingest (§4§6) — the shared implementation of shared/ingest-spec.md; zero runtime deps, MAF-free (D7) "llm-ingestion-okf", # Door A ingest (§4§6) — the shared implementation of shared/ingest-spec.md; zero runtime deps, MAF-free (D7)
"llm-ingestion-guard",
# U14 (økt 55, operatørbeslutning 23.08): the tracing seam's ONE new runtime dependency.
# `opentelemetry-api` was already here transitively via agent-framework-core, but the SDK is
# what `configure_otel_providers()` needs — without it MAF raises ModuleNotFoundError, so the
# seam cannot land as an opt-in without declaring it. The console exporter this framework uses
# (`opentelemetry.sdk.trace.export.ConsoleSpanExporter`) ships INSIDE this package, so console
# mode costs exactly one declaration. The OTLP exporter packages are deliberately NOT declared:
# they are egress, they drag grpc/protobuf into a published wheel, and MAF already raises a
# named ImportError telling the operator which one to install. Stated honesty limit, not an
# oversight — see tracing.py.
"opentelemetry-sdk>=1.42,<2",
# Door A content gate — scans materialized concepts before they reach the bundle; zero runtime deps, MAF-free (D7)
] ]
# Console entry points — the install surface a fresh clone gets from `uv sync` (P4 pkt. 5).
# Deliberately TWO, not five: `run` is the framework CLI (three documented modes) and `simulation`
# is the offline end-to-end proof the README points a newcomer at. `costsim` / `hitl` / `preflight`
# keep the `python -m` form — they are operator utilities, not the product's front door, and every
# name here is a name the freeze has to carry. Pinned by tests/test_console_entry_points.py.
[project.scripts]
portfolio-optimiser = "portfolio_optimiser.run:main"
portfolio-optimiser-demo = "portfolio_optimiser.simulation:main"
# Distribution channel for the shared ingest library (mirrors portfolio-optimiser-claude, # Distribution channel for the shared ingest library (mirrors portfolio-optimiser-claude,
# verified in consumer CI): git pin against the public Forgejo repo — reproducible for every # verified in consumer CI): git pin against the public Forgejo repo — reproducible for every
# consumer, uv.lock pins the exact commit behind the tag. Bump the rev on a new library tag. # consumer, uv.lock pins the exact commit behind the tag. Bump the rev on a new library tag.
[tool.uv.sources] [tool.uv.sources]
llm-ingestion-okf = { git = "https://git.fromaitochitta.com/open/llm-ingestion-okf.git", rev = "v0.3.2" } llm-ingestion-okf = { git = "https://git.fromaitochitta.com/open/llm-ingestion-okf.git", rev = "v0.3.2" }
# Pinned to a TAG, never a floating ref — a security component whose version can move under a
# `uv sync` is a gate that can stop gating without a local diff (the okf `generated`-literal
# lesson, measured 2026-08-09). Bumping this is a decision, not maintenance.
llm-ingestion-guard = { git = "https://git.fromaitochitta.com/open/llm-ingestion-pipeline-security.git", rev = "v0.3.4" }
# Dev tooling as a PEP 735 dependency-group (uv includes it by default in `uv sync`/`uv run`), # Dev tooling as a PEP 735 dependency-group (uv includes it by default in `uv sync`/`uv run`),
# so the documented bare `uv sync` + `uv run pytest` workflow installs it without `--extra`. # so the documented bare `uv sync` + `uv run pytest` workflow installs it without `--extra`.
@ -60,6 +85,14 @@ build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel] [tool.hatch.build.targets.wheel]
packages = ["src/portfolio_optimiser"] packages = ["src/portfolio_optimiser"]
# Fase 4a: the wheel CARRIES shared/ (spec, persona skill, example bundles) as packaged data —
# a byte-identical mirror under portfolio_optimiser/_shared/, resolved by shared_root() only when
# no working tree is present (checkout wins; PORTFOLIO_SHARED_ROOT wins over both). This is what
# makes an installed wheel — and a container — work without a clone, while shared/ itself stays a
# pull-only subtree at the repo root. Pinned by tests/test_shared_packaged_data_loadbearing.py.
[tool.hatch.build.targets.wheel.force-include]
"shared" = "portfolio_optimiser/_shared"
[tool.ruff] [tool.ruff]
line-length = 100 line-length = 100
src = ["src", "tests", "spikes"] src = ["src", "tests", "spikes"]
@ -74,5 +107,10 @@ python_version = "3.10"
# Third-party libs without bundled type stubs (py.typed) — analysed as untyped, not errors. # Third-party libs without bundled type stubs (py.typed) — analysed as untyped, not errors.
[[tool.mypy.overrides]] [[tool.mypy.overrides]]
module = ["pulp.*", "agent_framework_foundry.*"] module = ["pulp.*", "agent_framework_foundry.*", "llm_ingestion_guard.*"]
ignore_missing_imports = true ignore_missing_imports = true
# NOTE on `llm_ingestion_guard`: the override alone would make the seam type-BLIND, not
# type-safe — every symbol arrives as `Any`, so a field rename upstream would type-check
# happily and fail at runtime. `ingest.materialize_gated` therefore coerces each value it
# reads off the guard's result objects to a concrete type at the boundary (the okf precedent),
# and the seam's behaviour is pinned by tests/test_ingest_content_gate_loadbearing.py.

View file

@ -0,0 +1,35 @@
#!/usr/bin/env bash
# Build the external handover package (Fase 5): one archive a receiver deploys into their own
# Microsoft Foundry, without cloning this repository or having an account on our Forgejo.
#
# The archive is git's view of HEAD -- TRACKED FILES ONLY. That is deliberate and it is the whole
# exposure control: STATE.md is gitignored, *.local.md is gitignored, .env is gitignored, so they
# cannot enter the archive. A filter maintained here would be a second copy of that rule, and the
# second copy is the one that drifts (kø-(p)).
#
# What the receiver gets is therefore HEAD itself -- never a hand-curated selection. That also
# decided how the 14.08 directive ("runnable Python, no container wrapper") was carried out: the
# Dockerfile and azure.yaml were removed from the TREE, not filtered out here. A filter would have
# been a curation step deciding what a receiver sees, i.e. exactly the second copy this comment
# exists to forbid. Absence is asserted in tests/test_handover_package_loadbearing.py.
#
# Usage: scripts/make-handover-package.sh [dest-dir] (default: dist/)
# Gated by tests/test_handover_package_loadbearing.py.
set -euo pipefail
DEST="${1:-dist}"
mkdir -p "$DEST"
# Version from pyproject.toml -- the one place the build stamps it. Read, never hardcoded: a second
# copy here would go stale at the next bump exactly like the README wheel filename did (Fase 3).
VERSION=$(grep -m1 '^version = ' pyproject.toml | cut -d'"' -f2)
if [ -z "$VERSION" ]; then
echo "make-handover-package: could not read version from pyproject.toml" >&2
exit 1
fi
OUT="$DEST/portfolio-optimiser-foundry-$VERSION.zip"
git archive --format=zip --output "$OUT" HEAD
echo "$OUT"

View file

@ -0,0 +1,151 @@
# V1-etterspill — krever `generated.by` / `generated.at` egne rader i §12?
> **Status: UNDERLAG, ikke ratifisert. Ingen frossen tekst er endret på dette punktet.**
> Funnet under utførelsen av V1 (`54e0ec7`, 2026-08-09). V1 selv er ratifisert og utført;
> dette er en spenning utførelsen *avdekket*, ikke en del av vedtaket.
>
> Beslektet: `2026-07-26-v1-generated-felt-okf-v0.2.md` (V1-vedtaket),
> `2026-08-02-ss11-mangler-rad-for-ss8.md` (samme klasse: intern spenning i frossen tekst).
---
## 1. Funnet
O2 gjør `generated` om fra en literal til en **inline mapping med to navngitte undernøkler**:
```
generated: { by: process:okf-ingest, at: <ingested_at> }
```
§12s kryssjekk-tabell bærer fortsatt **én rad** for `generated` (`| generated | provenance
frontmatter | §3, §7 |`). Spørsmålet er om `by` og `at` skal ha egne rader.
To setninger i frossen tekst gjør dette til mer enn kosmetikk:
> **§12, ingressen** — «Every field of the machine-readable contracts, mapped to its normative
> section (completeness is enforced by the spec-integrity test)»
> **§11, søm «Spec integrity»** — «this spec goes missing, names a concrete agent toolkit, or
> **stops documenting a contract field**»
§12 er altså ikke en bekvemmelighetstabell. Den står under en **load-bearing søm**.
## 2. Presedensen i vår egen tekst — målt begge veier
Dette er poenget som avgjør, og det peker ikke én vei før man skiller aksene.
**Presedens FOR egne rader — `source`:**
`source` er et strukturert kontraktsfelt med navngitte undernøkler. §12 gir det **både** en
toppnivå-rad **og** en rad per undernøkkel:
| Rad i §12 | Hva den er |
|---|---|
| `source` | toppnivå-feltet, «polymorphic on `source.type`» |
| `type` | undernøkkel (diskriminator) |
| `id` | undernøkkel (felles) |
| `root` | undernøkkel, kun `type: "file"` |
| `connection_ref` | undernøkkel, kun `type: "sql"` |
| `base_url` | undernøkkel, kun `type: "http"` |
| `credential_ref` | undernøkkel, kun `type: "http"`, valgfri |
Merk at undernøklene er definert i **prosa** i §4 (punktlisten), ikke i §4s tabell — men de får
likevel egne rader i §12. Tabell-plassering i §4 avgjør altså ikke §12-plikten.
**Presedens MOT egne rader — `ingest_manifest`:**
`ingest_manifest` har intern struktur (`{stem}@{hash16}`, §5) og får **nøyaktig én** rad. Struktur
inne i en verdi utløser altså ikke automatisk rader.
**Aksen som skiller dem:**
| Felt | Intern struktur er… | Egne rader? |
|---|---|---|
| `source` | **navngitte nøkler i en mapping** | ja (4 undernøkler + felles) |
| `ingest_manifest` | et **strengformat** med posisjonelle deler | nei |
| `generated` (etter O2) | **navngitte nøkler i en mapping** | *åpent — men faller på `source`-siden* |
`generated: { by, at }` er en mapping med navngitte nøkler. På den målte aksen ligner den
`source`, ikke `ingest_manifest`.
## 3. Hvorfor V1-vedtaket ikke fanget dette
`2026-07-26-v1-generated-felt-okf-v0.2.md:161` sier: «`:152` og `:309` navngir bare nøkkelen og
overlever.»
**Den påstanden er sann om den eksisterende raden** — raden heter fortsatt `generated`, ligger
fortsatt i provenance-frontmatter, og peker fortsatt på §3/§7. Ingenting ved raden ble usant.
**Den er taus om de to NYE nøklene.** Kostnaden ble talt som «kontraktslinjer som må skrives
om» (§5) — en *omskrivings*-akse. Rader som må **tilføyes** er en annen akse, og den ble aldri
stilt. Dette er ikke en feil i ratifiseringen; det er et hull i dens scope-formulering. Samme
klasse som «de 5 linjene var ikke homogene» og «`:214` er ikke en literal»: kostnadstellingen var
riktig på sin egen akse og blind for en nabo-akse.
## 4. Er sømmen rød i dag? Nei — og det er grunnen til at dette ikke haster
§11-sømmens ordlyd er «**stops documenting** a contract field». §7s omskrevne feltrad
**dokumenterer begge undernøklene** ordrett — den navngir `by`, fastslår at det er en
`process:`-aktør, navngir `at`, og binder den til `ingested_at`. Specen har altså ikke sluttet å
dokumentere noe.
Eksponeringen er mot **§12s egen ingress** («every field … mapped to its normative section»), som
er en fullstendighets-påstand om tabellen. Det er en svakere binding enn sømmens ordlyd.
**Konsekvens:** ingen kjent implementasjon går rød av dagens tilstand. Dette er en intern
spenning, ikke en defekt i drift.
## 5. Opsjoner (ingen anbefaling — operatøren ratifiserer)
| | Hva | Kostnad | Hva den koster i konformans |
|---|---|---|---|
| **O-A** | Tilføy to rader i §12 (`by`, `at` → §7) | 2 linjer, ren prosa | Utvider hva §12 påstår fullstendighet over. Ingen fixture-endring, ingen konsument-kostnad. |
| **O-B** | La §12 stå, men **snevre ingressen** til «every top-level field» | 1 linje | Gjør dagens tilstand eksplisitt konform. Men svekker en påstand `source`-radene allerede motsier. |
| **O-C** | La alt stå | 0 | Spenningen består, udokumentert. |
**O-B har en målt selvmotsigelse:** `root`/`connection_ref`/`base_url`/`credential_ref` er *ikke*
toppnivå-felter og står allerede i tabellen. En «top-level»-innsnevring ville gjort fire
eksisterende rader uhjemlede. Det er ikke et argument mot O-B, men det må løses samtidig.
**Konsument-måling mottatt 2026-08-09 (`portfolio-optimiser-claude`, ført som DERES):** deres
§12-vakt keyer på at radens første kolonne er ordrett `| `generated` |`. Konsekvensen skiller
skarpt mellom to former som ellers leser likt:
| Form | Konsekvens hos dem (deres måling) |
|---|---|
| **TILFØYE** rader for `by`/`at` (= O-A) | ingen — vakten er urørt |
| **ERSTATTE** `generated`-raden med to undernøkkel-rader | vakten går **RØD**, by design (den skal fange at en rad forsvinner) |
To ting følger. (1) O-A-radens «ingen konsument-kostnad» er nå målt utenfra, ikke bare antatt av
oss. (2) Erstatnings-formen er en **fjerde** opsjon underlaget ikke listet, og den er den eneste
som koster en konsument en samtidig endring. De ber ikke om noe; de opplyser.
## 6. Et separat, mindre funn fra samme utførelse
`generated.at` gjentar verdien av `ingested_at`, som er sitt **eget felt i samme
frontmatter-prefiks** (§5s sju nøkler; §7s tabell). Etter O2 bærer et stemplet dokument altså
samme tidsstempel to steder.
Dette er **en følge av den ratifiserte formen**, ikke en feil i utførelsen — v0.2s `generated`
tar `at` som påkrevd del av mappingen, og §1s premiss («der `at` finnes, bindes den til
`ingested_at`») er innfridd nøyaktig som vedtatt. Ført her fordi det er den slags redundans som
senere leses som drift hvis ingen skrev ned at den var tilsiktet.
**Ikke oppe til vurdering her.** En eventuell konsolidering ville rørt §5s ordnede prefiks, som er
en helt annen og dyrere sak.
## 7. Ankere re-målt (2026-08-09, etter `54e0ec7`)
Utførelsen flyttet tre av våre egne ankere. Ført ordrett, ikke som linjenumre:
| Sted | Seksjon | Status |
|---|---|---|
| Honesty rule | §1 | omskrevet — «`generated.by` naming the ingest actor» |
| Ingest owns only its own files | §3 | omskrevet — «`generated.by` equal to the ingest actor» |
| No other writer may forge the stamp | §3 | omskrevet — samme gjengivelse |
| Feltraden for `generated` | §7 | omskrevet, definerer begge undernøkler |
| Load-bearing «Stamp integrity (curated writers)» | §11 | omskrevet — aktør-spesifikt predikat |
| Frontmatter-prefikset (sju nøkler) | §5 | **uendret** — navngir bare nøkkelen |
| Kryssjekk-raden for `generated` | §12 | **uendret** — dette dokumentets tema |
`generated: true` finnes ikke lenger i specen (verifisert med `grep`).

View file

@ -0,0 +1,10 @@
{
"_note": "Prosjektets kostdata for TUNNEL-HAUGLIA, i det formatet den konsumerende implementasjonen definerer (deres akse - commons normerer ikke dette formatet). Raden er tunnelens arlige energikostnad: dagsone 60 kW x 2/3 x 4 500 t = 180 000 kWh/ar, indre-/utkjoringssone 21 kW x (4 500 t x 1,00 + 4 260 t x 0,50) = 139 230 kWh/ar, ovrige tekniske anlegg 16 020 kWh/ar, sum 335 250 kWh/ar a 1,00 NOK/kWh eks. mva. quantity og unit_cost er BYTE-IDENTISKE med affected_items-raden i validator-input.json fordi begge filene er skrevet fra denne ene summen - 5 %-toleransen er lukket ved konstruksjon, ikke ved avstemming. Investeringskostnad er BEVISST utelatt: NFF Publication 4 gir 1 000-3 000 NOK per lopemeter for tunnelbelysning, men publikasjonen er UDATERT (et belop uten arstall kan ikke prisjusteres) og prisen dekker HELE belysningsanlegget, mens tiltaket bytter bare styringen. En utledet verdi horer ikke hjemme i en kostbase. Se hauglia-tunnelen.md og tiltak-trinnstyring-innkjoringssone.md.",
"project_id": "TUNNEL-HAUGLIA",
"items": {
"ENERGI-TUNNEL-EL": {
"quantity": 335250,
"unit_cost": 1.0
}
}
}

View file

@ -0,0 +1,95 @@
---
type: project
title: "Hauglia-tunnelen"
description: "Fiktiv toløps vegtunnel, 2 400 m, 80 km/t, ÅDT under 4 000. Energibaseline for belysningen sone for sone, og rammene de lystekniske kravene setter."
resource: TUNNEL-HAUGLIA
tags: [tunnel, tunnelbelysning, innkjoringssone, energibaseline, V124, N500]
timestamp: 2026-08-09
---
# Hauglia-tunnelen (TUNNEL-HAUGLIA)
**Fiktivt anlegg.** Tallene er illustrative, men geometrien og de lystekniske rammene er
hentet fra norske primærkilder med årstall. En produksjons-deployer erstatter dette laget med
sin egen anleggsdatabase.
Tunnelen er **2 400 m, to løp, to felt**, skiltet **80 km/t**, med **ÅDT(10) under 4 000**.
Geometrien er ikke funnet på: den er valgt slik at den faller innenfor referansetunnelen
CEDR REETS D2.1 modellerer på (`>500 m, 2-bore, 2 lane`), slik at det ene eksterne
kryss-sjekk-tallet vi har, faktisk gjelder samme anleggstype. Se
[kilder-tunnelbelysning-realisering.md](kilder-tunnelbelysning-realisering.md).
## Soneinndeling
Håndbok V124 (2021) § 9.2: «Lysteknisk sett inndeles en tunnel i innkjøringssone,
overgangssone, indre sone og utkjøringssone». Lengden på innkjøringssonen er lik avstanden
fra portalen til målepunktet for adaptasjonsluminans — **99 m ved 80 km/t** (V124 tabell 9.1,
iht. CIE 088).
| Sone | Utstrekning | Merknad |
|---|---|---|
| Innkjørings- + overgangssone («dagsonen») | **300 m per løp**, 2 løp = 600 m | [I] innkjøringssone 99 m [V] + overgangssone; V124 § 9.6.1 styrer dem som **ett** objekt |
| Indre sone + utkjøringssone | 2 100 m per løp, 2 løp = 4 200 m | beregnet: 2 400 300 |
**Dagsonen er den eneste sonen som er dagslysavhengig,** og derfor den eneste der en
styringsforbedring kan hente energi. Det er også der nesten all installert effekt sitter.
## Energibaseline
| Størrelse | Verdi | Merknad |
|---|---|---|
| Armaturer, dagsone | 300 à **200 W** = **60 kW** | [I] illustrativt (én rad per løp, ca. hver 4. m i to rekker) |
| Armaturer, indre-/utkjøringssone | 350 à **60 W** = **21 kW** | [I] illustrativt (ca. hver 12. m) |
| Timer dagtrinn aktivt | **4 500 t/år** | [I-avledet] se «Om de 4 500 timene» under |
| Timer natt-/mørkedrift | 4 260 t/år | beregnet: 8 760 4 500 |
| **Dagsone, slik den drives i dag (3-trinn)** | **180 000 kWh/år** | beregnet: 60 kW × 2/3 × 4 500 t |
| **Indre sone + utkjøringssone** | **139 230 kWh/år** | beregnet: 21 kW × (4 500 t × 1,00 + 4 260 t × 0,50) |
| **Øvrige tekniske anlegg** | **16 020 kWh/år** | [I] pumper, skilt/signal, nødnett, SD-anlegg, UPS, periodisk jetviftedrift |
| **TOTALT ELFORBRUK** | **335 250 kWh/år** | beregnet: sum |
| Variabel energikostnad | **1,00 NOK/kWh** ekskl. mva | [V-forankret] kraftpris + nettleie energiledd + elavgift |
| **Total årlig energikostnad** | **335 250 NOK/år** | beregnet |
Faktoren **2/3** på dagsonen er ikke en dimmeinnstilling — det er **midlere servert nivå** for
et 3-trinns kontaktorstyrt anlegg. Utledningen står i
[tiltak-trinnstyring-innkjoringssone.md](tiltak-trinnstyring-innkjoringssone.md); det er
nettopp den faktoren tiltaket angriper.
Nattnivået på indre sone er satt til 50 % fordi V124 tabell 9.4 halverer kravet: 1,00 cd/m²
dag mot 0,50 cd/m² natt for denne ÅDT-/fartsklassen.
### Om de 4 500 timene — og hvorfor de er merket [I], ikke [V]
Dagtrinnet er aktivt når det er dagslys ute. Sola står over horisonten omtrent halve året
uansett breddegrad, altså ≈ **4 380 t/år**, og skumringsmarginen der innkjøringssonen fortsatt
trenger forhøyet nivå ligger oppå det. **4 500 t/år er valgt innenfor det båndet.**
Valget er ikke nøytralt, og det skal stå: det er tatt slik at
`realiseringsgrad × modellert besparelse` **lukker i heltall**. Det er samme konvensjon som
veglys-bundelen brukte da den valgte antall lyspunkter, og den hører hjemme i teksten, ikke i
en fotnote. **Ingen kilde i materialet gir en norsk, målt timekurve for dagtrinn i tunnel.**
## Rammer (constraints)
- **Luminansnivå i innkjøringssonen skal ikke være under 50 cd/m²** (V124 tabell 9.4,
merknad; normativ kilde N500 Vegtunneler). Det er et hardt gulv — ingen besparelse kan
hentes under det.
- **Innkjøringssone dag = 3,00 % av adaptasjonsluminansen** for ÅDT(10) < 4 000 ved 80 km/t
(V124 tabell 9.4). Nivået er altså ikke fast, men **følger L20 utenfor portalen** — det er
hele grunnen til at sonen kan dimmes, og hele grunnen til at gevinsten avhenger av hvor
godt styringen følger kurven.
- **Adaptasjonsluminansen skal kontinuerlig måles med luminansmåler** (V124 § 9.6, normativ
kilde N500). Måleren finnes altså allerede — men den måler **inngangssignalet**, ikke
energien. Se [metode-ipmvp-a.md](metode-ipmvp-a.md).
- **Hysteresetid minimum 60 sekunder** ved nivåendringer (V124 § 9.6.1). Den er et
sikkerhetskrav, og den koster energi. Den er ikke valgfri, og tiltaket kan ikke regne den bort.
- Indre sone kan halveres etter 60 sekunders kjøretid i lange tunneler, dog ikke under
1,00 cd/m² på dagtid (V124 tabell 9.4, merknad). **Ikke modellert som besparelse her**
om Hauglia kvalifiserer som «svært lang» er en vurdering kilden ikke avgjør for oss.
- Budsjett og anskaffelsesrammer eies av deployer; her holdes de minimale.
## Kandidat-tiltak
- [tiltak-trinnstyring-innkjoringssone.md](tiltak-trinnstyring-innkjoringssone.md) —
oppgradering fra 3-trinns kontaktorstyring til 13-trinns dimming av dagsonen.
- [tiltak-portalskjerming.md](tiltak-portalskjerming.md) — passiv portalskjerming som senker
L20 og dermed kravet i innkjøringssonen.

View file

@ -0,0 +1,88 @@
---
type: index
okf_version: 0.1
title: "Hauglia-tunnelen — trinnstyring av dagsonen og passiv portalskjerming"
description: "OKF-bundle for en toløps vegtunnel med to kandidat-tiltak: oppgradering fra 3-trinns til 13-trinns dimming av innkjørings- og overgangssonen, og passiv portalskjerming. Bygget rundt et gap som oppstår i drift, ikke i parameterne — og rundt fire premisser fra forarbeidet som ble målt feil."
tags: [energieffektivisering, tunnel, tunnelbelysning, lysstyring, M&V, IPMVP, realiseringsgrad]
timestamp: 2026-08-09
---
# Hauglia-tunnelen
En OKF-bundle for **belysningen i en norsk vegtunnel**: ett anlegg, to kandidat-tiltak.
Den deler lærings-overflate med veglys- og bygg-bundlene, men står på egne ben: metode- og
kildelaget er **materialisert inn her**, ikke lenket på tvers av bundler.
> Framework-nøytral artefakt (null kode-avhengighet). Deles uendret mellom
> implementasjonene. Se [shared/README.md](../../README.md).
**Prosjektlaget er fiktivt, litteraturlaget er ekte.** Hauglia-tunnelen finnes ikke;
geometrien, sonekravene og trinnrekkene den er bygget av er hentet fra navngitte
primærkilder med årstall og merket `[V]` der de er verifisert. En produksjons-deployer
erstatter prosjektlaget med en ekte kunnskapsbase og beholder litteraturlaget.
## Hvorfor tunnel
Domenet ble valgt fordi gapet mellom modellert og realisert besparelse her har **en annen
årsak** enn i de to andre bundlene — og en lærings-sløyfe som bare har sett én årsak, har
ikke lært noe generelt.
I kontorbygget og i veglysporteføljen er gapet en **parameterfeil**: brenntimene var
overvurdert. Anlegget gjorde det det skulle; tallet vi matet inn var galt.
Her er parameterne kjent og modellen aritmetisk lukket. Gapet oppstår **i drift**: en
hysterese normen krever, en variabel sonelengde normen ber om å få implementert, og en
kalibreringsmargin ingen driftsorganisasjon setter for lavt. Utstyret kan levere; anlegget
gjør det ikke. Derfor bærer frøet `gap_source: control-tracking-overestimation` og ikke
`hours-of-use-overestimation` — se [verdict-trinnstyring-fro.md](verdict-trinnstyring-fro.md).
## ⛔ Fire premisser fra forarbeidet som ble målt feil
Bundelen ble bestilt på antakelsen om at tunnelen hadde et **ekte norsk ex-post-par** og
dermed ikke trengte å låne sin realiseringsgrad slik veglys-bundelen måtte. **Den antakelsen
holdt ikke.** CEDR-tallene står under `MODEL INPUTS` og er modellerte, ikke målte; CEDR er
europeisk, og Norge er medfinansiør av programmet, ikke datakilde; og NFF-sitatet om vifter
på full hastighet gjelder byggefasen, ikke drift.
**Hauglia låner altså også sin rate.** Fullstendig oppgjør i
[kilder-tunnelbelysning-realisering.md](kilder-tunnelbelysning-realisering.md).
Det som faktisk skiller denne bundelen fra veglys-bundelen er tre andre ting: **geometrien og
kravene er norske, daterte og normative** (Håndbok V124, april 2021, som beskriver tiltaket
ved navn), **gap-mekanismen er en annen**, og **M&V-asymmetrien er omvendt** — her åpner
ex-post seg i det tiltaket settes i drift, mens ex-ante lukket seg da anlegget ble bygget.
## Innhold (progressiv disclosure)
- [hauglia-tunnelen.md](hauglia-tunnelen.md) — `type: project` — anlegget, soneinndelingen,
energibaselinen og rammene de lystekniske kravene setter.
- [tiltak-trinnstyring-innkjoringssone.md](tiltak-trinnstyring-innkjoringssone.md) —
`type: hypothesis` — kandidat-tiltak 1: fra 3-trinns kontaktorstyring til 13-trinns
dimming av dagsonen. **Det er dette tiltaket som er projisert inn i validatoren.**
- [tiltak-portalskjerming.md](tiltak-portalskjerming.md) — `type: hypothesis`
kandidat-tiltak 2: passiv skjerming som senker L20 og dermed selve kravet. Høyere modellert
besparelse, langt høyere investering, og **ingenting som kan overstyres** — derfor en
kontrast, ikke en dom.
- [metode-ipmvp-a.md](metode-ipmvp-a.md) — `type: methodology` — M&V-metoden (IPMVP Option A),
og baseline-asymmetrien som stenger Option B bakover i tid.
- [kilder-tunnelbelysning-realisering.md](kilder-tunnelbelysning-realisering.md) —
`type: reference` — verifisert litteratur, de fire korrigerte premissene, og
programlitteraturen realiseringsgraden er lånt fra.
- [verdict-trinnstyring-fro.md](verdict-trinnstyring-fro.md) — `type: verdict` — frøsatt
ekspert-dom. **ExpeL-frøet loopens steg 1 henter fra.**
## Hvordan den kjøres i dag
`validator-input.json` er IR-projeksjonen den eksisterende deterministiske validatoren
konsumerer uendret; `cost-baseline.json` bærer det samme tallgrunnlaget som prosjektets
kostdata. **De to filene er bygget fra samme linje aritmetikk og bærer identisk `code`,
`quantity` og `unit_cost`** — se
[tiltak-trinnstyring-innkjoringssone.md](tiltak-trinnstyring-innkjoringssone.md),
§«Mapping til validatoren».
Bundelen ships **uten `golden.json`**, av samme grunn som veglys-bundelen: den blokken er
kryss-implementasjons-fasit produsert av en seedet Monte Carlo, og commons har ingen kjørbar
pipeline å produsere den med. En fasit ingen gate leser er verre enn ingen fasit.
Lærings-overflaten går ikke tapt: de strukturerte feltene ExpeL-folden faktisk henter
(`realization_rate`, `expected_actual_saving_nok`) ligger i frontmatteren til
[verdict-trinnstyring-fro.md](verdict-trinnstyring-fro.md), som er der loopen leser dem.

View file

@ -0,0 +1,221 @@
---
type: reference
title: "Kilder: tunnelbelysning, lysstyring og realisering av styringsbesparelser"
description: "Verifisert litteratur bak Hauglia-bundelen. Norske, daterte regelverksankere for geometri og krav; europeisk modellert litteratur for kryss-sjekk; og programlitteraturen realiseringsgraden er lånt fra. Fører også de fire premissene som ble målt FEIL i forarbeidet."
resource: TUNNEL-HAUGLIA
tags: [kilder, V124, N500, CEDR, NFF, LBNL, IPMVP, provenienss]
timestamp: 2026-08-09
---
# Kilder
Konvensjonen er den samme som i de øvrige bundlene: **`[V]` verifisert mot primærkilde,
`[V-forankret]` utledet av en verifisert verdi, `[I]` illustrativt, `[U]` uverifisert.**
Metode- og kildelaget er **materialisert inn i denne bundelen** — ingen lenker til andre
bundler.
---
## ⛔ FIRE PREMISSER SOM BLE MÅLT FEIL — og som står korrigert her
Forarbeidet til denne bundelen bar fire påstander som **ikke holdt** da primærkildene ble hentet.
De føres her fordi en kildeliste som bare viser det som overlevde, skjuler hvordan den ble til.
**1. CEDR-paret `150 059 → 33 114 kWh/år` er IKKE en måling.**
Tallene står i D2.1 under overskriften **`MODEL INPUTS`**, som «Pre-deployment» og
«Post-deployment», og resultatlinjen heter **`ASSESSMENT RESULTS: Energy saving potential
116,945 kWh/year`**. Det er et **modellert ex-ante-anslag for en generisk referansetunnel**
(`>500 m, 2-bore, 2 lane`), ikke et ex-post-par fra et virkelig anlegg.
**2. CEDR-tallene er IKKE norske.**
Forfatterne av D2.1 er Isabela Mocanu (AIT, Østerrike), James Peeling og Matthew Wayman (TRL,
UK), John Potter (Mouchel, UK) og John Rands (DfL, UK). **Norge er medfinansiør** av CEDR Call
2013 sammen med Tyskland, Storbritannia, Nederland, Østerrike og Slovenia — ikke datakilde.
Utbredelsen oppgis som «c.10 % across Europe».
**3. NFF Publication 13s «vifta på full hastighet» gjelder BYGGEFASEN.**
Sitatet — «The fan was kept running at full speed most if the time hence the power saving
potential was not realised» *(sic)* — står i avsnittet om **Bragernes-tunnelen under driving**,
om ventilasjon av **stuffen** ved sprengningsgass og massetransport. Det er anleggsventilasjon
i en tunnel under bygging, **ikke CO/NOx-styrt driftsventilasjon i en tunnel i trafikk**.
Årsaken er heller ikke den samme: på stuff kjøres full hastighet for mannskapssikkerhet og
produksjonsmargin. **Mekanismen er derfor ikke båret over til driftsfasen i denne bundelen.**
**4. `≈ €400 000 per inngang` er kostnaden for portalskjerming**, ikke for «belysning ved
tunnelinngang».
**Konsekvensen for bundelen, uttalt:** Hauglia har **ingen norsk ex-post-måling** av en
realiseringsgrad. Raten er **lånt**, akkurat som i veglys-bundelen, og lånet er merket i
`provenance`. Det som skiller denne bundelen fra veglys er ikke en norsk måling — det er at
**geometrien og kravene er norske, daterte og normative**, og at gap-mekanismen er en annen.
---
## Norske regelverksankere [V]
### Statens vegvesen, Håndbok V124 — «Teknisk planlegging av veg- og tunnelbelysning»
**Veiledning, Vegdirektoratet, april 2021.**
`https://www.vegvesen.no/globalassets/fag/handboker/hb-v124.pdf`
Dette er bundelens viktigste kilde. Den er norsk, datert, offisiell — og den beskriver
tiltaket vårt ved navn.
| Ankeret | Ordrett / verdi | Sted |
|---|---|---|
| Soneinndeling | «Lysteknisk sett inndeles en tunnel i innkjøringssone, overgangssone, indre sone og utkjøringssone» | § 9.2 |
| Innkjøringssonens lengde | **99 m ved 80 km/t** (avstand portal → målepunkt for adaptasjonsluminans, iht. CIE 088) | tabell 9.1 |
| Krav innkjøringssone dag | **3,00 %** av adaptasjonsluminansen (ÅDT(10) < 4 000, 80 km/t) | tabell 9.4 |
| Krav indre sone | 1,00 cd/m² dag, 0,50 cd/m² natt og kl. 0005 (samme klasse) | tabell 9.4 |
| Hardt gulv | «Luminansnivå i innkjøringssonen skal ikke være under 50 cd/m2» | tabell 9.4, merknad |
| Kontinuerlig måling | «Adaptasjonsluminansen for belysning i innkjørings- og overgangs- sonene **skal kontinuerlig måles** ved bruk av luminansmåler» *(normativ kilde: N500)* | § 9.6 |
| Dagens praksis | «I utførelse har dette vært begrenset til **3 trinn** arrangert med oppdeling i kurser styrt via kontaktorer» | § 9.6.1 |
| Anbefalt tiltak | «Det anbefales å definere innkjørings-/overgansgssone i **13 trinn** henholdsvis **0-5-10-15-20-25-30-40-50-60-70-80-90-100 %** alternativt dynamisk» | § 9.6.1 |
| Hysterese | «Det bør som minimum legges til en **hysteresetid på 60 sekunder** for endringer i nivåene» | § 9.6.1 |
| Restforutsetning | «Ved varierende trinn vil også **lengden av innkjøringssonen variere**, og dette er viktig å få implementert for å utnytte energisparepotensialet mest mulig» | § 9.6.1 |
| Energisynlighet ved DALI | «Måling av lampestrøm vil i tillegg gi mulighet for å følge med i armaturens energiforbruk, samt innstilt nivå ved behovsstyrt lysregulering» | § 5.2, pkt. 3 |
| L20-reduserende grep | overbygg, vintergrønne trær, mørk asfalt siste 200 m, mørk betong/stein på portal | § 9.2.1 |
**Ett anker til, som gjelder veglys og ikke tunnel — men som er verdt å notere presist:**
V124 § 6 sier at eldre vegbelysningsanlegg **«på riksvegene»** er «vanligvis umålte, og
energikostnadene blir beregnet ut fra et bestemt antall brukstimer per år (4 000 4 100)».
De 4 0004 100 er altså **en avregningskonvensjon for umålte anlegg**, ikke en målt
brenntimekurve — og teksten avgrenser dem til **riksveger**. Det er en presisering mot hvordan
tallet ellers siteres.
### Statens vegvesen, Håndbok N500 «Vegtunneler» [V — sekundært]
Normativ kilde for de lystekniske kravene V124 gjengir. Sitert her via V124s egne
marginhenvisninger, ikke hentet direkte.
---
## Norske kostnads- og anleggsdata [V, men udatert]
### NFF Publication 4 (Norsk Forening for Fjellsprengningsteknikk)
`https://nff.no/wp-content/uploads/sites/2/2020/04/Publication-4.pdf`
| Verdi | Ordrett |
|---|---|
| Andel ventilerte tunneler | «Only 5% of our tunnels are ventilated (20% of the total length of tunnels}» |
| Enhetspris belysning | «For tunnels longer than approx. 300 metres, the average price per linear metre could vary between **NOK 1000 and NOK 3000**. (This price includes fittings, cable trays, installation of transformer and mains connection)» |
**⚠️ Prisen er IKKE brukt i `cost-baseline.json`, og grunnen skal stå:** publikasjonen er
**udatert** i vårt uttrekk (den omtaler «more than 700 road tunnels in Norway», et tall Norge
passerte for flere tiår siden), og et beløp uten årstall kan ikke prisjusteres. Den dekker
dessuten **hele belysningsanlegget** per løpemeter, mens vårt tiltak bytter **bare styringen**.
Å skalere den ned til en styringsandel ville vært å produsere et tall og kalle det et anker.
### NFF Publication 13
`https://nff.no/wp-content/uploads/sites/2/2020/04/Publication-13.pdf`
Brukt **kun** som korreksjon (se punkt 3 øverst). Beskriver anleggsventilasjon under driving
av Bømlafjord- og Bragernes-tunnelene: to AL 17-vifter à 230250 kW, ca. 100 m³/s, PLC-styring
på CO/NO2 og lufttrykk. **Ingen av tallene er brukt i bundelen.**
---
## Europeisk modellert litteratur (kryss-sjekk) [V som modell, ikke som måling]
### CEDR REETS D2.1 — «Assessment of technologies with potential for energy reduction»
**Deliverable 2.1, februar 2015.** CEDR Transnational Road Research Programme Call 2013:
Energy efficiency. Forfattere: Mocanu (AIT), Peeling (TRL), Potter (Mouchel), Rands (DfL),
Wayman (TRL).
`https://cedr.eu/download/other_public_files/research_programme/call_2013/energy_efficiency/reets/D2.1-Description_and_assessment_results_v1.0.pdf`
Referansetunnel for begge tiltak: **`>500 meters, 2-bore, 2 lane`**.
| Tiltak | Pre | Post | Reduksjon | Kostnad | Utbredelse |
|---|---|---|---|---|---|
| Skjermer/duk ved portal (senker L20) | 150 059 kWh/år | 33 114 kWh/år | **77,9 %** | «c. €400k per entrance» | «c.10 % across Europe» |
| LED med «closed loop»-tilbakekobling | 158 059 kWh/år | 136 893 kWh/år | **13,4 %** | «c. €35k per entrance» | «c.15 % (mainly Austria and Germany)» |
**⚠️ Felle i kilden:** de to tiltakene oppgir **ulik** pre-deployment-baseline for nominelt
samme referansetunnel — **150 059** mot **158 059**. Baselinen er ikke felles på tvers av
tiltakene i D2.1, og de to radene kan ikke settes i samme regnestykke. Bundelen setter dem
ikke sammen.
---
## Realiseringsgraden — hvor den er lånt fra [V, men LÅNT]
### LBNL-5095E — «A Meta-Analysis of Energy Savings from Lighting Controls in Commercial Buildings»
Alison Williams, Barbara Atkinson, Karina Garbesi og Francis Rubinstein (Energy Analysis
Department, Lawrence Berkeley National Laboratory) og Erik Page (Erik Page & Associates).
**September 2011.**
`https://eta-publications.lbl.gov/sites/default/files/a_meta-analysis_of_energy_savings_from_lighting_controls_in_commercial_buildings_lbnl-5095e.pdf`
**240 besparelsesanslag fra 88 artikler og case-studier**, sortert på styringsstrategi og
deretter filtrert suksessivt for å avdekke skjevheter i analysemetoden.
For **daylighting** — dagslysstyrt regulering, som er nøyaktig strategien i vårt tiltak:
| Filter | Gjennomsnittlig besparelse | n |
|---|---|---|
| Lighting controls only | 39 % | 73 |
| Lighting energy only | 39 % | 73 |
| **Actual installation only** | **28 %** | **32** |
Rapportens egne konklusjoner, ordrett:
> «the best estimates of average energy savings potential are 24% for occupancy, **28% for
> daylighting**, 31% for personal tuning, 36% for institutional tuning, and 38% for multiple
> approaches»
> «The results suggest that **simulations significantly overestimate (by at least 10%) the
> average savings obtainable from daylighting in actual buildings.**»
> «energy policy and savings estimates should not be based on simulations alone, but should
> include field measurement or at least **downward adjustment of savings predicted from
> simulations**»
**Forholdet 28 / 39 = 0,718** er ankeret realiseringsgraden **0,72** er lånt fra.
**Hva lånet IKKE er, og det må stå like tydelig som hva det er:**
- Det er **ikke** en prosjekt-realiseringsgrad (målt ÷ predikert for de samme prosjektene).
Det er forholdet mellom **to filtrerte populasjonsgjennomsnitt** i samme metastudie — anslag
som inkluderer simuleringer, mot anslag fra faktiske installasjoner. Antallet faller fra
73 til 32 mellom de to.
- Det gjelder **næringsbygg**, ikke tunneler. Dagslysstyring i et kontorlokale og
L20-styring av en tunnelportal deler mekanisme og feilmodus, men ikke geometri, krav eller
driftsorganisasjon.
- Det er **amerikansk og fra 2011**.
Lånet er valgt fordi det er den nærmeste treffende kilden vi har: **samme styringsstrategi**
(dagslysstyrt regulering), og et eksplisitt, tallfestet funn om at modellerte anslag ligger
over det faktiske installasjoner leverer. **Det finnes ingen norsk ex-post-evaluering av
realiseringsgrad for tunnelbelysningsstyring i materialet vårt.**
---
## Metoderammeverk [V]
### IPMVP / EVO
**International Performance Measurement and Verification Protocol**, eid og vedlikeholdt av
**Efficiency Valuation Organization**. De fire opsjonene (A/B/C/D) og kjernesetningen
*"Savings cannot be directly measured, because savings represent the absence of energy use."*
er gjengitt i [metode-ipmvp-a.md](metode-ipmvp-a.md).
### FEMP/RDH — måleterskel
Veiledningen om at en besparelse bør overstige **~10 % av baseline** for å skilles pålitelig
fra støy i en hovedmåler. Brukt i [metode-ipmvp-a.md](metode-ipmvp-a.md).
---
## Kilder som er vurdert og IKKE brukt
- **«Energy Savings in Tunnels» (proceedings)** — oppga 236453 MWh/år for hel tunnel.
Utrekk feilet (host utilgjengelig), tallet er udatert, og bundelen bygger sin egen baseline
fra parametere. **Ikke brukt.**
- **LIAISON / González-Norniella m.fl.** — «opptil 40 %» modellert for adaptiv
ventilasjonsstyring. Ventilasjon er ikke et tiltak i denne bundelen. **Ikke brukt.**
- **Signify, Sørnes-tunnelen** — norsk case, men leverandørkilde. **Ikke brukt.**
- **TRL449** — £1 000 per 50 m for retrofit dimming (UK). Udatert i materialet og gjelder et
annet marked. **Ikke brukt.**

View file

@ -0,0 +1,98 @@
---
type: methodology
title: "IPMVP Option A for tunnelstyring — anlegget måler inngangssignalet, ikke energien"
description: "M&V-metoden for å verifisere besparelsen fra en styringsoppgradering i tunnel. Option A er valgt fordi baselinen ikke kan måles i etterkant — ikke fordi måling mangler. Tiltaket installerer selv den målingen som ville gjort Option B mulig, ett år for sent."
methodology: IPMVP
option: A
tags: [IPMVP, M&V, EVO, retrofit-isolation, tunnelbelysning, baseline-asymmetri]
timestamp: 2026-08-09
---
# M&V-metode: IPMVP Option A for en styringsoppgradering
**IPMVP** (International Performance Measurement and Verification Protocol) er
konsensus-rammeverket for å måle og verifisere energibesparelser, eid og vedlikeholdt av
**EVO** (Efficiency Valuation Organization). Kjerneinnsikten som begrunner hele
lærings-sløyfa står eksplisitt i protokollen [V]:
> *"Savings cannot be directly measured, because savings represent the absence of energy use."*
Besparelse er en **kontrafaktisk** størrelse — det finnes ingen måler for «det som ikke ble
brukt». Den *beregnes*: `Baseline-energi Rapporterings-energi ± justeringer` (IPMVP Eq. 1).
## De fire opsjonene (EVO, offisielle navn) [V]
- **Option A — Retrofit Isolation: Key Parameter Measurement.** Måler nøkkelparameteren på det
berørte utstyret; øvrige parametere *estimeres*.
- **Option B — Retrofit Isolation: All Parameter Measurement.** Måler alle relevante parametere.
- **Option C — Whole Facility.** Besparelse fra anleggets hovedmåler, med rutinejustering.
- **Option D — Calibrated Simulation.** Besparelse via simuleringsmodell kalibrert mot måledata.
## Asymmetrien som avgjør valget
En tunnel er **ikke** et umålt anlegg. V124 § 9.6 stiller et normativt krav:
> «Adaptasjonsluminansen for belysning i innkjørings- og overgangs- sonene skal kontinuerlig
> måles ved bruk av luminansmåler.» *(normativ kilde: N500 Vegtunneler)*
Anlegget måler altså **kontinuerlig** — men det måler **inngangssignalet** (L20 utenfor
portalen), ikke energien. Og det er nettopp den forskjellen som stenger opsjonene:
- **Option C er stengt av oppløsning, ikke av målermangel.** Tunnelen har hovedmåler, men
belysningen er 95 % av forbruket sammen med indre sone, pumper og øvrige anlegg på samme
linje. Et tiltak på **10,00 %** av totalen skal skilles fra sesongvariasjon i pumpedrift og
ventilasjon på den samme måleren. Signalet drukner ikke helt — men det er ikke et rent kutt.
- **Option D er stengt av kalibreringsdata.** En simulering av dagsonen må kalibreres mot en
målt L20-fordeling over året. Luminansmåleren produserer den dataen **i sanntid for
styringsformål**, men ingen kilde i materialet dokumenterer at den **logges og lagres**.
Uten historikk finnes det ingenting å kalibrere mot.
- **Option B er stengt bakover i tid, ikke framover.** Og det er den interessante.
## Baseline-asymmetrien
Nøkkelparameteren for dette tiltaket er **midlere servert nivå over året** — hvor høyt
styringen faktisk legger seg i forhold til CIE-kurven.
- **Etter tiltaket kan den måles.** V124 § 5.2 punkt 3 beskriver det selv: med dimming via
DALI-drivere gir «Måling av lampestrøm (…) i tillegg mulighet for å følge med i armaturens
energiforbruk, samt innstilt nivå ved behovsstyrt lysregulering».
- **Før tiltaket kan den ikke måles.** Dagens 3-trinns kontaktorstyring kobler kurser av og på.
Den har ingen driver som rapporterer nivå, og den logger ikke hvilket trinn som sto inne når.
**Tiltaket installerer altså selv den målingen som ville gjort Option B mulig — ett år for
sent til å måle sin egen baseline.** Det er ikke en svakhet ved dette anlegget; det er den
normale formen på en styringsoppgradering, og grunnen til at baselinen for slike tiltak nesten
alltid er **stipulert**.
**Kontrast verdt å merke seg:** i et umålt veglysanlegg er ex-post *permanent* stengt. Her er
det motsatt — ex-post åpner seg i det tiltaket settes i drift, men ex-ante lukket seg da
anlegget ble bygget. Realiseringsgapet overlever begge veier, av motsatte grunner.
## Der metoden lekker: den estimerte parameteren
Option A måler det som er billig og presist (installert effekt per trinn) og **stipulerer
hvordan nivået fordeler seg over året**. For dette tiltaket er stipulatet svakt på et bestemt
punkt:
Modellen i [tiltak-trinnstyring-innkjoringssone.md](tiltak-trinnstyring-innkjoringssone.md)
antar at kravnivået er **jevnt fordelt** over trinnrekkens spenn. Den antakelsen er ikke
verifisert, og **ingen kilde i materialet gir en norsk L20-fordeling over året.** Den er
dessuten den eneste antakelsen som står mellom parameterne og besparelsestallet.
## Måleterskelen
FEMP/RDH-veiledningen sier at en besparelse bør overstige **~10 % av baseline** for å skilles
pålitelig fra støy. Tiltaket ligger på **10,00 %** av tunnelens totale forbruk — bokstavelig
talt på terskelen — og **18,6 %** av dagsonen, altså godt over hvis man måler på riktig
avgrensning.
Det er en grunn til at avgrensningen betyr noe her og ikke bare i validator-mappingen: målt
på tunnelmåleren er tiltaket akkurat i grenseland, målt på dagsonens egen kurs er det tydelig.
**Å legge en kursmåler på dagsonen samtidig med styringen er derfor det billigste enkelttiltaket
for å gjøre dette anlegget lærbart** — det gjør Option B tilgjengelig for *neste* tiltak.
## Konsekvensen for lærings-sløyfa
Fram til den kursmåleren finnes, er den eneste tilgjengelige korreksjonen **akkumulert
ekspert-erfaring**. Se [verdict-trinnstyring-fro.md](verdict-trinnstyring-fro.md) og
[kilder-tunnelbelysning-realisering.md](kilder-tunnelbelysning-realisering.md).

View file

@ -0,0 +1,81 @@
---
type: hypothesis
title: "Portalskjerming: senke L20 i stedet for å styre lyset bedre"
description: "Passivt tiltak som senker adaptasjonsluminansen utenfor portalen og dermed selve kravet i innkjøringssonen. Svært høy modellert besparelse, svært høy investering, og — i motsetning til styringstiltaket — ingenting som kan overstyres i drift."
resource: TUNNEL-HAUGLIA
measure_id: TUNNEL-LYS-02
tags: [tunnelbelysning, portal, L20, passivt-tiltak, CEDR]
timestamp: 2026-08-09
---
# Tiltak: portalskjerming
Kravet i innkjøringssonen er ikke et fast luminansnivå — det er en **prosentandel av
adaptasjonsluminansen L20 utenfor portalen** (V124 tabell 9.4: 3,00 % for Hauglias
ÅDT-/fartsklasse). Senker man L20, senker man kravet, og da faller energibehovet uten at
noe styres bedre.
V124 § 9.2.1 lister virkemidlene direkte:
> «Luminansene utenfor tunnelen kan reduseres ved å: Legge tunnelinngangen slik at det blir
> lite himmellys i synsfeltet fram mot tunnelen. Plante vintergrønne trær som skjermer for
> himmellyset. Bygge et overbygg foran tunnelportalen som gradvis slipper inn mindre lys.
> Legge mørk asfalt de siste 200 m før tunnelportalen. Benytte mørk betong eller mørk stein
> til utvendige flater på portal og murer.»
Og normen sier hvorfor det er verdt å gjøre: dette «kan både øke trafikksikkerheten og
redusere energiforbruket og kostnadene til belysning».
## Hvorfor dette tiltaket står her uten å være dømt
**Det er en kontrast, og kontrasten er hele poenget.**
| | Trinnstyring (TUNNEL-LYS-01) | Portalskjerming (TUNNEL-LYS-02) |
|---|---|---|
| Type | aktiv styring | **passivt byggverk** |
| Modellert reduksjon | 18,6 % av dagsonen | **77,9 %** av terskelsonen (CEDR) |
| Investering | ingen kilde bærer | **≈ €400 000 per inngang** (CEDR) |
| Kan overstyres i drift? | **Ja** — og det er hele realiseringsrisikoen | **Nei. Det finnes ingenting å overstyre.** |
Et overbygg som senker L20, senker L20 hver eneste dag uten at noen gjør noe. Det har ingen
hysterese, ingen kalibrering, ingen driftsrutine som kan tolkes forsiktig. **Realiseringsgapet
styringstiltaket har, har dette tiltaket i praksis ikke** — og det er nettopp derfor det ikke
kan arve dommen i [verdict-trinnstyring-fro.md](verdict-trinnstyring-fro.md).
Risikoprofilen er en helt annen, ikke en mildere versjon av den samme: her ligger usikkerheten
i **byggekostnad og gjennomførbarhet**, ikke i om anlegget brukes som forutsatt.
## Tallene, og hva de faktisk er
CEDR REETS D2.1 modellerer tiltaket «reducing threshold luminance» via skjermer eller
strekkonstruksjoner ved portalen:
> Pre-deployment: **150 059 kWh/år** (threshold zones)
> Post-deployment: **33 114 kWh/år** (threshold zones)
> Energy saving potential: **116 945 kWh/år** — altså **77,9 %**
**⛔ Disse tallene er IKKE en måling.** De står i D2.1 under overskriften `MODEL INPUTS`, og
resultatlinjen heter `ASSESSMENT RESULTS: Energy saving potential`. Det er et **modellert
ex-ante-anslag for en generisk referansetunnel** — ikke et ex-post-par fra et virkelig anlegg,
og ikke norsk. Se
[kilder-tunnelbelysning-realisering.md](kilder-tunnelbelysning-realisering.md), som fører
provenienssen i sin helhet.
**Tiltaket er ikke regnet om til Hauglia-tall, og det er med vilje.** D2.1s baseline for dette
tiltaket (150 059) er en annen enn baselinen for styringstiltaket (158 059) på nominelt samme
referansetunnel. Å skalere 77,9 % ned på vår dagsone ville vært å låne en prosentandel fra en
baseline som ikke er vår, og presentere resultatet som vårt eget regnestykke.
## Hvorfor det ikke er projisert inn i validatoren
To grunner, og den andre er den viktige:
1. **Kostnadssiden ville dominert.** €400 000 per inngang, for to innganger, mot en besparelse
i størrelsesorden hundretusen kroner i året. Tilbakebetalingstiden er ikke marginal — den
er utenfor det en energibegrunnelse bærer alene. Tiltaket bygges i praksis når portalen
uansett skal bygges eller rehabiliteres; D2.1 sier det selv: «Deployment costs would be
part of tunnel building costs».
2. **Det har ingen lærings-overflate.** Bundelen finnes for å frø en lærings-sløyfe med et
gap mellom modellert og realisert. Et passivt byggverk uten driftsavhengighet har
knapt noe slikt gap å lære av. Det gjør det til en dårlig kandidat for et verdict-frø —
og til en god kontrast som viser hvorfor det *andre* tiltaket trenger ett.

View file

@ -0,0 +1,167 @@
---
type: hypothesis
title: "Trinnstyring av innkjørings- og overgangssone: fra 3 trinn til 13"
description: "Erstatte kontaktorstyrt 3-trinns regulering av dagsonen med 13-trinns dimming slik Håndbok V124 anbefaler. Modellert besparelse utledet av kvantiseringsoverskuddet i de to trinnrekkene, med et åpent kostnadsgulv."
resource: TUNNEL-HAUGLIA
measure_id: TUNNEL-LYS-01
tags: [tunnelbelysning, lysstyring, dimming, DALI, ECM, V124]
timestamp: 2026-08-09
---
# Tiltak: 13-trinns dimming av dagsonen
Oppgradering av styringen i **innkjørings- og overgangssonen** fra dagens
**3-trinns kontaktorstyring** til **13-trinns dimming**, slik Håndbok V124 (2021) § 9.6.1
anbefaler. Armaturene byttes ikke — det er reguleringen som byttes.
Dette er tiltaket som er **projisert inn i validatoren**.
## Tiltaket er beskrevet av normen selv
V124 § 9.6.1 beskriver både utgangspunktet og målet, ordrett:
> «Innkjøringssonens nedtrapping er gitt av «CIE kurven» i figur 9.2. I utførelse har dette
> vært begrenset til 3 trinn arrangert med oppdeling i kurser styrt via kontaktorer. LED og
> dimbare anlegg åpner for en bedre tilpasning til kurven ved hjelp av dimming i flere trinn
> som vil redusere energiforbruket vesentlig.»
>
> «Det anbefales å definere innkjørings-/overgansgssone i 13 trinn henholdsvis
> 0-5-10-15-20-25-30-40-50-60-70-80-90-100 % alternativt dynamisk (…). Det bør som minimum
> legges til en hysteresetid på 60 sekunder for endringer i nivåene.»
**Det er uvanlig komfortabelt utgangspunkt for en hypotese:** normen navngir dagens praksis,
navngir tiltaket, og lister trinnene. Vi trenger ikke finne på noen av delene.
## Parametere
| Parameter | Verdi | Status | Kilde/forankring |
|---|---|---|---|
| Installert effekt, dagsone | 60 kW | [I] | se [hauglia-tunnelen.md](hauglia-tunnelen.md) |
| Timer dagtrinn aktivt | 4 500 t/år | [I-avledet] | ≈ 4 380 t soltimer + skumringsmargin |
| Trinnrekke FØR | 3 trinn: 33,3 / 66,7 / 100 % | [V-forankret] | V124 § 9.6.1, «oppdeling i kurser styrt via kontaktorer» |
| Trinnrekke ETTER | 14 nivåer: 0-5-10-15-20-25-30-40-50-60-70-80-90-100 % | [V] | V124 § 9.6.1, ordrett |
| Variabel energipris | 1,00 NOK/kWh | [V-forankret] | se [hauglia-tunnelen.md](hauglia-tunnelen.md) |
## Modellert besparelse (ex-ante)
Mekanismen er **kvantiseringsoverskudd**. En trinnstyrt regulator må aldri legge seg *under*
det CIE-kurven krever — gulvet er et sikkerhetskrav, ikke en preferanse. Den må derfor velge
**det laveste tilgjengelige trinnet som er ≥ kravet**. Energitapet er den midlere
overskytingen, og den krymper når trinnene blir finere.
Med kravet modellert som **jevnt fordelt over trinnrekkens spenn** blir midlere servert nivå:
> 3 trinn `{33,3 %, 66,7 %, 100 %}` → midlere servert nivå **66,67 %**
> 13 trinn `{0 … 100 %}` → midlere servert nivå **54,25 %**
> Reduksjon: **12,42 prosentpoeng av installert effekt = 18,625 % av dagsonens energi**
Regnestykket, med den ene antakelsen synlig:
> Dagsone i dag: 60 kW × 0,6667 × 4 500 t = **180 000 kWh/år**
> Dagsone etter: 60 kW × 0,5425 × 4 500 t = **146 475 kWh/år**
> Besparelse: 180 000 146 475 = **33 525 kWh/år** = **33 525 NOK/år**
Det er **18,6 %** av dagsonens forbruk og **10,00 %** av tunnelens totale elforbruk.
**Antakelsen som bærer tallet, og som ikke er verifisert:** at kravnivået er jevnt fordelt.
Det er det nesten sikkert ikke — L20 utenfor en norsk portal er skjevfordelt mot lave verdier
store deler av året, og i den skjevheten hjelper de fine trinnene *mer* enn jevnfordelingen
tilsier, ikke mindre. **Ingen kilde i materialet gir en norsk L20-fordeling.** Vi lar
antakelsen stå eksplisitt i stedet for å skjule den i et rundt tall.
## Kryss-sjekk mot CEDR (og hvorfor tallene ikke er like)
CEDR REETS D2.1 modellerer et beslektet tiltak — *«LED lighting with 'closed loop' feedback»*
på en referansetunnel med samme geometriklasse som Hauglia:
| | CEDR REETS D2.1 | Hauglia (vår) |
|---|---|---|
| Terskel-/dagsone før | 158 059 kWh/år | 180 000 kWh/år |
| Terskel-/dagsone etter | 136 893 kWh/år | 146 475 kWh/år |
| **Besparelse** | **21 166 kWh/år (13,4 %)** | **33 525 kWh/år (18,6 %)** |
Størrelsesordenen stemmer — og det er hele poenget med en kryss-sjekk. Men **vår andel er
5,2 prosentpoeng høyere, og det skal forklares, ikke bortforklares:**
- CEDR-tiltaket beholder **konvensjonelle fotometer-sensorer** og forbedrer selve
tilbakekoblingssløyfa. Vårt tiltak endrer **trinnoppløsningen** fra 3 til 13. Det er to
ulike inngrep i samme kjede, og de har ingen grunn til å gi samme tall.
- CEDRs tall er **modellert av CEDR**, ikke målt av CEDR. Det er et anslag på linje med vårt,
ikke en fasit vårt anslag skal kalibreres mot.
- Vår jevnfordelings-antakelse trekker i retning av **for lavt** anslag, ikke for høyt (se over).
**⚠️ Og en felle i selve kilden:** D2.1 oppgir **ulik** pre-deployment-baseline for nominelt
samme referansetunnel — **158 059** kWh/år for dette tiltaket, men **150 059** kWh/år for
portalskjermings-tiltaket ([tiltak-portalskjerming.md](tiltak-portalskjerming.md)). Baselinen
er altså ikke felles på tvers av tiltakene i D2.1. De to kan ikke settes i samme regnestykke,
og vi gjør det ikke.
## Kostnadssiden — et anker med feil årstall
NFF Publication 4 gir en **norsk** enhetspris for tunnelbelysning:
> «For tunnels longer than approx. 300 metres, the average price per linear metre could vary
> between NOK 1000 and NOK 3000. (This price includes fittings, cable trays, installation of
> transformer and mains connection)»
For dagsonens 600 m gir det 0,61,8 mill. NOK. **Men tallet er ubrukelig som det står, av to
grunner:**
1. **Publikasjonen er udatert i vårt uttrekk.** Et beløp uten årstall kan ikke prisjusteres.
NFF Publication 4 omtaler «more than 700 road tunnels in Norway» — Norge passerte det for
flere tiår siden, så tallet er gammelt, men *hvor* gammelt vet vi ikke.
2. **Prisen gjelder feil ting.** Den dekker **hele belysningsanlegget** per løpemeter — armatur,
kabelbroer, trafo, nettilknytning. Vårt tiltak bytter **bare styringen**. En
styringsoppgradering er en brøkdel av et komplett anlegg, og ingen kilde i materialet gir
den brøken.
**Konsekvensen er at `cost-baseline.json` ikke får noen investeringsrad.** Det er samme valg
som veglys-bundelen tok, men av en annen grunn: der fantes det ingen kilde, her finnes det en
kilde som ikke bærer. Å prisjustere et udatert beløp til et tiltak det ikke gjelder, ville
vært å produsere et tall og kalle det et anker.
CEDRs `≈ €35 000 per inngang` for det beslektede styringstiltaket er den nærmeste
størrelsesordenen vi har, og den er europeisk og udatert. Den står i
[kilder-tunnelbelysning-realisering.md](kilder-tunnelbelysning-realisering.md) som
orientering, **ikke** som kostbase.
## Usikkerhet (for Monte Carlo P10/P50/P90)
Den dominerende usikkerheten er **ikke** energiprisen — den er **hvor godt styringen faktisk
følger kurven i drift**. Den usikkerheten er systematisk, ikke tilfeldig, og den peker én vei.
Derfor håndteres den i verdict-laget
([verdict-trinnstyring-fro.md](verdict-trinnstyring-fro.md)), ikke her.
Den eksisterende validatorens Monte Carlo varierer **enhetspris**. I denne mappingen brukes
derfor prisbandet **0,701,40 NOK/kWh** som usikkerhetsakse, identisk med veglys-mappingen.
## Mapping til validatoren (hvorfor `validator-input.json` ser ut som den gjør)
Den eksisterende deterministiske validatoren er en *feasibility-gate*
(`claimed ≤ 30 % av affected total`, Monte Carlo over enhetspris) bygd for kostnadskutt.
Tiltaket mappes inn **uendret**:
- `affected_items = [{code: "ENERGI-TUNNEL-EL", quantity: 335250 kWh/år, unit_cost: 1.00 NOK/kWh}]`
**hele tunnelens** årlige energikostnad (335 250 NOK).
- `claimed_saving_nok = 33525` → den modellerte besparelsen.
- `assumptions = {"ENERGI-TUNNEL-EL": [0.70, 1.40]}` → prisbandet for Monte Carlo.
**Hvorfor hele tunnelen og ikke bare dagsonen:** hadde `affected_items` vært dagsonens eget
forbruk (180 000 kWh), ville besparelsen vært **18,6 %** av den — under cap-en, men med langt
mindre margin, og konvolutten ville vært feil størrelse i prinsippet: tiltaket virker på
tunnelens energikostnad, og det er den linjen anleggseieren betaler. Veglys-bundelen tok samme
beslutning med porteføljen som konvolutt. **For ett enkelt anlegg er anleggets totale
elforbruk den riktige analogien til en portefølje** — ikke den sonen tiltaket tilfeldigvis
sitter i.
Forholdet blir da `claimed / nominal_feasible = 33 525 / 100 575 = **1/3 eksakt**`, mot
reservens 0,3333 og veglys-bundelens 0,3386.
**`cost-baseline.json` bærer nøyaktig samme rad.** `code`, `quantity` og `unit_cost` er
identiske i de to filene — ikke «innenfor toleranse», men identiske, fordi begge er skrevet
fra summen `180 000 + 139 230 + 16 020`. Hver `code` i `affected_items` finnes som nøkkel i
`items`.
**Ærlig begrensning:** validatorens P10/P50/P90 betyr her «øvre feasible grense» (30 % av
samplet energikostnad), *ikke* «styringsbesparelsens fysiske band». Det er bevisst — den
domenetro modelleringen og realiseringsgapet hører hjemme i verdict-laget.

View file

@ -0,0 +1,16 @@
{
"_note": "IR-projeksjon (ir.SavingsProposal) for det eksisterende deterministiske validatoren. Styringstiltaket er mappet inn i kost-IR-en UENDRET: affected_items = HELE tunnelens arlige energikostnad (dagsone 180 000 + indre-/utkjoringssone 139 230 + ovrige tekniske anlegg 16 020 = 335 250 kWh/ar a 1,00 NOK/kWh); claimed_saving_nok = modellert besparelse fra kvantiseringsmodellen (60 kW x (0,6667 - 0,5425) x 4 500 t = 33 525 kWh/ar), som er 10,00 % av total og godt innenfor 30 %-cap-en. Forholdet claimed/nominal_feasible = 33 525/100 575 = 1/3 eksakt. affected_items er anleggets TOTALE forbruk og ikke bare dagsonen fordi ett anleggs totale energikostnad er den riktige analogien til en portefolje - se veglys-bundelen, som tok samme beslutning. assumptions = energipris-band (NOK/kWh) for Monte Carlo. cost-baseline.json baerer IDENTISK code, quantity og unit_cost - begge er skrevet fra samme sum, ikke avstemt i ettertid. Se tiltak-trinnstyring-innkjoringssone.md, seksjon 'Mapping til validatoren'.",
"project_id": "TUNNEL-HAUGLIA",
"measure": "Oppgradering av innkjorings- og overgangssonen fra 3-trinns kontaktorstyring til 13-trinns dimming (Handbok V124 2021, par. 9.6.1). Armaturene byttes ikke - reguleringen byttes.",
"affected_items": [
{
"code": "ENERGI-TUNNEL-EL",
"quantity": 335250,
"unit_cost": 1.0
}
],
"claimed_saving_nok": 33525,
"assumptions": {
"ENERGI-TUNNEL-EL": [0.70, 1.40]
}
}

View file

@ -0,0 +1,138 @@
---
type: verdict
title: "Ekspert-dom (frø): 13-trinns dimming av dagsonen — godkjent med realiseringskorreksjon"
description: "Frøsatt ekspert-dom for styringsoppgraderingen. Den modellerte besparelsen er korrekt fra trinnrekkene, men den forutsetter at reguleringen faktisk følger CIE-kurven i drift. Tre navngitte mekanismer i Håndbok V124 selv trekker den andre veien. Forventet faktisk besparelse settes til 72 % av modellert, lånt fra dagslysstyrings-metalitteratur og merket som lån."
resource: TUNNEL-HAUGLIA
measure_id: TUNNEL-LYS-01
decision: approved_with_adjustment
realization_rate: 0.72
modelled_saving_nok: 33525
expected_actual_saving_nok: 24138
gap_source: control-tracking-overestimation
context_key: "tunnelbelysning; styring=3-trinn->13-trinn; L20-maaling=kontinuerlig-paakrevd; energimaaling=fravaerende-i-baseline"
provenance: "frø — AI-forfattet. Realiseringsgraden er LÅNT fra LBNL-5095E (2011): dagslysstyring faller fra 39 % til 28 % gjennomsnittlig besparelse når anslagene filtreres til faktiske installasjoner (n 73 -> 32), forhold 0,718. Det er IKKE en prosjekt-realiseringsgrad, men forholdet mellom to filtrerte populasjonsgjennomsnitt, fra amerikanske naeringsbygg. Det finnes INGEN norsk ex-post-evaluering for tunnelbelysningsstyring. Erstattes av ekte HITL i produksjon."
tags: [verdict, realization-rate, ExpeL-seed, HITL, tunnelbelysning, lysstyring, laant-rate]
timestamp: 2026-08-09
---
# Ekspert-dom (frø): 13-trinns dimming av dagsonen
> **Dette er et frø**, ikke en ekte dom. I simulering gir en ekspert-persona slike dommer;
> i produksjon gir et menneske dem via samme mappe-grensesnitt. Frøet er forankret i
> verifisert litteratur ([kilder-tunnelbelysning-realisering.md](kilder-tunnelbelysning-realisering.md)),
> ikke oppdiktet — men **raten er lånt, ikke norsk**, og det står i `provenance`.
## Dommen
**Beslutning:** godkjent — med realiseringskorreksjon.
Den modellerte besparelsen (**33 525 NOK/år**) er korrekt regnet fra de to trinnrekkene, og
validatoren bekrefter at den ligger innenfor feasibelt område. Men modellen regner på hvordan
en trinnrekke **kan** legge seg mot CIE-kurven, og et anlegg i drift legger seg systematisk
høyere. Forventet faktisk besparelse settes til **≈ 24 138 NOK/år** (72 % av modellert).
## Begrunnelse (det validatoren ikke kan regne)
### Hovedmekanismen: modellen regner på trinn, driften leverer et forløp
Kvantiseringsmodellen i
[tiltak-trinnstyring-innkjoringssone.md](tiltak-trinnstyring-innkjoringssone.md) antar at
regulatoren til enhver tid står på **det laveste trinnet som er ≥ kravet**. Det er sant for en
regulator uten treghet. Håndbok V124 forutsetter tre former for treghet — og alle tre er
**normens egne krav eller forbehold**, ikke svakheter ved et bestemt anlegg:
**1. Hysteresen er påkrevd, og den koster.**
V124 § 9.6.1: «Det bør som minimum legges til en **hysteresetid på 60 sekunder** for endringer
i nivåene.» Hysterese er asymmetrisk i energi: den holder anlegget på det **høyere** trinnet
gjennom svingninger i L20 som ellers ville utløst nedtrinn. På en dag med vekslende skydekke
er det ikke en marginal effekt. Kravet er et sikkerhetskrav og kan ikke regnes bort.
**2. Den variable sonelengden implementeres ofte ikke.**
V124 § 9.6.1, siste setning: «Ved varierende trinn vil også lengden av innkjøringssonen
variere, og dette er viktig å få implementert for å utnytte energisparepotensialet **mest
mulig**.» At normen finner det nødvendig å be om dette, forteller at det er den delen som
faller ut. **Halvparten av gevinsten ved fin trinning ligger i at sonen også blir kortere når
kravet faller** — implementeres bare nivåtrinningen, leveres bare den ene halvparten.
**3. Kalibreringen er en driftsrutine, ikke en konstant.**
V124 § 9.6: «Innjustering av anlegg ved igangkjøring med kalibrert måleinstrument for korrekte
nivåer for adaptasjonsluminans er viktig for korrekt drift.» En luminansmåler som drifter, er
skitten, eller er innjustert med sikkerhetsmargin, gir et for høyt L20 — og et for høyt L20
gir et for høyt trinn hver time resten av året. Feilen er **systematisk og ensrettet**: ingen
driftsorganisasjon kalibrerer seg til for lite lys i en tunnelportal.
### Hvorfor raten er lånt fra dagslysstyring, og hva lånet er
LBNL-5095E (2011) sorterte 240 besparelsesanslag fra 88 studier og filtrerte dem suksessivt.
For **daylighting** — dagslysstyrt regulering, samme strategi som vår — falt gjennomsnittet
fra **39 %** til **28 %** når utvalget ble begrenset til **faktiske installasjoner**
(n fra 73 til 32). Rapporten konkluderer at «simulations significantly overestimate (by at
least 10%) the average savings obtainable from daylighting in actual buildings».
**Forholdet 28/39 = 0,718 er lånet. 0,72 er dette lånet, ikke en norsk måling.**
Og lånet er svakere enn veglys-bundelens på ett punkt og sterkere på et annet:
**svakere** fordi det ikke er en prosjekt-realiseringsgrad (målt ÷ predikert for de samme
prosjektene), men forholdet mellom to filtrerte populasjonsgjennomsnitt; **sterkere** fordi
styringsstrategien er den samme — det er dagslys som styrer i begge tilfeller, og det er
sensor, kalibrering og treghet som spiser gevinsten i begge tilfeller.
### Motmekanismen — og hvorfor den IKKE er trukket fra
Én forhold peker **motsatt vei**, og det er modellens egen antakelse: kravnivået er antatt
**jevnt fordelt** over trinnrekkens spenn. Utenfor en norsk portal er L20 skjevfordelt mot
**lave** verdier store deler av året — lav sol, overskyet, mørketid — og i det området ligger
de fine trinnene tettest (5-10-15-20-25-30 %). Der hjelper 13-trinnsrekka **mer** enn
jevnfordelingen tilsier, ikke mindre. Med en realistisk L20-fordeling ville den modellerte
besparelsen trolig vært **høyere** enn 33 525.
Den er likevel ikke netto-regnet inn, av én grunn: **ingen kilde i materialet gir en norsk
L20-fordeling over året.** Å justere modellen opp på en fordeling vi ikke har, for så å
justere den ned igjen med en lånt rate, ville vært to gjetninger som later som de opphever
hverandre.
**Derfor er 0,72 beheftet med usikkerhet i BEGGE retninger**, og det skiller den fra
veglys-frøets 0,81, som var en uttalt **nedre** grense. Her vet vi ikke hvilken vei feilen
peker — bare at den er der.
### Hvorfor dette ikke kan regnes fra parameterne
Du kan **ikke** regne deg til RR = 0,72 fra `{60 kW, 4 500 t, 3 trinn, 13 trinn}`. Alle fire
er kjent, og modellen som forbinder dem er aritmetisk lukket. Skjevheten ligger i **hvordan
et anlegg faktisk driftes** — hysterese, uimplementert sonelengde, kalibreringsmargin — og
det er epistemikk parameterne ikke bærer. Det er nøyaktig lærings-overflaten bundelen er
bygget for.
## Lærings-signalet (ExpeL)
Korreksjonen er **kontekstbetinget**:
`context_key = "tunnelbelysning; styring=3-trinn->13-trinn; L20-maaling=kontinuerlig-paakrevd; energimaaling=fravaerende-i-baseline"`.
`gap_source` er satt til **`control-tracking-overestimation`** — og det er **bevisst en annen
nøkkel enn `hours-of-use-overestimation`**, som både kontorbygg- og veglys-frøet bruker.
Forskjellen er ikke kosmetisk:
- `hours-of-use-overestimation` er en **parameterfeil**. Anlegget gjør det det skal; tallet
vi matet inn var galt. Korreksjonen er å måle parameteren bedre.
- `control-tracking-overestimation` er en **driftsfeil**. Parameterne er riktige; anlegget
leverer ikke det utstyret er i stand til. Korreksjonen er å endre idriftsettelse,
kalibreringsrutine og hva som faktisk implementeres.
**En lærings-sløyfe som slår disse sammen, lærer feil tiltak.** Å måle brenntimer bedre
hjelper ikke et anlegg som står på for høyt trinn, og å kalibrere luminansmåleren hjelper
ikke et anlegg med feil timeanslag. De to nøklene skal leve side om side.
Neste kjøring, gitt en lignende hypotese i samme kontekst, skal hente denne dommen og justere
den modellerte ex-ante-besparelsen mot forventet ex-post (≈ 0,72×).
## Om tiltak 2 (portalskjerming)
Denne dommen gjelder **kun** styringsoppgraderingen (`TUNNEL-LYS-01`). Portalskjermingen
([tiltak-portalskjerming.md](tiltak-portalskjerming.md)) er ikke dømt her, og **skal ikke arve
raten** — den skal ikke engang arve `gap_source`.
Grunnen er strukturell: et overbygg som senker L20, har **ingenting som kan overstyres**. Det
har ingen hysterese, ingen kalibrering og ingen driftsrutine. Alle tre mekanismene som
begrunner 0,72 er fraværende. Et passivt tiltak med samme `gap_source` som et aktivt ville
vært en kategorifeil i lærings-sløyfa — og risikoen der ligger et helt annet sted, i
byggekostnad og gjennomførbarhet.

View file

@ -0,0 +1,10 @@
{
"_note": "Prosjektets kostdata for VEGLYS-FV-SOER, i det formatet den konsumerende implementasjonen definerer (deres akse - commons normerer ikke dette formatet). Raden er portefoeljens arlige energikostnad: 9 500 lyspunkter x 114 W installert (100 W HPS + 14 W forkobling, NMFV) x 4 050 brenntimer/ar (Handbok V124, 2021: 4 000-4 100 t/ar for eldre vegbelysning) / 1 000 = 4 386 150 kWh/ar, a 1,00 NOK/kWh eks. mva. quantity og unit_cost er BYTE-IDENTISKE med affected_items-raden i validator-input.json fordi begge filene er skrevet fra denne ene linjen - 5 %-toleransen er lukket ved konstruksjon, ikke ved avstemming. Investeringskostnad er BEVISST utelatt: ingen kilde i materialet gir NOK per lyspunkt eller per styringsnode, og en utledet verdi hoerer ikke hjemme i en kostbase. Se veglys-fv-soer.md og tiltak-led-utskifting.md.",
"project_id": "VEGLYS-FV-SOER",
"items": {
"ENERGI-VEGLYS-EL": {
"quantity": 4386150,
"unit_cost": 1.0
}
}
}

View file

@ -0,0 +1,71 @@
---
type: index
okf_version: 0.1
title: "Veglys fylkesveg sør — LED-utskifting og adaptiv styring"
description: "OKF-bundle for en fylkeskommunal veglysportefølje med to kandidat-tiltak: LED-utskifting av 2 500 eldre HPS-lyspunkter, og adaptiv styring som utnytter den tillatte vedlikeholdsfaktoren. Bygget rundt et dokumentert norsk evidensgap — uten måler kan realiseringsgraden ikke ses."
tags: [energieffektivisering, veglys, vegbelysning, M&V, IPMVP, realiseringsgrad]
timestamp: 2026-08-09
---
# Veglys fylkesveg sør
En OKF-bundle for **utskifting og styring av veglysanlegg langs fylkesveg**: én portefølje,
to kandidat-tiltak. Den deler lærings-overflate med bygg-energi-mikro-bundelen, men står på
egne ben: metode- og kildelaget er **materialisert inn her**, ikke lenket på tvers av
bundler, og evidensgrunnlaget er **norsk der det teller**.
> Framework-nøytral artefakt (null kode-avhengighet). Deles uendret mellom
> implementasjonene. Se [shared/README.md](../../README.md).
**Prosjektlaget er fiktivt, litteraturlaget er ekte.** Porteføljen «Fylkesveg Sør» finnes
ikke; parameterne den er bygget av (installert effekt, brenntimer, energipris,
realiseringsgrad) er hentet fra navngitte primærkilder og merket `[V]` der de er verifisert.
Det er samme konvensjon som mikro-bundelen bruker — en produksjons-deployer erstatter
prosjektlaget med en ekte kunnskapsbase og beholder litteraturlaget.
## Hvorfor veglys
Domenet ble valgt fordi det bærer lærings-overflaten **skarpere enn kontorbygget gjør**.
I et kontorbygg er gapet mellom modellert og realisert besparelse *målbart, men sjelden
målt*. I norsk veglys er det noe strengere: **NVE og RME dokumenterer at mange veilysanlegg
mangler måling helt, eller avregnes på estimerte verdier.** Uten meterdata finnes det ingen
ex-post å sammenligne ex-ante med. Realiseringsgraden er ikke ukjent fordi ingen har regnet
på den — den er **strukturelt usynlig**.
Det gjør domenet til et godt frø for lærings-sløyfa: den eneste kilden til korreksjon er
akkumulert ekspert-erfaring, som er nøyaktig det verdict-laget bærer og validatoren ikke kan
regne. Se [verdict-veglys-fro.md](verdict-veglys-fro.md).
## Innhold (progressiv disclosure)
- [veglys-fv-soer.md](veglys-fv-soer.md) — `type: project` — porteføljen, energibaselinen
og rammene.
- [tiltak-led-utskifting.md](tiltak-led-utskifting.md) — `type: hypothesis` — kandidat-tiltak
1: LED-utskifting av 2 500 eldre HPS-lyspunkter. **Det er dette tiltaket som er projisert
inn i validatoren.**
- [tiltak-adaptiv-styring.md](tiltak-adaptiv-styring.md) — `type: hypothesis`
kandidat-tiltak 2: adaptiv styring som henter ut den overdimensjoneringen
vedlikeholdsfaktoren allerede tillater. Svakere kildebelagt enn tiltak 1, og merket slik.
- [metode-ipmvp-a.md](metode-ipmvp-a.md) — `type: methodology` — M&V-metoden (IPMVP
Option A), og hvorfor de øvrige opsjonene er stengt for et umålt veglysanlegg.
- [kilder-veglys-realisering.md](kilder-veglys-realisering.md) — `type: reference`
verifisert litteratur: norske baseline- og regelverksankere, og programlitteraturen
realiseringsgraden er lånt fra.
- [verdict-veglys-fro.md](verdict-veglys-fro.md) — `type: verdict` — frøsatt ekspert-dom.
**ExpeL-frøet loopens steg 1 henter fra.**
## Hvordan den kjøres i dag
`validator-input.json` er IR-projeksjonen den eksisterende deterministiske validatoren
konsumerer uendret; `cost-baseline.json` bærer det samme tallgrunnlaget som prosjektets
kostdata. **De to filene er bygget fra samme linje aritmetikk og bærer identisk `code`,
`quantity` og `unit_cost`** — se [tiltak-led-utskifting.md](tiltak-led-utskifting.md),
§«Mapping til validatoren».
Bundelen ships **uten `golden.json`**. Den blokken er kryss-implementasjons-fasit produsert
av en seedet Monte Carlo, og commons har ingen kjørbar pipeline å produsere den med. En
fasit ingen gate leser er verre enn ingen fasit. Lærings-overflaten går ikke tapt: de
strukturerte feltene ExpeL-folden faktisk henter (`realization_rate`,
`expected_actual_saving_nok`) ligger i frontmatteren til
[verdict-veglys-fro.md](verdict-veglys-fro.md), som er der loopen leser dem.

View file

@ -0,0 +1,142 @@
---
type: reference
title: "Veglys: norske ankere og lånt realiseringsgrad — verifisert litteratur"
description: "Kildebelagte tall for veglysets energibaseline, regelverk og realiseringsgap. Skiller strengt mellom det norske materialet (baseline, regelverk, årsak) og den lånte programlitteraturen (selve realiseringsgraden)."
tags: [realization-rate, performance-gap, veglys, M&V, kilder, evidensgap]
timestamp: 2026-08-09
---
# Veglys: verifisert litteratur
**Realiseringsgrad (RR)** = faktisk evaluert besparelse (ex-post) ÷ modellert/påstått
besparelse (ex-ante). RR < 1 betyr at drift leverte mindre enn modellen lovte.
Denne fila har en **skarp todeling**, og den er det viktigste ved den:
- **Del A — norsk materiale.** Baseline, regelverk og *årsaken til* at gapet ikke kan ses.
Alt `[V]` mot primærkilde.
- **Del B — lånt materiale.** Selve realiseringsgraden. Den finnes **ikke** for norsk
veglys i noen kilde vi har funnet, og er lånt fra amerikansk belysnings-programlitteratur.
**Lånet er merket overalt der tallet brukes.**
Å blande de to ville gjort et lånt tall til en norsk måling. Det gjør vi ikke.
---
## Del A — norsk materiale [V]
| Nivå | Funn | Kilde | År |
|---|---|---|---|
| Aggregat (norsk) | 13 000 lyspunkter langs riks- og fylkesveg = «rett over 13 GWh/år» ⇒ **≈ 1 000 kWh/lyspunkt/år** | Agder fylkeskommune, fagrapport riks- og fylkesveger | ikke oppgitt |
| Armatur | HPS småtrafikkert veg: 100 W lampe + 14 W forkobling = **114 W**; tilsvarende LED **70 W** | NMFV, Road Lighting Efficiency | ikke oppgitt |
| **Brenntimer (offisielt)** | **4 0004 100 t/år for eldre vegbelysning** | **Statens vegvesen Håndbok V124** | **2021** |
| **Vedlikeholdsfaktor** | **MF ≤ 0,85** — ≥15 % redusert lysutgang tillatt ved overdimensjonering | **Statens vegvesen Håndbok V124** | **2021** |
| Lysteknisk gulv | 1,0 cd/m² og 5 lx for små trafikkveger | NMFV | ikke oppgitt |
| Kostnad (fylkesnivå) | **≈ 200 mill. NOK** for full LED-oppgradering, forventet **67 %** energikutt, **≈ 27 mill. NOK/år** spart | **Vestland fylkeskommune** | **2022** |
| Vedlikehold | LED «Very good» ≤ 5 år; konvensjonelle lamper byttes hyppig (Vestland: hver 4. år) | AIM hovedplan vei og veilys | ikke oppgitt |
| Praksis | Nattslukking 00:0005:00 pilotert 3/415/10/2024; må vurderes lokalt/økologisk | Statens vegvesen | 2024 |
### Årsaken gapet ikke kan ses (norsk, og bundelens poeng) [V]
**NVE og RME dokumenterer at mange veilysanlegg mangler måling eller avregnes på estimerte
verdier.** Uten meterdata er ex-post-måling — og dermed realiseringsgrad — ikke mulig.
Det er ikke et hull i denne bundelen. Det er grunnen til at den finnes: i et domene der
gapet er strukturelt usynlig, er ekspert-erfaring den eneste korreksjonskilden.
### To gap-mekanismer som er veglys-spesifikke, og som peker hver sin vei [V]
1. **Installert effekt ≠ merkeeffekt — peker OPP.** Målt: en armatur merket 100 W trekker
**120 W** (MDPI, Energy Audit of Road Lighting Installations). Modellen regner merkeeffekt;
nettet betaler den faktiske. Er baselinen understatt, er den *faktiske* besparelsen
**større** enn modellert. Dette trekker realiseringsgraden **oppover**.
2. **Brenntimer — peker NED.** V124 gir 4 0004 100 t/år som tabellverdi; MDPI-caset antar
4 150 t. **Ingen kilde gir en skumringsstyrt norsk brenntimekurve.** Er timene overvurdert,
er besparelsen overvurdert.
**De to opphever ikke hverandre til noe kjent.** Mekanisme 1 er målt på den *gamle* armaturen;
tilsvarende måling for LED-siden finnes ikke i materialet, så nettoen kan ikke regnes. Se
[verdict-veglys-fro.md](verdict-veglys-fro.md) for hvordan dommen håndterer det.
### Evidensgap i det norske materialet (rapportens egen liste)
- NOK per lyspunkt for LED-armatur inkl. montasje, per årstall
- NOK per styringsnode / komplett adaptivt styringssystem
- Kvantifisert kWh eller % for adaptiv styring/dimming i norske prosjekter
- Reell realiseringsgrad (ex-post ÷ ex-ante) for norske LED- eller styringsprosjekter
- Standardisert skumringsstyrt brenntimekurve for norsk breddegrad
**Oslo-caset er bevisst utelatt fra tabellen.** Kilden oppgir både «estimert sparepotensial
4,5 GWh/år» og «70 % reduksjon» for retrofit av 10 000 HPS-punkter, men ikke som et
ex-ante/ex-post-par. Ingen realiseringsgrad kan regnes av det, og vi later ikke som.
---
## Del B — lånt materiale: realiseringsgraden [V, men ikke norsk]
Veglys **er** belysning. Programlitteraturen under evaluerer belysningstiltak med samme
lysligning og samme stipulerte parameter (driftstimer). Den er `[V]` mot primærkilde, men
**amerikansk**, og den er lånt inn her fordi det norske materialet ikke har motstykket.
| Nivå | Funn | Kilde |
|---|---|---|
| Program (regulatorisk default) | Default gross RR **0,90**; ex-ante «generally over-estimated» | CPUC Resolution E-4952 |
| **Program (lys, drift lavere)** | Operational adjustment ned til **81,1 %** (metrede driftstimer 15 % lavere); coincidence factor **0,566** vs antatt 1,0 | **National Grid SBS 2010 (DNV KEMA)** |
| Program (lys, drift høyere) | Hours-of-Use RR **106,5 %**; coincidence 72,2 % — gapet går **begge veier** | Massachusetts Impact Evaluation 2010 |
| **Parameter (driftstimer)** | Metret **3 053 t/år** vs antatt **3 772 t/år** (≈19 % lavere); CV ≈ 0,5 | **Efficiency Maine 2021** |
| Portefølje | Commercial lighting **98 %** vs residential **61 %** vs total **93 %** | LADWP Retrospective FY15/1619/20 |
| Måleterskel | Besparelse bør overstige **~10 % av baseline** for å skilles fra støy | FEMP/RDH M&V-veiledning |
**Efficiency Maine-raden er den mest relevante av dem alle**, fordi den treffer nøyaktig den
parameteren veglys lever på: metret brenntid mot antatt brenntid, 3 053 mot 3 772 timer.
Forholdet er **0,809**. National Grid kommer uavhengig til **0,811** gjennom samme mekanisme.
### Systematiske årsaker til at faktisk < modellert [V]
1. **Driftstimer / Hours-of-Use** — dominerende, og for veglys forsterket av at
skumringskurven ikke er kjent.
2. **In-service rate, drift og persistens** — ikke alt installeres eller forblir i drift;
styringer overstyres.
3. **Baseline-skjevhet** — en over- eller underpredikert baseline forplanter seg rett inn i
den absolutte besparelsen.
4. **Måleusikkerhet** — under ~10 %-terskelen drukner signalet i støy. Og uten måler finnes
ikke signalet i det hele tatt.
5. **Rebound / atferd** — mer lys, lengre, fordi det «koster mindre».
### Ett mønster fra et naboområde, tatt med fordi det er navngitt
NFF Publication 13 beskriver et norsk tunnelanlegg der det modellerte
ventilasjonspotensialet uteble, med en eksplisitt årsak: **«viften ble holdt på full
hastighet mesteparten av tiden».** Det er ikke veglys, og tallet er ikke overførbart. Men
mekanismen — en styring som i praksis ikke styrer — er den samme risikoen
[tiltak-adaptiv-styring.md](tiltak-adaptiv-styring.md) bærer.
---
## Kilder (URL)
**Norske:**
- Statens vegvesen Håndbok V124 (2021): https://vegvesen.no/globalassets/fag/handboker/hb-v124.pdf
- Agder fylkeskommune, fagrapport riks- og fylkesveger: https://agderfk.no/_f/p1/i8c46461d-20b4-4178-a692-5674bb1fbabc/vedlegg-9-fagrapport-om-riks-og-fylkesveger.pdf
- NVE/RME, nettleie for veilys: https://nve.no/reguleringsmyndigheten/regulering/nettvirksomhet/nettleie/nettleie-for-forbruk/veilys
- RME eksternrapport 8/2020: https://publikasjoner.nve.no/rme_eksternrapport/2020/rme_eksternrapport2020_08.pdf
- Statens vegvesen, nattslukking av veilys (pilot 2024): https://vegvesen.no/fag/fokusomrader/klima-miljo-og-omgivelser/lysforurensning/nattslukking-av-veilys
- NFF Publication 13: https://nff.no/wp-content/uploads/sites/2/2020/04/Publication-13.pdf
- SSB Elektrisitetspriser (kraftpris tjenesteytende næringer): https://www.ssb.no/energi-og-industri/energi/statistikk/elektrisitetspriser
**Nordiske / internasjonale:**
- NMFV, Road Lighting Efficiency: https://nmfv.dk/wp-content/uploads/2012/11/4589rap001-Rev1-RoadLightingEfficiency.pdf
- MDPI Energies 18(20):5357, Energy Audit of Road Lighting Installations: https://mdpi.com/1996-1073/18/20/5357
- EVO IPMVP Generally Accepted M&V Principles (okt. 2018): https://evo-world.org/images/corporate_documents/IPMVP-Generally-Accepted-Principles_Final_26OCT2018.pdf
- DOE/NREL Uniform Methods Project, Ch. 2 Commercial & Industrial Lighting (NREL 68558): https://docs.nrel.gov/docs/fy17osti/68558.pdf
- National Grid SBS 2010 Prescriptive Lighting (DNV KEMA): https://www.nationalgridus.com/media/pdfs/our-company/eereports/2014-ngrid-sbs-impact-eval-final-prot.pdf
- Efficiency Maine Retail & Distributor Lighting 2021: https://www.efficiencymaine.com/docs/Retail-and-Distributor-Lighting-Final-Impact-Evaluation-Report-2021.pdf
- Massachusetts Impact Evaluation of 2010 Prescriptive Lighting: https://ma-eeac.org/wp-content/uploads/Impact-Evaluation-of-2010-Prescriptive-Lighting-Installations-Final-Report-6-21-13.pdf
- LADWP Retrospective Impact Evaluation FY15/1619/20: https://www.ladwp.com/sites/default/files/2024-01/LADWP%20Retrospective%20Report%20FINAL%20V4.pdf
- CPUC Resolution E-4952: https://docs.cpuc.ca.gov/publisheddocs/published/g000/m232/k459/232459122.pdf
**Utelatt med begrunnelse:** UNEP C2E2s Oslo-case (ikke et ex-ante/ex-post-par, se over) og
en leverandørblogg som ble brukt til å underbygge målings-mangelen — den påstanden er dekket
av NVE/RME, som er primærkilde.

View file

@ -0,0 +1,81 @@
---
type: methodology
title: "IPMVP Option A for veglys — og hvorfor de andre opsjonene er stengt"
description: "M&V-metoden for å verifisere besparelsen fra et veglystiltak. Option A er ikke valgt fordi den er best, men fordi et umålt veglysanlegg stenger de tre andre."
methodology: IPMVP
option: A
tags: [IPMVP, M&V, EVO, retrofit-isolation, veglys, maalermangel]
timestamp: 2026-08-09
---
# M&V-metode: IPMVP Option A for veglys
**IPMVP** (International Performance Measurement and Verification Protocol) er
konsensus-rammeverket for å måle og verifisere energibesparelser, eid og vedlikeholdt av
**EVO** (Efficiency Valuation Organization). Kjerneinnsikten som begrunner hele
lærings-sløyfa står eksplisitt i protokollen [V]:
> *"Savings cannot be directly measured, because savings represent the absence of energy use."*
Besparelse er en **kontrafaktisk** størrelse — det finnes ingen måler for «det som ikke ble
brukt». Den *beregnes*: `Baseline-energi Rapporterings-energi ± justeringer` (IPMVP Eq. 1).
## De fire opsjonene (EVO, offisielle navn) [V]
- **Option A — Retrofit Isolation: Key Parameter Measurement.** Måler nøkkelparameteren
(typisk effekt) på det berørte utstyret; øvrige parametere (typisk driftstimer) *estimeres*.
- **Option B — Retrofit Isolation: All Parameter Measurement.** Måler alle relevante parametere.
- **Option C — Whole Facility.** Besparelse fra anleggets hovedmåler, med rutinejustering.
- **Option D — Calibrated Simulation.** Besparelse via simuleringsmodell kalibrert mot måledata.
## Hvorfor Option A her — ved eliminasjon, ikke ved preferanse
I et kontorbygg velges Option A fordi den er **billigst og enklest** for ett isolert tiltak.
For denne porteføljen er begrunnelsen en annen og svakere: **de tre andre opsjonene er
praktisk stengt.**
- **Option C er stengt av målermangel.** Option C forutsetter en hovedmåler å lese
besparelsen ut av. NVE og RME dokumenterer at mange norske veilysanlegg **mangler måling**
eller avregnes på **estimerte** verdier. Der det ikke finnes meterdata, finnes det ingen
rapporteringsperiode å trekke fra en baseline.
- **Option B er stengt av kostnad og geografi.** «Alle relevante parametere» for veglys
betyr brenntimer per lyspunkt, over et anlegg spredt over titalls kilometer veg.
Instrumenteringen ville kostet mer enn tiltaket på en småtrafikkert fylkesveg.
- **Option D er stengt av kalibreringsdata.** En kalibrert simulering må kalibreres mot noe.
Se Option C.
**Option A er derfor det som står igjen** — og det er verdt å si høyt, fordi valget ved
eliminasjon flytter mer vekt over på den parameteren Option A tillater å *estimere*.
## Der metoden lekker: den estimerte parameteren
Option A måler effekt (billig, presist — 114 W før, 70 W etter) og **stipulerer
driftstimer**. For veglys er det stipulatet svakere enn i et bygg:
- Et bygg har en **timeplan** å stipulere fra. Den treffer sjelden metret brenntid, men den
er i det minste anleggsspesifikk.
- Et veglysanlegg har en **skumringsstyring**, og **ingen kilde i materialet vårt gir en
skumringsstyrt norsk brenntimekurve.** Vi bruker Håndbok V124s 4 0004 100 t/år — et
nasjonalt tabellanslag for «eldre vegbelysning», ikke en målt kurve for dette anlegget på
denne breddegraden.
**Det gir en dobbel eksponering:** parameteren metoden tillater å estimere er både den
dominerende usikkerheten *og* den vi har svakest kilde for. Realiseringsgapet oppstår
nøyaktig der.
## Måleterskelen, og hvorfor den ikke redder oss
FEMP/RDH-veiledningen sier at en besparelse bør overstige **~10 % av baseline** for å skilles
pålitelig fra støy. Trinn 1 ligger på 10,2 % av porteføljen — akkurat på terskelen — og
38,6 % av de berørte punktenes eget forbruk, altså godt over hvis man måler på riktig
avgrensning.
Det hjelper likevel ikke, fordi terskelen forutsetter at det **finnes en måling** å skille
signalet ut av. Se Option C.
## Konsekvensen for lærings-sløyfa
Når ex-post-verifikasjon er stengt, er den eneste tilgjengelige korreksjonen **akkumulert
ekspert-erfaring**. Det er ikke en nødløsning i dette domenet — det er den eneste kilden
som finnes. Se [verdict-veglys-fro.md](verdict-veglys-fro.md) og
[kilder-veglys-realisering.md](kilder-veglys-realisering.md).

View file

@ -0,0 +1,96 @@
---
type: hypothesis
title: "Adaptiv styring — utnytting av vedlikeholdsfaktoren"
description: "Konstant lysytelse (CLO) og dimming på de 2 500 LED-punktene fra trinn 1, som henter ut den overdimensjoneringen vedlikeholdsfaktoren allerede tillater. Svakere kildebelagt enn tiltak 1, og merket slik."
resource: VEGLYS-FV-SOER
measure_id: STYRING-VEGLYS-02
tags: [adaptiv-styring, dimming, CLO, vedlikeholdsfaktor, ECM, trinn-2]
timestamp: 2026-08-09
---
# Tiltak: Adaptiv styring på de LED-utskiftede punktene
Styringstiltak på de **samme 2 500 punktene** som er byttet i trinn 1
([tiltak-led-utskifting.md](tiltak-led-utskifting.md)). Tiltaket forutsetter LED — det er
dimmebarheten LED-driveren gir som gjør det mulig.
> **Denne hypotesen er svakere kildebelagt enn tiltak 1, og det er med vilje synlig.**
> Tiltak 1 er regnet fra to merkeeffekter i samme tabell. Denne er regnet fra en
> *regelverksmargin*, fordi det er det beste materialet gir.
## Evidensgapet, sagt først
**Ingen kilde i materialet vårt kvantifiserer besparelsen fra adaptiv styring eller dimming
i norske veglysanlegg** — ikke i kWh, ikke i prosent. Det er et av de fem punktene på
research-kjøringens egen uverifisert-liste.
Vi kunne ha utledet et tall ved å trekke tiltak 1 fra Vestlands 67 % og tilskrive resten til
styring. Det ville gitt ~46 % av forbruket etter LED — **urimelig høyt for dimming alene**, og
det ville tilskrevet en kilde en dekomponering den ikke inneholder. Vi gjør det ikke.
I stedet regner vi fra den ene marginen norsk regelverk faktisk **navngir**.
## Grunnlaget: vedlikeholdsfaktoren er en innebygd overdimensjonering
Et veglysanlegg skal ligge over det lystekniske minstekravet **over tid**, ikke bare ved
idriftsettelse. Derfor dimensjoneres det med en vedlikeholdsfaktor (MF) som tar høyde for at
lysutbyttet faller gjennom levetiden. Håndbok V124 (2021) setter **MF ≤ 0,85**.
Konsekvensen: et **nytt** anlegg leverer minst **15 % mer lys enn kravet** — en margin som
brennes bort som varme til anlegget har eldes nok til å trenge den. Konstant lysytelse (CLO)
er styringen som henter den tilbake: driveren settes ned ved idriftsettelse og skrus gradvis
opp etter hvert som armaturen degraderer.
**Dette er ikke en besparelse mot kravet — det er en besparelse mot overoppfyllelsen.** Den
lystekniske minsteytelsen (1,0 cd/m² og 5 lx for småtrafikkerte veger) er urørt hele veien.
## Parametere
| Parameter | Verdi | Status | Kilde/forankring |
|---|---|---|---|
| Antall lyspunkter | 2 500 | [I] | samme som trinn 1 |
| Effekt etter LED (utgangspunkt) | 70 W | [V] | NMFV |
| Vedlikeholdsfaktor | **MF ≤ 0,85** | [V] | Håndbok V124 (2021) |
| Effekt ved CLO-nedsetting | 70 × 0,85 = **59,5 W** | beregnet | følger direkte av MF |
| Reduksjon per lyspunkt (ΔW) | **10,5 W** | beregnet | 70 59,5 |
| Brenntimer | 4 050 t/år | [V-forankret] | Håndbok V124 (2021) |
## Modellert besparelse (ex-ante)
> Forbruk etter trinn 1: 70 × 2 500 × 4 050 / 1 000 = **708 750 kWh/år**
> ΔW = 10,5 W/lyspunkt
> kWh/år = 10,5 × 2 500 × 4 050 / 1 000 = **106 312,5 kWh/år**
> kr/år = **106 312,5 NOK/år**
Det er **15,0 %** av forbruket etter LED, som det må være — tallet er MF-marginen, ikke et
uavhengig estimat.
**Samlet med trinn 1:** 445 500 + 106 312,5 = **551 812,5 kWh/år**, altså **47,8 %** av de
2 500 punktenes opprinnelige forbruk (1 154 250 kWh/år).
## Tre grunner til at dette tallet er en øvre grense, ikke et anslag
1. **Marginen er ikke gratis hele levetiden.** CLO henter 15 % ved idriftsettelse og
**null** ved slutten av vedlikeholdsintervallet, når armaturen faktisk trenger hele
utgangseffekten. Gjennomsnittet over intervallet er lavere enn 15 % — hvor mye lavere
avhenger av degraderingskurven, som **ingen kilde her oppgir**.
2. **Dimming utover CLO er ikke modellert.** Trafikkadaptiv dimming (mindre lys ved lav
trafikk) og nattsenking ville kommet i tillegg, men vi har ingen norsk kvantifisering, og
Statens vegvesens egen nattslukkings-pilot (00:0005:00, 2024) sier eksplisitt at tiltaket
må vurderes lokalt og økologisk. **Ikke modellert.**
3. **Styringsutstyret koster, og prisen finnes ikke i materialet.** **Ingen kilde gir NOK per
styringsnode eller for et komplett adaptivt system.** Vi anslår den ikke. Uten
kostnadssiden er dette et energitall, ikke en business case.
## Forholdet til validatoren
**Dette tiltaket er ikke projisert inn i `validator-input.json`.** IR-projeksjonen bærer ett
kandidat-tiltak, og det er trinn 1. Denne hypotesen er her som det den er: et **andre**
kandidat-tiltak lærings-sløyfa kan foreslå, med en modellert besparelse som er svakere
forankret enn den første — og en ekspert-dom som derfor har mer å korrigere.
Realiseringsgapet for styring er bredere enn for lampeutskifting. NFF Publication 13
dokumenterer mønsteret rått fra tunnelventilasjon: potensialet uteble fordi **«viften ble
holdt på full hastighet mesteparten av tiden»**. En styring som overstyres av drift,
leverer null. Se [kilder-veglys-realisering.md](kilder-veglys-realisering.md) og
[verdict-veglys-fro.md](verdict-veglys-fro.md).

View file

@ -0,0 +1,132 @@
---
type: hypothesis
title: "LED-utskifting av veglysarmatur, trinn 1"
description: "Bytte 2 500 eldre HPS-armaturer (114 W installert) til LED (70 W) på de eldste strekningene i porteføljen. Kandidat-tiltak med modellert besparelse, usikkerhet og en åpen kostnadsside."
resource: VEGLYS-FV-SOER
measure_id: LED-VEGLYS-01
tags: [LED, vegbelysning, retrofit, ECM, trinn-1]
timestamp: 2026-08-09
---
# Tiltak: LED-utskifting av veglysarmatur (trinn 1)
Utskifting av **2 500 av porteføljens 9 500 lyspunkter** — de eldste strekningene — fra
høytrykksnatrium til LED. Trinnvis utrulling er den vanlige formen i fylkeskommunale
veglysprosjekter: alderen på armaturen, ikke effekten, avgjør rekkefølgen.
Dette er tiltaket som er **projisert inn i validatoren**. Tiltak 2
([tiltak-adaptiv-styring.md](tiltak-adaptiv-styring.md)) forutsetter at dette er utført.
## Parametere
| Parameter | Verdi | Status | Kilde/forankring |
|---|---|---|---|
| Antall lyspunkter i trinn 1 | 2 500 | [I] | trinnvis utrulling, andel valgt |
| Effekt før (HPS 100 W + forkobling) | 114 W | [V] | NMFV, Road Lighting Efficiency |
| Effekt etter (LED, tilsvarende veg) | 70 W | [V] | NMFV, samme tabell |
| Reduksjon per lyspunkt (ΔW) | **44 W** | beregnet | 114 70 |
| Brenntimer (HOU) | 4 050 t/år | [V-forankret] | Håndbok V124 (2021): 4 0004 100 t/år |
| Variabel energipris | 1,00 NOK/kWh | [V-forankret] | se [veglys-fv-soer.md](veglys-fv-soer.md) |
## Modellert besparelse (ex-ante)
Samme lysligning som for innendørs belysning (DOE/NREL Uniform Methods Project, Eq. 3):
`kWh = Σ (W_før W_etter) × antall × HOU / 1000`
> ΔW = 114 70 = **44 W/lyspunkt**
> kWh/år = 44 × 2 500 × 4 050 / 1 000 = **445 500 kWh/år**
> kr/år = 445 500 × 1,00 = **445 500 NOK/år**
Det er **38,6 %** av de berørte punktenes eget forbruk (1 154 250 kWh/år) og **10,2 %** av
porteføljens totale forbruk.
**Ingen HVAC-interaktiv effekt.** Utendørs finnes ikke motstykket til spillvarme-korreksjonen
et kontorbygg trenger (UMP Eq. 6). Kjernetallet står uten den justeringen.
## Hvorfor 38,6 % og ikke 67 %
Vestland fylkeskommune anslo i 2022 **67 % energireduksjon** ved full LED-oppgradering, til
≈ 200 mill. NOK og ≈ 27 mill. NOK/år spart. Vår bunn-opp-beregning fra merkeeffekt kommer til
38,6 %. Differansen er stor nok til at den må forklares, ikke bortforklares:
- Vår 38,6 % er **ren lampeutskifting**, regnet fra to merkeeffekter i samme NMFV-tabell.
- Vestlands 67 % er et **fylkeskommunalt aggregat-anslag** hvis sammensetning kilden ikke
bryter ned. Det er rimelig å anta at det også inneholder styring/dimming og korreksjon av
overdimensjonering — men **kilden sier det ikke**, og vi tilskriver den ikke noe den ikke
skriver.
- Legger vi tiltak 2 oppå ([tiltak-adaptiv-styring.md](tiltak-adaptiv-styring.md)), kommer vi
til **47,8 %** — fortsatt et godt stykke under 67 %.
**Det gjenstående gapet på ~19 prosentpoeng er uforklart i materialet vårt.** Vi lar det stå
åpent. Det er en av grunnene til at en ekspert-dom, ikke en modell, må sette forventet
faktisk besparelse.
## Kostnadssiden — et navngitt evidenshull
**Ingen kilde i materialet gir NOK per lyspunkt for LED-armatur inkl. montasje.** Vestlands
200 mill. NOK er en totalsum uten per-punkt-oppløsning, og antall lyspunkter oppgis ikke.
En per-punkt-kostnad kan **utledes**, men bare gjennom tre ledd som hver bærer sin egen
usikkerhet:
> 27 mill. NOK/år spart ÷ 1,00 NOK/kWh = 27 GWh/år spart
> 27 GWh/år = 67 % ⇒ baseline ≈ 40,3 GWh/år
> 40,3 GWh/år ÷ ~1 000 kWh per lyspunkt (Agder) ≈ 40 300 lyspunkter
> 200 mill. NOK ÷ 40 300 ≈ **~4 960 NOK per lyspunkt** `[U-utledet]`
Kjeden låner energiprisen fra vår egen baseline og lyspunkt-intensiteten fra et **annet**
fylke. Den er en størrelsesorden, ikke et tall.
**Konsekvensen er ubehagelig og skal stå:** 2 500 × ~4 960 ≈ 12,4 mill. NOK mot 445 500
NOK/år spart gir **~28 års tilbakebetaling på energi alene**. Det er langt dårligere enn
Vestlands egne ~7,4 år, og forskjellen er ikke mystisk — våre punkter er småveg-armatur med
462 kWh/år, mot ~1 000 kWh/år i et riks-inkluderende aggregat. Lavforbrukspunkter har
dårligere energiøkonomi.
Et ekte veglysprosjekt bæres derfor sjelden av energi alene. Vedlikehold er den andre halvdelen:
LED holder «Very good» i ≤ 5 år mens konvensjonelle lamper byttes langt hyppigere (Vestland
oppgir hver 4. år). **Den besparelsen er ikke modellert her** — vi har ingen kilde som
kvantifiserer den i NOK, og vi fyller ikke hullet med et anslag.
**Ingenting av dette går inn i `cost-baseline.json`.** Kostbasen ligger på aggregat-nivå
(energi), der Agder- og Vestland-tallene faktisk bærer.
## Usikkerhet (for Monte Carlo P10/P50/P90)
Den dominerende usikkerheten i en veglysbesparelse er **brenntimer**, ikke pris — og for
norsk veglys er den verre enn i et bygg: **ingen kilde i materialet gir en skumringsstyrt
norsk brenntimekurve.** V124s 4 0004 100 t/år er et tabellanslag for «eldre vegbelysning»,
ikke en målt kurve for et bestemt anlegg på en bestemt breddegrad.
Den eksisterende validatorens Monte Carlo varierer likevel **enhetspris**, ikke brenntimer.
I denne mappingen brukes derfor prisbandet **0,701,40 NOK/kWh** som usikkerhetsakse. Den
fysiske brenntime-usikkerheten — og viktigere, den *systematiske* brenntime-skjevheten —
håndteres i verdict-laget ([verdict-veglys-fro.md](verdict-veglys-fro.md)), ikke her.
## Mapping til validatoren (hvorfor `validator-input.json` ser ut som den gjør)
Den eksisterende deterministiske validatoren er en *feasibility-gate* (`claimed ≤ 30 % av
affected total`, Monte Carlo over enhetspris) bygd for kostnadskutt. Veglystiltaket mappes
inn **uendret**:
- `affected_items = [{code: "ENERGI-VEGLYS-EL", quantity: 4386150 kWh/år, unit_cost: 1.00 NOK/kWh}]`
**hele porteføljens** årlige energikostnad (4 386 150 NOK). Trinn 1-besparelsen er
10,2 % av den, godt innenfor 30 %-cap-en.
- `claimed_saving_nok = 445500` → den modellerte besparelsen fra trinn 1.
- `assumptions = {"ENERGI-VEGLYS-EL": [0.70, 1.40]}` → prisbandet for Monte Carlo.
**Hvorfor porteføljen og ikke bare de 2 500 punktene:** hadde `affected_items` vært de
berørte punktenes eget forbruk (1 154 250 kWh), ville den modellerte besparelsen vært 38,6 %
av den — **over 30 %-cap-en**, og det riktige forslaget ville blitt avvist av en gate som
måler feil størrelse. Porteføljen er den korrekte kostnads-linjen tiltaket virker på, på
samme måte som byggets totale elforbruk er det for et innendørs LED-tiltak.
**`cost-baseline.json` bærer nøyaktig samme rad.** `code`, `quantity` og `unit_cost` er
identiske i de to filene — ikke «innenfor toleranse», men identiske, fordi begge er skrevet
fra linjen `114 W × 9 500 × 4 050 t / 1 000`. Hver `code` i `affected_items` finnes som
nøkkel i `items`.
**Ærlig begrensning:** validatorens P10/P50/P90 betyr her «øvre feasible grense» (30 % av
samplet energikostnad), *ikke* «LED-besparelsens fysiske band». Det er bevisst — den domenetro
besparelses-modelleringen og realiseringsgapet hører hjemme i verdict-laget, som er nettopp
det lærings-sløyfa skal lære.

View file

@ -0,0 +1,16 @@
{
"_note": "IR-projeksjon (ir.SavingsProposal) for det eksisterende deterministiske validatoren. Veglystiltaket er mappet inn i kost-IR-en UENDRET: affected_items = HELE portefoeljens arlige energikostnad (9 500 lyspunkter x 114 W x 4 050 t / 1 000 = 4 386 150 kWh/ar a 1,00 NOK/kWh); claimed_saving_nok = modellert besparelse fra trinn 1 (2 500 punkter x 44 W x 4 050 t / 1 000 = 445 500 kWh/ar), som er 10,2 % av total og godt innenfor 30 %-cap-en; assumptions = energipris-band (NOK/kWh) for Monte Carlo. Hadde affected_items vaert kun de berorte punktenes eget forbruk, ville besparelsen vaert 38,6 % av den og det RIKTIGE forslaget blitt avvist. cost-baseline.json baerer IDENTISK code, quantity og unit_cost - begge er skrevet fra samme linje aritmetikk, ikke avstemt i ettertid. Se tiltak-led-utskifting.md, seksjon 'Mapping til validatoren'.",
"project_id": "VEGLYS-FV-SOER",
"measure": "LED-utskifting av 2 500 eldre HPS-armaturer (114 W -> 70 W) pa smatrafikkerte fylkesveger, trinn 1 av portefoeljen",
"affected_items": [
{
"code": "ENERGI-VEGLYS-EL",
"quantity": 4386150,
"unit_cost": 1.0
}
],
"claimed_saving_nok": 445500,
"assumptions": {
"ENERGI-VEGLYS-EL": [0.70, 1.40]
}
}

View file

@ -0,0 +1,81 @@
---
type: project
title: "Veglysportefølje Fylkesveg Sør"
description: "Fiktiv fylkeskommunal veglysportefølje: 9 500 lyspunkter med eldre høytrykksnatrium-armatur langs småtrafikkerte fylkesveger. Energibaseline og rammer for utskifting og styring."
resource: VEGLYS-FV-SOER
tags: [veglys, vegbelysning, fylkesveg, energibaseline, HPS]
timestamp: 2026-08-09
---
# Veglysportefølje Fylkesveg Sør (VEGLYS-FV-SOER)
**Fiktiv portefølje.** Tallene er illustrative, men bygget av parametere som er forankret i
norske primærkilder — ikke et ekte fylke. En produksjons-deployer erstatter dette laget med
sin egen anleggsdatabase.
Porteføljen er **9 500 lyspunkter** langs småtrafikkerte fylkesveger, alle med eldre
høytrykksnatrium-armatur (HPS). Den er valgt uniform med vilje: hele variasjonen som
betyr noe for lærings-overflaten ligger i **brenntimer og realisering**, ikke i
armatur-miksen.
## Energibaseline
| Størrelse | Verdi | Merknad |
|---|---|---|
| Antall lyspunkter | 9 500 | [I] illustrativt |
| Installert effekt per lyspunkt | **114 W** | [V] 100 W HPS-lampe + 14 W forkobling (NMFV) |
| Brenntimer | **4 050 t/år** | [V-forankret] midtpunkt i Håndbok V124s 4 0004 100 t/år for eldre vegbelysning |
| **Totalt elforbruk** | **4 386 150 kWh/år** | beregnet: 114 W × 9 500 × 4 050 t / 1 000 |
| Per lyspunkt | 461,7 kWh/år | beregnet |
| Variabel energikostnad | **1,00 NOK/kWh** ekskl. mva | [V-forankret] kraftpris + nettleie energiledd + elavgift |
| **Total årlig energikostnad** | **4 386 150 NOK/år** | beregnet |
**Energiprisen** (1,00 NOK/kWh) er den marginale variable kostnaden et spart kWh faktisk
unngår, ekskl. mva. Sammensetningen er den samme som for næringsbygg — kraftpris + nettleie
energiledd + elavgift — og varierer kraftig med prisområde og sesong. Derfor er den
konfigurerbar, og usikkerheten håndteres i Monte Carlo-steget (band 0,701,40 NOK/kWh). Se
[kilder-veglys-realisering.md](kilder-veglys-realisering.md).
**Én forskjell fra næringsbygg er verdt å merke:** veglys har sitt eget nettleie-regime, og
NVE/RME dokumenterer at mange veilysanlegg avregnes på **estimerte** verdier fordi de mangler
måler. Det påvirker ikke den marginale kostnaden per spart kWh, men det er grunnen til at
ex-post-verifikasjon er stengt her. Se [metode-ipmvp-a.md](metode-ipmvp-a.md).
## Kryss-sjekk mot norsk aggregat (og hvorfor tallene ikke er like)
Agder fylkeskommune oppgir at **13 000 lyspunkter** langs riks- og fylkesveger bruker «rett
over 13 GWh/år» — altså **≈ 1 000 kWh per lyspunkt per år**. Vår portefølje ligger på
**461,7 kWh** per lyspunkt, under halvparten.
**Avviket er reelt og forklarlig, ikke en feil:** Agder-tallet dekker **riks- OG fylkesveger**,
altså også høyere vegklasser med 150 W- og 250 W-lamper og lengre master. Fylkesveg Sør er
med vilje modellert som ren småtrafikkert fylkesveg med 100 W-armatur — den vegklassen NMFV
måler på. Retningen på avviket stemmer med den forklaringen: vår portefølje **skal** ligge
under et riks-inkluderende aggregat.
**Dette er en design-beslutning, ikke en måling.** Et ekte fylke ville hatt blandet
armatur-mikse, og en deployer som bytter ut dette laget må regne baselinen på nytt fra sin
egen anleggsdatabase. Konsekvensen for lærings-overflaten er null — realiseringsgapet er en
*rate*, ikke et absolutt tall.
## Rammer (constraints)
- Tiltak vurderes **inne i** denne porteføljen (ikke på tvers av fylkeskommunens øvrige
anlegg).
- **Lystekniske minstekrav setter gulvet.** For småtrafikkerte veger: 1,0 cd/m² og 5 lx
(NMFV). Ingen besparelse kan hentes ved å gå under kravet.
- **Vedlikeholdsfaktoren (MF) må inn i beregningen.** Anlegget skal ligge over kravet **over
tid**, ikke bare ved idriftsettelse. Håndbok V124 setter MF ≤ 0,85. Se
[tiltak-adaptiv-styring.md](tiltak-adaptiv-styring.md) — det er nettopp den marginen
tiltak 2 lever av.
- **Nattslukking kan ikke antas.** Statens vegvesen kjørte pilot på slukking 00:0005:00
i 2024, men dokumentasjonen sier at tiltaket må vurderes lokalt og økologisk. Det er
derfor **ikke** modellert som besparelse i noen av hypotesene her.
- Budsjett og anskaffelsesrammer eies av deployer; her holdes de minimale.
## Kandidat-tiltak
- [tiltak-led-utskifting.md](tiltak-led-utskifting.md) — LED-utskifting, trinn 1
(2 500 lyspunkter).
- [tiltak-adaptiv-styring.md](tiltak-adaptiv-styring.md) — adaptiv styring på de samme
2 500 punktene, etter LED.

View file

@ -0,0 +1,97 @@
---
type: verdict
title: "Ekspert-dom (frø): LED-utskifting veglys — godkjent med realiseringskorreksjon"
description: "Frøsatt ekspert-dom for LED-utskiftingen. Den modellerte besparelsen er teknisk korrekt fra parameterne, men brenntimene er et nasjonalt tabellanslag, ikke en målt kurve, og anlegget mangler måler — så avviket kan ikke oppdages i drift. Forventet faktisk besparelse settes til 81 % av modellert, lånt fra belysnings-programlitteratur og merket som lån. Raten er en NEDRE grense: en motvirkende mekanisme (målt effekt over merkeeffekt) er dokumentert, men ikke kvantifiserbar for LED-siden."
resource: VEGLYS-FV-SOER
measure_id: LED-VEGLYS-01
decision: approved_with_adjustment
realization_rate: 0.81
modelled_saving_nok: 445500
expected_actual_saving_nok: 360855
gap_source: hours-of-use-overestimation
context_key: "veglys; HOU-kilde=V124-tabellverdi; maaling=fravaerende"
provenance: "frø — AI-forfattet. Realiseringsgraden er LÅNT fra belysnings-programlitteratur (National Grid SBS 2010: 81,1 %; Efficiency Maine 2021: 3 053 metrede mot 3 772 antatte timer = 0,809). Det finnes INGEN norsk ex-post-måling for veglys. Erstattes av ekte HITL i produksjon."
tags: [verdict, realization-rate, ExpeL-seed, HITL, veglys, laant-rate]
timestamp: 2026-08-09
---
# Ekspert-dom (frø): LED-utskifting veglys
> **Dette er et frø**, ikke en ekte dom. I simulering gir en ekspert-persona slike dommer;
> i produksjon gir et menneske dem via samme mappe-grensesnitt. Frøet er forankret i
> verifisert litteratur ([kilder-veglys-realisering.md](kilder-veglys-realisering.md)), ikke
> oppdiktet — men **raten er lånt, ikke norsk**, og det står i `provenance`.
## Dommen
**Beslutning:** godkjent — med realiseringskorreksjon.
Den modellerte besparelsen (**445 500 NOK/år**) er teknisk korrekt fra parameterne, og
validatoren bekrefter at den ligger godt innenfor feasibelt område. Men parameteren
besparelsen henger på — brenntimer — er et **nasjonalt tabellanslag**, og anlegget har ingen
måler som kan avsløre at anslaget bommer. Forventet faktisk besparelse settes til
**≈ 360 855 NOK/år** (81 % av modellert).
## Begrunnelse (det validatoren ikke kan regne)
### Hovedmekanismen: brenntimene er stipulert, ikke målt
Håndbok V124s 4 0004 100 t/år gjelder «eldre vegbelysning» som kategori. Det er ikke en
målt kurve for dette anlegget, og **ingen kilde gir en skumringsstyrt norsk
brenntimekurve**. Metoden ([metode-ipmvp-a.md](metode-ipmvp-a.md)) er Option A nettopp fordi
måling er stengt — og Option A er den opsjonen som *tillater* å estimere denne parameteren.
Erfaringen fra belysningsprogrammer med samme lysligning og samme stipulerte parameter er
entydig i retning: Efficiency Maine metret **3 053 t/år** der programmet antok **3 772**
(forholdet 0,809), og National Grid kom uavhengig til en operational adjustment på **81,1 %**
gjennom samme mekanisme. **0,81 er dette lånet, ikke en norsk måling.**
### Sekundære mekanismer, samme retning
- **In-service rate < 1.** Ikke alle 2 500 punktene er nødvendigvis montert og i drift ved
evaluering. Et veglysanlegg spredt over vegnettet har lengre haler enn et bygg.
- **Persistens.** Armaturer som feiler, blir stående mørke til neste vedlikeholdsrunde. Et
mørkt punkt sparer riktignok energi, men leverer ikke tiltaket — og telles typisk ikke som
besparelse i en evaluering.
### Motmekanismen — og hvorfor den IKKE er trukket fra
Én dokumentert mekanisme peker **motsatt vei**: en armatur merket 100 W er målt til å trekke
**120 W** (MDPI). Er den faktiske baseline-effekten høyere enn merkeeffekten modellen regner
med, er den faktiske besparelsen **større** enn modellert — det ville løftet
realiseringsgraden.
Den er likevel ikke netto-regnet inn, av én grunn: **målingen finnes bare for den gamle
armaturen.** Om LED-siden har et tilsvarende påslag — og hvor stort — sier ingen kilde i
materialet. Å anta at LED treffer merkeeffekten eksakt, mens HPS bommer med 20 %, ville vært
en gratis oppjustering av besparelsen bygget på fravær av data.
**Derfor er 0,81 en NEDRE grense, og den er merket slik.** En ekte ekspert med målt
LED-effekt for dette anlegget ville sannsynligvis satt raten høyere.
### Hvorfor dette ikke kan regnes fra parameterne
Du kan **ikke** regne deg til RR = 0,81 fra `{2 500, 114 W, 70 W, 4 050 t, 1,00 NOK/kWh}`.
Skjevheten er epistemikk parameterne ikke bærer — den finnes bare i akkumulert
drifts-erfaring, og i dette domenet finnes den ikke engang i norske måledata. Det er
nøyaktig lærings-overflaten bundelen er bygget for.
## Lærings-signalet (ExpeL)
Korreksjonen er **kontekstbetinget**:
`context_key = "veglys; HOU-kilde=V124-tabellverdi; maaling=fravaerende"`.
Neste kjøring, gitt en lignende hypotese i samme kontekst, skal hente denne dommen og justere
den modellerte ex-ante-besparelsen mot forventet ex-post (≈ 0,81×) — uten å vente på 12
måneders måling som uansett ikke kommer, fordi måleren ikke finnes.
`gap_source` er bevisst satt til **`hours-of-use-overestimation`**, samme nøkkel som
kontorbygg-frøet bruker. Domenene er ulike, men mekanismen er den samme, og en lærings-sløyfa
som ikke ser den koblingen lærer to ganger det den kunne lært én gang.
## Om tiltak 2 (adaptiv styring)
Denne dommen gjelder **kun** LED-utskiftingen (`LED-VEGLYS-01`). Styringstiltaket
([tiltak-adaptiv-styring.md](tiltak-adaptiv-styring.md)) er ikke dømt her, og bør ikke arve
raten: dets modellerte besparelse er en regelverksmargin uten kostnadsside, og
risikoprofilen er en annen — en styring som overstyres av drift leverer null, ikke 81 %.

View file

@ -31,7 +31,8 @@ spec, the golden suite, or agent behaviour.
connector) does not require any change to this spec, and NOT implementing it does not break connector) does not require any change to this spec, and NOT implementing it does not break
conformance. conformance.
- **Honesty rule (unwaivable, method spec §1):** a machine-generated bundle is labelled as - **Honesty rule (unwaivable, method spec §1):** a machine-generated bundle is labelled as
such (`generated: true` plus a manifest reference, §7) everywhere it is presented. such (`generated.by` naming the ingest actor, plus a manifest reference, §7) everywhere it is
presented.
- **Boundary:** the deploying organisation owns processing purposes and impact assessments; - **Boundary:** the deploying organisation owns processing purposes and impact assessments;
ingest provides only the technical prerequisites (local-only default, provenance, no silent ingest provides only the technical prerequisites (local-only default, provenance, no silent
egress). egress).
@ -67,9 +68,10 @@ approved knowledge into. Two rules keep ingest and the learning loop apart:
self-contamination the gate exists to prevent. This MUST be enforced fail-fast at manifest self-contamination the gate exists to prevent. This MUST be enforced fail-fast at manifest
validation (before any source call) and proven by a load-bearing test (§11). validation (before any source call) and proven by a load-bearing test (§11).
- **Ingest owns only its own files.** Re-materialization replaces EXACTLY the files carrying - **Ingest owns only its own files.** Re-materialization replaces EXACTLY the files carrying
the ingest stamp (`generated: true` plus an `ingest_manifest` reference, §7) and MUST NOT the ingest stamp (`generated.by` equal to the ingest actor plus an `ingest_manifest`
touch curated or promoted files. If a generated filename collides with an existing file that reference, §7) and MUST NOT touch curated or promoted files. If a generated filename
does NOT carry the stamp, materialization MUST fail — never overwrite curated content. collides with an existing file that does NOT carry the stamp, materialization MUST fail —
never overwrite curated content.
Index updating is idempotent and preserves curated links (§6). The stamp is unforgeable Index updating is idempotent and preserves curated links (§6). The stamp is unforgeable
against **accident**, not against **will**: an operator who hand-copies a generated file — against **accident**, not against **will**: an operator who hand-copies a generated file —
stamp and all — into curated content makes it indistinguishable from ingest-owned content, stamp and all — into curated content makes it indistinguishable from ingest-owned content,
@ -79,10 +81,11 @@ approved knowledge into. Two rules keep ingest and the learning loop apart:
- **No other writer may forge the stamp.** The stamp is the sole mark distinguishing - **No other writer may forge the stamp.** The stamp is the sole mark distinguishing
ingest-owned files from curated ones, so any authoring primitive that materializes a concept ingest-owned files from curated ones, so any authoring primitive that materializes a concept
file from **caller-supplied** frontmatter MUST reject a frontmatter carrying the *complete* file from **caller-supplied** frontmatter MUST reject a frontmatter carrying the *complete*
ownership stamp — `generated: true` together with an `ingest_manifest` reference — while ownership stamp — `generated.by` equal to the ingest actor together with an `ingest_manifest`
permitting either field alone (curated content may legitimately carry a single provenance reference — while permitting either field alone (curated content may legitimately carry a
field). The check is on the complete stamp, never on the individual field names, so a single provenance field). The check is on the complete stamp, never on the individual field
legitimate verbatim round-trip is preserved; it is a **validation, never a repair**. names, so a legitimate verbatim round-trip is preserved; it is a **validation, never a
repair**.
## 4. The ingest manifest (the contract) ## 4. The ingest manifest (the contract)
@ -211,7 +214,7 @@ that are never mixed — the same discipline as the two falsifiers.
| `source_query` | The query that fetched the content (whitespace-collapsed, §5). | | `source_query` | The query that fetched the content (whitespace-collapsed, §5). |
| `ingested_at` | The explicit timestamp argument, verbatim (§5). | | `ingested_at` | The explicit timestamp argument, verbatim (§5). |
| `ingest_manifest` | The manifest reference `{stem}@{hash16}` (§5). | | `ingest_manifest` | The manifest reference `{stem}@{hash16}` (§5). |
| `generated` | Literally `true` — the machine-generated marker (§1 honesty rule). | | `generated` | The inline mapping `{ by: process:okf-ingest, at: <ingested_at> }`. `by` is the fixed ingest actor this spec defines — a `process:` actor, never a producer's name (the preamble rule: this layer is implementable **from this spec alone**, and the prose never names a concrete toolkit or vendor stack); it is what marks the file machine-generated (§1 honesty rule), since the key's mere presence does not (curated content may carry a `human:` actor). `at` repeats the `ingested_at` value verbatim. Quoting is NOT normative: frontmatter is parsed line-oriented (method spec §3), so a quote character would be part of the value. |
- OKF consumers preserve unknown frontmatter fields, so this layer rides through navigation - OKF consumers preserve unknown frontmatter fields, so this layer rides through navigation
and context rendering unchanged. and context rendering unchanged.
@ -272,7 +275,7 @@ spec §11 regime):
| Seam | The test MUST fail when… | | Seam | The test MUST fail when… |
|---|---| |---|---|
| Provenance stamping | a generated file no longer carries the §7 layer | | Provenance stamping | a generated file no longer carries the §7 layer |
| Stamp integrity (curated writers) | a caller-supplied frontmatter carrying the complete ownership stamp (`generated: true` with `ingest_manifest`) stops being rejected by the verbatim authoring path (§3) | | Stamp integrity (curated writers) | a caller-supplied frontmatter carrying the complete ownership stamp (`generated.by` equal to the ingest actor, with `ingest_manifest`) stops being rejected by the verbatim authoring path (§3) |
| Navigability | the generated bundle stops being consumable by the UNCHANGED bundle-navigation code, index links included | | Navigability | the generated bundle stops being consumable by the UNCHANGED bundle-navigation code, index links included |
| Verdict reservation | a manifest mapping to `type: verdict` (or the reserved filename namespace) stops being rejected | | Verdict reservation | a manifest mapping to `type: verdict` (or the reserved filename namespace) stops being rejected |
| Title link-safety | a `title` containing `[` or `]` stops being rejected fail-fast at manifest load (§4) | | Title link-safety | a `title` containing `[` or `]` stops being rejected fail-fast at manifest load (§4) |

View file

@ -1,5 +1,5 @@
{ {
"decision": "approved", "decision": "approved",
"marker": "realiseringsgrad=0.79", "marker": "realiseringsgrad=0.79",
"rationale": "Godkjent med realiseringskorreksjon. Den modellerte besparelsen er teknisk korrekt fra parameterne og validatoren bekrefter at den er innenfor feasibelt omraade. Men i drift realiseres erfaringsvis ~79% av en timeplan-stipulert LED-besparelse i kontorbygg (realiseringsgrad=0.79) pga. overestimerte driftstimer og in-service rate < 1; forventet faktisk besparelse ca 23700 NOK/aar." "rationale": "Godkjent med realiseringskorreksjon. Den modellerte besparelsen er teknisk korrekt fra parameterne og validatoren bekrefter at den er innenfor feasibelt omraade. Men i drift realiseres erfaringsvis ~79% av en timeplan-stipulert LED-besparelse i tilsvarende anlegg (realiseringsgrad=0.79) pga. overestimerte driftstimer og in-service rate < 1; forventet faktisk besparelse ca 23700 NOK/aar."
} }

646
spikes/e_magentic.py Normal file
View file

@ -0,0 +1,646 @@
"""Spike E — the Magentic exploration loop, measured BEFORE it is built
(order 20260823T162224Z; plan ``docs/plan/2026-08-23-magentic-utforskningssloeyfe.md`` § D.1).
Nothing here is production code and nothing here is wired into ``src/``. Each function is one
measurement whose outcome moves a row of the plan's § F assumption table from "umålt" to a fact.
**The client is the repo's own ``ScriptedChatClient``, not an ad-hoc fake.** The scratch scripts
this spike ports used a bare ``BaseChatClient``, on which ``BudgetMiddleware`` is silently a
no-op (measured, ``simulation.py:373-375``) so a budget claim proved with one would have proved
nothing. ``ScriptedChatClient`` subclasses the LAYERED ``OpenAIChatCompletionClient``, which is
what makes S2 a real measurement.
**The budget types are the PRODUCTION ones** (``portfolio_optimiser.budget``), deliberately NOT
``spikes/_harness.py``'s private copy. The harness copy is exactly why ``tick_round``'s
``observed`` went four raise-sites without coverage (-(y)); S2's whole question is whether the
SHIPPED middleware reaches the manager, and only the shipped object can answer it.
**Routing is on the joined prompt blob, in a fixed priority order, and that order is
load-bearing.** ``ScriptedChatClient``'s selector receives the concatenation of every message in
the call, so one manager call carries two markers (the plan prompt is built on a history that
still holds the pre-survey text measured: 1 ambiguous call in 5). Checking the later-stage
marker first resolves it; the five-kind call shape each experiment asserts is what proves the
routing stayed correct.
"""
from __future__ import annotations
import json
import statistics
import subprocess
import sys
import time
from collections.abc import Callable, Sequence
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from agent_framework import Agent, FileCheckpointStorage
from agent_framework.orchestrations import (
AgentRequestInfoResponse,
MagenticAgentExecutor,
MagenticBuilder,
MagenticPlanReviewResponse,
MagenticResetSignal,
StandardMagenticManager,
)
# ``AgentApprovalExecutor`` is the ONE name S3b needs that the package does not re-export
# (measured: ``hasattr(agent_framework.orchestrations, "AgentApprovalExecutor")`` is False while
# its response type IS public). Reaching into the private module is therefore part of the S3b
# FINDING, not an oversight: door 3 of § C.6 currently costs a private-API dependency, and that
# is a fact the operator's decision needs to carry.
from agent_framework_orchestrations._orchestration_request_info import AgentApprovalExecutor
import portfolio_optimiser
from portfolio_optimiser.budget import Budget, BudgetExceeded, BudgetMiddleware, TokenMeter
from portfolio_optimiser.ir import SavingsProposal
from portfolio_optimiser.okf import load_ir_projection, load_optional_cost_baseline
from portfolio_optimiser.simulation import ScriptedChatClient
from portfolio_optimiser.validator import validate_proposal
TASK_ALPHA = "TASK-ALPHA: find the saving in the alpha project."
TASK_BETA = "TASK-BETA: find the saving in the beta project."
WORKER_SENTINEL = "WORKER-SAW"
def _ledger(*, satisfied: bool, speaker: str) -> str:
"""A progress ledger naming ``speaker`` as the next talker.
The name is a PARAMETER because an unknown ``next_speaker`` is a silent footgun: the
orchestrator does not error on it, it quietly produces a final answer with zero participant
work (``_magentic.py:1128-1131``). Hard-coding "worker" here made the S3b run whose
participant is the ``expert_liaison`` finish without ever asking anyone (measured).
"""
return json.dumps(
{
"is_request_satisfied": {
"reason": "the participant replied" if satisfied else "no one has spoken yet",
"answer": satisfied,
},
"is_in_loop": {"reason": "no", "answer": False},
"is_progress_being_made": {"reason": "yes", "answer": True},
"next_speaker": {"reason": "it does the work", "answer": speaker},
"instruction_or_question": {
"reason": "done" if satisfied else "kick off",
"answer": "none" if satisfied else "Do the work now.",
},
}
)
@dataclass(frozen=True)
class ExplorationCallRecord:
"""One manager call, reduced to the facts every verdict here reads.
``sees_marker`` is the HITL half (S3b): whether a caller-supplied sentinel an expert's
answer injected mid-run had reached this manager prompt. It defaults to ``False`` so the
contamination experiments, which supply no marker, are unchanged.
"""
kind: str
messages: int
sees_alpha: bool
sees_beta: bool
sees_marker: bool = False
def _route(blob: str, speaker: str) -> tuple[str, str]:
"""Map a manager prompt blob to ``(kind, reply)``.
Order matters: the later-stage marker is tested FIRST because an earlier stage's text is
still present in the joined blob. Reversing two of these silently changes which prompt a
kind is attributed to, which is why every experiment asserts the resulting call shape.
"""
if "provide the final answer" in blob:
return "final", "FINAL: the worker did it."
if "pure JSON format" in blob:
if WORKER_SENTINEL in blob:
return "ledger_SAT", _ledger(satisfied=True, speaker=speaker)
return "ledger_UNSAT", _ledger(satisfied=False, speaker=speaker)
if "went wrong on this last run" in blob:
return "plan_update", "PLAN-UPDATE: ask the worker again."
if "rewrite the following fact sheet" in blob:
return "facts_update", "FACTS-UPDATE: still nothing."
if "bullet-point plan" in blob:
return "plan", "PLAN: - ask the worker"
if "pre-survey" in blob:
return "facts", "FACTS: nothing given."
return "unknown", "{}"
def _manager_client(
records: list[ExplorationCallRecord],
*,
marker: str | None = None,
speaker: str = "worker",
) -> ScriptedChatClient:
"""A manager-shaped scripted client appending one record per call to ``records``.
``records`` is CALLER-owned (the parse-failure-capture precedent): the evidence must survive
however the run ended, including a run cut short by ``BudgetExceeded`` mid-way.
"""
def _select(blob: str, _role: str) -> str:
kind, reply = _route(blob, speaker)
records.append(
ExplorationCallRecord(
kind=kind,
messages=blob.count("\n") + 1,
sees_alpha="TASK-ALPHA" in blob,
sees_beta="TASK-BETA" in blob,
sees_marker=marker is not None and marker in blob,
)
)
return reply
return ScriptedChatClient(reply_selector=_select, role="manager")
def _worker_client(seen: list[str]) -> ScriptedChatClient:
def _select(blob: str, _role: str) -> str:
which = "ALPHA" if "TASK-ALPHA" in blob else ("BETA" if "TASK-BETA" in blob else "NOTHING")
seen.append(which)
return f"{WORKER_SENTINEL}-{which}"
return ScriptedChatClient(reply_selector=_select, role="worker")
def _manager_agent(client: ScriptedChatClient, *, middleware: Sequence[Any] | None = None) -> Agent:
return Agent(
client,
"You are the Magentic manager.",
name="manager",
description="plans the work",
middleware=middleware,
)
def _worker_agent(client: ScriptedChatClient) -> Agent:
return Agent(client, "You are the worker.", name="worker", description="does the work")
async def _run(workflow: Any, task: str) -> dict[str, Any]:
try:
result = await workflow.run(task)
outputs = [str(o) for o in (result.get_outputs() or [])]
return {"ok": True, "outputs": outputs}
except Exception as exc: # noqa: BLE001 - the failure mode IS the measurement
return {"ok": False, "error": type(exc).__name__, "message": str(exc)}
def manager_keeps_persistent_session() -> bool:
"""Does the INSTALLED ``StandardMagenticManager`` hold one ``AgentSession`` for its whole
life, or mint a throwaway one per call?
This is the single structural property that decides E2 and E4. Orchestrations 1.0.0 assigns
``self._session = self._agent.create_session()`` in ``__init__``; 1.0.1 removed that line and
creates the session inside the call instead (upstream regression fix #4371). Probing the
attribute rather than the version string states the CAUSE, and keeps the spike honest across
a version the plan has not seen.
"""
manager = StandardMagenticManager(agent=_manager_agent(_manager_client([])))
return hasattr(manager, "_session")
async def single_use_second_run() -> dict[str, Any]:
"""E1: build one Magentic workflow, run it twice. Measures the second run's outcome and
what it COST a refusal that still made model calls would be a different finding."""
records: list[ExplorationCallRecord] = []
worker_seen: list[str] = []
workflow = MagenticBuilder(
participants=[_worker_agent(_worker_client(worker_seen))],
manager_agent=_manager_agent(_manager_client(records)),
max_round_count=6,
).build()
first = await _run(workflow, TASK_ALPHA)
manager_before, worker_before = len(records), len(worker_seen)
second = await _run(workflow, TASK_BETA)
return {
"first_ok": first["ok"],
"second_error": second.get("error"),
"second_message": second.get("message", ""),
"manager_calls_added": len(records) - manager_before,
"worker_calls_added": len(worker_seen) - worker_before,
}
async def _two_runs(
build_second: Callable[[list[ExplorationCallRecord]], Any],
*,
shared_records: bool,
) -> tuple[int, int, list[str]]:
"""Run ALPHA, then BETA, and report how many of run 2's manager calls still see ALPHA.
``shared_records`` says whether run 2's manager is the same object as run 1's when it is,
run 2's records are the tail of one list; when it is not, they are their own list. Both
shapes reduce to the same verdict triple so the callers stay comparable.
"""
records: list[ExplorationCallRecord] = []
first_worker: list[str] = []
first = MagenticBuilder(
participants=[_worker_agent(_worker_client(first_worker))],
manager_agent=_manager_agent(_manager_client(records)),
max_round_count=6,
).build()
await _run(first, TASK_ALPHA)
split = len(records)
second_records = records if shared_records else []
second = build_second(second_records)
await _run(second, TASK_BETA)
tail = records[split:] if shared_records else second_records
bled = sum(1 for record in tail if record.sees_alpha)
return bled, len(tail), [record.kind for record in tail]
async def shared_manager_contamination() -> tuple[int, int, list[str]]:
"""E2: two builders sharing ONE ``StandardMagenticManager`` instance.
Built directly (not via ``_two_runs``) because run 1 must go through the SAME manager object,
which ``manager=`` accepts and ``manager_agent=`` does not.
"""
records: list[ExplorationCallRecord] = []
shared = StandardMagenticManager(
agent=_manager_agent(_manager_client(records)), max_round_count=6
)
first = MagenticBuilder(
participants=[_worker_agent(_worker_client([]))], manager=shared
).build()
await _run(first, TASK_ALPHA)
split = len(records)
second = MagenticBuilder(
participants=[_worker_agent(_worker_client([]))], manager=shared
).build()
await _run(second, TASK_BETA)
tail = records[split:]
return sum(1 for r in tail if r.sees_alpha), len(tail), [r.kind for r in tail]
async def shared_builder_contamination() -> tuple[int, int, list[str]]:
"""E4: ONE ``MagenticBuilder``, ``.build()`` twice. ``manager_agent=`` constructs the manager
eagerly and hands the same instance to every build the accidental route into E2."""
records: list[ExplorationCallRecord] = []
builder = MagenticBuilder(
participants=[_worker_agent(_worker_client([]))],
manager_agent=_manager_agent(_manager_client(records)),
max_round_count=6,
)
await _run(builder.build(), TASK_ALPHA)
split = len(records)
await _run(builder.build(), TASK_BETA)
tail = records[split:]
return sum(1 for r in tail if r.sees_alpha), len(tail), [r.kind for r in tail]
async def fresh_manager_contamination() -> tuple[int, int, list[str]]:
"""E3, the control and the mitigation: a fresh builder, agent and client per exploration."""
def _second(records: list[ExplorationCallRecord]) -> Any:
return MagenticBuilder(
participants=[_worker_agent(_worker_client([]))],
manager_agent=_manager_agent(_manager_client(records)),
max_round_count=6,
).build()
return await _two_runs(_second, shared_records=False)
async def reset_signal_resets_participant_session() -> dict[str, Any]:
"""E7: does ``MagenticResetSignal`` actually give the participant a clean session?
Measured no: the fresh session is written to ``_agent_thread``, which nothing reads, while
the live ``_session`` keeps its identity. The stall-replan path therefore hands the manager a
clean ledger and the participants their old memory.
"""
executor = MagenticAgentExecutor(_worker_agent(_worker_client([])))
session_before = id(executor._session)
executor._cache.append("sentinel") # type: ignore[arg-type]
executor._full_conversation.append("sentinel") # type: ignore[arg-type]
await executor.handle_magentic_reset(MagenticResetSignal(), None) # type: ignore[arg-type]
return {
"cache_cleared": len(executor._cache) == 0,
"conversation_cleared": len(executor._full_conversation) == 0,
"session_identity_changed": id(executor._session) != session_before,
"orphan_attribute_written": hasattr(executor, "_agent_thread"),
}
async def manager_budget_enforced(
*, max_tokens: int, attach: bool, return_exception: bool = False
) -> dict[str, Any]:
"""S2: put the SHIPPED ``BudgetMiddleware`` on the manager agent and see whether the typed
refusal leaves ``workflow.run``.
``attach=False`` is the detach control: the same one-token budget with no middleware must
stop nothing. Without that arm the positive test would pass on any implementation in which
something, anything, raised.
"""
meter = TokenMeter(Budget(max_tokens=max_tokens, max_rounds=8))
middleware = [BudgetMiddleware(meter)] if attach else None
workflow = MagenticBuilder(
participants=[_worker_agent(_worker_client([]))],
manager_agent=_manager_agent(_manager_client([]), middleware=middleware),
max_round_count=6,
).build()
raised: str | None = None
kind: str | None = None
exception: BaseException | None = None
completed = False
try:
result = await workflow.run(TASK_ALPHA)
completed = bool(result.get_outputs())
except BudgetExceeded as exc:
raised, kind, exception = type(exc).__name__, exc.kind, exc
except Exception as exc: # noqa: BLE001 - a DIFFERENT exception type is itself the finding
raised, exception = type(exc).__name__, exc
payload: dict[str, Any] = {
"raised": raised,
"kind": kind,
"completed": completed,
"meter_tokens": meter.tokens,
}
if return_exception:
payload["exception"] = exception
return payload
# ---------------------------------------------------------------------------
# S3 / S3b — the two HITL doors (plan § C.5, § C.6)
# ---------------------------------------------------------------------------
def _pending_requests(result: Any) -> list[Any]:
return [event for event in result if event.type == "request_info"]
async def plan_review_round_trip() -> dict[str, Any]:
"""S3: ``enable_plan_review=True`` → the run stops with a ``MagenticPlanReviewRequest`` and no
output ``revise(...)`` replans and asks AGAIN ``approve()`` lets the loop run.
The manager-call count PER revise is the number the contract needs: a revise costs model
calls but is not counted as a round by the orchestration (measured: no ledger call), so an
uncapped reviser is an unbounded spend the plan's ``max_plan_revisions`` has to bound.
"""
records: list[ExplorationCallRecord] = []
workflow = MagenticBuilder(
participants=[_worker_agent(_worker_client([]))],
manager_agent=_manager_agent(_manager_client(records)),
max_round_count=6,
enable_plan_review=True,
).build()
first = await workflow.run(TASK_ALPHA)
pending = _pending_requests(first)
stopped_without_output = not first.get_outputs()
review_kinds = [r.kind for r in records]
before_revise = len(records)
revised = await workflow.run(
responses={pending[0].request_id: MagenticPlanReviewResponse.revise("Test the LED case.")}
)
revise_calls = [r.kind for r in records[before_revise:]]
pending_after_revise = _pending_requests(revised)
approve_id = (
pending_after_revise[0].request_id if pending_after_revise else pending[0].request_id
)
approved = await workflow.run(responses={approve_id: MagenticPlanReviewResponse.approve()})
return {
"pending_before_review": len(pending),
"request_type": type(pending[0].data).__name__ if pending else None,
"is_stalled": bool(pending[0].data.is_stalled) if pending else None,
"stopped_without_output": stopped_without_output,
"kinds_before_review": review_kinds,
"revise_manager_calls": revise_calls,
"pending_after_revise": len(pending_after_revise),
"outputs_after_approve": [str(o) for o in (approved.get_outputs() or [])],
}
async def expert_liaison_answer_round_trip(
*, answer: str, probe: str | None = None
) -> dict[str, Any]:
"""S3b: an ``AgentApprovalExecutor`` standing in as the ``expert_liaison`` PARTICIPANT — the
third door of § C.6, the one that lets the manager ask a question MID-run.
Measured shape, two round-trips per human turn: the manager picks the liaison its output
becomes a ``request_info`` ``from_strings([answer])`` feeds the human's words back INTO the
liaison, which runs again a second ``request_info`` ``approve()`` forwards the liaison's
(now informed) output to the manager, which resumes. ``from_strings`` alone does NOT resume
the manager: measured zero manager calls between the two requests.
``probe`` is the CONTROL knob: the manager's prompts are scanned for IT instead of for
``answer``, so a sentinel the expert never sent must come back absent. Without that arm,
"the manager saw the answer" could equally mean "the scanner matches anything".
"""
records: list[ExplorationCallRecord] = []
liaison_prompts: list[str] = []
def _liaison_reply(blob: str, _role: str) -> str:
liaison_prompts.append(blob)
if answer in blob:
return f"{WORKER_SENTINEL}-LIAISON heard: {answer}"
return f"{WORKER_SENTINEL}-LIAISON has no expert input yet."
liaison = AgentApprovalExecutor(
Agent(
ScriptedChatClient(reply_selector=_liaison_reply, role="expert_liaison"),
"You relay the expert's answers.",
name="expert_liaison",
description="asks the human expert",
)
)
workflow = MagenticBuilder(
participants=[liaison],
manager_agent=_manager_agent(
_manager_client(records, marker=probe or answer, speaker="expert_liaison")
),
max_round_count=6,
).build()
first = await workflow.run(TASK_ALPHA)
asked = _pending_requests(first)
before_answer = len(records)
answered = await workflow.run(
responses={asked[0].request_id: AgentRequestInfoResponse.from_strings([answer])}
)
manager_calls_on_answer = [r.kind for r in records[before_answer:]]
second = _pending_requests(answered)
approved = (
await workflow.run(responses={second[0].request_id: AgentRequestInfoResponse.approve()})
if second
else answered
)
return {
"reachable": bool(asked),
"request_type": type(asked[0].data).__name__ if asked else None,
"manager_calls_between_requests": manager_calls_on_answer,
"second_request": len(second),
"liaison_saw_answer": any(answer in prompt for prompt in liaison_prompts),
"manager_saw_answer": any(r.sees_marker for r in records),
"outputs": [str(o) for o in (approved.get_outputs() or [])],
}
# ---------------------------------------------------------------------------
# S4 — resume a pending plan review in a NEW PROCESS (plan U12)
# ---------------------------------------------------------------------------
async def checkpoint_until_plan_review(storage_dir: str) -> dict[str, Any]:
"""Run until the plan review stops the workflow, leaving checkpoints on disk.
This is the FIRST half of S4 and runs in the parent process; the resume half must run in a
separate interpreter (``spikes.e_magentic_resume``), because a resume that quietly rode on
live in-process objects would prove nothing about the asynchronous file inbox U12 needs.
"""
records: list[ExplorationCallRecord] = []
workflow = _plan_review_workflow(records, storage_dir)
result = await workflow.run(TASK_ALPHA)
pending = _pending_requests(result)
checkpoints = await _checkpoint_storage(storage_dir).list_checkpoints(
workflow_name=workflow.name
)
return {
"request_id": pending[0].request_id if pending else None,
"checkpoint_ids": [c.checkpoint_id for c in checkpoints],
"outputs": [str(o) for o in (result.get_outputs() or [])],
}
# Measured, and a real cost of the asynchronous HITL door: ``FileCheckpointStorage`` refuses to
# unpickle a plan-review request unless its type is declared. Without this the checkpoint file is
# written but comes back UNREADABLE ("Checkpoint deserialization blocked for type ..."), and the
# listing is empty — a resume that fails as an absence rather than as an error, which is exactly
# the shape the fourth face of the verification law warns about. Both processes must declare it.
_ALLOWED_CHECKPOINT_TYPES = [
"agent_framework_orchestrations._magentic:MagenticPlanReviewRequest",
"agent_framework_orchestrations._magentic:MagenticPlanReviewResponse",
]
def _checkpoint_storage(storage_dir: str) -> FileCheckpointStorage:
return FileCheckpointStorage(storage_dir, allowed_checkpoint_types=_ALLOWED_CHECKPOINT_TYPES)
def _plan_review_workflow(records: list[ExplorationCallRecord], storage_dir: str) -> Any:
"""The workflow BOTH processes build — identical construction, so the only thing carried
across the process boundary is the checkpoint on disk."""
return (
MagenticBuilder(
participants=[_worker_agent(_worker_client([]))],
manager_agent=_manager_agent(_manager_client(records)),
max_round_count=6,
enable_plan_review=True,
)
.with_checkpointing(_checkpoint_storage(storage_dir))
.build()
)
async def resume_from_checkpoint(
storage_dir: str, *, request_id: str, checkpoint_id: str
) -> dict[str, Any]:
"""The SECOND half of S4, called by ``spikes.e_magentic_resume`` in a fresh interpreter.
**Measured, and it contradicts the plan's E-table:** ``checkpoint_storage=`` on ``run()`` is
NOT the load-bearing seam here removing it leaves the whole suite green (920 passed),
because ``.with_checkpointing(...)`` on the builder already gave this workflow its storage.
The two arguments that ARE load-bearing are ``checkpoint_id=`` (drop it red) and the
builder's ``.with_checkpointing(...)`` (drop it → red). It is passed anyway, explicitly,
because an exploration layer that builds its workflow WITHOUT checkpointing and resumes by
handing storage in at call time is a legitimate second shape but a criterion that names it
as the detach point would be a gate that cannot go red.
"""
records: list[ExplorationCallRecord] = []
workflow = _plan_review_workflow(records, storage_dir)
result = await workflow.run(
responses={request_id: MagenticPlanReviewResponse.approve()},
checkpoint_id=checkpoint_id,
checkpoint_storage=_checkpoint_storage(storage_dir),
)
return {
"manager_kinds": [r.kind for r in records],
"pending_after_resume": len(_pending_requests(result)),
"outputs": [str(o) for o in (result.get_outputs() or [])],
}
def run_resume_subprocess(
storage_dir: str, *, request_id: str, checkpoint_id: str
) -> dict[str, Any]:
"""Launch ``spikes.e_magentic_resume`` in a FRESH interpreter and parse its one JSON line.
``sys.executable`` is this venv's Python, and the repo root is the working directory, so the
child imports the same tree the parent did without any path juggling. A non-zero exit is
surfaced with the child's stderr attached: a resume that failed must read as a failed resume,
never as an empty result.
"""
completed = subprocess.run(
[sys.executable, "-m", "spikes.e_magentic_resume", storage_dir, request_id, checkpoint_id],
capture_output=True,
text=True,
cwd=str(Path(__file__).resolve().parents[1]),
)
if completed.returncode != 0:
raise RuntimeError(
f"resume subprocess exited {completed.returncode}: {completed.stderr.strip()}"
)
return dict(json.loads(completed.stdout.strip().splitlines()[-1]))
def micro_bundle_dir() -> str:
"""The repo's own anchored micro bundle — the one bundle that ships BOTH a cost baseline and
an IR projection, so a latency number measured here is measured through the WHOLE gate
(stage 0 reconciliation + CBC solve + 512-sample Monte Carlo), not a subset of it."""
return str(
Path(portfolio_optimiser.__file__).parent
/ "data"
/ "bundles"
/ "bygg-energi-baseline-mikro"
)
def micro_proposal() -> SavingsProposal:
"""The bundle's IR projection as a validated ``SavingsProposal``, carrying an assumption band.
The band is not decoration: without one, ``validator._monte_carlo`` falls back to each item's
own ``unit_cost`` and every draw is identical a cheaper computation than any real hypothesis
would trigger, so a latency measured without it would understate the in-loop cost.
"""
projection = dict(load_ir_projection(micro_bundle_dir()))
projection.pop("_note", None)
item = projection["affected_items"][0]
unit_cost = float(item["unit_cost"])
projection["assumptions"] = {item["code"]: (unit_cost * 0.9, unit_cost * 1.1)}
return SavingsProposal.model_validate(projection)
def validator_latency_seconds(*, runs: int) -> tuple[float, int]:
"""S5: median wall-clock of one ``validate_proposal`` against the micro reference bundle —
the cost the hypothesiser's ``quick_validate`` tool would pay per call, per hypothesis."""
baseline = load_optional_cost_baseline(micro_bundle_dir())
proposal = micro_proposal()
timings: list[float] = []
for _ in range(runs):
start = time.perf_counter()
validate_proposal(proposal, baseline=baseline)
timings.append(time.perf_counter() - start)
return statistics.median(timings), len(timings)

View file

@ -0,0 +1,39 @@
"""S4's second half: resume a pending Magentic plan review in a **fresh interpreter**.
A resume proved inside the parent process would prove nothing about U12. The whole point of the
asynchronous HITL time-scale is that the expert answers days later, in a process that never saw
the run so the only thing allowed to cross the boundary is the checkpoint on disk. Running the
resume as its own module, launched by ``subprocess``, is what makes that boundary real
(``tests/test_hosting_loadbearing.py`` set this precedent for the hosted shim).
Usage (the test supplies the arguments; nothing here is wired into ``src/``)::
python -m spikes.e_magentic_resume <storage_dir> <request_id> <checkpoint_id>
Writes ONE line of JSON to stdout: the resumed run's manager calls, remaining requests and
outputs. Anything else on stdout would make the parent's parse the fragile part of the spike.
"""
from __future__ import annotations
import asyncio
import json
import sys
from spikes.e_magentic import resume_from_checkpoint
def main(argv: list[str]) -> int:
if len(argv) != 4:
print(f"usage: {argv[0]} <storage_dir> <request_id> <checkpoint_id>", file=sys.stderr)
return 2
storage_dir, request_id, checkpoint_id = argv[1], argv[2], argv[3]
result = asyncio.run(
resume_from_checkpoint(storage_dir, request_id=request_id, checkpoint_id=checkpoint_id)
)
print(json.dumps(result, sort_keys=True))
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv))

View file

@ -26,7 +26,7 @@ from portfolio_optimiser.run import (
run_project, run_project,
) )
__version__ = "0.1.0" __version__ = "1.1.0"
__all__ = [ __all__ = [
# Portfolio orchestration # Portfolio orchestration

View file

@ -37,6 +37,34 @@ _MODEL_MAP_ENV = "PORTFOLIO_MODEL_MAP"
_PLACEHOLDER_PREFIX = "REPLACE-WITH-" _PLACEHOLDER_PREFIX = "REPLACE-WITH-"
# Loopback only — never a remote host (D6 / research 03 no-egress). Override via env. # Loopback only — never a remote host (D6 / research 03 no-egress). Override via env.
_DEFAULT_LOCAL_BASE_URL = "http://127.0.0.1:11434/v1" _DEFAULT_LOCAL_BASE_URL = "http://127.0.0.1:11434/v1"
# Fase 4b — the Foundry project endpoint may arrive under either name, OURS FIRST. Ours predates
# the hosting flow and is what every doc/recipe/test sets, so an operator who exports it is making
# a deliberate choice; the platform-injected name is the fallback that lets a hosted container run
# with no extra wiring. Precedence is over VALUES, not declarations — an exported-but-empty name
# falls through rather than shadowing a real one into a fail-fast.
_ENDPOINT_ENVS = ("PORTFOLIO_FOUNDRY_PROJECT_ENDPOINT", "FOUNDRY_PROJECT_ENDPOINT")
# Injected by the platform at startup inside a Foundry hosted agent, and set nowhere else — so its
# presence is the marker for "there is no Azure CLI here". Truthiness, not presence: an
# exported-but-empty value is a shell accident, not a hosting signal.
_HOSTING_MARKER_ENV = "FOUNDRY_HOSTING_ENVIRONMENT"
def _resolve_endpoint() -> str:
"""First non-empty of ``_ENDPOINT_ENVS``; fail-fast (``ValueError``) naming BOTH, since the
operator in a container and the operator on a laptop set different ones."""
for name in _ENDPOINT_ENVS:
value = os.environ.get(name)
if value:
return value
raise ValueError(
f"{_ENDPOINT_ENVS[0]} (or the platform-injected {_ENDPOINT_ENVS[1]}) "
"is required for the AZURE profile"
)
def _is_hosted() -> bool:
"""True inside a Foundry hosted agent (Fase 4b)."""
return bool(os.environ.get(_HOSTING_MARKER_ENV))
def _load_effective_map() -> dict[str, Any]: def _load_effective_map() -> dict[str, Any]:
@ -100,20 +128,26 @@ class AzureFoundryBackend:
profile = Profile.AZURE profile = Profile.AZURE
def create_chat_client(self, *, model: str) -> BaseChatClient: def create_chat_client(self, *, model: str) -> BaseChatClient:
endpoint = os.environ.get("PORTFOLIO_FOUNDRY_PROJECT_ENDPOINT") endpoint = _resolve_endpoint()
if not endpoint:
raise ValueError("PORTFOLIO_FOUNDRY_PROJECT_ENDPOINT is required for the AZURE profile")
# FoundryChatClient REQUIRES an explicit credential (verified against agent-framework-foundry # FoundryChatClient REQUIRES an explicit credential (verified against agent-framework-foundry
# 1.8.2 — it raises ``ValueError`` without one; there is NO lazy DefaultAzureCredential # 1.8.2 — it raises ``ValueError`` without one; there is NO lazy DefaultAzureCredential
# default). Lazy import so the LOCAL path never pulls azure.identity. AzureCliCredential is # default). Lazy import so the LOCAL path never pulls azure.identity.
# the documented, friction-minimal path on a non-Azure host — constructing it acquires NO #
# token (``az login`` is the operator's manual step), so this is not auto-login. Recipe: # Fase 4b — the credential is chosen by ENVIRONMENT, because the two environments have
# docs/2026-07-15-foundry-auth-recipe.md. # different identities available:
from azure.identity.aio import AzureCliCredential # * developer host: AzureCliCredential, the friction-minimal path — constructing it
# acquires NO token (``az login`` is the operator's manual step), so this is not
# auto-login. Recipe: docs/2026-07-15-foundry-auth-recipe.md.
# * Foundry hosted agent: there is no Azure CLI in the container. The platform mints a
# dedicated Entra agent identity for it at deploy time, so ManagedIdentityCredential is
# the identity that exists. Learn's MAF guidance names it explicitly over
# DefaultAzureCredential ("prefer a specific credential such as ManagedIdentityCredential
# to avoid unintended credential probing") — probing would otherwise walk a chain of
# credentials that cannot succeed here, turning a config error into a slow one.
from azure.identity.aio import AzureCliCredential, ManagedIdentityCredential
return FoundryChatClient( credential = ManagedIdentityCredential() if _is_hosted() else AzureCliCredential()
project_endpoint=endpoint, model=model, credential=AzureCliCredential() return FoundryChatClient(project_endpoint=endpoint, model=model, credential=credential)
)
class LocalBackend: class LocalBackend:

View file

@ -107,7 +107,11 @@ class Contracts(BaseModel):
data_source: DataSourceContract data_source: DataSourceContract
model_map: ModelMapContract model_map: ModelMapContract
termination: TerminationContract termination: TerminationContract
feedback: FeedbackContract #: ``None`` when the run carries no expert verdict at all (F2, non-goal 3). A run nobody
#: reviewed has no feedback to validate; a run that DOES claim one is validated exactly as
#: before, so a half-given verdict still fails fast here rather than being completed for the
#: expert further down.
feedback: FeedbackContract | None
def _bundled_model_map() -> dict[str, Any]: def _bundled_model_map() -> dict[str, Any]:
@ -119,17 +123,19 @@ def _bundled_model_map() -> dict[str, Any]:
def load_contracts( def load_contracts(
data_source: dict[str, Any], data_source: dict[str, Any],
termination: dict[str, Any], termination: dict[str, Any],
feedback: dict[str, Any], feedback: dict[str, Any] | None,
*, *,
model_map: dict[str, Any] | None = None, model_map: dict[str, Any] | None = None,
) -> Contracts: ) -> Contracts:
"""Validate ALL contracts at startup (fail-fast, before any chat-client is built). Raises """Validate ALL contracts at startup (fail-fast, before any chat-client is built). Raises
``pydantic.ValidationError`` on the first malformed contract. ``model_map`` defaults to the ``pydantic.ValidationError`` on the first malformed contract. ``model_map`` defaults to the
bundled ``data/model_map.json`` (the same file Step 8 ships).""" bundled ``data/model_map.json`` (the same file Step 8 ships). ``feedback`` is ``None`` for a
run nobody reviewed the ONE place the expert-verdict shape is validated, so a caller that
supplies half a verdict is refused here by field name (F2)."""
raw_map = _bundled_model_map() if model_map is None else model_map raw_map = _bundled_model_map() if model_map is None else model_map
return Contracts( return Contracts(
data_source=DataSourceContract(**data_source), data_source=DataSourceContract(**data_source),
model_map=ModelMapContract(**raw_map), model_map=ModelMapContract(**raw_map),
termination=TerminationContract(**termination), termination=TerminationContract(**termination),
feedback=FeedbackContract(**feedback), feedback=None if feedback is None else FeedbackContract(**feedback),
) )

File diff suppressed because it is too large Load diff

View file

@ -15,17 +15,19 @@ Two entry points, because the LLM call is async while ``validator.self_repair``
attempts. Used for deterministic candidate sources. attempts. Used for deterministic candidate sources.
* ``generate_via_llm`` the ASYNC LLM path: an async mirror of the same bounded retry that * ``generate_via_llm`` the ASYNC LLM path: an async mirror of the same bounded retry that
awaits the chat call (parse-retry inside the meter budget, then ``validate_proposal``). awaits the chat call (parse-retry inside the meter budget, then ``validate_proposal``).
Returns ``ValidatedProposal | Rejection``; never a malformed proposal; raises Returns a ``GenerationResult`` (the outcome PLUS the falsifications that informed it); never a
``BudgetExceeded`` when the meter cap is crossed. malformed proposal; raises ``BudgetExceeded`` when the meter cap is crossed.
""" """
from __future__ import annotations from __future__ import annotations
import json import json
from collections.abc import Callable from collections.abc import Callable, Mapping
from dataclasses import dataclass, field
from typing import Any
from agent_framework import BaseChatClient, Message from agent_framework import BaseChatClient, Message
from pydantic import ValidationError from pydantic import BaseModel, ValidationError
from portfolio_optimiser.budget import TokenMeter from portfolio_optimiser.budget import TokenMeter
from portfolio_optimiser.ir import CostBaseline, SavingsProposal from portfolio_optimiser.ir import CostBaseline, SavingsProposal
@ -43,6 +45,234 @@ class GenerationError(RuntimeError):
"""No parseable proposal could be produced within the attempt budget.""" """No parseable proposal could be produced within the attempt budget."""
class StructuredOutputUnsupported(TypeError):
"""A schema node cannot be expressed in the provider's strict structured-output subset.
Fail-closed, and deliberately so (mirrors ``write_concept_file`` / ``promote_verdict``:
validation, never repair). The alternative silently dropping what cannot be expressed would
stop commissioning a field without saying so, and the field it would have dropped first is
``assumptions``, whose absence makes the Monte Carlo falsifier inert while it still reports
percentiles. A schema this module cannot express is a decision for a human, not a default.
"""
#: Type-specific JSON Schema keywords the provider's structured-output subset does NOT support,
#: transcribed from Azure's published table (Structured outputs -> "Unsupported type-specific
#: keywords", https://learn.microsoft.com/azure/foundry/openai/how-to/structured-outputs), which
#: states it is the same subset OpenAI accepts.
#:
#: ``exclusiveMinimum``/``exclusiveMaximum`` are NOT literally in that table — it names
#: ``minimum maximum multipleOf`` — but they are the same family, and pydantic emits them for
#: ``Field(gt=...)``/``Field(lt=...)``, which is exactly how this repo's IR spells its bounds. Being
#: stricter than the table costs nothing here: every constraint stripped is re-applied by pydantic in
#: ``_parse_ir`` and by ``validate_proposal``. The schema's job is SHAPE; the validator's job is
#: VALUES. ``default`` is stripped for a different reason — strict mode requires every property to be
#: required, so a default can never apply.
UNSUPPORTED_SCHEMA_KEYWORDS = frozenset(
{
# String
"minLength",
"maxLength",
"pattern",
"format",
# Number
"minimum",
"maximum",
"multipleOf",
"exclusiveMinimum",
"exclusiveMaximum",
# Objects
"patternProperties",
"unevaluatedProperties",
"propertyNames",
"minProperties",
"maxProperties",
# Arrays
"unevaluatedItems",
"contains",
"minContains",
"maxContains",
"minItems",
"maxItems",
"uniqueItems",
# Meaningless once every property is required
"default",
}
)
#: The strict-legal stand-in for ``SavingsProposal.assumptions``.
#:
#: The IR spells the uncertainty bands as ``dict[str, tuple[float, float]]`` — a free-form map whose
#: values are tuples. Neither half is expressible: strict mode requires ``additionalProperties:
#: false`` in every object (so a map with arbitrary keys cannot be described), and tuples arrive as
#: ``prefixItems``, which is outside the supported type list. Dropping the field instead would be
#: silent damage: ``validator._monte_carlo`` falls back to the item's stated ``unit_cost`` for every
#: code with no band, so with no bands at all the samples are identical and P10 == P50 == P90 — the
#: stochastic falsifier goes inert while still reporting percentiles.
#:
#: So the WIRE carries an array of named entries and ``_parse_ir`` folds it back into the IR's map.
#: The IR itself is untouched; the entry names spell out what the tuple positions mean, which the
#: model would otherwise have to guess.
_ASSUMPTIONS_WIRE_NODE: dict[str, Any] = {
"type": "array",
"description": (
"Uncertainty band per affected cost line: the low and high unit cost the true price is "
"expected to fall between. The band MUST enclose that item's own unit_cost. Omit an entry "
"for a line whose unit cost is certain; an empty list means no uncertainty is claimed."
),
"items": {
"type": "object",
"properties": {
"code": {"type": "string"},
"low_unit_cost": {"type": "number"},
"high_unit_cost": {"type": "number"},
},
},
}
#: Dotted paths (from the root model's own properties) whose node is replaced before sanitising.
_PROPOSAL_SCHEMA_OVERRIDES: Mapping[str, dict[str, Any]] = {"assumptions": _ASSUMPTIONS_WIRE_NODE}
def _sanitise_schema_node(node: Any, *, path: str, overrides: Mapping[str, dict[str, Any]]) -> Any:
"""Rewrite one JSON Schema node into the strict subset, or raise ``StructuredOutputUnsupported``.
An override is applied FIRST, so a declared replacement is what gets checked and emitted that
is how the one inexpressible node in this repo's IR (``assumptions``) is expressed rather than
excused. The replacement is then sanitised by the same code as everything else, so an override
cannot smuggle in an illegal node.
"""
if not isinstance(node, Mapping):
return node
if path in overrides:
node = overrides[path]
if "prefixItems" in node:
raise StructuredOutputUnsupported(
f"{path or '<root>'}: tuple types (prefixItems) are outside the strict subset"
)
for combinator in ("oneOf", "allOf"):
if combinator in node:
raise StructuredOutputUnsupported(
f"{path or '<root>'}: {combinator} is outside the strict subset (anyOf is the "
"only supported combinator)"
)
if isinstance(node.get("additionalProperties"), Mapping):
raise StructuredOutputUnsupported(
f"{path or '<root>'}: a free-form map cannot be expressed — strict mode requires "
"additionalProperties: false in every object. Declare an override that spells the "
"entries out as an array."
)
out: dict[str, Any] = {}
for key, value in node.items():
if key in UNSUPPORTED_SCHEMA_KEYWORDS:
continue
if key == "properties" and isinstance(value, Mapping):
out[key] = {
name: _sanitise_schema_node(
sub, path=f"{path}.{name}" if path else name, overrides=overrides
)
for name, sub in value.items()
}
elif key == "$defs" and isinstance(value, Mapping):
out[key] = {
name: _sanitise_schema_node(sub, path=f"$defs.{name}", overrides=overrides)
for name, sub in value.items()
}
elif key == "items":
out[key] = _sanitise_schema_node(value, path=f"{path}[]", overrides=overrides)
elif key == "anyOf" and isinstance(value, list):
out[key] = [_sanitise_schema_node(sub, path=path, overrides=overrides) for sub in value]
else:
out[key] = value
if "properties" in out:
# Strict mode's two structural demands, applied to EVERY object rather than the root only:
# no undeclared keys, and every declared key required.
out["additionalProperties"] = False
out["required"] = sorted(out["properties"])
return out
def strict_json_schema(
model: type[BaseModel], *, overrides: Mapping[str, dict[str, Any]] | None = None
) -> dict[str, Any]:
"""Derive a strict-structured-output schema from ``model``'s own pydantic schema.
DERIVED rather than hand-written on purpose: a hand-written copy of a shape that already exists
in ``ir.py`` is the second copy that drifts (-(p)), and it drifts silently the model would
keep being commissioned for the old shape. ``$defs``/``$ref`` are kept (the published subset
supports definitions), so nested models need no inlining.
"""
schema = _sanitise_schema_node(model.model_json_schema(), path="", overrides=overrides or {})
assert isinstance(schema, dict) # a model's root schema is always an object
return schema
def proposal_response_format() -> dict[str, Any]:
"""The ``response_format`` mapping commissioning a ``SavingsProposal`` from the proposer.
A MAPPING, not the ``type[BaseModel]`` the option also accepts, and the reason is measured: given
a class, the client converts it with ``type_to_response_format_param``, which emits ``minimum`` /
``exclusiveMinimum`` / ``minItems`` / ``prefixItems`` and an ``assumptions`` node whose
``additionalProperties`` is a schema four things the published subset rules out. Our own
mapping is the only way to control what reaches the wire.
ONE mapping serves both wired profiles (measured against agent-framework-openai 1.8.2 /
agent-framework-foundry 1.8.2): the Chat Completions client passes it through verbatim, and the
Responses client which ``FoundryChatClient`` delegates to converts this exact envelope into
``text.format``.
"""
return {
"type": "json_schema",
"json_schema": {
"name": SavingsProposal.__name__,
"strict": True,
"schema": strict_json_schema(SavingsProposal, overrides=_PROPOSAL_SCHEMA_OVERRIDES),
},
}
@dataclass(frozen=True)
class ParseFailure:
"""One model reply that did NOT parse into the typed IR, kept VERBATIM (Fase 1b, funn 1).
``text`` is the reply exactly as the model produced it never truncated, stripped or
summarised. It is the thing the run PAID for and the only evidence of *why* the reply did not
parse; a paraphrase would make the next paid run a guess again, which is the defect this type
exists to close. ``error`` names the parse error itself (``json.JSONDecodeError`` vs a pydantic
``ValidationError`` are very different diagnoses: leaked prose vs a wrong-shaped object).
Collected into a CALLER-OWNED sink rather than returned see ``generate_via_llm``.
"""
text: str
error: str
@dataclass(frozen=True)
class GenerationResult:
"""What one ``generate_via_llm`` call produced: the outcome, and the falsification history that
informed it (Step 5, målbilde §5/§7).
A TYPED RETURN VALUE rather than an out-parameter or a callback, deliberately: the informed
refinement loop already computed this history internally and then dropped it, so Step 5 was the
one step of the eight with no observable output. A returned value cannot be silently lost by a
caller that forgets to pass a collector, and it forces every call site to acknowledge the seam.
``refinements`` holds ONLY the rejections that were actually fed back into a later attempt's
prompt the honest reading of "informed refinement". When the attempt budget runs out, the
final rejection IS ``outcome``: it informed nothing and is not repeated here. So the total
number of validator falsifications this call produced is ``len(refinements)`` plus one when
``outcome`` is itself a ``Rejection``. It is empty on the common single-attempt path, which is
honest rather than merely convenient: nothing was falsified, so there is nothing to show.
"""
outcome: ValidatedProposal | Rejection
refinements: tuple[Rejection, ...] = field(default=())
def _build_messages( def _build_messages(
project: Project, project: Project,
context: str, context: str,
@ -96,6 +326,37 @@ def _build_messages(
return [Message(role="user", contents=[prompt])] return [Message(role="user", contents=[prompt])]
def _normalise_assumptions(data: dict[str, Any]) -> None:
"""Fold the WIRE's array-of-entries assumption bands back into the IR's ``code -> (low, high)``
map, in place.
ADDITIVE, never a replacement: a reply that already uses the IR's map form (every scripted reply
in the suite, and any model that answers without honouring the schema) is left untouched. A
malformed entry is raised as ``ValueError`` rather than ``KeyError`` on purpose ``ValueError``
is what ``_fetch_parsed`` catches, so a bad band is captured as the parse failure it is instead
of escaping the loop and killing the run.
"""
entries = data.get("assumptions")
if not isinstance(entries, list):
return
bands: dict[str, tuple[Any, Any]] = {}
for entry in entries:
if (
not isinstance(entry, Mapping)
or not {
"code",
"low_unit_cost",
"high_unit_cost",
}
<= entry.keys()
):
raise ValueError(
f"each assumption entry needs code, low_unit_cost and high_unit_cost; got {entry!r}"
)
bands[entry["code"]] = (entry["low_unit_cost"], entry["high_unit_cost"])
data["assumptions"] = bands
def _parse_ir(text: str, project: Project) -> SavingsProposal: def _parse_ir(text: str, project: Project) -> SavingsProposal:
"""Parse the model's structured reply into the typed IR. Raises on malformed/text-leaked """Parse the model's structured reply into the typed IR. Raises on malformed/text-leaked
output (JSON error or Pydantic ``ValidationError``).""" output (JSON error or Pydantic ``ValidationError``)."""
@ -103,6 +364,7 @@ def _parse_ir(text: str, project: Project) -> SavingsProposal:
if not isinstance(data, dict): if not isinstance(data, dict):
raise ValueError("reply is not a JSON object") raise ValueError("reply is not a JSON object")
data.setdefault("project_id", project.id) data.setdefault("project_id", project.id)
_normalise_assumptions(data)
return SavingsProposal(**data) return SavingsProposal(**data)
@ -140,7 +402,8 @@ async def generate_via_llm(
max_attempts: int = 3, max_attempts: int = 3,
baseline: CostBaseline | None = None, baseline: CostBaseline | None = None,
approach: Approach | None = None, approach: Approach | None = None,
) -> ValidatedProposal | Rejection: parse_failures: list[ParseFailure] | None = None,
) -> GenerationResult:
"""Async LLM path: non-streaming chat -> parse -> validate, with TWO bounded retry kinds, """Async LLM path: non-streaming chat -> parse -> validate, with TWO bounded retry kinds,
the meter checked in this loop: the meter checked in this loop:
@ -165,32 +428,71 @@ async def generate_via_llm(
``baseline`` (S4.0) is handed straight to ``validate_proposal``, so a fabricated cost line is ``baseline`` (S4.0) is handed straight to ``validate_proposal``, so a fabricated cost line is
falsified per ATTEMPT like any other rejection and its reason feeds the next attempt's prompt falsified per ATTEMPT like any other rejection and its reason feeds the next attempt's prompt
through the SAME informed-refinement path (Step 5), which is why no new loop appears here. through the SAME informed-refinement path (Step 5), which is why no new loop appears here.
Returns
``ValidatedProposal | Rejection``; never a malformed proposal; raises ``BudgetExceeded`` ``parse_failures`` (Fase 1b, funn 1) is a CALLER-OWNED sink: every reply that fails to parse is
appended to it VERBATIM, at the moment it fails. It is an out-parameter and not part of the
return value ON PURPOSE, and the reason is measured rather than stylistic. ``meter.tick_round``
raises ``BudgetExceeded`` inside the inner fetch loop, so on the path this capture exists for
a model whose replies never parse, which burns the round ledger this function raises and
returns NOTHING. That is exactly the live Fase-1b failure. A field on ``GenerationResult`` (the
Step-5 ``refinements`` shape) would be blind to it, as would any artefact written by the caller
*after* a successful return. The sink mirrors ``meter`` instead: a caller-owned accumulator this
loop mutates, whose contents the caller still holds however the loop ended. Step 5's "a returned
value cannot be silently lost by a caller that forgets to pass a collector" governs a value that
REACHES the caller; here it does not, so the rule is cited and departed from deliberately. That
a caller can forget is answered by a test on the wiring, not by a shape that cannot work.
Returns a ``GenerationResult``: the ``ValidatedProposal | Rejection`` outcome plus every
rejection that was fed back into a later attempt's prompt. Surfacing that history changes
nothing about the loop's BOUND — ``max_attempts`` and ``meter.tick_round`` are exactly as
before ("refine until good enough" without a cap stays forbidden, §6); it only stops the loop
from discarding what it already knew. Never a malformed proposal; raises ``BudgetExceeded``
when the meter cap is crossed.""" when the meter cap is crossed."""
async def _fetch_parsed(messages: list[Message]) -> SavingsProposal: async def _fetch_parsed(messages: list[Message]) -> SavingsProposal:
# Parse-robust: a malformed/text-leaked reply is retried; the meter caps total work. # Parse-robust: a malformed/text-leaked reply is retried; the meter caps total work.
while True: while True:
meter.tick_round() # between-attempt bound (BudgetExceeded over cap) meter.tick_round() # between-attempt bound (BudgetExceeded over cap)
reply = await chat_client.get_response(messages) # non-streaming # Fase 1b, funn 1b: hand the model a GRAMMAR, not a prose request. The prompt's
# "Respond with ONLY a JSON object" line stays — a provider that ignores
# ``response_format`` (or a local model that does not implement it) must still be told
# what is wanted, and the parse-retry below remains the backstop either way.
reply = await chat_client.get_response( # non-streaming
messages, options={"response_format": proposal_response_format()}
)
_charge_usage(meter, reply) _charge_usage(meter, reply)
try: try:
return _parse_ir(reply.text, project) return _parse_ir(reply.text, project)
except (ValidationError, ValueError, TypeError): except (ValidationError, ValueError, TypeError) as exc:
# Capture BEFORE the retry: this reply was paid for, and once ``continue`` runs the
# only record of what the model actually said is gone (Fase 1b, funn 1). Verbatim —
# the operator is diagnosing a format failure, so any shortening removes evidence.
if parse_failures is not None:
parse_failures.append(
ParseFailure(text=reply.text, error=f"{type(exc).__name__}: {exc}")
)
continue continue
last: Rejection | None = None last: Rejection | None = None
# The falsifications that were FED BACK, in attempt order. ``last`` still drives the PROMPT and
# is still overwritten each round -- only the most-recent falsification reaches the model, so
# prompt growth is unchanged. This list is a record for the CALLER, appended to only once a
# rejection is about to inform a further attempt; it is never read back into a prompt.
fed_back: list[Rejection] = []
for _ in range(max_attempts): for _ in range(max_attempts):
# Informed refinement: feed the PREVIOUS attempt's validator rejection into this # Informed refinement: feed the PREVIOUS attempt's validator rejection into this
# attempt's prompt. ``last`` is None on attempt 1 -> the unchanged base prompt; it is # attempt's prompt. ``last`` is None on attempt 1 -> the unchanged base prompt; it is
# overwritten each round -> only the most-recent falsification ("forrige"), never an # overwritten each round -> only the most-recent falsification ("forrige"), never an
# accumulated history (bounded prompt growth). # accumulated history (bounded prompt growth).
if last is not None:
fed_back.append(last)
messages = _build_messages(project, context, prior_rejection=last, approach=approach) messages = _build_messages(project, context, prior_rejection=last, approach=approach)
candidate = await _fetch_parsed(messages) candidate = await _fetch_parsed(messages)
result = validate_proposal(candidate, baseline=baseline) result = validate_proposal(candidate, baseline=baseline)
if isinstance(result, ValidatedProposal): if isinstance(result, ValidatedProposal):
return result return GenerationResult(outcome=result, refinements=tuple(fed_back))
last = result last = result
assert last is not None # max_attempts >= 1, so at least one validation ran assert last is not None # max_attempts >= 1, so at least one validation ran
return last # validation never passed within the attempt budget -> typed Rejection # Validation never passed within the attempt budget -> typed Rejection. ``last`` is the outcome
# and was never fed back, so it is deliberately absent from ``refinements``.
return GenerationResult(outcome=last, refinements=tuple(fed_back))

View file

@ -165,6 +165,138 @@ def pending(outbox_dir: str, verdict_dir: str) -> list[PendingProposal]:
return sorted(unjudged, key=lambda p: (p.run_id, p.approach_id, p.verdict_id)) return sorted(unjudged, key=lambda p: (p.run_id, p.approach_id, p.verdict_id))
# --- U12: the pending PLAN REVIEWS of parked explorations, and the expert's answer ---------------
# The same registry shape as ``pending`` above, one time-scale earlier: there the outbox holds a
# PROPOSAL awaiting a verdict, here it holds a QUESTION awaiting a decision. Both live in this
# MAF-free module because both are read by an operator tool that must not drag the framework in.
#: The closed answer vocabulary, identical to the terminal door's (``explore.terminal_plan_reviewer``).
#: Two words, matched structurally — a file cannot be re-asked, so anything else is a refusal.
_PLAN_REVIEW_ANSWERS = frozenset({"approve", "revise"})
@dataclass(frozen=True)
class PendingPlanReview:
"""One parked exploration still waiting on a human. ``plan`` is carried because a registry that
only counted questions could not be used to answer one."""
run_id: str
request_id: str
index: int
plan: str
is_stalled: bool
@dataclass(frozen=True)
class PlanReviewAnswer:
"""The expert's decision, as read off a file. ``feedback`` is empty exactly when approving —
the same encoding ``explore.PlanReviewDecision`` uses, kept plain so this module stays
MAF-free and the adapter between them lives at ONE call site."""
run_id: str
request_id: str
decision: str
feedback: str
class PlanReviewAnswerError(ValueError):
"""An answer file that cannot be read as a decision.
Fail-closed, and deliberately NOT the tolerant rule the verdict inbox uses. A dropped verdict
that will not parse is one opinion missing from a fold; an unreadable plan-review answer is the
one thing standing between a suspended run and a plan nobody signed. A ``ValueError`` so the
CLI's existing structured-refusal arm surfaces it as ``rc 1`` rather than a traceback."""
def _answer_path(review_dir: str, run_id: str) -> Path:
return Path(review_dir) / f"{run_id}-plan-review-answer.json"
def read_plan_review_question(outbox_dir: str, run_id: str) -> dict[str, Any] | None:
"""The open question of ``run_id``, or ``None`` when there is none. Tolerant: an outbox with no
such file simply has no parked review."""
return _load_json_dict(Path(outbox_dir) / f"{run_id}-plan-review.json")
def load_plan_review_answer(review_dir: str, run_id: str, *, request_id: str) -> PlanReviewAnswer:
"""Read the expert's answer to ONE named review, fail-closed at every step.
``request_id`` is a required argument rather than something read off the file and trusted: two
reviews of one run share a file name, so an answer left over from the previous round would
otherwise be applied to a plan the expert never saw. A mismatch is REFUSED by name it is a
stale answer, not an absent one, and the two need different words.
A missing file raises rather than returning ``None``: "not answered yet" is the normal state of
this door, and the caller asking to resume has already said it believes otherwise."""
path = _answer_path(review_dir, run_id)
data = _load_json_dict(path)
if data is None:
raise PlanReviewAnswerError(
f"no answer for plan review {request_id} of run {run_id!r} in {review_dir!r} "
f"(expected {path.name}): the review is still waiting on a human"
)
found = str(data.get("request_id", ""))
if found != request_id:
raise PlanReviewAnswerError(
f"the answer in {path.name} answers plan review {found!r}, but the open review of run "
f"{run_id!r} is {request_id!r}. Refused: an answer to another question is not an "
f"answer to this one"
)
decision = str(data.get("decision", ""))
if decision not in _PLAN_REVIEW_ANSWERS:
raise PlanReviewAnswerError(
f"{path.name} answers {decision!r}, which is outside the vocabulary "
f"{sorted(_PLAN_REVIEW_ANSWERS)}. Refused, never read as a sign-off"
)
feedback = str(data.get("feedback", ""))
if decision == "revise" and not feedback.strip():
raise PlanReviewAnswerError(
f"{path.name} answers 'revise' with nothing to revise: the manager would be asked to "
f"replan against an empty instruction. Say what to change, or answer 'approve'"
)
return PlanReviewAnswer(
run_id=run_id, request_id=request_id, decision=decision, feedback=feedback
)
def pending_plan_reviews(outbox_dir: str, review_dir: str) -> list[PendingPlanReview]:
"""Every parked plan review whose OWN answer has not landed, sorted by ``run_id``.
Mirrors ``pending``: an outbox artefact joined against an inbox, with the join on the key each
side names. The key here is ``request_id`` an answer to a different review leaves this one
pending rather than quietly clearing it, which is the same fail-closed rule the resume path
applies and for the same reason.
Tolerant on the READ side (an unreadable file in either folder is not a question and not an
answer), fail-closed on the DECIDE side (``load_plan_review_answer``). The registry says who is
waiting; it never decides what they said."""
waiting: list[PendingPlanReview] = []
directory = Path(outbox_dir)
if not directory.is_dir():
return waiting
for file in sorted(directory.glob("*-plan-review.json")):
data = _load_json_dict(file)
if data is None:
continue
run_id = str(data.get("run_id", ""))
request_id = str(data.get("request_id", ""))
if not run_id or not request_id:
continue
answer = _load_json_dict(_answer_path(review_dir, run_id))
if answer is not None and str(answer.get("request_id", "")) == request_id:
continue
waiting.append(
PendingPlanReview(
run_id=run_id,
request_id=request_id,
index=int(data.get("index", 0)),
plan=str(data.get("plan", "")),
is_stalled=bool(data.get("is_stalled", False)),
)
)
return sorted(waiting, key=lambda p: (p.run_id, p.index))
# --- Routing config: self-contained dimension→expert table (fail-fast) ---------------------------- # --- Routing config: self-contained dimension→expert table (fail-fast) ----------------------------
# A minimal MVP stand-in for the S3.5 dimension catalog (kept DISTINCT — see the plan's Non-Goals). # A minimal MVP stand-in for the S3.5 dimension catalog (kept DISTINCT — see the plan's Non-Goals).
# Field names mirror ``dimension.Dimension`` so the two reconcile cleanly when S3.5 lands. No ``label`` # Field names mirror ``dimension.Dimension`` so the two reconcile cleanly when S3.5 lands. No ``label``

View file

@ -0,0 +1,358 @@
"""Hosted entrypoint (Fase 4d): the Foundry hosted-agent runtime contract implemented
DIRECTLY around ``run_project`` a wrapper, never ``Workflow.as_agent()``.
The form was decided by TWO measurements (13.08), not preference:
* ``agent-framework-foundry-hosting``'s ``InvocationsHostServer`` exists only in builds
requiring ``agent-framework-core>=1.13.0`` (this tree locks 1.9.0); the sole
1.9-compatible build (``1.0.0a260618``) ships broken metadata it imports ``mcp``
without declaring it and is superseded. With no usable protocol library, the runtime
contract (port 8088/``PORT``, ``GET /readiness``, ``POST /invocations``, SIGTERM
shutdown spike §1.1) is served HERE, including ``/readiness``, which a protocol
library would otherwise have provided.
* A BUILT workflow is single-use on core 1.9.0 (measured: client-call series [2, 0, 0]
across three ``.run()`` calls on ONE object the round cap persists in the object, so
reuse yields EMPTY runs, not just contaminated ones). A long-lived hosted process must
therefore never hold a workflow; every invocation goes through ``run_project``, which
builds a fresh one per call (the B7 factory).
``as_agent()`` alone would also serve UNGATED proposals: the deterministic validator,
baseline anchoring, checker gate, ledger and learning loop all live OUTSIDE the Workflow
graph (spike §5) wrapping the graph wraps the wrong boundary.
**The server is asyncio on the ONE loop no threads, by NG1.** ``http.server``'s
threading variant would put concurrent ``run_project`` calls on OS threads, where none of
S3.3's determinism reasoning holds and MAF's thread-safety is undocumented exactly what
``test_no_thread_or_process_path_exists_under_src`` ratchets against. ``asyncio.start_server``
plus ~40 lines of HTTP/1.1 parsing keeps liveness (``/readiness`` answers while an
invocation awaits model I/O) and stays inside the sanctioned concurrency model: concurrent
invocations interleave as coroutines, the same way ``run_portfolio``'s waves do. Honest
limit: during a CPU-bound stretch (the CBC solve) the loop and thus readiness stalls
for that stretch; chunked request bodies are not supported (``Content-Length`` only).
Surface: ``POST /invocations`` takes a JSON object whitelisted onto ``run_project``'s
signature. ``profile`` defaults to ``"azure"`` on THIS surface only (``run_project``'s own
default stays LOCAL): a hosted container has no local OpenAI-compatible endpoint, and the
AZURE profile reads its environment at call time (Fase 4b). Validation, never repair: an
unknown field is a 400 naming the field the permissive-schema trap (valg-doc §0) applied
to our own surface. Error mapping is honest: ``ValueError`` (pydantic contract violations
subclass it) 400; any other failure 500 ``{error_type, error}`` (mirrors
``RunFailure``); a ``Rejection`` is a SUCCESSFUL run 200 with ``outcome_type:
"rejected"`` the negative outcome belongs to the payload, never to the transport.
``BudgetExceeded`` gets its OWN arm 429, for the same reason ``BudgetStop`` is kept out of
``stop_reason`` (S3.4): a cap that fires is the feature working (``Budget`` exists so a run can
never hang unbounded), and answering it on the crash channel makes "it did not work"
unreadable the first live run died exactly here and the surface said 500, the same thing it
says when the endpoint falls over. It is NOT 200 either: unlike a ``Rejection``, which is a run
that CONCLUDED, an exhausted budget produced no proposal, and a 2xx would let an automated
caller record "analysed" for a run that analysed nothing. 429 because the condition arises from
an ALLOWANCE ``max_rounds``/``max_tokens`` are whitelisted request fields and raising them is
the caller's own remedy — never from a server fault. The ``kind``/``limit``/``observed`` triple
is carried as STRUCTURE, not flattened into ``str(exc)`` (-(y): it describes one ledger and
answering "which cap bound, and by how much" is the operational question), and ``error_type``
is deliberately absent that key belongs to the failure channel. Honesty limit, stated: no
``Retry-After``. Retrying an unchanged body hits the same cap; the remedy is a larger allowance
or accepting the stop, and a header promising time would be a lie. The
platform's injected headers (``x-agent-user-id``/``x-agent-foundry-call-id``) are absent
locally by contract and unused here; forwarding the call-id on outgoing Foundry calls has
no seam in ``backends.py`` today and is deliberately not built (90 %-prinsippet).
"""
from __future__ import annotations
import asyncio
import json
import os
import signal
import sys
from collections.abc import Mapping
from typing import Any
from portfolio_optimiser.budget import BudgetExceeded
from portfolio_optimiser.explore import ExplorationContract, explore
from portfolio_optimiser.outbox import outcome_payload
from portfolio_optimiser.run import RunResult, run_project
from portfolio_optimiser.tracing import configure_tracing, tracing_notice
DEFAULT_PORT = 8088
_HOSTED_DEFAULT_PROFILE = "azure"
_REQUIRED_FIELDS = ("project_id", "docs_dir")
#: ``verdict_input`` is OPTIONAL since F2 (non-goal 3). It used to be required, which forced an
#: external caller to invent an expert verdict just to get a run at all — a field that could not be
#: filled honestly, on the surface handed over 2026-08-14. The move is a pure WIDENING: a caller
#: that still sends it is unaffected, and one that omits it now gets a run whose verdict is
#: honestly absent.
_OPTIONAL_FIELDS = (
"bundle_dir",
"profile",
"max_rounds",
"max_tokens",
"top_k",
"verdict_input",
)
#: Fields this surface CONSUMES rather than forwards (U4). They are not ``run_project``
#: parameters — the exploration runs first and hands ``run_project`` a ``Mandate`` — so passing one
#: through would be a ``TypeError`` answered as a 500. The whitelist is therefore a THREE-way
#: partition, and the Fase 4e proof gained a negative half to match: every forwarded field must be
#: a real ``run_project`` parameter, and every consumed field must not be.
_CONSUMED_FIELDS = ("explore_prompt", "explore_contract")
_ALLOWED_FIELDS = frozenset(_REQUIRED_FIELDS + _OPTIONAL_FIELDS + _CONSUMED_FIELDS)
_REASONS = {
200: "OK",
400: "Bad Request",
404: "Not Found",
429: "Too Many Requests",
500: "Internal Server Error",
}
class InvocationRefused(ValueError):
"""A request the invocations contract refuses — unknown, missing or non-object input.
Validation, never repair (``write_concept_file`` precedent): nothing is dropped,
defaulted or corrected on the caller's behalf."""
def resolve_port() -> int:
"""PORT on truthiness, not presence (the 4b rule): an exported-empty PORT is a shell
accident, not a bind instruction. 8088 is the hosted-agent contract's port."""
return int(os.environ.get("PORT") or DEFAULT_PORT)
def _run_kwargs(payload: Any) -> tuple[str, dict[str, Any], dict[str, Any]]:
"""Whitelist the JSON payload onto ``run_project``'s signature. Everything not named in
the whitelist including server-side seams like ``outbox_dir``, ``client_factory`` or
``verdict_dir`` is refused by name, never silently dropped.
Returns ``(project_id, forwarded_kwargs, consumed)``. The consumed half is split out HERE
rather than filtered at the call site so there is one place that decides which fields reach
``run_project``: a consumed field left in ``kwargs`` is an argument the signature does not have,
which the container answers as a 500 for what is really a wiring mistake."""
if not isinstance(payload, dict):
raise InvocationRefused("body must be a JSON object")
unknown = sorted(set(payload) - _ALLOWED_FIELDS)
if unknown:
raise InvocationRefused(f"unknown field(s): {', '.join(unknown)}")
missing = [field for field in _REQUIRED_FIELDS if field not in payload]
if missing:
raise InvocationRefused(f"missing required field(s): {', '.join(missing)}")
consumed = {k: payload[k] for k in _CONSUMED_FIELDS if k in payload}
kwargs: dict[str, Any] = {
k: payload[k] for k in payload if k != "project_id" and k not in _CONSUMED_FIELDS
}
kwargs.setdefault("profile", _HOSTED_DEFAULT_PROFILE)
return payload["project_id"], kwargs, consumed
async def _shaped_mandate(consumed: Mapping[str, Any], kwargs: Mapping[str, Any]) -> Any:
"""Run the U4 exploration this invocation asked for and return the mandate it shaped.
Every refusal here is the CALLER's error and therefore a ``ValueError`` (the 400 arm), by name.
That placement is deliberate rather than incidental: ``explore()`` refuses two of these itself,
but ``ExplorationError`` is a ``RuntimeError``, so leaving them to the loop would answer a
caller's configuration mistake on the crash channel — the same conflation ``BudgetExceeded``
was given its own 429 to end.
``enable_plan_review`` is refused outright. The U13 door is SYNCHRONOUS: it blocks the loop on
a human or persona, and an HTTP request has neither the invocation would hang rather than
answer. The library API is where that door opens."""
prompt = consumed.get("explore_prompt")
raw_contract = consumed.get("explore_contract")
if prompt is None:
raise InvocationRefused(
"explore_contract without explore_prompt: the bounds describe an exploration that "
"would never run"
)
if raw_contract is None:
raise InvocationRefused(
"explore_prompt without explore_contract: an exploration's bounds are never defaulted "
"(an omitted cap falls back to an unbounded loop)"
)
if not kwargs.get("bundle_dir"):
raise InvocationRefused(
"explore_prompt without bundle_dir: the exploration navigates knowledge bases, and "
"with none configured it would spend its budget reading nothing"
)
if not isinstance(raw_contract, dict):
raise InvocationRefused("explore_contract must be a JSON object")
contract = ExplorationContract(**raw_contract) # ValidationError subclasses ValueError -> 400
if contract.enable_plan_review:
raise InvocationRefused(
"explore_contract sets enable_plan_review, but this surface has no reviewer to answer "
"it: the synchronous plan review would block the request on nobody, and would block "
"the event loop that answers /readiness while doing it. The operator door is the CLI's "
"--plan-review (or explore(..., plan_reviewer=...) in-process)"
)
result = await explore(
str(prompt),
contract=contract,
bundle_dirs=(kwargs["bundle_dir"],),
profile=kwargs["profile"],
)
return result.mandate
def _response_payload(result: RunResult) -> dict[str, Any]:
return {
**outcome_payload(
result.outcome,
checker_verdict=result.checker_verdict,
# The candidate's KEY, not evidence that anybody decided (F2): identical to the
# captured verdict's id whenever one was given, and still the id under which a later
# expert verdict on this candidate will arrive when none was.
verdict_id=result.verdict_key,
),
"proposal": result.outcome.proposal.model_dump(),
"provenance": result.provenance.model_dump(),
"refinements": [rejection.reason for rejection in result.refinements],
}
async def invoke(payload: Any) -> dict[str, Any]:
"""One invocation: validate → ``run_project`` → outbox-shaped response payload.
``run_project`` is resolved through this module's namespace at call time (the test
seam). ``live_dry_run`` is not on the whitelist, so the union narrows to RunResult.
With ``explore_prompt`` the exploration runs FIRST and its mandate is what the pipeline then
evaluates level 2 and 3 of the guarantee table are unchanged, and the exploration itself
still writes nothing."""
project_id, kwargs, consumed = _run_kwargs(payload)
if consumed:
kwargs["mandate"] = await _shaped_mandate(consumed, kwargs)
result = await run_project(project_id, **kwargs)
assert isinstance(result, RunResult)
return _response_payload(result)
def _budget_payload(exc: BudgetExceeded) -> dict[str, Any]:
"""The exhausted-budget body: the ledger's own triple, plus the human line for the log.
The ``budget_exhausted`` key's PRESENCE is the discriminator — it is not folded into
``outcome_type`` (whose values, ``validated``/``rejected``, mean "the run concluded and
here is the verdict") for the same reason ``BudgetStop`` was given its own field instead of
widening ``stop_reason``. Nor could it be: ``outcome_payload`` is the ONE copy of that fork
and takes a ``ValidatedProposal | Rejection``, neither of which an exhausted run has."""
return {
"budget_exhausted": {"kind": exc.kind, "limit": exc.limit, "observed": exc.observed},
"error": str(exc),
}
def _http_response(status: int, content_type: str, body: bytes) -> bytes:
head = (
f"HTTP/1.1 {status} {_REASONS[status]}\r\n"
f"Content-Type: {content_type}\r\n"
f"Content-Length: {len(body)}\r\n"
"Connection: close\r\n\r\n"
)
return head.encode("latin-1") + body
def _json_response(status: int, payload: dict[str, Any]) -> bytes:
body = (json.dumps(payload, sort_keys=True) + "\n").encode("utf-8")
return _http_response(status, "application/json", body)
async def _read_request(
reader: asyncio.StreamReader,
) -> tuple[str, str, bytes] | None:
"""Parse one HTTP/1.1 request: (method, path, body), or None when unparseable. The
platform terminates TLS and speaks plain HTTP/1.1 (spike §1.1); ``Content-Length`` is
required for a body chunked transfer is not supported (an honest MVP limit)."""
request_line = await reader.readline()
parts = request_line.decode("latin-1", errors="replace").split()
if len(parts) != 3:
return None
method, path = parts[0], parts[1]
content_length = 0
while True:
line = await reader.readline()
if line in (b"\r\n", b"\n", b""):
break
name, _, value = line.decode("latin-1", errors="replace").partition(":")
if name.strip().lower() == "content-length":
try:
content_length = int(value.strip())
except ValueError:
return None
body = b""
if content_length > 0:
try:
body = await reader.readexactly(content_length)
except asyncio.IncompleteReadError:
return None
return method, path, body
async def _respond(method: str, path: str, body: bytes) -> bytes:
if method == "GET" and path == "/readiness":
return _http_response(200, "text/plain", b"ok\n")
if method == "POST" and path == "/invocations":
try:
payload = json.loads(body.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError):
return _json_response(400, {"error": "body is not valid JSON"})
try:
return _json_response(200, await invoke(payload))
except BudgetExceeded as exc:
# A cap that fired, not a failure — its own channel, and the triple kept as
# structure rather than re-parsed out of the message by whoever reads this.
return _json_response(429, _budget_payload(exc))
except ValueError as exc:
# The caller's error: InvocationRefused + run_project's fail-fast contract
# violations (pydantic ValidationError subclasses ValueError).
return _json_response(400, {"error": str(exc)})
except Exception as exc:
# The run's failure, answered rather than dropped — RunFailure's honest shape
# (error_type + text), so BudgetExceeded reads as what it is, not as a 400.
return _json_response(500, {"error_type": type(exc).__name__, "error": str(exc)})
return _json_response(404, {"error": f"no such path: {path}"})
async def _handle(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
try:
request = await _read_request(reader)
if request is None:
writer.write(_json_response(400, {"error": "malformed HTTP request"}))
else:
writer.write(await _respond(*request))
await writer.drain()
except (ConnectionError, asyncio.CancelledError):
pass # client went away / server shutting down — nothing to answer
finally:
writer.close()
try:
await writer.wait_closed()
except ConnectionError:
pass
async def start_server(host: str, port: int) -> asyncio.AbstractServer:
"""The contract server on the CURRENT loop, bindable to port 0 for tests."""
return await asyncio.start_server(_handle, host, port)
async def _serve_until_sigterm() -> None:
stop = asyncio.Event()
asyncio.get_running_loop().add_signal_handler(signal.SIGTERM, stop.set)
server = await start_server("0.0.0.0", resolve_port())
async with server:
await stop.wait()
# Leaving the context closes the listening socket; in-flight handlers already hold
# their connections and finish on the loop before asyncio.run() tears it down.
def main() -> None:
"""Serve the hosted-agent contract until SIGTERM (bind 0.0.0.0 — the platform
terminates TLS in front of us), then exit 0.
U14: the tracing seam is installed before the loop starts and announced on stderr, which in a
container IS the log. This is the entry where "an organisation must be able to see what the run
did" is actually cashed — the demo is a scripted proof, not the product. A malformed
``PORTFOLIO_OTEL`` propagates: a server whose telemetry cannot be configured as asked must not
start and then look healthy on ``/readiness``."""
setup = configure_tracing()
notice = tracing_notice(setup)
if notice is not None:
print(notice, file=sys.stderr)
asyncio.run(_serve_until_sigterm())

View file

@ -17,13 +17,17 @@ it re-exports the library's typed surface and keeps the historical ``materialize
repo-local name rather than to the library's evolving one. repo-local name rather than to the library's evolving one.
**Gating is the CALL SITE's responsibility (library README, "What is gated today: nothing").** **Gating is the CALL SITE's responsibility (library README, "What is gated today: nothing").**
Door A calls no guard function before writing to disk ``materialize_bundle`` writes what it The library writes what it is given, so this module owns both gates Door A has:
is given. The repo's own local-only posture still holds at this seam via ``allow_network``
(default ``False``): an ``http`` source is refused fail-fast at the library's network gate - NETWORK (§8, no silent egress) ``allow_network`` (default ``False``): an ``http`` source is
unless a run explicitly opts in the manifest can never grant itself network (§8, no silent refused fail-fast unless a run explicitly opts in, so the manifest can never grant itself
egress). Untrusted-content scanning remains the separate, still-planned ``llm-ingestion-guard`` network access.
wiring (see ``docs/plan/2026-07-16-llm-ingestion-guard-inclusion.md``); adopting this library - CONTENT (P2/S1.b) :func:`materialize_gated` scans every concept an ingest run generates,
does NOT provide it. with ``llm-ingestion-guard`` (git-pinned to ``v0.3.4``), BEFORE any of it reaches the bundle.
:func:`materialize` is the UNGATED form and stays that way: the four golden suites pin its bytes,
and a caller who wants the gate asks for it by name. Nothing on the run path calls either Door
A is not on the 8-step loop's path (``run.py`` / ``simulation.py`` do not import this module).
MAF-free (D7-portable), like the rest of the context seam: the library has zero runtime MAF-free (D7-portable), like the rest of the context seam: the library has zero runtime
dependencies and imports no ``agent_framework`` / ``mcp``. Guarded by dependencies and imports no ``agent_framework`` / ``mcp``. Guarded by
@ -33,8 +37,11 @@ dependencies and imports no ``agent_framework`` / ``mcp``. Guarded by
from __future__ import annotations from __future__ import annotations
import hashlib import hashlib
import shutil
import socket import socket
import tempfile
from pathlib import Path from pathlib import Path
from typing import Any
from llm_ingestion_okf import ( from llm_ingestion_okf import (
Extraction, Extraction,
@ -62,6 +69,12 @@ from llm_ingestion_okf.connectors import (
) )
from llm_ingestion_okf.manifest import generated_filename from llm_ingestion_okf.manifest import generated_filename
from llm_ingestion_okf.render import render_fenced_block, render_table from llm_ingestion_okf.render import render_fenced_block, render_table
from llm_ingestion_guard.okf import (
Channel,
Origin,
format_log_entry,
import_bundle,
)
# Two historical PRIVATE names the repo's existing tests bind to, re-exported so those bindings # Two historical PRIVATE names the repo's existing tests bind to, re-exported so those bindings
# survive the adoption unchanged. `_urllib_get` backs an identity assertion that the default http # survive the adoption unchanged. `_urllib_get` backs an identity assertion that the default http
@ -85,6 +98,8 @@ HTTP_TIMEOUT_SECONDS = 30.0
__all__ = [ __all__ = [
"HTTP_TIMEOUT_SECONDS", "HTTP_TIMEOUT_SECONDS",
"Channel",
"ContentGateRefused",
"Extraction", "Extraction",
"FileSource", "FileSource",
"HttpGet", "HttpGet",
@ -96,6 +111,7 @@ __all__ = [
"ManifestV1", "ManifestV1",
"MaterializationError", "MaterializationError",
"NetworkGateError", "NetworkGateError",
"Origin",
"RenderError", "RenderError",
"SourceError", "SourceError",
"SqlSource", "SqlSource",
@ -103,6 +119,7 @@ __all__ = [
"load_manifest", "load_manifest",
"materialize", "materialize",
"materialize_bundle", "materialize_bundle",
"materialize_gated",
"read_csv", "read_csv",
"read_http", "read_http",
"read_sql", "read_sql",
@ -200,3 +217,143 @@ def materialize(
http_get=transport, http_get=transport,
).written ).written
) )
class ContentGateRefused(IngestError):
"""The Door A content gate refused an ingest run; NOTHING was written (P2/S1.b).
Code: ``content_gate_refused``. ``rejected`` names every generated concept file that failed
the gate, in sorted order the outcome is per BUNDLE, but the diagnostics are per DOCUMENT,
so one run surfaces every poisoned source rather than one per re-run.
"""
def __init__(self, message: str, *, rejected: tuple[str, ...]) -> None:
super().__init__(message, code="content_gate_refused")
self.rejected = rejected
#: The lowest disposition the guard emits — a CLEAN concept scores `warn`, not `allow`
#: (measured against v0.3.4; `Disposition` = warn < quarantine_review < fail_secure). The gate
#: therefore accepts `warn` and refuses everything above it. Written against an `allow` tier
#: that does not exist, the gate would refuse every document ever ingested.
_ACCEPTED_DISPOSITION = "warn"
def _stamp_line(concept: Any, ingested_at: str) -> str | None:
"""The concept's ``log.md`` line, or ``None`` when the guard produced no stamp.
A hard-rejected concept (bad path, unsafe frontmatter, non-https ``resource``) carries no
stamp it never got far enough to be judged on content so there is nothing to log; such a
run is refused anyway. Isolated into its own function so the coercion to ``str`` is a typed
boundary the type checker can see, rather than an ``Any`` flowing through a comprehension.
"""
stamp = concept.stamp
if stamp is None:
return None
return str(format_log_entry(stamp, timestamp=ingested_at))
def materialize_gated(
manifest_path: str | Path,
bundle_dir: str | Path,
*,
ingested_at: str,
allow_network: bool = False,
http_get: HttpGet | None = None,
origin: Origin = Origin.EXTERNAL,
channel: Channel = Channel.AUTOMATIC,
) -> list[Path]:
""":func:`materialize`, with every generated concept scanned BEFORE it reaches the bundle.
The Door A content gate. Same signature and same return as :func:`materialize`, plus the
guard's provenance pair. On refusal nothing is written and ``ContentGateRefused`` carries
the offending filenames.
**Why the gate is not inside** :func:`materialize` **(measured, and the reason the plan's
premise was wrong):** ``materialize`` delegates wholly to the pinned library's
``materialize_bundle``, which stages in memory and performs its own disk phase. No callback
exists between the two, so a gate placed there could only run after the bytes had landed
a cleanup, not a gate. The seam is instead: stage into a COPY of the live bundle, scan what
was generated, then publish the copy or discard it.
**The copy is load-bearing, not a convenience.** The library's §3 ownership scan, its
collision gate against curated files, and its §6 index merge all read the EXISTING bundle
contents. Materializing into an empty temp directory would lose all three, and publishing
that on top of the live bundle would drop curated files and their index links a data-loss
bug wearing a security fix's clothing.
**Trust follows origin, never channel** (the guard's own rule): Door A pulls external
sources named in a manifest, automatically, so the defaults are ``EXTERNAL`` / ``AUTOMATIC``
``UNTRUSTED``. This is deliberately NOT one of the guard's two ``Policy`` presets:
``PRESET_USER_UPLOAD`` additionally carries ``quarantine_default=True``, an upload
semantics Door A does not have, and ``PRESET_TRUSTED_SOURCE`` would grant a trust tier a
manifest-named external source has not earned.
**Only what this run generated is scanned.** Curated files already in the bundle are
human-authored and are not re-judged here; gating the bundle READ path is a separate
decision with a separate rationale, and it has not been taken.
Validation, ALWAYS repair, NEVER: a refused document is not sanitised into the bundle,
it stays out of it (the ``write_concept_file`` / ``promote_verdict`` precedent).
"""
target = Path(bundle_dir)
with tempfile.TemporaryDirectory(prefix="po-ingest-gate-") as tmp:
# `resolve()` because the library returns resolved paths and macOS hands out `/var/...`
# temp dirs that are symlinks to `/private/var/...` — `relative_to` below compares the
# two literally, so an unresolved base raises ValueError on every macOS run.
staging = Path(tmp).resolve() / "bundle"
if target.exists():
shutil.copytree(target, staging)
written = materialize(
manifest_path,
staging,
ingested_at=ingested_at,
allow_network=allow_network,
http_get=http_get,
)
# Scan ONLY this run's output, keyed by the bundle-relative path the guard expects.
generated = {
path.relative_to(staging).as_posix(): path.read_text(encoding="utf-8")
for path in written
}
# ADAPTER at the untyped boundary (§4.4). The guard ships no `py.typed`, so everything
# below arrives as `Any`; the mypy override alone would make this seam type-BLIND, not
# type-safe. Each value read off a guard result object is therefore coerced to a
# concrete type HERE, so `Any` stops at this line instead of propagating into the
# module — and an upstream field rename fails loudly rather than type-checking happily.
verdicts: list[tuple[str, str, str | None]] = [
(str(concept.path), str(concept.disposition.value), _stamp_line(concept, ingested_at))
for concept in import_bundle(generated, origin=origin, channel=channel).concepts
]
rejected = tuple(
sorted(
path for path, disposition, _ in verdicts if disposition != _ACCEPTED_DISPOSITION
)
)
if rejected:
raise ContentGateRefused(
"Door A content gate refused "
f"{len(rejected)} of {len(verdicts)} generated concept(s): "
f"{', '.join(rejected)} — nothing was written to {target}",
rejected=rejected,
)
# Decision 3: the findings are recorded, never discarded — in `log.md` (OKF §7), the
# structural update log, NEVER in the concept frontmatter. The concept bytes are the
# pinned library's, and four golden suites pin them; a gate field injected there would
# break all four. `ingested_at` is stamped verbatim, mirroring the rest of Door A —
# `format_log_entry` keeps wall-clock out of the stamp itself.
log_lines = [line for _, _, line in verdicts if line is not None]
log_path = staging / "log.md"
existing = log_path.read_text(encoding="utf-8") if log_path.is_file() else ""
log_path.write_text(existing + "".join(f"{line}\n" for line in log_lines), "utf-8")
# Publish: the staged bundle IS the live bundle now. Replacing wholesale keeps the
# library's index merge intact — re-deriving it here would be a second copy of §6.
if target.exists():
shutil.rmtree(target)
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copytree(staging, target)
return [target / path.relative_to(staging) for path in written]

View file

@ -29,6 +29,7 @@ Two refusals are load-bearing, both at construction time:
from __future__ import annotations from __future__ import annotations
from collections.abc import Sequence
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import Literal from typing import Literal
@ -50,6 +51,14 @@ class Approach(BaseModel):
id: str = Field(min_length=1) id: str = Field(min_length=1)
label: str = Field(min_length=1) label: str = Field(min_length=1)
description: str = "" description: str = ""
#: Which knowledge base this approach belongs to (§ C.7), by the base's id — the directory's
#: BASENAME, exactly as ``explore._bundle_index`` names it. DEFAULTS to empty, meaning "no base
#: named": a legitimate statement when the run has only one base to name, and what keeps every
#: mandate written before multi-base existed valid and dispatchable unchanged.
#:
#: The field is a ROUTING key, never a claim about content. It says which pipeline the approach
#: must be evaluated in, and ``route_by_bundle`` is the one place that reads it.
bundle_id: str = ""
class Mandate(BaseModel): class Mandate(BaseModel):
@ -88,6 +97,85 @@ class Mandate(BaseModel):
return self return self
class MandateRoutingError(ValueError):
"""A commission that cannot be executed against the bases it was given (§ C.7).
**A ``ValueError`` by construction, and that is a measurement rather than a taxonomy note.**
økt 57 paid for the opposite: ``ExplorationError`` is a ``RuntimeError`` and therefore fell
outside ``run.main``'s ``(ValueError, FileNotFoundError, ValidationError)`` refusal tuple and
outside ``hosting``'s 400 arm, so a caller's configuration mistake would have left as a
traceback on one surface and a 500 the crash channel on the other. A routing refusal is
exactly that class of caller mistake, so it is born inside both nets instead of being
retrofitted into them later.
"""
def route_by_bundle(mandate: Mandate, bundle_ids: Sequence[str]) -> tuple[tuple[str, Mandate], ...]:
"""Partition one commission into one sub-mandate PER knowledge base (§ C.7).
This is the whole of "multi-base", and it is deliberately a partition rather than a widening.
``run_project`` derives four single-valued things from THE bundle it is given the project
(``_project_from_bundle`` fail-fasts when the bundle's own ``project_id`` is not the requested
one), the validator's stage-0 cost baseline, the agents' read context and the ExpeL query key
and returns ONE ``RunResult`` with ONE ``ProvenanceStamp``. A second ``bundle_dir`` on that
signature would force a silent pick-one for all four. § C.7 says the same thing in its own
words: *pipelinen kjøres per bundle som i dag* N calls, not one call taking N.
Order is taken from ``bundle_ids``, never from first appearance among the approaches, so the
dispatch's spend order is a property of how the run was configured rather than of how a model
happened to sequence its hypotheses.
**Fail-fast on a commission that cannot be executed as written**, mirroring ``load_mandate``'s
contract for exactly its reason: a run must never proceed on a *silently degraded* commission,
because the coverage report would then describe work nobody ordered. Two ways that happens, and
both refuse by name rather than resolving by position (the S3.2 key-collision class):
* an approach naming a base that was not configured;
* an approach naming NO base while more than one is configured with a single base there is no
other value the field could take, so resolving it there is the only answer rather than a
guess, and it is what keeps every pre-multi-base mandate dispatchable unchanged.
A base that no approach names is NOT run: a run costs money and the commission ordered nothing
for it. The single-base case keeps its own rule above, so an own-proposals-only mandate still
reaches the one base it could possibly mean.
:raises MandateRoutingError: no bases configured, or an approach that cannot be routed.
"""
if not bundle_ids:
raise MandateRoutingError(
"a mandate cannot be routed against zero knowledge bases: an empty plan reads as "
"'there was nothing to do', which is indistinguishable from a commission that was "
"fully evaluated against nothing"
)
known = tuple(bundle_ids)
sole = known[0] if len(known) == 1 else None
grouped: dict[str, list[Approach]] = {bundle_id: [] for bundle_id in known}
for approach in mandate.approaches:
target = approach.bundle_id or sole
if target is None:
raise MandateRoutingError(
f"approach {approach.id!r} names no knowledge base and {len(known)} are "
f"configured ({', '.join(known)}); which one it belongs to is not something this "
"layer may decide on the expert's behalf"
)
if target not in grouped:
raise MandateRoutingError(
f"approach {approach.id!r} names knowledge base {target!r}, which is not "
f"configured for this run; configured: {', '.join(known)}"
)
grouped[target].append(approach)
return tuple(
(
bundle_id,
mandate.model_copy(update={"approaches": tuple(grouped[bundle_id])}),
)
for bundle_id in known
if grouped[bundle_id] or sole is not None
)
@dataclass(frozen=True) @dataclass(frozen=True)
class ApproachOutcome: class ApproachOutcome:
"""What became of ONE commissioned approach — one row of the run's coverage report. """What became of ONE commissioned approach — one row of the run's coverage report.

View file

@ -19,6 +19,12 @@ fields. A target that fails to resolve for ANY reason (missing file, invalid pat
escape) is silently skipped, never raised. Path-safety reuses ``retrieval.safe_resolve`` (also pure escape) is silently skipped, never raised. Path-safety reuses ``retrieval.safe_resolve`` (also pure
stdlib): each cross-link is canonicalised and boundary-checked against the bundle dir, fail-closed stdlib): each cross-link is canonicalised and boundary-checked against the bundle dir, fail-closed
the SOLE in-/out-of-bundle test. the SOLE in-/out-of-bundle test.
Skipped is not SILENT, though: every link the walk could not follow is recorded on
``Bundle.skipped`` as a ``SkippedLink`` (which file it was written in, the link text verbatim, and
which of the two reasons applied). The tolerance is unchanged nothing raises but a bundle whose
other half was never reached is no longer indistinguishable from one where those documents were
never written.
""" """
from __future__ import annotations from __future__ import annotations
@ -28,7 +34,7 @@ import posixpath
import re import re
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any, Literal
from portfolio_optimiser.ir import CostBaseline from portfolio_optimiser.ir import CostBaseline
from portfolio_optimiser.retrieval import PathSecurityError, safe_resolve from portfolio_optimiser.retrieval import PathSecurityError, safe_resolve
@ -94,12 +100,55 @@ class BundleFile:
body: str body: str
#: Why navigation did not follow a cross-link. TWO values, because the two mean different things
#: to whoever has to fix the bundle: ``outside-bundle`` is a target that resolves OUTSIDE the bundle
#: root (frequently a deliberate link to a neighbouring base), ``missing`` is a target that resolves
#: INSIDE it with no readable file there (almost always a typo in the link). Collapsing them into
#: one "skipped" would answer neither question. De-duplication is NOT among them: a repeated link
#: and a cycle are correct navigation, never a skip.
SkipReason = Literal["outside-bundle", "missing"]
@dataclass(frozen=True)
class SkippedLink:
"""One cross-link the walk did NOT follow, and why.
STRUCTURED rather than a rendered string, for the reason ``BudgetExceeded`` carries
``kind``/``limit``/``observed`` as fields (-(y)): "which document is missing" and "why is it
missing" are two separate operative questions, and a caller that has to re-parse prose to tell
them apart has been handed a diagnostic it cannot act on.
``target`` is the link text VERBATIM as written in the source file, never the resolved path: the
operator fixing the bundle edits that text, and a normalised form would send them looking for a
string their file does not contain."""
#: Bundle-relative name of the file the link was written in.
from_file: str
#: The link target exactly as it appears in that file.
target: str
reason: SkipReason
@dataclass(frozen=True) @dataclass(frozen=True)
class Bundle: class Bundle:
"""A navigated OKF bundle: ``index.md`` plus every cross-linked file that resolves.""" """A navigated OKF bundle: ``index.md`` plus every cross-linked file that resolves — and, in
``skipped``, every cross-link that did not.
``skipped`` DEFAULTS to the empty tuple, and the default is the honest reading rather than a
convenience: an empty trace is a positive statement ("every cross-link was followed"), in the
same class as ``ProvenanceStamp.external_calls`` ("nothing outside this process was contacted").
That is the opposite of ``ProvenanceStamp.cost_baseline_anchored``, which is REQUIRED precisely
because both of its defaults would lie. The difference is what each absent value would assert:
a missing bool has to claim something about an event, while a missing trace asserts only that
the event list is empty which is exactly what a construction with no skips means."""
dir: str dir: str
files: tuple[BundleFile, ...] files: tuple[BundleFile, ...]
#: Every link navigation could not follow, in walk order. Read by ``run`` to render the one line
#: a run prints about its own reachability; NEVER read by ``bundle_context``, whose rendering is
#: built from ``index_summary`` + ``context_files`` alone — which is what keeps the commons-owned
#: nav-golden fasit byte-identical.
skipped: tuple[SkippedLink, ...] = ()
@property @property
def index_summary(self) -> str: def index_summary(self) -> str:
@ -165,29 +214,54 @@ def _resolve_target(bundle_dir: str, from_name: str, target: str) -> tuple[str,
return None return None
def _walk(bundle_dir: str, current: BundleFile, files: list[BundleFile], seen: set[str]) -> None: def _walk(
bundle_dir: str,
current: BundleFile,
files: list[BundleFile],
seen: set[str],
skipped: list[SkippedLink],
) -> None:
"""Follow ``current``'s cross-links depth-first in first-seen order, appending each newly """Follow ``current``'s cross-links depth-first in first-seen order, appending each newly
reached file and recursing into it. De-duplication is on the CANONICAL RESOLVED path (so reached file and recursing into it. De-duplication is on the CANONICAL RESOLVED path (so
``./a.md``, ``a.md`` and ``/a.md`` are one entry), which is also what terminates cycles.""" ``./a.md``, ``a.md`` and ``/a.md`` are one entry), which is also what terminates cycles.
A link that cannot be followed is still SKIPPED, never raised (OKF §4) the tolerance is the
spec but it is now RECORDED in ``skipped``, with the reason distinguishing the two cases.
The dedup branch records NOTHING: a repeated link and a cycle are correct navigation, and an
implementation that logged every ``continue`` would report a healthy bundle as half-unread.
A caller-owned accumulator rather than a return value, for the reason ``generate``'s
parse-failure sink is one: the recursion is depth-first over an unbounded tree, so every frame
appends into the SAME list and the walk's shape stays unchanged."""
for target in _LINK_RE.findall(current.body): for target in _LINK_RE.findall(current.body):
resolved = _resolve_target(bundle_dir, current.name, target) resolved = _resolve_target(bundle_dir, current.name, target)
if resolved is None: if resolved is None:
# The target left the bundle. Often deliberate (a link to a neighbouring base), so it is
# reported rather than refused — the tolerance is unchanged.
skipped.append(
SkippedLink(from_file=current.name, target=target, reason="outside-bundle")
)
continue continue
rel, canonical = resolved rel, canonical = resolved
if canonical in seen: if canonical in seen:
continue continue # de-duplication / cycle termination: correct navigation, NOT a skip
seen.add(canonical) seen.add(canonical)
linked = _load_file(bundle_dir, rel) linked = _load_file(bundle_dir, rel)
if linked is None: if linked is None:
continue # broken link: tolerated, never raised (OKF §4) # In-bundle, but nothing readable is there: broken link, tolerated, never raised (§4).
# Recorded once per resolved target — the ``seen`` entry above absorbs repeats.
skipped.append(SkippedLink(from_file=current.name, target=target, reason="missing"))
continue
files.append(linked) files.append(linked)
_walk(bundle_dir, linked, files, seen) _walk(bundle_dir, linked, files, seen, skipped)
def navigate_bundle(bundle_dir: str) -> Bundle: def navigate_bundle(bundle_dir: str) -> Bundle:
"""Navigate the OKF bundle from ``index.md``: parse the root index, then follow intra-bundle """Navigate the OKF bundle from ``index.md``: parse the root index, then follow intra-bundle
``.md`` cross-links RECURSIVELY, depth-first in first-seen link order, reading each reached ``.md`` cross-links RECURSIVELY, depth-first in first-seen link order, reading each reached
file's frontmatter + body. Fully deterministic. Broken / escaping links are skipped (§4). file's frontmatter + body. Fully deterministic. Broken / escaping links are skipped (§4) — and
RECORDED on the returned ``Bundle.skipped``, so "this document was never written" and "the link
to it was wrong" stop looking identical from the outside.
Navigation follows LINKS ONLY a directory is never enumerated. Hence the missing-``index.md`` Navigation follows LINKS ONLY a directory is never enumerated. Hence the missing-``index.md``
error binds the bundle ROOT alone (a bundle has no entry point without it); an intermediate error binds the bundle ROOT alone (a bundle has no entry point without it); an intermediate
@ -197,10 +271,11 @@ def navigate_bundle(bundle_dir: str) -> Bundle:
if index is None: if index is None:
raise ValueError(f"OKF bundle has no readable {_INDEX_NAME}: {bundle_dir!r}") raise ValueError(f"OKF bundle has no readable {_INDEX_NAME}: {bundle_dir!r}")
files: list[BundleFile] = [index] files: list[BundleFile] = [index]
skipped: list[SkippedLink] = []
root = _resolve_target(bundle_dir, _INDEX_NAME, _INDEX_NAME) root = _resolve_target(bundle_dir, _INDEX_NAME, _INDEX_NAME)
seen = {root[1]} if root is not None else set() seen = {root[1]} if root is not None else set()
_walk(bundle_dir, index, files, seen) _walk(bundle_dir, index, files, seen, skipped)
return Bundle(dir=bundle_dir, files=tuple(files)) return Bundle(dir=bundle_dir, files=tuple(files), skipped=tuple(skipped))
def bundle_context(bundle: Bundle, *, dimension: str | None = None) -> str: def bundle_context(bundle: Bundle, *, dimension: str | None = None) -> str:
@ -251,17 +326,30 @@ class IngestStampError(ValueError):
forged it could be silently deleted by a later ingest run.""" forged it could be silently deleted by a later ingest run."""
def _carries_complete_ingest_stamp(frontmatter: dict[str, str]) -> bool: _YAML_TRUE_LITERALS = frozenset({"true", "yes", "on"})
"""Whether ``frontmatter`` carries BOTH halves of the ingest ownership stamp: ``generated: """Every scalar a real YAML reader parses to boolean ``True`` (measured with PyYAML's ``safe_load``
true`` together with a non-empty ``ingest_manifest`` reference (ingest-spec §7). core-schema resolver: ``true``/``yes``/``on``, any case, are bool; the same resolver reads bare
``y``/``n`` and ``1``/``0`` as string/int, never bool so those are deliberately EXCLUDED here.
Widening past what a YAML reader actually resolves would over-block curated content no ingest
pipeline ever produces, on a form nothing downstream would honour as the stamp either."""
The test is on the COMPLETE stamp, never on the individual field names curated content may
legitimately carry a single provenance field, and a verbatim round-trip of one half must keep def _carries_complete_ingest_stamp(frontmatter: dict[str, str]) -> bool:
working. Values are compared the way ``parse_frontmatter`` yields them (line-oriented strings, """Whether ``frontmatter`` carries BOTH halves of the ingest ownership stamp: a ``generated``
quotes retained), so surrounding quotes and case are normalised away here.""" value a YAML reader would read as boolean ``True`` (``_YAML_TRUE_LITERALS``) together with a
non-empty ``ingest_manifest`` reference (ingest-spec §7).
FAIL-CLOSED on the value literal: the field previously matched only the exact string ``"true"``,
so a pinned ingest writer emitting any other YAML-1.1 truthy form (``yes``, ``on``) would have
slipped the stamp past this gate undetected inert only by the accident of the pinned writer's
current output, per the CLAUDE.md ingest-stamp invariant. The test is on the COMPLETE stamp,
never on the individual field names curated content may legitimately carry a single provenance
field, and a verbatim round-trip of one half must keep working. Values are compared the way
``parse_frontmatter`` yields them (line-oriented strings, quotes retained), so surrounding quotes
and case are normalised away here."""
generated = str(frontmatter.get("generated", "")).strip().strip('"').lower() generated = str(frontmatter.get("generated", "")).strip().strip('"').lower()
manifest = str(frontmatter.get("ingest_manifest", "")).strip().strip('"') manifest = str(frontmatter.get("ingest_manifest", "")).strip().strip('"')
return generated == "true" and bool(manifest) return generated in _YAML_TRUE_LITERALS and bool(manifest)
def write_concept_file(bundle_dir: str, name: str, frontmatter: dict[str, str], body: str) -> Path: def write_concept_file(bundle_dir: str, name: str, frontmatter: dict[str, str], body: str) -> Path:

View file

@ -31,6 +31,7 @@ called by ``run_project``, not a public authoring API (contrast ``verdicts.write
from __future__ import annotations from __future__ import annotations
import json import json
from collections.abc import Mapping, Sequence
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
@ -88,9 +89,33 @@ def write_outbox(
encoding="utf-8", encoding="utf-8",
) )
outcome_path = directory / f"{stem}-outcome.json"
outcome_path.write_text(
_dump(
{
**keys,
**outcome_payload(outcome, checker_verdict=checker_verdict, verdict_id=verdict_id),
}
),
encoding="utf-8",
)
return proposal_path, outcome_path
def outcome_payload(
outcome: ValidatedProposal | Rejection,
*,
checker_verdict: str | None,
verdict_id: str,
) -> dict[str, Any]:
"""The outcome artefact's payload minus the file keys — the ONE copy of the
validated/rejected branching, shared by ``write_outbox`` and the hosted invocations
response (``hosting._response_payload``). Two copies of the branch would drift, and a
drifted copy would let the HTTP surface describe an outcome the outbox never wrote
the ``to_ore`` single-source rule (-(p)) applied to a payload shape."""
if isinstance(outcome, ValidatedProposal): if isinstance(outcome, ValidatedProposal):
outcome_payload: dict[str, Any] = { return {
**keys,
"outcome_type": "validated", "outcome_type": "validated",
"p10": outcome.p10, "p10": outcome.p10,
"p50": outcome.p50, "p50": outcome.p50,
@ -99,18 +124,98 @@ def write_outbox(
"checker_verdict": checker_verdict, "checker_verdict": checker_verdict,
"verdict_id": verdict_id, "verdict_id": verdict_id,
} }
else: return {
outcome_payload = { "outcome_type": "rejected",
**keys, "reason": outcome.reason,
"outcome_type": "rejected", "checker_verdict": checker_verdict,
"reason": outcome.reason, "verdict_id": verdict_id,
"checker_verdict": checker_verdict, }
"verdict_id": verdict_id,
}
outcome_path = directory / f"{stem}-outcome.json"
outcome_path.write_text(_dump(outcome_payload), encoding="utf-8")
return proposal_path, outcome_path
def write_parse_failures(
outbox_dir: str,
run_id: str,
*,
failures: Sequence[Mapping[str, str]],
) -> Path:
"""Write ``{run_id}-parse-failures.json`` — the raw model replies that did NOT parse into the
typed IR (Fase 1b, funn 1) and return its path.
**This is the only outbox artefact written from a ``finally``**, because it is the only one whose
subject is a run that may never finish: the measured 1b failure exhausted the round ledger inside
the generation loop and left ``run_project`` as a ``BudgetExceeded``, so the proposal/outcome
writers below were never reached. An artefact that recorded parse failures only for runs that
survived them would be silent for exactly the runs that need it.
Takes plain mappings (the caller flattens ``generate.ParseFailure``), so this module stays
MAF-free ``generate`` imports ``agent_framework``, and importing it here would drag MAF into
the RAW output layer.
Byte-determinism is NOT claimed for this file, unlike its two neighbours: its content is a live
model's prose, which is not reproducible by construction. It uses the same ``_dump`` form for
consistency of reading, not to pin bytes. The caller writes it only when there is at least one
failure, so the file's PRESENCE is itself the signal that something did not parse."""
directory = Path(outbox_dir)
directory.mkdir(parents=True, exist_ok=True)
path = directory / f"{run_id}-parse-failures.json"
path.write_text(
_dump({"run_id": run_id, "parse_failures": [dict(f) for f in failures]}),
encoding="utf-8",
)
return path
def write_exploration(
outbox_dir: str,
run_id: str,
*,
payload: Mapping[str, Any],
) -> Path:
"""Write ``{run_id}-exploration.json`` — what the U4 exploration did before the pipeline ran
(§ C.2) and return its path.
Takes an already-rendered plain mapping (``explore.trace_payload``) for the reason
``write_parse_failures`` takes plain mappings: ``explore`` imports ``agent_framework``, and
importing it here would drag MAF into the RAW output layer. The ONE renderer lives beside the
dataclasses it renders; this writer only decides bytes and a filename.
Byte-deterministic like its neighbours (the caller supplies ``run_id``; no wall-clock), and
written even when the exploration RAISED the caller writes it from a ``finally``, because a
capped exploration is precisely the one whose per-round ledger a reader needs."""
directory = Path(outbox_dir)
directory.mkdir(parents=True, exist_ok=True)
path = directory / f"{run_id}-exploration.json"
path.write_text(_dump({"run_id": run_id, **dict(payload)}), encoding="utf-8")
return path
def write_plan_review(
outbox_dir: str,
run_id: str,
*,
payload: Mapping[str, Any],
) -> Path:
"""Write ``{run_id}-plan-review.json`` — the open question of a PARKED exploration (U12) — and
return its path.
This is the outbox half of the asynchronous HITL door: the run writes the question, the expert
writes the answer into a separate review INBOX, days later. The two folders are never the same
one, for the reason the verdict inbox is never the outbox a run that read its own output as
input would be answering itself.
Takes an already-rendered plain mapping (``explore.parked_payload``) for the reason
``write_exploration`` does: ``explore`` imports ``agent_framework`` and this layer stays
MAF-free. Byte-deterministic like its neighbours.
**Last write wins**, exactly one open question per run: a revision produces a NEW review of a
REPLANNED plan, and leaving the superseded one on disk would let an expert answer a question
the loop has already moved past. Staleness is caught anyway the answer names the
``request_id`` it answers but the file should not invite it."""
directory = Path(outbox_dir)
directory.mkdir(parents=True, exist_ok=True)
path = directory / f"{run_id}-plan-review.json"
path.write_text(_dump({"run_id": run_id, **dict(payload)}), encoding="utf-8")
return path
def write_run_config( def write_run_config(

View file

@ -2,9 +2,11 @@
call before the operator pays for one. call before the operator pays for one.
Runs ``python -m portfolio_optimiser.preflight --profile azure``. It checks, purely offline Runs ``python -m portfolio_optimiser.preflight --profile azure``. It checks, purely offline
(config/string/env only NO client construction, NO network, NO auto-login): (1) the endpoint env (config/string/env only NO client construction, NO network, NO auto-login): (1) an endpoint is set
``PORTFOLIO_FOUNDRY_PROJECT_ENDPOINT`` is set and shaped like a Foundry project endpoint under EITHER name the run path accepts (``PORTFOLIO_FOUNDRY_PROJECT_ENDPOINT`` first, then the
(``https://`` + host ``*.services.ai.azure.com``); (2) the effective model-map (honoring platform-injected ``FOUNDRY_PROJECT_ENDPOINT`` same tuple, imported from ``backends``) and is
shaped like a Foundry project endpoint (``https://`` + host ``*.services.ai.azure.com``); (2) the
effective model-map (honoring
``PORTFOLIO_MODEL_MAP``) is structurally valid (``ModelMapContract``); (3) no azure deployment is ``PORTFOLIO_MODEL_MAP``) is structurally valid (``ModelMapContract``); (3) no azure deployment is
still a ``REPLACE-WITH-*`` placeholder (via ``resolve_model`` the SAME seam the run path uses, so still a ``REPLACE-WITH-*`` placeholder (via ``resolve_model`` the SAME seam the run path uses, so
preflight and run never validate different maps). preflight and run never validate different maps).
@ -26,10 +28,14 @@ from dataclasses import dataclass
from pydantic import ValidationError from pydantic import ValidationError
from portfolio_optimiser.backends import Profile, _load_effective_map, resolve_model from portfolio_optimiser.backends import _ENDPOINT_ENVS, Profile, _load_effective_map, resolve_model
from portfolio_optimiser.contracts import ModelMapContract from portfolio_optimiser.contracts import ModelMapContract
_ENDPOINT_ENV = "PORTFOLIO_FOUNDRY_PROJECT_ENDPOINT" # Fase 5 — the SAME tuple the run path resolves against, imported rather than restated. A second
# copy here is how the gate and the run path came to know different variable names in the first
# place: preflight refused a hosted container's platform-injected endpoint that backends.py would
# have accepted (measured from the extracted handover package, 14.08).
_ENDPOINT_ENV = _ENDPOINT_ENVS[0]
_FOUNDRY_HOST_SUFFIX = ".services.ai.azure.com" _FOUNDRY_HOST_SUFFIX = ".services.ai.azure.com"
_ROLES = ("default", "proposer", "checker") _ROLES = ("default", "proposer", "checker")
# Exact operator-facing disclaimer marker (Norwegian, per docs-language convention). The docs note # Exact operator-facing disclaimer marker (Norwegian, per docs-language convention). The docs note
@ -52,12 +58,28 @@ class PreflightRefusal:
reason: str reason: str
def _resolve_endpoint() -> str | None:
"""First NON-EMPTY of ``_ENDPOINT_ENVS`` — ours first, the platform-injected name as fallback.
Precedence over VALUES, not declarations: an exported-but-empty own name falls through instead
of shadowing a real injected one into a refusal (the 4b rule, same seam as ``backends.py``)."""
for name in _ENDPOINT_ENVS:
value = os.environ.get(name)
if value:
return value
return None
def _endpoint_error() -> str | None: def _endpoint_error() -> str | None:
"""Return an actionable reason if the endpoint env is missing/misshapen, else ``None``. Pure """Return an actionable reason if the endpoint env is missing/misshapen, else ``None``. Pure
string work no ``urllib`` (both the NFR and the offline grep-guard forbid it).""" string work no ``urllib`` (both the NFR and the offline grep-guard forbid it)."""
endpoint = os.environ.get(_ENDPOINT_ENV) endpoint = _resolve_endpoint()
if not endpoint: if not endpoint:
return f"{_ENDPOINT_ENV} er ikke satt (påkrevd for azure-profilen)" # Name BOTH: the operator on a laptop and the operator in a hosted container are looking
# for different variables (the fail-fast in ``backends.py`` says the same thing).
return (
f"{_ENDPOINT_ENVS[0]} (eller plattformens injiserte {_ENDPOINT_ENVS[1]}) "
"er ikke satt (påkrevd for azure-profilen)"
)
if not endpoint.startswith("https://"): if not endpoint.startswith("https://"):
return f"{_ENDPOINT_ENV} må være en https://-URL, fikk: {endpoint!r}" return f"{_ENDPOINT_ENV} må være en https://-URL, fikk: {endpoint!r}"
# Host = between the scheme and the first '/', minus any port; lowercased. Do NOT require the # Host = between the scheme and the first '/', minus any port; lowercased. Do NOT require the

View file

@ -57,6 +57,20 @@ class ProvenanceStamp(BaseModel):
role: str role: str
validator_decision: Literal["validated", "rejected"] validator_decision: Literal["validated", "rejected"]
token_usage: int token_usage: int
#: Was the deterministic gate ANCHORED to the project's own cost lines? ``True`` means
#: ``validate_proposal`` ran its stage-0 reconciliation (every ``affected_item`` checked against
#: a real ``CostBaseline`` line, within tolerance, BEFORE the solver); ``False`` means the
#: bundle shipped no ``cost-baseline.json``, so that stage was SKIPPED and the gate reasoned
#: only about numbers the proposal itself supplied. Anchoring stays OPTIONAL (a pre-amendment
#: bundle is legitimately un-anchored) — this field does not gate anything, it makes the skip
#: legible. A STRUCTURED field rather than prose, for the reason ``BudgetExceeded`` carries
#: ``kind``/``limit``/``observed`` as fields (kø-(y)): "was the falsifier anchored" is an
#: operative question that must be readable by machine.
#:
#: REQUIRED, with no default, because both defaults lie: ``True`` would let a constructor that
#: forgot claim an anchoring that never happened, and ``False`` would under-claim a real one.
#: A binary fact about a falsifier has no honest default.
cost_baseline_anchored: bool
#: External service calls the run made (B4). EMPTY is a positive statement — "nothing outside #: External service calls the run made (B4). EMPTY is a positive statement — "nothing outside
#: this process was contacted" — not an absent field, which is why it is always serialized. #: this process was contacted" — not an absent field, which is why it is always serialized.
external_calls: list[ExternalCall] = Field(default_factory=list) external_calls: list[ExternalCall] = Field(default_factory=list)

View file

@ -45,7 +45,12 @@ class Project:
currency: str currency: str
cost_items: tuple[CostItem, ...] cost_items: tuple[CostItem, ...]
docs_dir: str # absolute path to this project's bundled cost-docs folder (config-driven) docs_dir: str # absolute path to this project's bundled cost-docs folder (config-driven)
verdict_input: dict[str, str] # SYNTHETIC Layer-2 expert decision/rationale (config-driven) #: The Layer-2 expert decision/rationale, when an expert actually gave one. OPTIONAL and
#: ``None`` by default (F2, non-goal 3): a project nobody has reviewed has no honest value
#: here, and the previous required field made silence indistinguishable from an approval.
#: The shipped reference fixture DOES carry values, and labels them SYNTHETIC at every point —
#: that dataset's honesty is F5's subject (measuring the mission claim), not F2's.
verdict_input: dict[str, str] | None = None
bundle_dir: str | None = None # abs path to an OKF bundle backing this project (Fase 2a S2.0) bundle_dir: str | None = None # abs path to an OKF bundle backing this project (Fase 2a S2.0)
verdict_dir: str | None = None # abs path to this project's async verdict inbox (Fase 2a S2.0) verdict_dir: str | None = None # abs path to this project's async verdict inbox (Fase 2a S2.0)
@ -59,8 +64,9 @@ def load_reference_projects() -> tuple[Project, ...]:
Each project's ``docs_dir`` is stored in the JSON relative to the package ``data/`` root Each project's ``docs_dir`` is stored in the JSON relative to the package ``data/`` root
and resolved here to an absolute filesystem path; ``verdict_input`` carries the SYNTHETIC and resolved here to an absolute filesystem path; ``verdict_input`` carries the SYNTHETIC
Layer-2 expert decision/rationale. Missing keys raise ``KeyError`` (fail-fast, matching the Layer-2 expert decision/rationale when the row declares one, and is ``None`` when it does not
existing loader contract).""" (F2). Missing keys raise ``KeyError`` (fail-fast, matching the existing loader contract)
``verdict_input`` is deliberately NOT among them."""
resource = files("portfolio_optimiser").joinpath(_DATA_RESOURCE) resource = files("portfolio_optimiser").joinpath(_DATA_RESOURCE)
raw = json.loads(resource.read_text(encoding="utf-8")) raw = json.loads(resource.read_text(encoding="utf-8"))
@ -87,7 +93,9 @@ def load_reference_projects() -> tuple[Project, ...]:
for c in p["cost_items"] for c in p["cost_items"]
), ),
docs_dir=str(files("portfolio_optimiser").joinpath(f"data/{p['docs_dir']}")), docs_dir=str(files("portfolio_optimiser").joinpath(f"data/{p['docs_dir']}")),
verdict_input=p["verdict_input"], # ``.get`` — never fail-fast: a reference row without a reviewed verdict is now legal
# (F2), and a missing key must mean "nobody reviewed it", not a load error.
verdict_input=p.get("verdict_input"),
bundle_dir=_resolve(p.get("bundle_dir")), bundle_dir=_resolve(p.get("bundle_dir")),
verdict_dir=_resolve(p.get("verdict_dir")), verdict_dir=_resolve(p.get("verdict_dir")),
) )

File diff suppressed because it is too large Load diff

View file

@ -1,10 +1,15 @@
"""Resolver for the location of the shared framework-neutral core (S3, R1-forberedelse). """Resolver for the location of the shared framework-neutral core (S3, R1-forberedelse; 4a pakket).
``shared/`` is today an in-repo directory, but is slated for extraction into its own commons repo ``shared/`` is a pull-only subtree of the commons repo at the repository root. Every MAF-side
(R1/S4). Every MAF-side runtime consumer (``persona``, ``simulation``) resolves its location through runtime consumer (``persona``, ``simulation``) resolves its location through this ONE seam, so a
this ONE seam so the extraction is a re-point (env var), not a code change. Test fixtures pointing at re-point is an env var, not a code change. Since Fase 4a the wheel also carries a byte-identical
the real in-repo bundle hardcode the path deliberately they must not be redirected by a production mirror of the tree as packaged data (``portfolio_optimiser/_shared/``, hatchling force-include in
env var. Pure stdlib the shared core itself stays framework-free. pyproject.toml), so an installed distribution and a container built from it works without a
checkout. Resolution order at CALL time: explicit env override the working tree's ``shared/``
when present (a repo checkout stays authoritative, which is what keeps the subtree contract and
the byte-level goldens untouched) the packaged copy. Test fixtures pointing at the real in-repo
bundle hardcode the path deliberately they must not be redirected by a production env var. Pure
stdlib the shared core itself stays framework-free.
""" """
from __future__ import annotations from __future__ import annotations
@ -14,12 +19,20 @@ from pathlib import Path
ENV_VAR = "PORTFOLIO_SHARED_ROOT" ENV_VAR = "PORTFOLIO_SHARED_ROOT"
_DEFAULT = Path(__file__).resolve().parents[2] / "shared" _WORKTREE = Path(__file__).resolve().parents[2] / "shared"
_PACKAGED = Path(__file__).resolve().parent / "_shared"
def shared_root() -> Path: def shared_root() -> Path:
"""Resolve the shared-core root at CALL time: ``PORTFOLIO_SHARED_ROOT`` if set (non-empty), """Resolve the shared-core root at CALL time: ``PORTFOLIO_SHARED_ROOT`` if set (non-empty),
else the in-repo default. The call-time read is what keeps the override testable and the S4 else the working tree's ``shared/`` when it exists (a checkout), else the packaged copy
extraction re-pointable without touching consumers.""" shipped inside the distribution. The call-time read is what keeps the override testable and
the ordering observable; when neither directory exists the packaged path is returned so the
consumer's own fail-fast names the installed distribution's gap, not a checkout that was
never there."""
override = os.environ.get(ENV_VAR) override = os.environ.get(ENV_VAR)
return Path(override) if override else _DEFAULT if override:
return Path(override)
if _WORKTREE.is_dir():
return _WORKTREE
return _PACKAGED

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,265 @@
"""U14 — the opt-in OpenTelemetry seam: one variable, two sinks, and no silent egress.
A run of this framework already *makes* spans: MAF's ``ENABLE_INSTRUMENTATION`` defaults to True
(``observability.py:697``), so every workflow, executor and chat call is instrumented and, with
no provider configured, every one of those spans is discarded. This module is the provider, and
nothing else. It exists because the exploration loop being built on top of it (U4) hands a manager
the freedom to choose its own next step, and the programme's ordering rule is that nothing which
grants that freedom lands before the freedom can be *watched*. An organisation cannot be asked to
trust an autonomous exploration it cannot read afterwards.
**The contract, in one paragraph.** ``PORTFOLIO_OTEL`` is read on truthiness. Absent or empty:
``configure_otel_providers`` is not called at all, so nothing is configured and nothing can leave
"off" means off, not "on, exporting to nowhere". ``console``: spans are written to **stderr**, so a
traced run and an untraced run print byte-identical stdout and the pinned demo transcript survives.
``otlp``: spans go over the network, and ONLY when the operator has named a destination in one of
the standard ``OTEL_EXPORTER_OTLP_*_ENDPOINT`` variables. Anything else is refused by name.
**Two rules here are measurements, not preferences.** ``configure_otel_providers`` composes its
exporter list in a fixed order (``observability.py:849``):
1. exporters derived from the standard ``OTEL_EXPORTER_OTLP_*`` variables **unconditionally**,
2. the exporters passed in as ``exporters=``,
3. a ``ConsoleSpanExporter()`` default sink ``sys.stdout`` when ``enable_console_exporters``
is true, taken from the argument *or*, if that is ``None``, from ``ENABLE_CONSOLE_EXPORTERS``
in the environment.
Step 3 is why ``enable_console_exporters=False`` is passed explicitly in **both** modes: left to
the environment, an operator with that variable exported gets a span dump on stdout, which spike
S6 measured as destroying the golden transcript outright. Step 1 is why console mode **refuses**
when an OTLP endpoint variable is present: the word "console" would otherwise be a false statement
about where the run's contents went. The refusal names the variable and leaves it alone —
validation, never repair, the same rule ``write_concept_file`` and ``load_optional_cost_baseline``
follow. Unsetting an operator's environment behind their back would be a fix that hides its own
cause.
**What is deliberately absent.** The OTLP exporter *packages*
(``opentelemetry-exporter-otlp-proto-grpc`` / ``-http``) are not declared dependencies. They are
egress, they drag grpc and protobuf into a published wheel for a mode that is off by default, and
MAF already raises an ``ImportError`` that names the package to install. Stated honesty limit:
``PORTFOLIO_OTEL=otlp`` works only after the operator installs one of them.
**The ``PLAN_CREATED`` / ``REPLANNED`` / ``PROGRESS_LEDGER_UPDATED`` events now exist** (U4, økt
56). They were held back here in økt 55 on the ground that an emitter written before its call site
is a shape guessed rather than measured; the call site is ``explore._absorb``, and the events are
recorded on the exploration span this module's ``exploration_tracer`` hands out. Nothing about the
contract above changed: with tracing off there is no provider, so those events are discarded like
every other span this process makes.
MAF-touching by construction, so this module never enters the framework-neutral context layer
(``okf.py``); the ``test_okf_is_maf_free`` guard keeps that boundary.
"""
from __future__ import annotations
import os
from collections.abc import Callable, Mapping
from dataclasses import dataclass
from typing import IO, Any, Final
#: The one variable that turns tracing on. Read on TRUTHINESS, never presence (the 4b rule): an
#: exported-but-empty value is a shell accident, and treating it as a request would turn
#: ``export PORTFOLIO_OTEL=`` into a fail-fast on an unknown mode.
TRACING_ENV: Final = "PORTFOLIO_OTEL"
MODE_CONSOLE: Final = "console"
MODE_OTLP: Final = "otlp"
#: The closed set. A value outside it is refused by name rather than falling back to off — an
#: operator who asked for a trace and mistyped would otherwise get the black box this seam exists
#: to remove, arrived at by accident and without a word.
_MODES: Final = (MODE_CONSOLE, MODE_OTLP)
#: Every standard variable that makes ``_get_exporters_from_env`` construct a NETWORK exporter.
#: All four are checked, not just the base one: a run configured only via
#: ``OTEL_EXPORTER_OTLP_TRACES_ENDPOINT`` exports exactly the signal this seam is about.
_OTLP_ENDPOINT_ENVS: Final = (
"OTEL_EXPORTER_OTLP_ENDPOINT",
"OTEL_EXPORTER_OTLP_TRACES_ENDPOINT",
"OTEL_EXPORTER_OTLP_METRICS_ENDPOINT",
"OTEL_EXPORTER_OTLP_LOGS_ENDPOINT",
)
class TracingConfigError(ValueError):
"""A tracing request that cannot be honoured as stated.
``ValueError`` so the hosted flate maps it to 400 through the existing arm rather than needing
a new one: it is a malformed request about this process's configuration, not a crash.
"""
@dataclass(frozen=True)
class TracingSetup:
"""What a run resolved about its own tracing — the single source both callers read.
``mode`` is ``None`` when tracing is off. ``endpoints`` carries ``(variable, value)`` pairs and
is non-empty only in OTLP mode, so the announcement can name the variable the operator edits
rather than only the URL. Both the configuring and the announcing descend from this one value:
a renderer that re-read the environment would be a second resolution of the same rule, free to
disagree with the run it describes (the ``cost_baseline_notice`` rule).
"""
mode: str | None
endpoints: tuple[tuple[str, str], ...]
def declared_otlp_endpoints(env: Mapping[str, str]) -> tuple[tuple[str, str], ...]:
"""Return the OTLP endpoint variables the environment actually declares, in a fixed order.
Truthiness again: an exported-but-empty endpoint variable declares nothing, and MAF's own
``os.getenv`` reads would skip it too, so treating it as a declaration would make console mode
refuse over a destination that does not exist.
"""
return tuple(
(name, env[name].strip()) for name in _OTLP_ENDPOINT_ENVS if env.get(name, "").strip()
)
def resolve_tracing_mode(env: Mapping[str, str]) -> str | None:
"""Read ``PORTFOLIO_OTEL`` into the closed mode set, or ``None`` when tracing is off."""
raw = env.get(TRACING_ENV, "").strip()
if not raw:
return None
if raw not in _MODES:
raise TracingConfigError(
f"{TRACING_ENV}={raw!r} is not a tracing mode. Allowed: "
+ ", ".join(repr(mode) for mode in _MODES)
+ f". Unset {TRACING_ENV} to run without tracing."
)
return raw
def configure_tracing(
*,
env: Mapping[str, str] | None = None,
stream: IO[str] | None = None,
configure: Callable[..., None] | None = None,
) -> TracingSetup:
"""Install OpenTelemetry providers for this process if — and only if — asked to.
Call ONCE, at process startup, before any telemetry is captured (MAF's own instruction; a
second call is a no-op behind its ``_executed_setup`` guard). Returns what was resolved, so the
caller can announce it without re-reading anything.
``configure`` is injectable for the same reason ``run._default_factory`` is: it is the one seam
a test can observe without installing global providers into the pytest process. The real proof
that the seam works is a subprocess running the actual demo, not this argument.
"""
env = os.environ if env is None else env
mode = resolve_tracing_mode(env)
endpoints = declared_otlp_endpoints(env)
if mode is None:
# Not "configure with nothing" — NOT CALLING is what makes "off" mean nothing can leave.
# A call with an empty exporter list would still install providers and re-read every
# OTEL_EXPORTER_OTLP_* variable in the ambient environment.
return TracingSetup(mode=None, endpoints=())
if configure is None: # pragma: no cover - trivial default resolution
from agent_framework.observability import configure_otel_providers
configure = configure_otel_providers
if mode == MODE_CONSOLE:
if endpoints:
named = ", ".join(name for name, _ in endpoints)
raise TracingConfigError(
f"{TRACING_ENV}={MODE_CONSOLE} promises that spans stay in this process, but the "
f"environment declares a network exporter: {named}. OpenTelemetry exporters are "
"built from those variables unconditionally, so the run would also ship its spans "
f"over the wire. Unset them, or ask for {TRACING_ENV}={MODE_OTLP} and say so."
)
# Imported here rather than at module scope: the exporter is constructed only in this
# branch, and every importer of the CLI would otherwise pay for a mode that is off by
# default.
from opentelemetry.sdk.trace.export import ConsoleSpanExporter
# `out=stream` is the whole of console mode's safety. The default sink is sys.stdout, and
# stdout is byte-pinned by tests/golden/demo-transcript.stdout.
exporters: list[Any] | None = [ConsoleSpanExporter(out=stream or _default_stream())]
else:
if not endpoints:
raise TracingConfigError(
f"{TRACING_ENV}={MODE_OTLP} was requested but no endpoint is declared. Set one of: "
+ ", ".join(_OTLP_ENDPOINT_ENVS)
+ ". Configuring providers with nowhere to export would produce a run that looks "
"traced and is not."
)
# No exporter of our own: MAF builds them from the standard variables, and a hand-rolled
# second one would be the duplicate free to drift from the OTel spec.
exporters = None
# `enable_console_exporters=False` is EXPLICIT in both modes, and load-bearing in both: left as
# None it falls back to ENABLE_CONSOLE_EXPORTERS in the environment, whose console exporter
# writes to stdout.
configure(enable_console_exporters=False, exporters=exporters)
return TracingSetup(mode=mode, endpoints=endpoints)
def _default_stream() -> IO[str]:
"""``sys.stderr`` resolved at CALL time, not import time.
The demo replaces neither, but a caller that redirects ``sys.stderr`` before startup should get
the redirected one an import-time binding would have captured whatever was current when the
module was first imported.
"""
import sys
return sys.stderr
def tracing_notice(setup: TracingSetup) -> str | None:
"""Render what a run says about its own tracing, or ``None`` when there is nothing to say.
ONE renderer with N call sites, never N copies of the wording (-(p)), and it takes the
already-resolved ``TracingSetup`` rather than an environment: the printed line and the
providers that were installed then descend from the same single resolution.
``None`` when tracing is off omission, never an empty row (``mandate.announce``'s rule, the
one ``cost_baseline_notice`` and ``skipped_links_notice`` follow). Here it is load-bearing past
style: the pinned demo stderr is four lines, and a "tracing: off" row would have made it five.
The OTLP form names the VARIABLE beside the value, because the variable is what the operator
edits and prints one row per declared endpoint rather than only the first, so a run exporting
logs and traces to different collectors declares both. English, like every other line this CLI
prints.
"""
if setup.mode is None:
return None
if setup.mode == MODE_CONSOLE:
return (
f" Tracing: {TRACING_ENV}={MODE_CONSOLE} — OpenTelemetry spans are written to stderr; "
"nothing leaves this process"
)
rows = "\n".join(f" {name} = {value}" for name, value in setup.endpoints)
return (
f" Tracing: {TRACING_ENV}={MODE_OTLP} — OpenTelemetry spans are EXPORTED OVER THE NETWORK "
f"to the endpoints declared below\n{rows}"
)
#: The instrumentation scope every exploration span is created under. One name, so a collector
#: can select this framework's own spans apart from MAF's (``invoke_agent``, ``workflow.run``)
#: without matching on span names that MAF owns and may rename.
EXPLORATION_TRACER_NAME: Final = "portfolio_optimiser.explore"
def exploration_tracer() -> Any:
"""The tracer the exploration loop records its decisions on.
``get_tracer`` is safe to call whether or not a provider was installed: with none, OpenTelemetry
hands back a no-op tracer and every span and event is discarded. That is the SAME shape MAF's
own instrumentation already has (``ENABLE_INSTRUMENTATION`` defaults to True and its spans are
thrown away for want of a provider), and it is what lets the exploration emit unconditionally.
Gating emission on ``PORTFOLIO_OTEL`` would be a second resolution of a rule this module owns,
free to disagree with the providers actually installed.
A FUNCTION rather than a module-level tracer, and the reason is ordering: ``configure_tracing``
runs at process startup, and a tracer bound at import time would have been taken from the
global provider that existed BEFORE it a no-op one, permanently. It is also the seam a test
substitutes a local provider through, without installing anything globally.
"""
from opentelemetry import trace
return trace.get_tracer(EXPLORATION_TRACER_NAME)

View file

@ -10,11 +10,13 @@ from __future__ import annotations
import json import json
from collections.abc import Callable, Sequence from collections.abc import Callable, Sequence
from pathlib import Path
import pytest import pytest
from agent_framework import BaseChatClient from agent_framework import BaseChatClient
from portfolio_optimiser.reference_domain import load_reference_projects from portfolio_optimiser.reference_domain import load_reference_projects
from portfolio_optimiser.run import RunResult
from portfolio_optimiser.simulation import ScriptedChatClient from portfolio_optimiser.simulation import ScriptedChatClient
from portfolio_optimiser.verdicts import VerdictStore, seed_store from portfolio_optimiser.verdicts import VerdictStore, seed_store
@ -192,3 +194,50 @@ def docs_dir(tmp_path) -> str:
encoding="utf-8", encoding="utf-8",
) )
return str(d) return str(d)
# ------------------------------------------------------------------------------------------------
# Fase 1b — the FULL-RUN contract, in ONE copy (kø-(p): a second copy of an assertion drifts).
# ------------------------------------------------------------------------------------------------
#: What the paid run must fell, stated as an assertion rather than as prose. The open honesty limit
#: after økt 37 is narrow and specific: *"that the emitted structured schema IS accepted by the LIVE
#: endpoint is NOT verified — the tests prove conformance with the DOCUMENTED subset, not
#: acceptance."* So the contract asserts schema ACCEPTANCE, never model JUDGEMENT.
#:
#: The discriminator is an artefact this repo already owns: ``{run_id}-parse-failures.json`` is
#: written if and ONLY if some reply failed to parse (økt 35 invariant — "the file's presence is the
#: signal"). Its ABSENCE beside a RunResult therefore proves that every generation reply came back
#: in the requested shape, which is exactly what "the live endpoint honoured the schema" means.
#:
#: ``validator_decision`` is the second half: it mirrors the VALIDATOR alone (never the checker), so
#: reading it proves the deterministic gate actually ran on a parsed candidate. Both ``validated``
#: and ``rejected`` satisfy the contract — a P90 rejection is a run that CONCLUDED, and demanding
#: ``validated`` would be asserting that the model reasons well, which no schema can promise and
#: which one paid run could not establish anyway.
def assert_full_run_contract(result: RunResult, outbox_dir: str | Path, run_id: str) -> None:
"""Assert the Fase 1b full-run contract on a completed ``run_project`` result.
Two things, and deliberately nothing else:
1. **No parse-failure artefact** every reply parsed, i.e. the live endpoint accepted the
emitted ``response_format`` schema. This is the honesty limit being felled.
2. **The validator was reached and decided** ``provenance.validator_decision`` is one of the
two decisions the deterministic gate emits.
Deliberately NOT asserted: ``checker_verdict``, token counts, ``validated_count``, or the
content of the proposal. Those are model-judgement claims, and one run cannot carry them.
"""
artefact = Path(outbox_dir) / f"{run_id}-parse-failures.json"
if artefact.exists():
# Quote the evidence in the failure message: the whole point of økt 35 was that the operator
# should never again have to guess WHY a reply did not parse.
raise AssertionError(
f"the endpoint did NOT honour the structured schema — {artefact.name} exists.\n"
f"{artefact.read_text(encoding='utf-8')[:2000]}"
)
assert result.provenance.validator_decision in {"validated", "rejected"}, (
"the deterministic validator never decided — the run did not reach the gate with a "
f"parsed candidate (validator_decision={result.provenance.validator_decision!r})"
)

View file

@ -0,0 +1,4 @@
<SITE-PACKAGES>/agent_framework/_skills.py:121: ExperimentalWarning: [SKILLS] SkillResource is experimental and may change or be removed in future versions without notice.
<SITE-PACKAGES>/agent_framework/_harness/_memory.py:651: ExperimentalWarning: [HARNESS] MemoryStore is experimental and may change or be removed in future versions without notice.
(arbeidskopi: <TMPDIR>/po-sim-<SUFFIKS>)

View file

@ -0,0 +1,61 @@
==============================================================================
OFFLINE SIMULERING — skriptede agent-svar, INGEN ekte modell.
Beviser dataflyten, den deterministiske ryggraden og at læringssløyfa lukkes.
Beviser IKKE at en levende modell ville produsert dette — forslag og dom er skriptet.
==============================================================================
KUNNSKAPSBASE: veglys-fv-soer — kostbaseline erklært (ENERGI-VEGLYS-EL 4386150 x 1)
validatorens stage 0 avstemmer forslagets kostlinjer mot disse, FØR løseren
tallene er levert i kunnskapsbasen — utledet av fagkilder (Håndbok V124, NMFV), ikke av demo-manuset
KJØRING A (VEGLYS-FV-SOER — fersk kunnskapsbase, ingen tidligere dommer)
Steg 1 — FORSTÅ KONTEKSTEN (navigert kunnskapsbase + tidligere dommer)
navigerte konseptfiler (5): kilder-veglys-realisering.md, tiltak-adaptiv-styring.md, tiltak-led-utskifting.md, veglys-fv-soer.md, metode-ipmvp-a.md
tidligere dommer hentet for kandidaten: 0
markør 'realiseringsgrad=0.79' i hypotese-prompten: False
Steg 2 — HYPOTESE (kandidat med parametere)
tiltak: LED-utskifting av 2 500 eldre HPS-armaturer (114 W -> 70 W)
kostlinjer: ENERGI-VEGLYS-EL 4386150 x 1
påstått besparelse: 2100000 NOK
Steg 3 — DEBATT (maker-checker, Group Chat)
proposer (konvergert): {"measure":"LED-utskifting av 2 500 eldre HPS-armaturer (114 W -> 70 W)","affected_items":[…
checker (gate på resonnementet): VERDICT=APPROVE
Steg 4 — VALIDER / FALSIFISER (deterministisk, blokkerende)
hypotese #1: REJECTED (claimed saving 2100000 exceeds P90 feasible 1769915)
Steg 5 — FORBEDRE, INFORMERT OG BUNDET
#1: grunnen fra 2100000 NOK-hypotesen mates tilbake i neste forsøk (bundet av max_attempts)
etter forbedring: VALIDATED (påstått 445500 <= P90 1769915 NOK; tiltak: LED-utskifting av 2 500 eldre HPS-armaturer (114 W -> 70 W))
Steg 6 — FORKAST ELLER FORESLÅ (typet utfall forlater kjøringen)
FORESLÅTT — LED-utskifting av 2 500 eldre HPS-armaturer (114 W -> 70 W): 445500 NOK (validator=validated, checker=approve)
Steg 7 — SVAR PÅ TILBAKEMELDING (ekspert-persona, kort løkke i kjøringen)
dom: approved
begrunnelse: Godkjent med realiseringskorreksjon. Den modellerte besparelsen er teknisk korrekt fra parameterne og validatoren bekrefter at den er innenfor feasibelt omraade. Men i drift realiseres erfaringsvis ~79% av en timeplan-stipulert LED-besparelse i tilsvarende anlegg (realiseringsgrad=0.79) pga. overes…
(forventet: markøren er FRAVÆRENDE her — dommen finnes ikke i wikien ennå)
MELLOM KJØRINGENE — to uavhengige tilbakemeldings-veier tas i bruk
Steg 7 (lang løkke) — EN EKSPERT LEGGER EN DOM I INNBOKSEN, ETTER KJØRINGEN
fil: verdict-innboks/STEG7-EKSPERT-DRIFTSNOTAT.json
bærer: realiseringsgrad=0.66 (ettersendt driftsmåling — ny kunnskap)
rollene byttes aldri: systemet LESER denne mappa, eksperten SKRIVER den
neste kjøring merger fila inn i minnet FØR hypotesen formes — dager kan gå
Steg 8 — PROMOTER GODKJENT KUNNSKAP (gatet wiki-promotering)
skrev: promoted-verdict-b26f6501ccf74eef.md (lenket i index.md, nøytral etikett)
bærer: realiseringsgrad=0.79 (personaens dom fra kjøring A)
gaten er fail-closed: kun en godkjent dom promoteres — rå agent-output aldri
KJØRING B (re-seedet kunnskapsbase + innboksen lest)
samme åtte steg kjøres igjen; her vises kun det som ENDRET seg:
tidligere dommer hentet for kandidaten: 3
av disse fulgte 1 av 3 med kunnskapsbasen; de øvrige 2 er dem demoen lærte i denne økten
markør 'realiseringsgrad=0.79' (Steg 8, wiki) i hypotese-prompten: True (forventet True)
markør 'realiseringsgrad=0.66' (Steg 7, innboks) i hypotese-prompten: True (forventet True)
utfall: FORESLÅTT — LED-utskifting av 2 500 eldre HPS-armaturer (114 W -> 70 W): 445500 NOK (validator=validated, checker=approve)
------------------------------------------------------------------------------
LÆRINGSSLØYFA ER LUKKET, PÅ BEGGE TIDSSKALAER: kunnskapen eksperten godkjente i
kjøring A nådde kjøring B's hypotese via den fil-baserte wikien (promoter ->
re-seed -> fold), OG driftsnotatet som ble lagt i innboksen etterpå nådde den via
fil-innboksen (skriv fil -> merge -> fold). Ingen av dem gikk gjennom minnet.
------------------------------------------------------------------------------

View file

@ -0,0 +1,260 @@
"""Spike E tests — Magentic exploration loop, MEASURED before anything is built
(order 20260823T162224Z, plan § D.1 spikes S0S6).
Every test here pins ONE binary outcome the plan's § F assumption table needs. Where an
outcome is version-dependent (the manager's session lifetime changed in orchestrations
1.0.1, upstream regression fix #4371), the test asserts against the STRUCTURAL probe of the
installed manager never a version string and carries a non-vacuity control so a zero
can never be the absence of a run.
Pattern: tests/spikes/test_b_footguns.py.
"""
from __future__ import annotations
from pathlib import Path
import pytest
from portfolio_optimiser.budget import BudgetExceeded
from spikes.e_magentic import (
ExplorationCallRecord,
checkpoint_until_plan_review,
expert_liaison_answer_round_trip,
fresh_manager_contamination,
manager_budget_enforced,
manager_keeps_persistent_session,
plan_review_round_trip,
reset_signal_resets_participant_session,
run_resume_subprocess,
shared_builder_contamination,
shared_manager_contamination,
single_use_second_run,
validator_latency_seconds,
)
# ---------------------------------------------------------------------------
# S1 — B7 state isolation (E1E4, E7) in the repo's own form
# ---------------------------------------------------------------------------
async def test_e1_second_run_of_one_built_workflow_is_refused() -> None:
"""E1: a built Magentic workflow is SINGLE-USE. The second ``.run()`` raises and makes
ZERO model calls stronger than GroupChat 1.9.0's silent empty [2, 0, 0] re-run, because
a workflow that cannot run cannot fabricate an answer."""
result = await single_use_second_run()
assert result["first_ok"] is True
assert result["second_error"] == "RuntimeError"
assert "already been completed" in result["second_message"]
# The refusal is FREE: nothing was spent proving it.
assert result["manager_calls_added"] == 0
assert result["worker_calls_added"] == 0
async def test_e3_fresh_manager_per_build_never_contaminates() -> None:
"""E3 — the CONTROL, and the invariant the plan's C.4 rule rests on: a fresh manager
(fresh builder, fresh agent, fresh client) per exploration leaks nothing across runs, on
EVERY measured version. This is what ``fresh_exploration_workflow`` will implement."""
bled, total, kinds = await fresh_manager_contamination()
assert total >= 4, f"only {total} manager calls — the control would be reporting on nothing"
assert bled == 0
# The five-call shape (facts, plan, ledger_UNSAT, ledger_SAT, final) is the discriminator:
# a contaminated run answers satisfied on its FIRST ledger and never calls the worker.
assert kinds == ["facts", "plan", "ledger_UNSAT", "ledger_SAT", "final"]
async def test_e2_shared_manager_bleed_tracks_the_persistent_session() -> None:
"""E2: two workflows sharing ONE ``StandardMagenticManager``. Whether run 2's manager still
sees run 1's task is decided by ONE structural property of the installed manager — whether
it holds a persistent ``AgentSession`` built in ``__init__`` (1.0.0) or creates a throwaway
one per call (1.0.1, #4371). The probe reads that property, never a version string."""
bled, total, kinds = await shared_manager_contamination()
assert total >= 4, f"only {total} manager calls — a zero here would be vacuous"
if manager_keeps_persistent_session():
assert bled == total, "a persistent manager session must bleed EVERY run-2 call"
# The fabricated-answer signature: satisfied on the first ledger, worker never called.
assert "ledger_UNSAT" not in kinds
else:
assert bled == 0, "a per-call manager session must leak nothing"
assert kinds == ["facts", "plan", "ledger_UNSAT", "ledger_SAT", "final"]
async def test_e4_shared_builder_bleed_tracks_the_persistent_session() -> None:
"""E4: ONE ``MagenticBuilder``, two ``.build()`` calls. ``manager_agent=`` constructs the
manager eagerly and hands the SAME instance to every build, so E4 is E2 reached by the
route a caller is most likely to take by accident. Same discriminator."""
bled, total, kinds = await shared_builder_contamination()
assert total >= 4, f"only {total} manager calls — a zero here would be vacuous"
if manager_keeps_persistent_session():
assert bled == total
assert "ledger_UNSAT" not in kinds
else:
assert bled == 0
assert kinds == ["facts", "plan", "ledger_UNSAT", "ledger_SAT", "final"]
async def test_e7_reset_signal_does_not_reset_the_participant_session() -> None:
"""E7: ``MagenticResetSignal`` clears the cache and the conversation but writes the fresh
session to ``_agent_thread`` an attribute the executor never reads. Stall-replan therefore
gives a fresh manager ledger and STALE participants. Measured on 1.0.0 and still true on
1.0.1: never rely on the reset to empty participant memory."""
result = await reset_signal_resets_participant_session()
assert result["cache_cleared"] is True
assert result["conversation_cleared"] is True
assert result["session_identity_changed"] is False # the whole finding
assert result["orphan_attribute_written"] is True
# ---------------------------------------------------------------------------
# S2 — does BudgetMiddleware fire on the MANAGER's calls? (plan § F / A1, A2)
# ---------------------------------------------------------------------------
async def test_s2_budget_middleware_fires_on_the_manager_path() -> None:
"""A1 + A2: the manager is the most talkative participant, and the plan's hard token cap is
a lie if agent-level ``ChatMiddleware`` does not reach it. Two halves, both required: the
typed ``BudgetExceeded`` must LEAVE ``workflow.run`` (A2), and the meter must have been
CHARGED (A1) a refusal with a zero meter would prove only that something raised."""
result = await manager_budget_enforced(max_tokens=1, attach=True)
assert result["raised"] == "BudgetExceeded"
assert result["kind"] == "tokens"
assert result["meter_tokens"] > 0, "the middleware never charged — it did not run"
assert result["completed"] is False
async def test_s2_control_detaching_the_manager_middleware_lets_the_run_finish() -> None:
"""The detach control the order requires: with no middleware on the manager the SAME
one-token budget stops nothing and the run completes. Without this the test above could
pass on an implementation where anything at all raised."""
result = await manager_budget_enforced(max_tokens=1, attach=False)
assert result["raised"] is None
assert result["completed"] is True
assert result["meter_tokens"] == 0
async def test_s2_budget_exceeded_is_the_repo_type_not_a_look_alike() -> None:
"""The exception that leaves ``workflow.run`` must be the repo's own ``BudgetExceeded``
carrying ``kind``/``limit``/``observed`` the triple the 429 channel reads (-(y)). An
orchestration layer that wrapped it in an ``ExceptionGroup`` would make the exploration
layer's error mapping a fiction, so the object itself is asserted, not just its name."""
result = await manager_budget_enforced(max_tokens=1, attach=True, return_exception=True)
exc = result["exception"]
assert isinstance(exc, BudgetExceeded)
assert exc.kind == "tokens"
assert exc.limit == 1
assert exc.observed > exc.limit # limit and observed must not be the same number
# ---------------------------------------------------------------------------
# S5 — quick_validate latency (plan C.0 level 1: the tool the hypothesiser calls)
# ---------------------------------------------------------------------------
def test_s5_validator_latency_is_affordable_as_an_in_loop_tool() -> None:
"""S5: ``validate_proposal`` is what ``quick_validate`` wraps. If a single call costs
seconds, every hypothesis the manager tests costs wall-clock the contract has to budget."""
median, samples = validator_latency_seconds(runs=20)
assert samples == 20
assert median > 0.0, "a zero median means the clock never moved — nothing was measured"
assert median < 2.0, f"median {median:.3f}s per validate_proposal — budget it in the contract"
def test_call_record_is_a_plain_readable_row() -> None:
"""The record type carries the four facts every contamination verdict is computed from;
a spike whose evidence cannot be printed is a spike nobody can re-check."""
record = ExplorationCallRecord(kind="facts", messages=2, sees_alpha=True, sees_beta=False)
assert record.kind == "facts"
assert record.sees_alpha is True
@pytest.mark.parametrize("attach", [True, False])
async def test_s2_arms_disagree(attach: bool) -> None:
"""Both arms of S2 run under one parametrisation too, so a future refactor that made the
two arms identical shows up as a shared outcome rather than as two green tests."""
result = await manager_budget_enforced(max_tokens=1, attach=attach)
assert result["completed"] is not attach
# ---------------------------------------------------------------------------
# S3 / S3b — the two HITL doors (plan § C.5 / § C.6, assumptions A3 and A5)
# ---------------------------------------------------------------------------
async def test_s3_plan_review_round_trip_revises_then_approves() -> None:
"""A3: the request/response round-trip the plan's synchronous HITL rests on.
Four facts in one run: the review STOPS the workflow before any output; a ``revise`` replans
and asks AGAIN (so an always-revising expert is an unbounded loop unless the contract caps
it); the revise costs manager calls but NO ledger call (it is not a round); and an
``approve`` lets the loop finish."""
result = await plan_review_round_trip()
assert result["pending_before_review"] == 1
assert result["request_type"] == "MagenticPlanReviewRequest"
assert result["is_stalled"] is False
assert result["stopped_without_output"] is True
# Before the review the manager has only surveyed and planned -- it never reached a ledger.
assert result["kinds_before_review"] == ["facts", "plan"]
# The measured cost of one revise: two manager calls, zero ledger calls, zero rounds.
assert result["revise_manager_calls"] == ["facts_update", "plan_update"]
assert not any(k.startswith("ledger") for k in result["revise_manager_calls"])
# ... and it asks again -- this is why max_plan_revisions must exist in the contract.
assert result["pending_after_revise"] == 1
assert result["outputs_after_approve"] == ["FINAL: the worker did it."]
async def test_s3b_expert_liaison_answer_reaches_the_manager() -> None:
"""A5: ``AgentApprovalExecutor`` as the ``expert_liaison`` PARTICIPANT — door 3 of § C.6.
Reachable, and the expert's words do become context: the liaison sees them, and the manager
sees them in a later prompt. The cost is two round-trips per human turn -- ``from_strings``
feeds the answer back into the liaison and does NOT resume the manager (measured: zero
manager calls between the two requests); only ``approve`` forwards the output."""
answer = "EXPERT-SAYS-TEST-THE-LED-RETROFIT"
result = await expert_liaison_answer_round_trip(answer=answer)
assert result["reachable"] is True
assert result["manager_calls_between_requests"] == [] # from_strings alone resumes nothing
assert result["second_request"] == 1 # ... it asks again, with the answer folded in
assert result["liaison_saw_answer"] is True
assert result["manager_saw_answer"] is True # the point: it becomes context for the next round
assert result["outputs"] == ["FINAL: the worker did it."]
async def test_s3b_control_an_unsent_sentinel_never_reaches_the_manager() -> None:
"""The control that makes the assertion above non-vacuous. Same run, same expert answer --
but the manager's prompts are scanned for a sentinel the expert NEVER sent. It must come
back absent. Without this arm, ``manager_saw_answer is True`` would be equally consistent
with a scanner that matches anything, and door 3 would look proven when it was not."""
result = await expert_liaison_answer_round_trip(
answer="EXPERT-SAYS-TEST-THE-LED-RETROFIT", probe="NEVER-SENT-SENTINEL-XYZ"
)
assert result["reachable"] is True
assert result["liaison_saw_answer"] is True # the run really happened
assert result["manager_saw_answer"] is False # ... and the unsent sentinel is nowhere in it
# ---------------------------------------------------------------------------
# S4 — resume a pending plan review in a NEW PROCESS (plan U12, assumption A4)
# ---------------------------------------------------------------------------
async def test_s4_pending_plan_review_resumes_in_a_fresh_process(tmp_path: Path) -> None:
"""A4: the asynchronous HITL time-scale. The parent runs until the plan review stops it and
leaves checkpoints on disk; a SEPARATE interpreter -- which never saw the run -- answers the
pending request from the checkpoint alone and drives the workflow to its final answer.
In-process resume would prove nothing here: U12's whole claim is that the expert can answer
days later from a file inbox."""
storage = tmp_path / "checkpoints"
storage.mkdir()
first = await checkpoint_until_plan_review(str(storage))
assert first["request_id"], "no plan review was raised -- nothing to resume"
assert first["outputs"] == []
assert first["checkpoint_ids"], "no checkpoint was written -- the resume would be vacuous"
resumed = run_resume_subprocess(
str(storage), request_id=first["request_id"], checkpoint_id=first["checkpoint_ids"][-1]
)
assert resumed["outputs"] == ["FINAL: the worker did it."]
assert resumed["pending_after_resume"] == 0
# The fresh process really did the remaining work -- not a replay of a cached answer.
assert any(k.startswith("ledger") for k in resumed["manager_kinds"])

View file

@ -0,0 +1,188 @@
"""P4 pkt. 0 — the demo's RESERVE bundle must anchor the deterministic gate to real cost lines.
The gap (egnethetsreview Funn 1, corrected by objection I1): the validator CAN reconcile a proposal
against the project's actual cost lines (S4.0, stage 0), but only when the knowledge base ships a
``cost-baseline.json``. No bundle under ``shared/examples/`` has that file so in the demo the
validator reasoned only about numbers the proposal itself supplied, and an internally consistent
hallucination would clear the gate on stage.
The reserve cannot receive the file IN ``shared/``: the subtree is pull-only and demo criterion 8
requires the commons-owned goldens byte-unchanged. But that is a PLACEMENT constraint, not an
impossibility the demo already runs on a COPY of the bundle, so a copy-and-extend variant gives an
anchored run without touching commons.
**Direction of derivation, and why it matters.** Here the baseline is derived FROM the scripted
register: the reserve's numbers are synthetic, so the script is the only ground truth available.
On GO day the direction reverses (plan P3 b) the register's numbers are written FROM the
delivered ``cost-baseline.json``. Deriving in code, not by hand, is what stops the two from drifting
apart; drift is precisely the failure the 10 % test below models.
**The 10 % test** is the answer to "you generated the ground truth from the answer, so of course it
passes": deviate the baseline beyond the 5 % tolerance and the same, unchanged script must be
FORKASTET at stage 0 before the solver while the undeviated run is FORESLÅTT.
"""
from __future__ import annotations
import json
import subprocess
import sys
import pytest
from portfolio_optimiser import okf
from portfolio_optimiser.ir import CostBaseline, CostBaselineLine
from portfolio_optimiser.simulation import (
ScriptedCandidate,
_default_bundle_dir,
baseline_from_scripted_candidate,
materialize_anchored_bundle,
simulate_learning_loop,
)
from portfolio_optimiser.validator import Rejection, ValidatedProposal
def _deviated(baseline: CostBaseline, factor: float) -> CostBaseline:
"""The same baseline with every quantity scaled — the delivered numbers disagreeing with the
script's by ``factor``, which is exactly the GO-day risk this models."""
return CostBaseline(
project_id=baseline.project_id,
items={
code: CostBaselineLine(quantity=line.quantity * factor, unit_cost=line.unit_cost)
for code, line in baseline.items.items()
},
)
async def test_the_anchored_reserve_runs_the_whole_demo(tmp_path) -> None:
"""CONTROL: with the baseline derived from the script, the anchored reserve behaves exactly as
the demo narrates hypothesis #1 falsified by the P90 stage, the corrected one validated.
This is the control that gives the 10 % test its meaning: a gate that rejects everything proves
nothing. It also pins WHICH stage rejects hypothesis #1 — if stage 0 started rejecting it, demo
criterion 2 would still show a REJECTED and a VALIDATED line while silently demonstrating a
different mechanism."""
bundle = materialize_anchored_bundle(tmp_path / "forankret")
result = await simulate_learning_loop(str(bundle), str(tmp_path))
assert isinstance(result.run_a.outcome, ValidatedProposal)
assert isinstance(result.run_b.outcome, ValidatedProposal)
assert result.run_a.refinements, "no falsification was fed back — Step 5 is not being shown"
assert "exceeds P90 feasible" in result.run_a.refinements[0].reason, (
"hypothesis #1 was rejected by some other stage than the P90 one the demo narrates"
)
async def test_a_deviating_baseline_forkaster_the_demo_run_before_the_solver(tmp_path) -> None:
"""LOAD-BEARING (the 10 % test): when the project's declared cost lines deviate by 10 % from
the numbers the script asserts, the run is FORKASTET at stage 0 with the reconciliation
reason, not the P90 one.
Goes RED the moment the demo stops being anchored: without the ``cost-baseline.json`` in the
bundle the run path passes ``baseline=None``, stage 0 is skipped, and this same deviating
number changes nothing at all (the run ends FORESLÅTT, as ``test_..._runs_the_whole_demo``
above shows). The script is byte-identical in both tests only the declared baseline moves."""
baseline = _deviated(baseline_from_scripted_candidate(_only_candidate()), 1.10)
bundle = materialize_anchored_bundle(tmp_path / "forankret", baseline=baseline)
result = await simulate_learning_loop(str(bundle), str(tmp_path))
outcome = result.run_a.outcome
assert isinstance(outcome, Rejection), (
"a proposal 10 % away from the project's declared cost lines was NOT rejected — the "
"deterministic gate is not anchored to the baseline"
)
assert "outside the 5.0% tolerance" in outcome.reason
assert "ENERGI-TOTAL-EL" in outcome.reason
assert "P90" not in outcome.reason, (
"rejected by the solver stage, not by the reconciliation stage 0 that must run BEFORE it"
)
def test_the_reserve_itself_ships_no_baseline(tmp_path) -> None:
"""The materializer must ADD something the reserve genuinely lacks — and must leave the
commons-owned bundle alone (criterion 8: the goldens stay byte-unchanged)."""
reserve = _default_bundle_dir()
assert okf.load_optional_cost_baseline(str(reserve)) is None, (
"the shared reserve now ships a cost baseline — the copy-and-extend variant is obsolete "
"and this whole seam should be re-measured"
)
bundle = materialize_anchored_bundle(tmp_path / "forankret")
assert okf.load_optional_cost_baseline(str(bundle)) is not None, (
"the materialized bundle is not readable by okf's own loader — the filename has drifted"
)
assert okf.load_optional_cost_baseline(str(reserve)) is None
def test_the_baseline_is_derived_from_the_scripted_register() -> None:
"""The baseline is DERIVED from the script's own cost lines, never typed alongside them: a
hand-written copy is a second source of the same numbers, and two sources drift."""
candidate = _only_candidate()
derived = baseline_from_scripted_candidate(candidate)
for reply in (candidate.overclaimed, candidate.corrected):
for item in json.loads(reply)["affected_items"]:
line = derived.items[item["code"]]
assert (line.quantity, line.unit_cost) == (item["quantity"], item["unit_cost"])
assert derived.project_id == candidate.project_id
def test_a_candidate_whose_two_replies_disagree_is_refused() -> None:
"""Validation, never repair. The two scripted replies must state the SAME cost lines: were they
to differ, hypothesis #1 would be rejected by stage 0 instead of by P90, and the demo's
REJECTED line would silently come from another mechanism than the one it narrates."""
candidate = _only_candidate()
skewed = ScriptedCandidate(
project_id=candidate.project_id,
overclaimed=candidate.overclaimed.replace("300000", "310000"),
corrected=candidate.corrected,
flip_key=candidate.flip_key,
)
with pytest.raises(ValueError):
baseline_from_scripted_candidate(skewed)
def test_the_demo_entry_point_runs_the_anchored_delivered_bundle() -> None:
"""LOAD-BEARING on the CALL SITE: the thing the operator actually runs on stage must be the
anchored variant and since P3 (GO), the anchor is the DELIVERED bundle's own shipped
``cost-baseline.json``, not the script-derived reserve one. Goes RED if ``main`` is pointed back
at either the plain reserve or the anchored reserve.
The declared baseline is printed because an anchoring nobody can see is an anchoring nobody can
check: every other line of the demo is byte-identical whether the gate is anchored or not.
**The first form of this test was vacuous, and the mutation caught it.** It asserted
``"kostbaseline erklært" in stdout`` but the un-anchored branch read "ingen kostbaseline
erklært", which CONTAINS that substring; and ``"ENERGI-TOTAL-EL" in stdout`` holds either way,
because the Step-2 line prints the proposal's own cost lines. Both survived the mutation. The
assertions below name the whole declared line and rule the other branch out explicitly.
The magnitude is asserted in FULL (``4386150``, not ``4.38615e+06``): ``:g`` switched to exponent
notation at the 7th significant digit, which the reserve's six-digit ``300000`` never reached —
so the delivered content exposed the formatting defect on its first run, and this assert is what
keeps it exposed."""
proc = subprocess.run(
[sys.executable, "-m", "portfolio_optimiser.simulation"],
capture_output=True,
text=True,
check=False,
)
assert proc.returncode == 0, proc.stderr
assert "kostbaseline erklært (ENERGI-VEGLYS-EL 4386150 x 1)" in proc.stdout, (
"the demo is not anchored on the DELIVERED cost baseline — either it ran a bundle without "
"one (the gate then reasons only about the proposal's own numbers), or it fell back to the "
"script-derived reserve baseline"
)
assert "validatorens stage 0 avstemmer" in proc.stdout
assert "uten kostbaseline" not in proc.stdout
assert "e+06" not in proc.stdout, "a cost magnitude regressed to exponent notation"
assert "tallene er syntetiske" not in proc.stdout, (
"the reserve's honesty sentence is on stage, so the run is NOT on delivered content"
)
def _only_candidate() -> ScriptedCandidate:
from portfolio_optimiser.simulation import _CANDIDATES, _PROJECT_ID
(candidate,) = [c for c in _CANDIDATES if c.project_id == _PROJECT_ID]
return candidate

View file

@ -0,0 +1,689 @@
"""U12 + asynchronous U13 (plan § D.2 row 3) — a plan review a human answers over DAYS.
F4 (økt 63) made "still spørsmål, be om svar, bruke svarene" reachable from the CLI, but only
SYNCHRONOUSLY: ``terminal_plan_reviewer`` blocks the loop on a human at a terminal, so the answer
has to arrive while the process is alive. Målbilde §3's time-scale is the other one — the expert
answers days later, in a process that never saw the run and that is impossible without carrying
the workflow's state to disk.
**The measured trap this row is built around** (§ F row A4, and confirmed here against the
INSTALLED source rather than the plan's prose): ``FileCheckpointStorage.list_checkpoints``
(``_workflows/_checkpoint.py:386-388``) swallows a deserialisation failure into a
``logger.warning`` and returns an EMPTY list. Without ``MagenticPlanReviewRequest`` and
``MagenticPlanReviewResponse`` in ``allowed_checkpoint_types``, a resume therefore fails as an
ABSENCE "nothing to resume" not as an error. A test asserting "the listing is empty, so there
is nothing to resume" would be GREEN against exactly that defect, which is why every test here
asserts that the resume DID something instead.
**The discriminator, in both halves.** A door that writes a question file and a resume that reads
an answer file both pass "the expert was asked" while failing the målbilde. So the goal test drives
the F4 T1 shape across TWO process boundaries: ``revise`` written into an inbox on day 1 must reach
the manager, make it replan, and produce a SECOND question about the NEW plan which an
always-approve resume, or one that discards the answer, cannot produce.
The witness is the artefacts, never scraped stdout: ``{run_id}-plan-review.json`` is the question
and ``{run_id}-exploration.json`` is the record of what was decided.
"""
from __future__ import annotations
import json
import subprocess
import sys
from pathlib import Path
from typing import Any
import pytest
from portfolio_optimiser import explore as ex
from portfolio_optimiser import hitl, run
_REPO = Path(__file__).resolve().parents[1]
_BUNDLE_DIR = _REPO / "shared" / "examples" / "bygg-energi-mikro"
_PID = "BYGG-KONTOR-NORD"
_RUN_ID = "async-review"
_PROPOSER_REPLY = json.dumps(
{
"measure": "LED-retrofit",
"affected_items": [{"code": "ENERGI-TOTAL-EL", "quantity": 300000, "unit_cost": 1.0}],
"claimed_saving_nok": 30000,
}
)
_MANAGER_REPLY = json.dumps(
{
"is_request_satisfied": {"reason": "r", "answer": True},
"is_in_loop": {"reason": "r", "answer": False},
"is_progress_being_made": {"reason": "r", "answer": True},
"next_speaker": {"reason": "r", "answer": "hypothesiser"},
"instruction_or_question": {"reason": "r", "answer": "go"},
}
)
_REPLIES = {
"proposer": _PROPOSER_REPLY,
"checker": "VERDICT: APPROVE",
"manager": _MANAGER_REPLY,
"navigator": "NAVIGATOR: read the index.",
"hypothesiser": "HYPOTHESIS: " + json.dumps({"label": "Night setback", "rationale": "y"}),
}
_FEEDBACK = "Also test night setback on the ventilation."
# ---------------------------------------------------------------------------------------------
# Fixture plumbing: the two operator surfaces, driven exactly as an operator would drive them
# ---------------------------------------------------------------------------------------------
def _config_file(tmp_path: Path, **overrides: Any) -> str:
path = tmp_path / "exploration.json"
path.write_text(
json.dumps(
{
"max_rounds": 4,
"max_tokens": 200_000,
"max_stall_count": 2,
"max_reset_count": 1,
"max_plan_revisions": 2,
"enable_plan_review": True,
**overrides,
}
),
encoding="utf-8",
)
return str(path)
def _replies_file(tmp_path: Path) -> str:
path = tmp_path / "replies.json"
path.write_text(json.dumps(_REPLIES), encoding="utf-8")
return str(path)
def _park_argv(tmp_path: Path, **config: Any) -> list[str]:
"""Day 1: explore with the ASYNCHRONOUS door armed. No reviewer at this terminal."""
return [
_PID,
"--docs-dir",
str(_BUNDLE_DIR),
"--bundle-dir",
str(_BUNDLE_DIR),
"--explore",
"Find the cheapest saving.",
"--explore-config",
_config_file(tmp_path, **config),
"--scripted-replies",
_replies_file(tmp_path),
"--outbox-dir",
str(tmp_path / "outbox"),
"--checkpoint-dir",
str(tmp_path / "checkpoints"),
"--run-id",
_RUN_ID,
]
def _resume_argv(tmp_path: Path) -> list[str]:
"""Day N: a process that never saw the run, resuming from the checkpoint and the answer."""
return [
_PID,
"--docs-dir",
str(_BUNDLE_DIR),
"--bundle-dir",
str(_BUNDLE_DIR),
"--scripted-replies",
_replies_file(tmp_path),
"--outbox-dir",
str(tmp_path / "outbox"),
"--checkpoint-dir",
str(tmp_path / "checkpoints"),
"--review-inbox",
str(tmp_path / "review-inbox"),
"--resume",
_RUN_ID,
]
def _question(tmp_path: Path) -> dict[str, Any]:
path = tmp_path / "outbox" / f"{_RUN_ID}-plan-review.json"
assert path.exists(), "a parked exploration must leave the question where an expert can read it"
return json.loads(path.read_text(encoding="utf-8"))
def _artefact(tmp_path: Path) -> dict[str, Any]:
path = tmp_path / "outbox" / f"{_RUN_ID}-exploration.json"
assert path.exists(), "the exploration artefact must be written even when the run parked"
return json.loads(path.read_text(encoding="utf-8"))
def _answer(tmp_path: Path, **payload: Any) -> None:
"""The expert's side of the loop: a file dropped into the review inbox, days later."""
inbox = tmp_path / "review-inbox"
inbox.mkdir(parents=True, exist_ok=True)
body = {"run_id": _RUN_ID, "request_id": _question(tmp_path)["request_id"], **payload}
(inbox / f"{_RUN_ID}-plan-review-answer.json").write_text(json.dumps(body), encoding="utf-8")
def _resume_in_a_fresh_process(tmp_path: Path) -> subprocess.CompletedProcess[str]:
"""The resume runs in its OWN interpreter, because an in-process resume would prove nothing.
The whole claim of U12 is that the only thing crossing the boundary is what is on disk. The
subprocess precedent is ``spikes/e_magentic_resume.py`` and ``test_hosting_loadbearing.py``.
"""
return subprocess.run(
[sys.executable, "-m", "portfolio_optimiser.run", *_resume_argv(tmp_path)],
capture_output=True,
text=True,
cwd=str(_REPO),
)
# ---------------------------------------------------------------------------------------------
# 1. THE GOAL — asked on day 1, answered on day N, and the answer USED
# ---------------------------------------------------------------------------------------------
def test_an_answer_written_days_later_reaches_the_manager_and_produces_a_new_question(
tmp_path, capsys
) -> None:
"""T1: the whole row, across two process boundaries.
Day 1 parks at the plan review. The expert writes ``revise`` into the inbox. A FRESH
interpreter resumes from the checkpoint alone and because the feedback reached the manager,
the manager replans and asks AGAIN about the NEW plan.
RED against a resume that discards the answer, and RED against one that always approves: both
yield a single review and no second question. RED against an in-process-only door: there is no
checkpoint for the child to resume from.
"""
assert run.main(_park_argv(tmp_path)) == 0, capsys.readouterr().err
first = _question(tmp_path)
assert first["index"] == 0
assert first["request_id"], "without the request id the answer can never be routed back"
_answer(tmp_path, decision="revise", feedback=_FEEDBACK)
completed = _resume_in_a_fresh_process(tmp_path)
assert completed.returncode == 0, completed.stderr
second = _question(tmp_path)
assert second["index"] == 1, (
"a revision must produce a SECOND question about the replanned plan — a resume that "
"discarded the answer would finish, or park again on the SAME review"
)
assert second["request_id"] != first["request_id"], (
"the replanned review is a new request; reusing the old id would route the next answer "
"into a request the orchestrator has already retired"
)
reviews = _artefact(tmp_path)["plan_reviews"]
assert [r["decision"] for r in reviews] == ["revise"], (
"the record must carry the decision the expert actually made"
)
assert reviews[0]["feedback"] == _FEEDBACK, (
"what a human told the loop is worth nothing paraphrased"
)
def test_the_answer_is_carried_all_the_way_to_a_completed_exploration(tmp_path, capsys) -> None:
"""T2: the control for T1 — the door does not only re-ask, it can also FINISH.
A gate that could only ever park again would be a hang wearing a loop's clothes. Answering
``approve`` must let the exploration conclude and the pipeline run, leaving NO open question.
"""
assert run.main(_park_argv(tmp_path)) == 0, capsys.readouterr().err
_answer(tmp_path, decision="approve")
completed = _resume_in_a_fresh_process(tmp_path)
assert completed.returncode in (0, 1), completed.stderr
assert "Traceback" not in completed.stderr, completed.stderr
artefact = _artefact(tmp_path)
assert artefact["completed"] is True, (
"an approved review must let the exploration conclude, not park again"
)
assert [r["decision"] for r in artefact["plan_reviews"]] == ["approve"]
assert (
hitl.pending_plan_reviews(str(tmp_path / "outbox"), str(tmp_path / "review-inbox")) == []
), "a concluded exploration leaves no question waiting for anybody"
# ---------------------------------------------------------------------------------------------
# 2. THE MEASURED TRAP — a checkpoint that cannot be read back
# ---------------------------------------------------------------------------------------------
def test_the_parked_checkpoint_can_actually_be_read_back(tmp_path, capsys) -> None:
"""T3: the trap, asserted POSITIVELY.
``list_checkpoints`` turns a blocked deserialisation into an empty list, so the failure mode
is silence. This asserts the opposite of silence: the id the question file names resolves to a
checkpoint that loads. RED the moment ``_ALLOWED_CHECKPOINT_TYPES`` stops naming both types.
"""
import asyncio
assert run.main(_park_argv(tmp_path)) == 0, capsys.readouterr().err
checkpoint_id = _question(tmp_path)["checkpoint_id"]
assert checkpoint_id, "parking on an unreadable checkpoint is an unanswerable question"
storage = ex.checkpoint_storage(str(tmp_path / "checkpoints"))
loaded = asyncio.run(storage.load(checkpoint_id))
assert loaded.checkpoint_id == checkpoint_id
def test_a_park_with_no_readable_checkpoint_refuses_instead_of_writing_a_dead_question(
tmp_path, capsys, monkeypatch
) -> None:
"""T4: fail LOUDLY where the framework fails silently.
If the listing comes back empty there is nothing to resume from, and writing the question
anyway would hand an expert a review whose answer can never be applied the fourth face of
the verification law, built into our own surface. Simulated by emptying the allow-list, which
is exactly what produces an empty listing in the installed source.
It leaves as a RAISE, not an rc-1 refusal, and that is the consistent call rather than a
softer one: argv was fine and the loop had already spent, so this is the run failing the
same channel ``BudgetExceeded`` and an unreadable marked hypothesis use. What the door owes is
that it fails LOUDLY where the framework fails silently, and that no dead question is left
behind for somebody to answer into the void.
"""
monkeypatch.setattr(ex, "_ALLOWED_CHECKPOINT_TYPES", ())
with pytest.raises(ex.CheckpointUnreadable):
run.main(_park_argv(tmp_path))
capsys.readouterr()
assert not (tmp_path / "outbox" / f"{_RUN_ID}-plan-review.json").exists(), (
"a question nobody can answer must not be written at all"
)
# ---------------------------------------------------------------------------------------------
# 3. FAIL-CLOSED ON THE EXPERT'S OWN INPUT (the F4 rule, on a file instead of a terminal)
# ---------------------------------------------------------------------------------------------
def test_an_answer_outside_the_vocabulary_is_refused_never_read_as_a_sign_off(
tmp_path, capsys
) -> None:
"""T5: the closed vocabulary survives the move from stdin to a file.
``terminal_plan_reviewer`` re-asks anything it does not recognise; a file cannot be re-asked,
so the only honest answer is a refusal. Reading it as approval would sign a plan nobody signed.
"""
assert run.main(_park_argv(tmp_path)) == 0, capsys.readouterr().err
_answer(tmp_path, decision="looks fine to me")
completed = _resume_in_a_fresh_process(tmp_path)
assert completed.returncode == 1, completed.stdout
assert "looks fine to me" in completed.stderr, completed.stderr
assert _artefact(tmp_path)["plan_reviews"] == [], (
"an unreadable answer must not be recorded as a decision"
)
def test_a_revision_with_nothing_to_revise_is_refused(tmp_path, capsys) -> None:
"""T6: ``revise`` without feedback is the same defect wearing a valid token.
``PlanReviewDecision.revise`` refuses an empty revision at the library door too, and that is
exactly why the assertion here is on the SHAPE of the failure rather than on the exit code:
measured, removing the inbox guard still gives rc 1 and still puts the word "revise" on
stderr as a TRACEBACK out of the library. A test that stopped at those two facts could not
tell a fail-closed door from an unhandled exception, so it asserts the structured refusal.
"""
assert run.main(_park_argv(tmp_path)) == 0, capsys.readouterr().err
_answer(tmp_path, decision="revise", feedback=" ")
completed = _resume_in_a_fresh_process(tmp_path)
assert completed.returncode == 1, completed.stdout
assert "Traceback" not in completed.stderr, completed.stderr
assert "run refused" in completed.stderr, completed.stderr
assert "revise" in completed.stderr.lower(), completed.stderr
def test_an_answer_to_a_different_review_is_refused_never_applied_to_this_one(
tmp_path, capsys
) -> None:
"""T7: staleness is a refusal, not a silent misapplication.
Two reviews of one run share a file name, so the answer names the ``request_id`` it answers.
An answer left over from the previous round must not be applied to the current question that
would sign off a plan the expert never saw.
"""
assert run.main(_park_argv(tmp_path)) == 0, capsys.readouterr().err
_answer(tmp_path, decision="approve")
inbox = tmp_path / "review-inbox" / f"{_RUN_ID}-plan-review-answer.json"
stale = json.loads(inbox.read_text(encoding="utf-8"))
stale["request_id"] = "a-request-from-last-week"
inbox.write_text(json.dumps(stale), encoding="utf-8")
completed = _resume_in_a_fresh_process(tmp_path)
assert completed.returncode == 1, completed.stdout
assert "a-request-from-last-week" in completed.stderr, completed.stderr
def test_a_resume_with_no_answer_yet_refuses_before_spending_anything(tmp_path, capsys) -> None:
"""T8: "not answered yet" is the normal state of this door, and it must be cheap.
The hoist rule from økt 57: a refusal that fires AFTER the model calls is indistinguishable
from one that fires before, by exit code alone so this asserts that NOTHING was spent.
"""
assert run.main(_park_argv(tmp_path)) == 0, capsys.readouterr().err
before = json.loads(
(tmp_path / "outbox" / f"{_RUN_ID}-plan-review.json").read_text(encoding="utf-8")
)
completed = _resume_in_a_fresh_process(tmp_path)
assert completed.returncode == 1, completed.stdout
after = json.loads(
(tmp_path / "outbox" / f"{_RUN_ID}-plan-review.json").read_text(encoding="utf-8")
)
assert after == before, (
"a resume with no answer must not touch the run at all — an unchanged question file is "
"what proves the exploration was never restarted"
)
# ---------------------------------------------------------------------------------------------
# 4. THE PENDING REGISTRY (hitl.py) — who is still waiting on whom
# ---------------------------------------------------------------------------------------------
def test_a_parked_review_is_pending_until_its_own_answer_lands(tmp_path, capsys) -> None:
"""T9: ``hitl.pending_plan_reviews`` is the machine-readable "still waiting", mirroring
``hitl.pending`` for proposals: an outbox question whose answer is not yet in the inbox.
The join is on ``request_id``, so an answer to a DIFFERENT review leaves the question pending
rather than quietly clearing it the same fail-closed rule the resume applies.
"""
outbox, inbox = str(tmp_path / "outbox"), str(tmp_path / "review-inbox")
assert run.main(_park_argv(tmp_path)) == 0, capsys.readouterr().err
waiting = hitl.pending_plan_reviews(outbox, inbox)
assert [p.run_id for p in waiting] == [_RUN_ID]
assert waiting[0].plan, "an expert cannot answer a review that does not show them the plan"
_answer(tmp_path, decision="approve")
inbox_file = tmp_path / "review-inbox" / f"{_RUN_ID}-plan-review-answer.json"
wrong = json.loads(inbox_file.read_text(encoding="utf-8"))
wrong["request_id"] = "someone-elses-review"
inbox_file.write_text(json.dumps(wrong), encoding="utf-8")
assert hitl.pending_plan_reviews(outbox, inbox) == waiting, (
"an answer to another review must not clear this one"
)
_answer(tmp_path, decision="approve")
assert hitl.pending_plan_reviews(outbox, inbox) == []
# ---------------------------------------------------------------------------------------------
# 5. THE BUDGET MUST SPAN THE SUSPENSION, NOT RESTART WITH IT
# ---------------------------------------------------------------------------------------------
def test_a_resumed_exploration_does_not_get_a_fresh_budget(tmp_path, capsys) -> None:
"""T10: the hole a park would otherwise open.
Both budget channels live in the process: a fresh ``TokenMeter`` and an empty ledger mean a
resumed exploration could spend its whole cap AGAIN, once per park unbounded consumption
behind guards that all look satisfied (the S3.4 class). The suspended state therefore carries
what was already spent, and the resume starts from it.
RED when the carry-over is detached: the resumed run then reports a spend of its own calls
only, and the ledger restarts at round 1.
"""
assert run.main(_park_argv(tmp_path)) == 0, capsys.readouterr().err
parked = ex.load_parked(_question(tmp_path))
assert parked.tokens_spent > 0, (
"the parked run made model calls; a zero here would make the assertion below vacuous"
)
_answer(tmp_path, decision="approve")
assert _resume_in_a_fresh_process(tmp_path).returncode in (0, 1)
artefact = _artefact(tmp_path)
assert artefact["tokens_spent"] > parked.tokens_spent, (
"the resumed exploration must add to the day-1 spend, never start over from zero"
)
def test_the_revision_cap_is_counted_across_the_suspension_not_restarted_by_it(
tmp_path, capsys
) -> None:
"""T11: the hole the carry-over closes, and the reason it exists at all.
A revise costs two manager calls, emits no ledger and consumes no round (§ F, A3), so
``max_plan_revisions`` is the ONLY bound on it. With ``max_plan_revisions=1`` a second revision
must be refused and refused on the strength of what the FIRST process did, which only the
carried ``plan_reviews`` can say.
RED when the carry-over is detached: every leg then counts zero prior revisions, the cap never
binds, and the run parks a third time. That mutation left the entire suite green before this
test existed, which is exactly the vacuous-gate class it was written against.
"""
assert run.main(_park_argv(tmp_path, max_plan_revisions=1)) == 0, capsys.readouterr().err
_answer(tmp_path, decision="revise", feedback=_FEEDBACK)
assert _resume_in_a_fresh_process(tmp_path).returncode == 0
assert _question(tmp_path)["index"] == 1, "the first revision must be applied"
_answer(tmp_path, decision="revise", feedback="And once more.")
second = _resume_in_a_fresh_process(tmp_path)
assert second.returncode in (0, 1), second.stderr
assert "Traceback" not in second.stderr, second.stderr
artefact = _artefact(tmp_path)
assert artefact["stop"] == "plan_revisions_exhausted", (
"the second revision is over the cap and must STOP the exploration, never be sent"
)
assert (
hitl.pending_plan_reviews(str(tmp_path / "outbox"), str(tmp_path / "review-inbox")) == []
), "a stopped exploration must not leave a third question waiting for anybody"
def test_the_review_history_survives_every_leg_not_just_the_last(tmp_path, capsys) -> None:
"""T16: the carried ``plan_reviews`` are the RECORD, and the record is the only witness.
Written because the mutation that detaches ``trace.plan_reviews.extend(parked.plan_reviews)``
left the ENTIRE suite green (measured, økt 64): the revision cap counts
``parked.plan_reviews`` DIRECTLY, so it binds either way, and the first two legs cannot tell
the difference a park with an empty carried history and one with none look identical until
there are two reviews to carry. An unmeasured seam is this repo's recurring defect class, so
the gate is the THIRD leg, where the two implementations finally diverge.
RED when the carry-over is detached: the artefact then records only the review the LAST
process saw the day-1 revision vanishes from the run's own history — and the next question
is numbered 1 again, so two distinct reviews of one run share an index.
"""
second_feedback = "And check the pumps while you are at it."
assert run.main(_park_argv(tmp_path, max_plan_revisions=2)) == 0, capsys.readouterr().err
assert _question(tmp_path)["index"] == 0
_answer(tmp_path, decision="revise", feedback=_FEEDBACK)
assert _resume_in_a_fresh_process(tmp_path).returncode == 0
assert _question(tmp_path)["index"] == 1, "the first revision must be applied"
_answer(tmp_path, decision="revise", feedback=second_feedback)
third = _resume_in_a_fresh_process(tmp_path)
assert third.returncode == 0, third.stderr
assert _question(tmp_path)["index"] == 2, (
"the third question is the third review of this run — a resume that dropped the carried "
"history would number it 1 again, and two different plans would share one index"
)
reviews = _artefact(tmp_path)["plan_reviews"]
assert [r["feedback"] for r in reviews] == [_FEEDBACK, second_feedback], (
"the record must carry every decision the expert made, not only the most recent one: "
"what a human told the loop on day 1 is not superseded by what they said on day 2"
)
def test_what_the_first_process_found_survives_into_the_resumed_mandate(tmp_path) -> None:
"""T12: the other half of the carry-over — the loop's own findings.
A plan review can fire mid-run after a stall, and everything the loop found before it would be
lost if the mandate were minted from only what the resuming process observed. Driven through a
CRAFTED parked state (the ``budget_stop`` precedent) because the scripted manager never stalls,
so the mid-run park has no reachable path today but the carry-over that serves it does.
RED when the ledger and hypotheses are dropped on resume: the mandate then names only what the
second leg saw, and the returned ledger restarts at the resumed round.
"""
import asyncio
import dataclasses
from portfolio_optimiser.simulation import scripted_factory
factory = scripted_factory(_REPLIES, [])
contract = ex.load_exploration_contract(_config_file(tmp_path))
checkpoints = str(tmp_path / "checkpoints")
with pytest.raises(ex.PlanReviewParked) as caught:
asyncio.run(
ex.explore(
"Find the cheapest saving.",
contract=contract,
bundle_dirs=(str(_BUNDLE_DIR),),
client_factory=factory,
checkpoint_dir=checkpoints,
)
)
carried = dataclasses.replace(
caught.value.parked,
hypotheses=("HYPOTHESIS: " + json.dumps({"label": "Carried", "rationale": "found first"}),),
ledger=(
ex.LedgerEntry(
round_index=1,
is_request_satisfied=False,
is_in_loop=False,
is_progress_being_made=True,
next_speaker="hypothesiser",
instruction_or_question="keep going",
speaker_known=True,
),
),
)
result = asyncio.run(
ex.resume_exploration(
carried,
ex.PlanReviewDecision.approve(),
checkpoint_dir=checkpoints,
client_factory=factory,
)
)
assert "Carried" in [a.label for a in result.mandate.approaches], (
"a hypothesis the FIRST process found must reach the mandate the second one mints"
)
assert result.ledger_log[0].instruction_or_question == "keep going", (
"the resumed ledger must continue the suspended run's, not restart it"
)
# ---------------------------------------------------------------------------------------------
# 6. THE CLI PARTITION — two doors onto one review, and the preconditions
# ---------------------------------------------------------------------------------------------
def test_the_two_review_doors_are_refused_together(tmp_path, capsys) -> None:
"""T11: ``--plan-review`` and ``--checkpoint-dir`` are two sources of one answer.
Refused rather than ranked, for the reason ``--explore`` + ``--mandate`` is: silently
preferring one would mean an operator who asked for the asynchronous door got the synchronous
one, and found out by being blocked at a terminal.
"""
rc = run.main([*_park_argv(tmp_path), "--plan-review"])
err = capsys.readouterr().err
assert rc == 1
assert "--plan-review" in err and "--checkpoint-dir" in err, err
def test_an_asynchronous_door_without_somewhere_to_put_the_question_is_refused_early(
tmp_path, capsys
) -> None:
"""T12: the økt-57 hoist. The question artefact IS the door — without ``--outbox-dir`` and
``--run-id`` the exploration would spend a full budget and then have nowhere to say what it
was waiting for. Refused before the first model call; the empty checkpoint dir is what proves
nothing ran.
"""
argv = [a for a in _park_argv(tmp_path) if a not in ("--outbox-dir", str(tmp_path / "outbox"))]
rc = run.main(argv)
err = capsys.readouterr().err
assert rc == 1
assert "--outbox-dir" in err and "--checkpoint-dir" in err, err
assert not (tmp_path / "checkpoints").exists() or not list(
(tmp_path / "checkpoints").iterdir()
), "the refusal must fire before the exploration starts, not after it has paid"
def test_a_review_with_neither_door_is_still_refused_and_says_which_two_exist(
tmp_path, capsys
) -> None:
"""T13: the F4 refusal is WIDENED, never weakened.
``enable_plan_review`` with no reviewer at all is still a hang. The message must now name BOTH
doors, because an operator told only about ``--plan-review`` cannot find the asynchronous one.
"""
argv = [
a
for a in _park_argv(tmp_path)
if a not in ("--checkpoint-dir", str(tmp_path / "checkpoints"))
]
rc = run.main(argv)
err = capsys.readouterr().err
assert rc == 1
assert "--plan-review" in err and "--checkpoint-dir" in err, err
@pytest.mark.parametrize(
"drop,expected",
[
(("--checkpoint-dir",), "--checkpoint-dir"),
(("--review-inbox",), "--review-inbox"),
],
)
def test_a_resume_names_the_coordinate_it_is_missing(
tmp_path, capsys, drop: tuple[str, ...], expected: str
) -> None:
"""T14: a resume needs the checkpoints AND the inbox. Refused by NAME rather than falling
through to a generic message, so an operator is told which of the two to add.
"""
argv = _resume_argv(tmp_path)
for flag in drop:
i = argv.index(flag)
del argv[i : i + 2]
rc = run.main(argv)
err = capsys.readouterr().err
assert rc == 1
assert expected in err, err
def test_resume_and_explore_are_refused_together(tmp_path, capsys) -> None:
"""T15: two sources of one exploration. ``--resume`` continues the exploration recorded in the
parked file; ``--explore`` starts a new one. Merging them would silently drop one prompt.
"""
argv = [
*_resume_argv(tmp_path),
"--explore",
"A different question entirely.",
"--explore-config",
_config_file(tmp_path),
]
rc = run.main(argv)
err = capsys.readouterr().err
assert rc == 1
assert "--resume" in err and "--explore" in err, err

View file

@ -59,7 +59,10 @@ def test_local_backend_returns_client_no_network(monkeypatch: pytest.MonkeyPatch
def test_azure_backend_fails_fast_without_endpoint(monkeypatch: pytest.MonkeyPatch) -> None: def test_azure_backend_fails_fast_without_endpoint(monkeypatch: pytest.MonkeyPatch) -> None:
# Fail-fast (no silent default endpoint) — the operator must supply the Foundry endpoint. # Fail-fast (no silent default endpoint) — the operator must supply the Foundry endpoint.
# Fase 4b: BOTH accepted names must be cleared, or this asserts "without OUR endpoint" while
# claiming "without endpoint" — the platform-injected name would satisfy the call.
monkeypatch.delenv("PORTFOLIO_FOUNDRY_PROJECT_ENDPOINT", raising=False) monkeypatch.delenv("PORTFOLIO_FOUNDRY_PROJECT_ENDPOINT", raising=False)
monkeypatch.delenv("FOUNDRY_PROJECT_ENDPOINT", raising=False)
with pytest.raises(ValueError): with pytest.raises(ValueError):
get_backend("azure").create_chat_client(model="dummy-deployment") get_backend("azure").create_chat_client(model="dummy-deployment")

View file

@ -0,0 +1,327 @@
"""An UN-ANCHORED run says so — in a machine-readable field AND in one line on stdout.
S4.0 made the deterministic gate anchorable: when a bundle ships ``cost-baseline.json`` the
validator's stage 0 reconciles every ``affected_item`` against the project's own cost lines BEFORE
the solver, and when it does not, that stage is simply skipped (``None`` = pre-amendment behaviour,
which is what keeps every commons-owned golden bundle running). The anchoring stayed OPTIONAL on
purpose and that is not what this file changes.
What it changes is that the skip was INVISIBLE. Measured (session 48, ``9d149b3``): four
``--live-dry-run``s over copies of the veglys bundle intact rc 0 · without ``validator-input.json``
rc 1 · **without ``cost-baseline.json`` rc 0 with no message at all** · corrupt baseline rc 1. And
``grep baseline provenance.py outbox.py`` returned 0 hits, so neither the stamp nor the outbox
artefacts carried it either. An operator could therefore run the whole gate un-anchored, read a
clean rc 0, and have nothing anywhere to tell them the fabrication stage never ran.
Two teeth, both small:
1. ``ProvenanceStamp.cost_baseline_anchored`` a REQUIRED bool, no default. "Was the gate
anchored" is a binary fact about a falsifier, in the same class as ``BudgetExceeded``'s
``kind``/``limit``/``observed`` (-(y)): it must be readable by machine, not inferred from
prose. It carries no default because BOTH defaults lie ``True`` would let a forgetful
constructor claim an anchoring that never happened, ``False`` would under-claim a real one and
a binary fact with no honest default is exactly what a required field is for. It reaches the
outbox for free: ``outbox.write_proposal`` dumps the whole stamp.
2. ``run.cost_baseline_notice`` ONE renderer, rendering ONE line when the run is un-anchored and
``None`` when it is anchored. Omission, not an empty row, mirrors ``mandate.announce``'s rule
that a line for something the run does not have is left out rather than rendered blank.
**The line is rendered from the run's OWN resolution, never from a second read of the bundle**
(-(p)). ``run_project`` is the single place that calls ``okf.load_optional_cost_baseline`` on the
run path; the fact leaves the run as a typed field on ``DryRunReport`` and on ``ProvenanceStamp``,
and ``main`` prints from that. Rendering it inside ``mandate.announce`` was MEASURED and rejected:
``announce`` fires only when ``--mandate`` is given, so the very runs this file exists for the
four bare dry-runs above, none of which had a mandate would still have printed nothing.
Arms:
(a) the provenance field is ``False`` on an un-anchored bundle run and ``True`` on an anchored one,
end-to-end through ``run_project`` (+ the road path, which is anchored by construction);
(b) the notice EXISTS un-anchored and is ABSENT anchored asserted on a sentinel that the anchored
branch cannot contain, because it prints no line at all (never a substring both branches share:
the 08-09 class);
(c) both CLI surfaces carry it ``--live-dry-run`` and the full run;
(d) the outbox artefact carries the field.
"""
from __future__ import annotations
import json
from pathlib import Path
import pytest
from conftest import SyntheticUsageChatClient
from portfolio_optimiser import run
from portfolio_optimiser.ir import AffectedItem, SavingsProposal
from portfolio_optimiser.provenance import Citation, ProvenanceStamp
from portfolio_optimiser.retrieval import TextSpan
from portfolio_optimiser.run import (
DryRunReport,
PortfolioResult,
RunResult,
cost_baseline_notice,
run_project,
)
from portfolio_optimiser.validator import Rejection
from portfolio_optimiser.verdicts import ProposalFeatures, Verdict, VerdictStore
_DATA = Path(__file__).resolve().parents[1] / "src" / "portfolio_optimiser" / "data" / "bundles"
#: The ONLY repo-local bundle shipping a ``cost-baseline.json`` (S4.0 fixture) -> anchored.
BASELINE_BUNDLE = _DATA / "bygg-energi-baseline-mikro"
#: A bundle written before the amendment -> legitimately un-anchored (this is the case under test).
PRE_AMENDMENT_BUNDLE = _DATA / "bygg-energi-mikro-a"
_VERDICT_INPUT = {"decision": "approved", "rationale": "expert reviewed (sim)"}
#: A reply that reconciles against the S4.0 fixture's own line, so the ANCHORED control run reaches
#: a proposal rather than being rejected by stage 0 — the arms below are about visibility, and a
#: control that died in the gate would not exercise the stamp.
_REPLY = json.dumps(
{
"measure": "LED-retrofit",
"affected_items": [{"code": "ENERGI-TOTAL-EL", "quantity": 180000, "unit_cost": 1.0}],
"claimed_saving_nok": 30000,
}
)
#: The word the un-anchored line carries and the anchored branch cannot: it prints NO line at all.
_SENTINEL = "un-anchored"
def _factory(reply: str = _REPLY):
def factory(role: str):
return SyntheticUsageChatClient(default_reply=reply)
return factory
@pytest.fixture(autouse=True)
def _isolate_model_env(monkeypatch: pytest.MonkeyPatch) -> None:
"""Hermetic env (mirrors ``test_scripted_cli_door_loadbearing``): the operator's Foundry
overrides must not reach the CLI arms."""
monkeypatch.delenv("PORTFOLIO_MODEL_MAP", raising=False)
monkeypatch.delenv("PORTFOLIO_FOUNDRY_PROJECT_ENDPOINT", raising=False)
# --- Arm (a): the structured field ----------------------------------------------------------------
async def test_provenance_records_an_unanchored_bundle_run(fresh_store) -> None:
"""RED: a bundle with no ``cost-baseline.json`` stamps ``cost_baseline_anchored=False``. Detach
the wiring (stamp a constant, or drop the field) and the run again records nothing about the
skipped stage."""
result = await run_project(
"BYGG-ENERGI-MIKRO-A",
"local",
docs_dir=str(PRE_AMENDMENT_BUNDLE),
bundle_dir=str(PRE_AMENDMENT_BUNDLE),
verdict_input=_VERDICT_INPUT,
client_factory=_factory(),
store=fresh_store,
)
assert result.provenance.cost_baseline_anchored is False
async def test_provenance_records_an_anchored_bundle_run(fresh_store) -> None:
"""Causality control: the SAME code path over a bundle that DOES ship a baseline stamps
``True``. Without this the arm above would pass on a constant ``False``."""
result = await run_project(
"BYGG-ENERGI-BASELINE-MIKRO",
"local",
docs_dir=str(BASELINE_BUNDLE),
bundle_dir=str(BASELINE_BUNDLE),
verdict_input=_VERDICT_INPUT,
client_factory=_factory(),
store=fresh_store,
)
assert result.provenance.cost_baseline_anchored is True
async def test_road_path_is_anchored_by_construction(docs_dir, fresh_store) -> None:
"""The road path derives its baseline from the reference project's own ``cost_items``, so it is
ALWAYS anchored the stamp says so rather than leaving the reader to know it."""
result = await run_project(
"FV42-GSV-E1",
"local",
docs_dir=docs_dir,
verdict_input=_VERDICT_INPUT,
client_factory=_factory(
json.dumps(
{
"measure": "Reduce scope",
"affected_items": [{"code": "05.2", "quantity": 4300.0, "unit_cost": 215.0}],
"claimed_saving_nok": 200000.0,
}
)
),
store=fresh_store,
)
assert result.provenance.cost_baseline_anchored is True
def test_the_field_has_no_default() -> None:
"""A binary fact about a falsifier gets no default: both defaults lie (see the module docstring),
so a stamp that forgot to say must not construct at all."""
with pytest.raises(Exception):
ProvenanceStamp( # type: ignore[call-arg]
citations=[],
model="m",
role="proposer",
validator_decision="validated",
token_usage=0,
)
# --- Arm (b): the renderer ------------------------------------------------------------------------
def test_notice_is_rendered_only_when_unanchored() -> None:
"""One renderer, two branches that share NO wording: un-anchored returns a line carrying the
sentinel, anchored returns ``None`` (omitted, never an empty row ``announce``'s rule)."""
unanchored = cost_baseline_notice(False)
assert unanchored is not None
assert _SENTINEL in unanchored
assert cost_baseline_notice(True) is None
def test_dry_run_report_carries_the_anchoring(fresh_store) -> None:
"""The dry-run type is the carrier for the surface the order measured: a run that stops before
the first model call still knows whether the gate would have been anchored."""
assert "cost_baseline_anchored" in DryRunReport.__dataclass_fields__
# --- Arm (c): both CLI surfaces -------------------------------------------------------------------
def _dry_run(bundle: Path, project_id: str) -> list[str]:
return [
project_id,
"--docs-dir",
str(bundle),
"--bundle-dir",
str(bundle),
"--live-dry-run",
]
def test_cli_dry_run_announces_an_unanchored_bundle(capsys) -> None:
"""RED (the measured defect, verbatim): ``--live-dry-run`` over a bundle without
``cost-baseline.json`` exits 0 and now SAYS the gate is un-anchored instead of exiting
silently."""
rc = run.main(_dry_run(PRE_AMENDMENT_BUNDLE, "BYGG-ENERGI-MIKRO-A"))
assert rc == 0
assert _SENTINEL in capsys.readouterr().out
def test_cli_dry_run_says_nothing_when_the_bundle_is_anchored(capsys) -> None:
"""Control: the anchored bundle prints NO baseline line at all. Lines for what a run does not
have are omitted (``announce``); a run that IS anchored has nothing to warn about."""
rc = run.main(_dry_run(BASELINE_BUNDLE, "BYGG-ENERGI-BASELINE-MIKRO"))
assert rc == 0
out = capsys.readouterr().out
assert _SENTINEL not in out
assert "Cost baseline" not in out
def test_cli_full_run_announces_an_unanchored_bundle(tmp_path, capsys) -> None:
"""The full-run surface too, through the offline scripted door — so the notice is a property of
a RUN, not of the dry-run branch alone."""
replies = tmp_path / "replies.json"
replies.write_text(
json.dumps({"proposer": _REPLY, "checker": "Holder. VERDICT: APPROVE"}), encoding="utf-8"
)
rc = run.main(
[
"BYGG-ENERGI-MIKRO-A",
"--docs-dir",
str(PRE_AMENDMENT_BUNDLE),
"--bundle-dir",
str(PRE_AMENDMENT_BUNDLE),
"--scripted-replies",
str(replies),
]
)
assert rc == 0
assert _SENTINEL in capsys.readouterr().out
# --- Arm (d): the outbox artefact -----------------------------------------------------------------
async def test_outbox_proposal_carries_the_anchoring(tmp_path, fresh_store) -> None:
"""The outbox needed no change of its own: the artefact dumps the whole stamp, so the field
lands in ``{run_id}-proposal.json`` the moment it exists on the stamp."""
await run_project(
"BYGG-ENERGI-MIKRO-A",
"local",
docs_dir=str(PRE_AMENDMENT_BUNDLE),
bundle_dir=str(PRE_AMENDMENT_BUNDLE),
verdict_input=_VERDICT_INPUT,
client_factory=_factory(),
store=fresh_store,
outbox_dir=str(tmp_path),
run_id="vis-1",
)
payload = json.loads((tmp_path / "vis-1-proposal.json").read_text(encoding="utf-8"))
assert payload["provenance"]["cost_baseline_anchored"] is False
# --- Arm (e): the portfolio surface (DEFENSIVE, and said out loud) --------------------------------
def _unanchored_run() -> RunResult:
"""One ``RunResult`` whose stamp says the gate was un-anchored."""
proposal = SavingsProposal(
project_id="P",
measure="m",
affected_items=[AffectedItem(code="05.2", quantity=1.0, unit_cost=1.0)],
claimed_saving_nok=1.0,
assumptions={},
)
return RunResult(
outcome=Rejection(proposal=proposal, reason="r"),
provenance=ProvenanceStamp(
citations=[Citation(file="f.md", locator=TextSpan(0, 1), snippet="x")],
model="synthetic",
role="proposer",
validator_decision="rejected",
token_usage=1,
cost_baseline_anchored=False,
),
verdict=Verdict(
id="v1",
proposal_features=ProposalFeatures(
affected_codes=frozenset({"05.2"}), measure_type="m", claimed_saving_nok=1.0
),
decision="rejected",
rationale="r",
),
retrieved=[],
store=VerdictStore([]),
debate_output="",
)
def test_portfolio_surface_announces_an_unanchored_run(monkeypatch, capsys) -> None:
"""The portfolio branch reports per project, because anchoring is a per-project fact.
Driven by a CRAFTED ``PortfolioResult`` (the ``budget_stop`` precedent in
``test_portfolio_cli_offline_loadbearing``), and for the same measured reason: no reference
project sets ``bundle_dir``, so every portfolio run today takes the road path and is anchored by
construction. This arm is therefore DEFENSIVE it guards the surface for the day a bundle-backed
project is wired into a pass, rather than covering a path reachable now."""
async def _fake(*_args, **_kwargs) -> PortfolioResult:
return PortfolioResult(
runs=(_unanchored_run(),),
store=VerdictStore([]),
validated_count=0,
rejected_count=1,
sum_claimed_saving_nok=0.0,
sum_token_usage=1,
)
monkeypatch.setattr(run, "run_portfolio", _fake)
rc = run.main(["--portfolio"])
assert rc == 0
assert _SENTINEL in capsys.readouterr().out

View file

@ -0,0 +1,183 @@
"""The catalogue call costs O(bases), never O(corpus) — and what it drops, it SAYS it dropped.
Measured 2026-08-25 (session 60, ``docs/2026-08-25-syretest-vei-ab.md``) and re-measured 26.08 with
the same instrument (``tiktoken`` ``o200k_base``, run through ``uv run --with tiktoken``, validated
first against the three commons example bundles whose numbers commons itself publishes):
list_bundles() over the three flat Vegnormal bases -> 201 196 chars / 112 116 tokens
list_bundles() over the 171 branch bases -> 234 611 chars / 124 942 tokens
The branch form (``vegnormal-okf`` ``8145c23``) closed the *bundle* side ``read_bundle`` fell 82-92
percent and made the *catalogue* side WORSE, exactly as that repo predicted: one call now costs
more than a 128k window, before the manager has read a single document.
The cause is in this repo. ``list_bundles`` returned ``Bundle.index_summary`` the WHOLE root index
body for EVERY configured base at once, plus one JSON object per unfollowed cross-link. Both grow
with the corpus, so the price of *finding out which bases exist* was set by how much those bases
contain. That is the opposite of progressive disclosure (målbilde §2/§4): the catalogue is the
cheapest rung of the ladder, and it was the most expensive.
**A MEASURED premise, felled before anything was built on it:** "the index body tells a manager what
the base is about" is FALSE for machine-imported bases. The branch bases' ``index.md`` carries no
frontmatter and no prose it is a pure link list (measured: ``B-n200-2024-gren-1-1-importert``,
959 bytes, first byte is ``-``). So the old field was not merely expensive, it was expensive AND
uninformative there; a truncated prefix loses nothing a manager was using.
**The ceiling lives in this file, not in ``explore.py``.** A test that imported the implementation's
budget would move with it, and raising the budget is precisely the regression this file exists to
catch. 500 characters per base is the number, chosen with headroom over the measured entry so that
ordinary field growth does not force a rewrite, and small enough that all 171 branch bases fit in
well under a tenth of the window they used to need.
What the arms pin, and what each one refuses:
(a) the bound itself, over many large bases refuses the unbounded form;
(b) cost does NOT track corpus size the same base with ten times the index costs the SAME, which
is the property "O(bases), not O(corpus)" stated directly rather than inferred from (a);
(c) the catalogue still IDENTIFIES what it lists without this, "return only the ids" passes (a)
perfectly and delivers a manager nothing to choose between (the repo's vacuous-gate class);
(d) truncation is ANNOUNCED, never silent, and the excerpt is a VERBATIM prefix validation, never
repair (``write_concept_file``'s rule). A base whose index FITS is not marked truncated and gets
its whole body: omission, never a lie in either direction;
(e) the ladder is intact ``read_file(id, "index.md")`` still returns the COMPLETE index, so the
bound is a disclosure level, not data loss;
(f) an unreachable link survives as a COUNT the fact stays visible (session 51's "a skip is
tolerated but no longer silent"), while the unbounded per-link detail does not ride along in a
call whose whole job is to be cheap. The detail is still carried where it is actionable, on
``RunResult.skipped_links`` / ``DryRunReport.skipped_links``;
(g) the CONTROL the ceiling is proved to discriminate. One base's raw index body alone exceeds the
budget for the entire catalogue, so a green (a) means the bound fired, not that the fixture was
small (Verifiseringsloven face 4: a gate that can only pass proves nothing).
"""
from __future__ import annotations
import json
from pathlib import Path
from portfolio_optimiser.explore import navigator_tools
#: Characters per base the catalogue may cost. Test-owned on purpose — see the module docstring.
_CEILING_CHARS_PER_BASE = 500
#: Big enough that the old form blew the ceiling by two orders of magnitude (arm (g) measures it).
_MANY_CONCEPTS = 300
def _write_base(root: Path, name: str, concepts: int, *, dangling: int = 0) -> str:
base = root / name
base.mkdir(parents=True)
lines = [f"# {name}", "", "Denne basen dekker et avgrenset fagområde.", ""]
for i in range(concepts):
lines.append(f"- [konsept-{i:04d}](konsept-{i:04d}.md)")
(base / f"konsept-{i:04d}.md").write_text(
f"---\ntype: concept\n---\n\n# Konsept {i}\n\nInnhold.\n", encoding="utf-8"
)
for i in range(dangling):
lines.append(f"- [borte-{i:04d}](borte-{i:04d}.md)")
(base / "index.md").write_text(
"---\ntype: index\n---\n\n" + "\n".join(lines) + "\n", encoding="utf-8"
)
return str(base)
def _catalogue(dirs: list[str]) -> list[dict[str, object]]:
tools = {t.name: t for t in navigator_tools(tuple(dirs))}
return tools["list_bundles"].func()
def _blob(entries: object) -> str:
return json.dumps(entries, ensure_ascii=False)
def test_catalogue_cost_is_bounded_per_base(tmp_path: Path) -> None:
"""(a) Many large bases, one call: the payload stays under a per-base ceiling."""
dirs = [_write_base(tmp_path, f"base-{n}", _MANY_CONCEPTS) for n in range(5)]
blob = _blob(_catalogue(dirs))
assert len(blob) <= len(dirs) * _CEILING_CHARS_PER_BASE, (
f"catalogue cost {len(blob)} chars over {len(dirs)} bases exceeds the ceiling "
f"{len(dirs) * _CEILING_CHARS_PER_BASE}"
)
def test_catalogue_cost_does_not_track_corpus_size(tmp_path: Path) -> None:
"""(b) Ten times the index, the same price. O(bases), not O(corpus)."""
small = _write_base(tmp_path, "small", 30)
large = _write_base(tmp_path, "large", 300)
entries = {str(e["id"]): e for e in _catalogue([small, large])}
small_entry, large_entry = entries["small"], entries["large"]
# The excerpt is a fixed window, so ten times the index yields the same number of characters.
# (The counts beside it grow by a DIGIT, which is honest and logarithmic — this arm is about
# the field that used to grow linearly, and asserting on the whole blob would measure that
# digit instead of the property.)
assert len(str(large_entry["index_excerpt"])) == len(str(small_entry["index_excerpt"]))
assert len(_blob(large_entry)) <= _CEILING_CHARS_PER_BASE
def test_catalogue_still_identifies_every_base(tmp_path: Path) -> None:
"""(c) The anti-vacuity arm: bounded is not the same as empty."""
dirs = [_write_base(tmp_path, f"base-{n}", 50) for n in range(3)]
entries = _catalogue(dirs)
assert sorted(str(e["id"]) for e in entries) == ["base-0", "base-1", "base-2"]
for entry in entries:
excerpt = str(entry["index_excerpt"])
assert excerpt.strip(), "a catalogue that says nothing about a base cannot be chosen from"
assert str(entry["id"]) in excerpt
# How big the base is, is part of choosing one: with the index body no longer riding along,
# this count is the only thing left that says what read_bundle would cost.
assert entry["documents"] == 50
def test_truncation_is_announced_and_the_excerpt_is_verbatim(tmp_path: Path) -> None:
"""(d) Both directions: a cut index SAYS it was cut; a whole one is not marked, and is whole."""
from portfolio_optimiser import okf
large = _write_base(tmp_path, "large", 300)
tiny = _write_base(tmp_path, "tiny", 1)
entries = {str(e["id"]): e for e in _catalogue([large, tiny])}
assert entries["large"]["index_truncated"] is True
body = okf.navigate_bundle(large).index_summary
assert body.startswith(str(entries["large"]["index_excerpt"]))
assert len(str(entries["large"]["index_excerpt"])) < len(body)
assert entries["tiny"]["index_truncated"] is False
assert str(entries["tiny"]["index_excerpt"]) == okf.navigate_bundle(tiny).index_summary
def test_the_full_index_is_still_one_call_away(tmp_path: Path) -> None:
"""(e) The bound is a disclosure LEVEL, not data loss."""
large = _write_base(tmp_path, "large", 300)
tools = {t.name: t for t in navigator_tools((large,))}
whole = tools["read_file"].func(bundle_id="large", path="index.md")
assert whole.count("- [konsept-") == 300
def test_unreachable_links_survive_as_a_count(tmp_path: Path) -> None:
"""(f) The fact stays; the unbounded per-link detail does not ride along."""
base = _write_base(tmp_path, "holes", 20, dangling=40)
entry = _catalogue([base])[0]
assert entry["unreachable_links"] == 40
assert len(_blob([entry])) <= _CEILING_CHARS_PER_BASE
def test_control_the_unbounded_form_would_blow_the_ceiling(tmp_path: Path) -> None:
"""(g) The ceiling discriminates — proved, not assumed."""
from portfolio_optimiser import okf
large = _write_base(tmp_path, "large", _MANY_CONCEPTS)
body = okf.navigate_bundle(large).index_summary
assert len(body) > 5 * _CEILING_CHARS_PER_BASE

View file

@ -0,0 +1,87 @@
"""P4 pkt. 5 — the console entry points are part of the FROZEN install surface.
The README's central claim is "download -> run". Until now every documented invocation went
through ``uv run python -m portfolio_optimiser.<module>``, which works but is not an install
surface: nothing in the distribution metadata promised a command. This test pins the two commands
that the demo and the framework CLI are reached by.
Why read ``importlib.metadata`` and not ``pyproject.toml``: a ``[project.scripts]`` line that has
never been ``uv sync``-ed is a claim, not a command. The distribution metadata is what a fresh
clone materializes after ``uv sync``, so it is the only reading that can fail when the surface is
merely *declared*. The declaration is checked too (the TOML is the source the metadata is built
from), but the metadata assert is the load-bearing one.
Scope, stated so it is a decision and not an oversight: exactly TWO commands are exposed. ``run``
is the framework CLI (three documented modes) and ``simulation`` is the offline end-to-end proof
the README points a newcomer at. ``costsim`` / ``hitl`` / ``preflight`` keep the ``-m`` form they
are operator utilities, not the product's front door, and every name added here is a name the
freeze has to carry.
"""
from __future__ import annotations
import importlib
import importlib.metadata
from pathlib import Path
import pytest
_DIST = "portfolio-optimiser"
# command name -> "module:function" target, verbatim as it must appear in the metadata.
_EXPECTED: dict[str, str] = {
"portfolio-optimiser": "portfolio_optimiser.run:main",
"portfolio-optimiser-demo": "portfolio_optimiser.simulation:main",
}
def _console_scripts() -> dict[str, str]:
"""The installed distribution's console scripts, as ``{name: "module:function"}``."""
return {
ep.name: ep.value
for ep in importlib.metadata.distribution(_DIST).entry_points
if ep.group == "console_scripts"
}
@pytest.mark.parametrize(("name", "target"), sorted(_EXPECTED.items()))
def test_console_script_is_installed(name: str, target: str) -> None:
"""T-P4.5a: the command exists in the INSTALLED distribution and points at the right target.
RED when the ``[project.scripts]`` entry is removed (or when it is added to the TOML without a
re-sync which is the same failure a fresh clone would hit for real).
"""
scripts = _console_scripts()
assert name in scripts, (
f"console script {name!r} is not installed; found {sorted(scripts)}. "
"Declare it under [project.scripts] in pyproject.toml and re-run `uv sync`."
)
assert scripts[name] == target
@pytest.mark.parametrize("name", sorted(_EXPECTED))
def test_console_script_target_resolves(name: str) -> None:
"""T-P4.5b: the INSTALLED target actually imports and is callable — a typo'd module or function
name installs a command that only fails when the operator runs it, which on demo day is on
stage. Resolves what the distribution says, not what this file expects: resolving ``_EXPECTED``
would only ever re-check a constant against itself."""
target = _console_scripts()[name]
module_name, _, func_name = target.partition(":")
module = importlib.import_module(module_name)
entry = getattr(module, func_name, None)
assert callable(entry), f"{target} (behind {name}) does not resolve to a callable"
def test_pyproject_declares_exactly_these_scripts() -> None:
"""T-P4.5c: the declaration in ``pyproject.toml`` matches the installed set exactly.
Guards the drift direction the metadata assert cannot see: a script installed from an older
sync but since deleted from the TOML would leave a command that a fresh clone never gets.
"""
pyproject = Path(__file__).resolve().parents[1] / "pyproject.toml"
try:
import tomllib
except ModuleNotFoundError: # pragma: no cover - Python 3.10 has no tomllib
pytest.skip("tomllib is 3.11+; the installed-metadata asserts cover the same surface")
declared = tomllib.loads(pyproject.read_text(encoding="utf-8"))["project"]["scripts"]
assert declared == _EXPECTED

View file

@ -0,0 +1,163 @@
"""The scripted demo proposer is DATA, keyed on the project the prompt names (demo-week plan §3
Monday, §4 risk 2): adding a project to the walkthrough must be a registry entry, never a
hand-written second script under time pressure.
**The open decision this file closes measured, not assumed.** The plan (§6) stated explicitly that
"the candidate is uniquely identifiable in the blob" was UNVERIFIED. Dumping every blob that reaches
the ``reply_selector`` across a full two-run simulation shows two prompt shapes:
* the DEBATE prompt (``run.py``: ``"Find a cost-saving measure for {project.id}.\\nContext:\\n..."``)
the whole bundle context, ~12k chars;
* the GENERATION prompt (``generate._build_messages``: ``"Project: {id} - {name}"`` plus, as its
context, the DEBATE OUTPUT).
The cost code and the measure name reach the *generation* prompt only because the scripted reply
itself is echoed back as ``debate_output`` keying on them would key the script on its own output.
The project id is the one identifier that BOTH shapes carry and that the FRAMEWORK stamps. So the
project id is the key, and these tests pin that decision.
The load-bearing set:
- per-project keying (RED on any proposer that ignores the prompt);
- the flip key is scoped to its own candidate (RED if one global flip token is shared, which would
make one project's falsification correct another project's proposal);
- unknown and ambiguous prompts FAIL LOUD (RED on a first-match/default-fallback implementation
the silent-wrong-script failure this whole seam exists to prevent);
- the wiring: the simulation's own proposer is the registry-driven one (RED the moment it reverts to
two hard-coded constants the seam would exist while the demo still ran off a hand-written
script);
- the data-entry rule: a candidate's flip key must be ABSENT from the bundle it is demoed against,
or attempt 1's prompt already contains it and the correction proves nothing.
"""
from __future__ import annotations
from pathlib import Path
import pytest
from portfolio_optimiser import simulation
from portfolio_optimiser.simulation import (
ScriptedCandidate,
ScriptedCandidateError,
scripted_proposer,
simulate_learning_loop,
)
_BUNDLE_DIR = Path(__file__).resolve().parents[1] / "shared" / "examples" / "bygg-energi-mikro"
# A two-entry registry: the whole point is that a SECOND project is data. The claims are distinct
# per candidate so an assert can tell whose script answered, and the flip keys are distinct so a
# leaked global flip token is observable.
_ALFA = ScriptedCandidate(
project_id="DEMO-ALFA",
overclaimed='{"measure":"alfa","affected_items":[],"claimed_saving_nok":111111}',
corrected='{"measure":"alfa","affected_items":[],"claimed_saving_nok":11}',
flip_key="111111",
)
_BETA = ScriptedCandidate(
project_id="DEMO-BETA",
overclaimed='{"measure":"beta","affected_items":[],"claimed_saving_nok":222222}',
corrected='{"measure":"beta","affected_items":[],"claimed_saving_nok":22}',
flip_key="222222",
)
_REGISTRY = (_ALFA, _BETA)
def _generation_prompt(project_id: str, *, tail: str = "") -> str:
"""A prompt in the measured shape of ``generate._build_messages`` — the framework stamps the
project id; ``tail`` stands in for the appended rejection block."""
return (
"Propose ONE concrete cost-saving measure for this project.\n"
f"Project: {project_id} - Et prosjekt\n"
"Context (prior verdicts / cited cost docs):\n\n" + tail
)
def test_the_reply_is_keyed_on_the_project_the_prompt_names() -> None:
"""LOAD-BEARING: one registry, two projects, two different scripts — selected by the project id
the framework stamped into the prompt. RED on a proposer that returns a constant, which is
exactly the hand-written-script state this replaces."""
proposer = scripted_proposer(_REGISTRY)
assert proposer(_generation_prompt("DEMO-ALFA"), "proposer") == _ALFA.overclaimed
assert proposer(_generation_prompt("DEMO-BETA"), "proposer") == _BETA.overclaimed
def test_the_falsification_flips_only_its_own_candidate() -> None:
"""LOAD-BEARING: the flip key belongs to the candidate, not to the module. A prompt that names
BETA but carries ALFA's rejected figure must still get BETA's *overclaimed* reply otherwise one
project's falsification would silently correct another project's proposal, and Step 5 would show
a correction nothing caused. RED on a single shared flip token.
**The prompt names the SECOND registry entry on purpose** (measured): the obvious wrong
implementation reads ``candidates[0].flip_key``, and asserting on the first entry cannot tell
that apart from reading the matched candidate's — the two coincide there. A test that cannot
separate two implementations proves nothing, so the assert is made where they diverge."""
proposer = scripted_proposer(_REGISTRY)
assert proposer(_generation_prompt("DEMO-BETA", tail=_ALFA.flip_key), "proposer") == (
_BETA.overclaimed
)
# Control: BETA's OWN key does flip it, so the assert above is not merely observing a proposer
# that never corrects at all.
assert proposer(_generation_prompt("DEMO-BETA", tail=_BETA.flip_key), "proposer") == (
_BETA.corrected
)
def test_an_unregistered_project_fails_loud() -> None:
"""CONTROL: no default, no first-match fallback. A project without a registry entry must raise —
a demo that silently answers with ANOTHER project's numbers is worse than one that stops, because
the numbers would look plausible on screen. RED on a fallback implementation."""
proposer = scripted_proposer(_REGISTRY)
with pytest.raises(ScriptedCandidateError, match="DEMO-GAMMA|no scripted candidate"):
proposer(_generation_prompt("DEMO-GAMMA"), "proposer")
def test_an_ambiguous_prompt_fails_loud() -> None:
"""CONTROL: two registered ids in one blob is undecidable, so it must raise rather than pick.
This is reachable for real a bundle's context can mention a sibling project — and the fix is
the DATA (distinct ids), which is why the failure must be visible at rehearsal, not at the demo."""
proposer = scripted_proposer(_REGISTRY)
both = _generation_prompt("DEMO-ALFA") + "\nSe også DEMO-BETA.\n"
with pytest.raises(ScriptedCandidateError, match="ambiguous|DEMO-BETA"):
proposer(both, "proposer")
def test_the_simulation_proposer_is_registry_driven() -> None:
"""WIRING: the seam must be the one the DEMO runs on. RED the moment ``_proposer_reply`` reverts
to two hard-coded constants a constant proposer answers an unknown project happily."""
assert any(c.project_id == simulation._PROJECT_ID for c in simulation._CANDIDATES), (
"the demo project has no registry entry — the walkthrough would raise at the first turn"
)
with pytest.raises(ScriptedCandidateError):
simulation._proposer_reply(_generation_prompt("IKKE-REGISTRERT"), "proposer")
def test_every_candidates_flip_key_is_absent_from_the_demo_bundle() -> None:
"""DATA-ENTRY RULE: the flip key is what tells the scripted proposer that the validator's
rejection came back. If it already occurs in the bundle, attempt 1's prompt carries it, the
proposer 'corrects' before anything was falsified, and Step 5 shows a correction with no cause.
Checked against the demo bundle for every registered candidate, so adding an entry with a
colliding key is caught here rather than on stage."""
corpus = "\n".join(p.read_text("utf-8") for p in sorted(_BUNDLE_DIR.rglob("*")) if p.is_file())
assert corpus, "control: the bundle was read, so an absence assert below means something"
for candidate in simulation._CANDIDATES:
assert candidate.flip_key not in corpus, (
f"{candidate.project_id}: flip key {candidate.flip_key!r} occurs in the demo bundle, so "
"attempt 1's prompt already contains it and the correction proves nothing"
)
async def test_the_project_id_is_data_too(tmp_path: Path) -> None:
"""A new bundle is pointed at by ARGUMENT, project id included — otherwise Tuesday's content
swap still needs a code edit. RED if ``project_id`` is ignored and the module constant is used:
the bundle's own IR projection would then match and no error would surface."""
with pytest.raises(ValueError, match="project_id"):
await simulate_learning_loop(
str(_BUNDLE_DIR), str(tmp_path), project_id="ET-ANNET-PROSJEKT"
)

View file

@ -0,0 +1,116 @@
"""P4 pkt. 2 — the demo's round-cap notice is damped, and the damping is NARROW by construction.
Measured on 2026-08-09, the demo wrote six stderr lines: two ``ExperimentalWarning``s from
``agent_framework`` (import time), two ``GroupChatOrchestrator reached max_rounds=3; forcing
completion.`` notices (``logging``, reaching stderr via ``logging.lastResort``), a blank line, and
the deliberately non-deterministic ``arbeidskopi:`` line.
**Only the round-cap notices are damped, and this file only tests those.** The two import-time
warnings fire while ``portfolio_optimiser/__init__.py`` imports ``run`` always before
``simulation``'s own imports, under both invocation forms — so damping them would mean filtering
warnings inside the library package on every consumer's behalf. They are pinned in pkt. 3 instead.
Measured, not assumed: see the decision recorded at the top of ``simulation.py``.
**Why narrowness is the property under test, not the silence.** Plan P4 pkt. 3 pins stderr to a
byte-fasit so a new warning after a MAF bump or a subtree pull TRIPS the pin. A damping keyed on the
logger rather than the message would swallow that new warning too, leaving a pin that can no longer
fail for the reason it exists. So the drop-assert here is paired with a control proving an
unmeasured message from the very same logger still gets through a filter that can only ever say
"drop" proves nothing.
"""
from __future__ import annotations
import logging
import subprocess
import sys
from portfolio_optimiser.simulation import ROUND_CAP_LOGGER, quiet_expected_round_cap_notice
_REAL_ROUND_CAP_MESSAGE = "GroupChatOrchestrator reached max_rounds=3; forcing completion."
class _Recorder(logging.Handler):
"""Collects whatever survives the logger's own filters."""
def __init__(self) -> None:
super().__init__()
self.messages: list[str] = []
def emit(self, record: logging.LogRecord) -> None:
self.messages.append(record.getMessage())
def _record_through_real_logger(message: str, *, quiet: bool) -> list[str]:
"""Log ``message`` through the REAL emitting logger and return what reached a handler.
Logger-level filters run in ``Logger.handle`` BEFORE ``callHandlers``, so a dropped record never
reaches the recorder the same point at which ``logging.lastResort`` would otherwise have
written it to stderr in the demo process.
"""
logger = logging.getLogger(ROUND_CAP_LOGGER)
recorder = _Recorder()
logger.addHandler(recorder)
installed: logging.Filter | None = None
try:
if quiet:
installed = quiet_expected_round_cap_notice()
logger.warning("%s", message)
finally:
logger.removeHandler(recorder)
if installed is not None:
logger.removeFilter(installed)
return recorder.messages
def test_round_cap_notice_reaches_stderr_without_the_damping() -> None:
"""T-P4.2a (the RED-proof for the test itself): the notice really is emitted through this
logger name. The negative assert below is worthless unless the event provably happens first."""
assert _record_through_real_logger(_REAL_ROUND_CAP_MESSAGE, quiet=False) == [
_REAL_ROUND_CAP_MESSAGE
]
def test_round_cap_notice_is_dropped_by_the_damping() -> None:
"""T-P4.2b: with the damping installed, the expected round-cap notice never reaches a handler."""
assert _record_through_real_logger(_REAL_ROUND_CAP_MESSAGE, quiet=True) == []
def test_unrelated_warning_from_the_same_logger_still_surfaces() -> None:
"""T-P4.2c (control): the damping is keyed on the message, not on the logger. A different
warning from the very same logger still gets through otherwise the pin in pkt. 3 could never
catch a genuine new orchestration problem."""
other = "GroupChatOrchestrator: participant 'checker' returned no message."
assert _record_through_real_logger(other, quiet=True) == [other]
def test_the_demo_run_emits_no_round_cap_notice() -> None:
"""T-P4.2e: the damping is WIRED — the real demo process writes no round-cap line.
Without this, the three asserts above would all pass with the ``main()`` call detached: they
install the filter themselves, so they measure the filter and not the demo. Runs the module form
(``-m``) rather than the console script, because that needs no assumption about PATH; both forms
were measured to write identical stderr when the entry point was added, and the console script
has its own tests in ``test_console_entry_points``.
"""
proc = subprocess.run(
[sys.executable, "-m", "portfolio_optimiser.simulation"],
capture_output=True,
text=True,
check=False,
)
assert proc.returncode == 0, proc.stderr
assert "forcing completion" not in proc.stderr, (
"the round-cap notice reached stderr; is quiet_expected_round_cap_notice() still called in "
f"main()? stderr was:\n{proc.stderr}"
)
# Control on the same output: the run really did happen, so the absence above is a damped line
# and not an unrun demo.
assert "LÆRINGSSLØYFA ER LUKKET" in proc.stdout
def test_damping_is_not_installed_at_import_time() -> None:
"""T-P4.2d: importing the module must not reconfigure logging for a library consumer — the
filter is runtime state installed by ``main()``. RED if the install call is moved to module
scope."""
assert logging.getLogger(ROUND_CAP_LOGGER).filters == []

View file

@ -52,6 +52,7 @@ _LIVE_DOCS = (
"docs/ekspert-svar.md", "docs/ekspert-svar.md",
"docs/bestille-en-kjoring.md", "docs/bestille-en-kjoring.md",
"docs/knowledge-base-recipe.md", "docs/knowledge-base-recipe.md",
"docs/kunnskapsbase-for-en-kjoring.md",
) )
# A dated path segment marks a point-in-time record. ``docs/fase1-spikes/`` is the one archive whose # A dated path segment marks a point-in-time record. ``docs/fase1-spikes/`` is the one archive whose

View file

@ -0,0 +1,766 @@
"""U4 + U13, part 2 — the CALL SITES. Load-bearing proofs for the seams econ 56 left open.
Three things are proved here, and each of them is a seam a mutation can detach.
**1. The trace is a CALLER-OWNED accumulator, for the reason the parse-failure sink is one
(Fase 1b, funn 1).** ``explore()`` raises ``BudgetExceeded`` on its round cap, and a token cap
fires from inside the middleware mid-run on both paths ``ExplorationResult`` never returns, so a
``ledger_log`` that existed only as a return value would be destroyed by exactly the endings § C.2
requires the artefact to be readable after ("så en stoppet utforskning er lesbar uansett hvilken
vakt som fyrte"). The accumulator the caller holds survives however the loop ended.
``ExplorationResult.ledger_log`` is BUILT FROM that accumulator rather than alongside it: two lists
holding one fact is the -(p) drift class, one layer up.
**2. The CLI door refuses everything it cannot honour, by name.** ``--explore`` and ``--mandate``
are two sources of ONE mandate and are REFUSED together rather than merged: ``explore()`` sets the
objective from the prompt and hardcodes ``allow_own_proposals=True``, so composing them would
silently overwrite three fields an operator wrote by hand. The refusal names the library API
(``explore(seed_approaches=)``) because § C.6 door 1 is a real need this surface does not serve.
**3. ``{run_id}-exploration.json`` is written from a ``finally``**, so the run that most needs the
evidence the one a cap cut short is the one that has it.
The client is the repo's own ``ScriptedChatClient`` throughout (a bare ``BaseChatClient`` no-ops
``BudgetMiddleware``), and every tool assertion calls the tool's ``func`` DIRECTLY: measured in
econ 56, a scripted run returns TEXT and never emits a tool call, so no scripted exploration
reaches a tool body and a gate that only drove ``explore()`` would be vacuous.
"""
from __future__ import annotations
import json
from collections.abc import Callable
from pathlib import Path
from typing import Any
import pytest
from agent_framework import BaseChatClient
import portfolio_optimiser
from portfolio_optimiser import explore, hosting, okf, run, simulation
from portfolio_optimiser.budget import BudgetExceeded
from portfolio_optimiser.explore import ExplorationContract, ExplorationTrace
from portfolio_optimiser.mandate import Approach, Mandate
from portfolio_optimiser.simulation import ScriptedChatClient
_BUNDLE_DIR = Path(__file__).resolve().parents[1] / "shared" / "examples" / "bygg-energi-mikro"
_PID = "BYGG-KONTOR-NORD"
#: The hypothesiser's marked line. The label is the marker the end-to-end arm looks for on stdout:
#: it appears nowhere in the bundle, in the reference projects or in any other test, so its presence
#: in the settlement can only have come through the mandate the exploration shaped.
_LABEL = "SENTINEL-EXPLORE-7c1d33"
_ENERGY_REPLY = (
'{"measure":"LED-retrofit av kontorbelysning","affected_items":'
'[{"code":"ENERGI-TOTAL-EL","quantity":300000,"unit_cost":1.0}],"claimed_saving_nok":30000}'
)
_CONTRACT_JSON: dict[str, Any] = {
"max_rounds": 4,
"max_tokens": 100_000,
"max_stall_count": 2,
"max_reset_count": 1,
"max_plan_revisions": 0,
"enable_plan_review": False,
}
@pytest.fixture(autouse=True)
def _isolate_model_env(monkeypatch: pytest.MonkeyPatch) -> None:
"""Hermetic env: no arm here may read the operator's Foundry configuration."""
monkeypatch.delenv("PORTFOLIO_MODEL_MAP", raising=False)
monkeypatch.delenv("PORTFOLIO_FOUNDRY_PROJECT_ENDPOINT", raising=False)
def _ledger_json(*, satisfied: bool, speaker: str = "hypothesiser") -> str:
return json.dumps(
{
"is_request_satisfied": {"reason": "r", "answer": satisfied},
"is_in_loop": {"reason": "r", "answer": False},
"is_progress_being_made": {"reason": "r", "answer": True},
"next_speaker": {"reason": "r", "answer": speaker},
"instruction_or_question": {"reason": "r", "answer": "Shape one hypothesis."},
}
)
def _manager_script(ledgers: list[str]) -> Callable[[str, str], str]:
"""Route a manager prompt blob to its scripted reply (the econ-56 helper, verbatim in shape).
The stage ORDER is load-bearing (§ F, A6): the selector sees the CONCATENATION of the call's
messages, so a later-stage prompt still carries the earlier stage's text.
"""
def _select(blob: str, _role: str) -> str:
if "provide the final answer" in blob:
return "FINAL: exploration done."
if "pure JSON format" in blob:
return ledgers.pop(0) if ledgers else _ledger_json(satisfied=True)
if "went wrong on this last run" in blob:
return "PLAN-UPDATE: revised plan."
if "rewrite the following fact sheet" in blob:
return "FACTS-UPDATE: revised facts."
if "bullet-point plan" in blob:
return "PLAN: - ask the hypothesiser"
if "pre-survey" in blob:
return "FACTS: the bundle is anchored."
return "{}"
return _select
def _hypothesis_line(label: str, rationale: str) -> str:
return f"{explore.HYPOTHESIS_MARKER} " + json.dumps({"label": label, "rationale": rationale})
def _factory(
*,
ledgers: list[str],
hypothesiser: list[str],
fallback: str = "ok",
sink: list[str] | None = None,
) -> Callable[[str], BaseChatClient]:
"""One fresh ``ScriptedChatClient`` per role — the exploration's three plus everyone else.
``fallback`` serves the roles the PIPELINE builds (proposer/checker), so one factory can drive
an exploration and the run it hands its mandate to. That is what makes the end-to-end arm an
end-to-end arm rather than two half-proofs.
"""
def factory(role: str) -> BaseChatClient:
if role == explore.MANAGER_ROLE:
return ScriptedChatClient(sink=sink, reply_selector=_manager_script(ledgers), role=role)
if role == explore.HYPOTHESISER_ROLE:
replies = list(hypothesiser)
def _hyp(_blob: str, _role: str) -> str:
return replies.pop(0) if replies else "nothing further."
return ScriptedChatClient(sink=sink, reply_selector=_hyp, role=role)
if role == explore.NAVIGATOR_ROLE:
return ScriptedChatClient("NAVIGATOR: index read.", sink, role=role)
return ScriptedChatClient(fallback, sink, role=role)
return factory
def _contract(**overrides: Any) -> ExplorationContract:
return ExplorationContract(**{**_CONTRACT_JSON, **overrides})
# ---------------------------------------------------------------------------------------------
# 1. The caller-owned accumulator (the funn-1 sink shape, applied to the exploration)
# ---------------------------------------------------------------------------------------------
def _micro_bundle_dir() -> str:
return str(
Path(portfolio_optimiser.__file__).parent
/ "data"
/ "bundles"
/ "bygg-energi-baseline-mikro"
)
def test_quick_validate_verdicts_reach_the_callers_trace() -> None:
"""T1: every advisory verdict the hypothesiser asked for is recorded where the caller can read
it the ONE thing ``ExplorationResult`` deliberately does not carry.
Called DIRECTLY, because a scripted run never reaches a tool body (measured, econ 56): an arm
that drove ``explore()`` and then asserted on an empty list would be green against every
implementation, including one with no sink at all.
Detach point: drop the ``sink`` append in ``quick_validate_tool`` RED.
"""
base = _micro_bundle_dir()
projection = dict(okf.load_ir_projection(base))
projection.pop("_note", None)
trace = ExplorationTrace()
validate = explore.quick_validate_tool((base,), sink=trace.quick_validations)
honest = validate.func(
bundle_id="bygg-energi-baseline-mikro", proposal_json=json.dumps(projection)
)
invented = dict(projection)
invented["affected_items"] = [
{**dict(projection["affected_items"][0]), "code": "CODE-THAT-DOES-NOT-EXIST"}
]
validate.func(bundle_id="bygg-energi-baseline-mikro", proposal_json=json.dumps(invented))
assert len(trace.quick_validations) == 2, "both calls must be recorded, in call order"
first, second = trace.quick_validations
assert first.bundle_id == "bygg-energi-baseline-mikro"
assert first.verdict == honest, "the recorded verdict must be the one the tool ANSWERED"
assert second.verdict["decision"] == "rejected"
assert "CODE-THAT-DOES-NOT-EXIST" in second.proposal_json
@pytest.mark.asyncio
async def test_the_returned_ledger_log_is_the_traces_own_entries() -> None:
"""T2: ``ExplorationResult.ledger_log`` is BUILT FROM the accumulator, never alongside it.
Two lists holding one fact drift (-(p)), and a drifted pair would let the returned result and
the written artefact describe different runs.
Detach point: accumulate rounds in a second local list RED.
"""
trace = ExplorationTrace()
result = await explore.explore(
"Find a saving.",
contract=_contract(),
bundle_dirs=(str(_BUNDLE_DIR),),
client_factory=_factory(
ledgers=[_ledger_json(satisfied=False), _ledger_json(satisfied=True)],
hypothesiser=[_hypothesis_line(_LABEL, "because the bundle says so")],
),
trace=trace,
)
assert result.stop is None
assert len(trace.ledger) == 2
assert tuple(trace.ledger) == result.ledger_log
@pytest.mark.asyncio
async def test_the_trace_survives_the_budget_exception_that_destroys_the_result() -> None:
"""T3: the round cap raises, and the caller STILL holds every round the loop recorded.
This is the whole reason the accumulator is caller-owned. The round cap leaves as a typed
``BudgetExceeded`` (econ 56), so nothing is returned and § C.2 requires the artefact to be
readable no matter which guard fired.
Detach point: return the log only, keeping no caller-visible accumulator RED.
"""
trace = ExplorationTrace()
with pytest.raises(BudgetExceeded) as excinfo:
await explore.explore(
"Find a saving.",
contract=_contract(max_rounds=2),
bundle_dirs=(str(_BUNDLE_DIR),),
client_factory=_factory(
ledgers=[_ledger_json(satisfied=False), _ledger_json(satisfied=False)],
hypothesiser=["still thinking."],
),
trace=trace,
)
assert excinfo.value.kind == "exploration_rounds"
assert len(trace.ledger) == 2, (
"the rounds the exploration DID record were destroyed with the result — the artefact a "
"capped run needs most would be empty"
)
# ---------------------------------------------------------------------------------------------
# 2. The CLI door — every refusal by name, never a silent merge or a silent drop
# ---------------------------------------------------------------------------------------------
def _config_file(tmp_path: Path, **overrides: Any) -> str:
path = tmp_path / "exploration.json"
path.write_text(json.dumps({**_CONTRACT_JSON, **overrides}), encoding="utf-8")
return str(path)
def _mandate_file(tmp_path: Path) -> str:
path = tmp_path / "mandate.json"
path.write_text(
json.dumps(
{
"objective": "cut energy cost",
"approaches": [{"id": "a1", "label": "LED", "description": "swap the fittings"}],
"allow_own_proposals": False,
}
),
encoding="utf-8",
)
return str(path)
def _base_argv(tmp_path: Path) -> list[str]:
return [
_PID,
"--docs-dir",
str(_BUNDLE_DIR),
"--bundle-dir",
str(_BUNDLE_DIR),
"--explore",
"Find the cheapest saving.",
"--explore-config",
_config_file(tmp_path),
]
def test_an_exploration_config_without_an_exploration_is_refused_by_name(tmp_path, capsys) -> None:
"""T4: ``--explore-config`` alone would be loaded and then dropped on the floor — the exact
silent-ignore ``--embedder-config requires --semantic-retrieval`` exists to prevent.
Detach point: drop the refusal RED.
"""
rc = run.main(
[_PID, "--docs-dir", str(_BUNDLE_DIR), "--explore-config", _config_file(tmp_path)]
)
assert rc == 1
assert "--explore-config" in capsys.readouterr().err
def test_an_exploration_without_its_bounds_is_refused_rather_than_defaulted(
tmp_path, capsys
) -> None:
"""T5: ``--explore`` alone is refused — the CLI may not invent bounds.
Every ``ExplorationContract`` field is required WITHOUT a default precisely because
``MagenticBuilder`` falls back to unbounded, and a CLI that supplied its own numbers would undo
that decision one layer up.
"""
rc = run.main(
[_PID, "--docs-dir", str(_BUNDLE_DIR), "--bundle-dir", str(_BUNDLE_DIR), "--explore", "go"]
)
assert rc == 1
assert "--explore-config" in capsys.readouterr().err
def test_explore_and_mandate_are_two_sources_of_one_mandate_and_are_refused_together(
tmp_path, capsys
) -> None:
"""T6: the decision, made deliberately and stated: REFUSE, never merge.
``explore()`` takes the objective from the prompt and hardcodes ``allow_own_proposals=True``, so
composing the two would silently overwrite fields the operator wrote by hand. The message names
the library door (``seed_approaches``) so the refusal teaches instead of only forbidding.
Detach point: let one source silently win RED.
"""
rc = run.main(_base_argv(tmp_path) + ["--mandate", _mandate_file(tmp_path)])
assert rc == 1
err = capsys.readouterr().err
assert "--explore" in err and "--mandate" in err
assert "seed_approaches" in err, "the refusal must name the door that DOES serve door 1"
def test_explore_and_live_dry_run_contradict_and_are_refused(tmp_path, capsys) -> None:
"""T7: ``--live-dry-run`` stops before the first model call; an exploration IS model calls."""
rc = run.main(_base_argv(tmp_path) + ["--live-dry-run"])
assert rc == 1
assert "--live-dry-run" in capsys.readouterr().err
def test_an_exploration_with_no_knowledge_base_is_refused(tmp_path, capsys) -> None:
"""T8: without ``--bundle-dir`` the navigator has nothing to open — the loop would run, cost
tokens and read nothing. Refused rather than run empty (the ``--semantic-retrieval`` shape).
Detach point: drop the requirement RED.
"""
rc = run.main(
[
_PID,
"--docs-dir",
str(_BUNDLE_DIR),
"--explore",
"go",
"--explore-config",
_config_file(tmp_path),
]
)
assert rc == 1
assert "--bundle-dir" in capsys.readouterr().err
def test_a_plan_review_nobody_can_answer_is_refused_at_the_cli(tmp_path, capsys) -> None:
"""T9: ``enable_plan_review`` is the U13 SYNCHRONOUS door, and a run must never stop at a
review nobody offered to answer.
Refused HERE rather than left to ``explore()``: ``ExplorationError`` is a ``RuntimeError``, so
it is outside ``main()``'s ``(ValueError, FileNotFoundError, ValidationError)`` refusal tuple
and would leave as a traceback instead of the rc-1 line every other misconfiguration produces.
**The assertion names wording unique to THIS branch.** Since F4 the CLI has a second refusal
carrying ``enable_plan_review`` (``--plan-review`` against a config that asks for no review),
so asserting on the shared token would pass against a surface missing this branch entirely
the økt-57 mutation, in the form this repo keeps meeting it.
Detach point: let the flag through to ``explore()`` RED (traceback, not rc 1).
"""
rc = run.main(
_base_argv(tmp_path)[:-1] + [_config_file(tmp_path, enable_plan_review=True)],
)
assert rc == 1
err = capsys.readouterr().err
assert "no reviewer was offered" in err
assert "--plan-review" in err, "the refusal must name the door that answers it (F4)"
def test_explore_belongs_to_single_project_mode(tmp_path, capsys) -> None:
"""T10: portfolio mode is a documented partition, and ``--explore`` is on the single-project
side of it one exploration shapes ONE mandate against ONE knowledge base.
The assertion names ``--portfolio``, and that was MEASURED rather than chosen: asserting only
that the message mentions ``--explore`` passed against an implementation with no partition
entry at all, because the run then fell through to ``--explore requires --bundle-dir``, which
names ``--explore`` too. Two refusals sharing a substring is this repo's "assert never on
wording two branches share" rule, caught by its own mutation.
Detach point: drop ``--explore`` from the portfolio ``single_only`` partition RED.
"""
rc = run.main(["--portfolio", "--explore", "go", "--explore-config", _config_file(tmp_path)])
assert rc == 1
err = capsys.readouterr().err
assert "--explore" in err and "--portfolio" in err
@pytest.fixture()
def _explored_main(monkeypatch: pytest.MonkeyPatch) -> list[str]:
"""Inject the role-dispatching scripted factory into the seam ``main()`` resolves through.
``main()`` passes no ``client_factory``, and ``explore()`` imports ``run._default_factory``
lazily at call time, so this ONE patch covers both the exploration and the pipeline it feeds
which is what makes the arm below end-to-end rather than a wiring spy.
"""
sink: list[str] = []
factory = _factory(
ledgers=[_ledger_json(satisfied=False), _ledger_json(satisfied=True)],
hypothesiser=[_hypothesis_line(_LABEL, "the index says the fittings are old")],
fallback=_ENERGY_REPLY,
sink=sink,
)
monkeypatch.setattr("portfolio_optimiser.run._default_factory", lambda profile: factory)
return sink
def test_the_shaped_mandate_reaches_the_pipeline(tmp_path, capsys, _explored_main) -> None:
"""T11: the approach the hypothesiser shaped is SETTLED by the run — the whole point of (1).
The settlement is printed only for a run that HAS a mandate, and the label appears nowhere in
the bundle or the reference projects, so it can have reached stdout only by travelling
prompt ``explore()`` ``Mandate`` ``run_project(mandate=)`` ``settle``.
Detach point: drop ``mandate=`` from the exploring branch's ``run_project`` call → RED.
"""
rc = run.main(_base_argv(tmp_path))
assert rc == 0
out = capsys.readouterr().out
assert _LABEL in out, "the exploration's mandate never reached the pipeline's settlement"
# ---------------------------------------------------------------------------------------------
# 3. The artefact — written from a ``finally``, because a capped run is what it exists for
# ---------------------------------------------------------------------------------------------
def test_the_exploration_artefact_carries_the_rounds_and_the_advisory_verdicts(
tmp_path, _explored_main
) -> None:
"""T12: ``{run_id}-exploration.json`` holds the per-round ledger AND the ``quick_validate``
verdicts the level-1 evidence ``ExplorationResult`` deliberately does not carry (§ C.2).
Detach point: drop the artefact write RED.
"""
outbox = tmp_path / "outbox"
rc = run.main(_base_argv(tmp_path) + ["--outbox-dir", str(outbox), "--run-id", "r1"])
assert rc == 0
payload = json.loads((outbox / "r1-exploration.json").read_text(encoding="utf-8"))
assert payload["run_id"] == "r1"
assert payload["completed"] is True
assert payload["stop"] is None
assert [row["round_index"] for row in payload["rounds"]] == [1, 2]
assert payload["rounds"][-1]["is_request_satisfied"] is True
assert payload["rounds"][0]["next_speaker"] == "hypothesiser"
assert "quick_validations" in payload
def test_the_artefact_is_written_even_when_the_exploration_was_cut_short(
tmp_path, monkeypatch
) -> None:
"""T13: a capped exploration is the run whose evidence matters MOST, and it is the one that
returns nothing so the write lives in a ``finally`` (the ``write_parse_failures`` precedent).
``completed`` is a field rather than an inference: with no result there is no ``stop``, and a
``stop: null`` that meant BOTH "concluded normally" and "we never found out" would be the kind
of silence this repo writes required fields to close.
Detach point: move the write out of the ``finally`` RED.
"""
factory = _factory(
ledgers=[_ledger_json(satisfied=False), _ledger_json(satisfied=False)],
hypothesiser=["still thinking."],
fallback=_ENERGY_REPLY,
)
monkeypatch.setattr("portfolio_optimiser.run._default_factory", lambda profile: factory)
outbox = tmp_path / "outbox"
with pytest.raises(BudgetExceeded):
run.main(
[
_PID,
"--docs-dir",
str(_BUNDLE_DIR),
"--bundle-dir",
str(_BUNDLE_DIR),
"--explore",
"go",
"--explore-config",
_config_file(tmp_path, max_rounds=2),
"--outbox-dir",
str(outbox),
"--run-id",
"r2",
]
)
payload = json.loads((outbox / "r2-exploration.json").read_text(encoding="utf-8"))
assert payload["completed"] is False
assert payload["stop"] is None
assert len(payload["rounds"]) == 2
def test_the_artefact_payload_is_byte_deterministic() -> None:
"""T14 (control): the same trace renders the same bytes, so the artefact is diff-stable like
every other outbox file. Drives the renderer directly the CLI arms above prove it is CALLED,
this proves what it produces."""
trace = ExplorationTrace()
trace.ledger.append(
explore.LedgerEntry(
round_index=1,
is_request_satisfied=True,
is_in_loop=False,
is_progress_being_made=True,
next_speaker="hypothesiser",
instruction_or_question="Shape one hypothesis.",
speaker_known=True,
)
)
trace.quick_validations.append(
explore.QuickValidation(
bundle_id="b", proposal_json="{}", verdict={"decision": "unparseable"}
)
)
first = explore.trace_payload(trace, stop=None, completed=True)
second = explore.trace_payload(trace, stop=None, completed=True)
assert json.dumps(first, sort_keys=True) == json.dumps(second, sort_keys=True)
def test_a_seeded_mandate_still_leads_the_shaped_one() -> None:
"""T15 (control for T6's refusal): the library door the refusal names actually works.
A refusal that pointed at a door which did not open would be worse than no message at all.
"""
seed = Approach(id="expert-1", label="expert's own", description="the domain expert asked")
minted = explore._mint_approaches((seed,), [(_LABEL, "shaped in the loop", "")])
assert [a.id for a in minted] == ["expert-1", "hypothesis-1"]
assert isinstance(Mandate(objective="o", approaches=minted, allow_own_proposals=True), Mandate)
# ---------------------------------------------------------------------------------------------
# 4. The hosted surface — a THREE-way whitelist, and the Fase 4e rule extended to cover it
# ---------------------------------------------------------------------------------------------
def _hosted_payload(**extra: Any) -> dict[str, Any]:
return {
"project_id": _PID,
"docs_dir": str(_BUNDLE_DIR),
"verdict_input": {"decision": "approved", "rationale": "expert reviewed (explore)"},
# LOCAL, never the hosted AZURE default: the AZURE arm resolves a Foundry deployment name
# from the model map before any client is built, so it cannot complete offline.
"profile": "local",
**extra,
}
@pytest.fixture()
def _hosted_backend(monkeypatch: pytest.MonkeyPatch) -> list[str]:
"""The scripted backend behind a hosted invocation, plus the prompt sink that proves it ran.
``client_factory`` is refused by the invocations whitelist on purpose the caller of a hosted
agent never chooses the server's model client — so ``run._default_factory`` is the only
injection point the surface leaves, and ``explore()`` resolves through the same one.
"""
sink: list[str] = []
factory = _factory(
ledgers=[_ledger_json(satisfied=False), _ledger_json(satisfied=True)],
hypothesiser=[_hypothesis_line(_LABEL, "the index says the fittings are old")],
fallback=_ENERGY_REPLY,
sink=sink,
)
monkeypatch.setattr("portfolio_optimiser.run._default_factory", lambda profile: factory)
return sink
@pytest.mark.asyncio
async def test_a_hosted_exploration_shapes_the_mandate_the_run_evaluates(_hosted_backend) -> None:
"""H1: ``explore_prompt`` over the hosted surface reaches the pipeline as a mandate.
Driven through ``hosting.invoke`` and the REAL ``run_project`` (Fase 4e): every other
invocations test hands ``invoke`` a recorder that swallows ``**kwargs`` and therefore cannot
see whether a new field composes with the signature at all.
The proof is the PROMPT, not the status code: an ``Approach``'s description reaches the
proposer VERBATIM, and this label exists nowhere in the bundle or the reference projects so
finding it in a generation prompt means it travelled prompt ``explore()`` ``Mandate``
``run_project(mandate=)``.
Detach point: stop passing the shaped mandate into ``run_project`` RED.
"""
body = await hosting.invoke(
_hosted_payload(
bundle_dir=str(_BUNDLE_DIR),
explore_prompt="Find the cheapest saving.",
explore_contract=dict(_CONTRACT_JSON),
)
)
assert body["outcome_type"] in {"validated", "rejected"}
assert any(_LABEL in prompt for prompt in _hosted_backend), (
"the shaped approach never reached a prompt — the hosted door does not wire the mandate"
)
@pytest.mark.asyncio
async def test_a_consumed_field_is_never_forwarded_to_run_project() -> None:
"""H2: the whitelist is a THREE-way partition, and the consumed half is proved NEGATIVELY.
``explore_prompt``/``explore_contract`` are accepted by the surface and consumed BY it they
are not ``run_project`` parameters, and forwarding one would be a ``TypeError`` answered as a
500. The positive half of Fase 4e (every forwarded field reaches the real signature) cannot
see that; without this arm a field sliding from consumed to forwarded is exactly the drift 4e
exists to catch.
Detach point: build ``kwargs`` from the whole payload again RED.
"""
import inspect
_, kwargs, consumed = hosting._run_kwargs(
_hosted_payload(
bundle_dir=str(_BUNDLE_DIR),
explore_prompt="p",
explore_contract=dict(_CONTRACT_JSON),
)
)
assert set(hosting._CONSUMED_FIELDS).isdisjoint(kwargs), (
"a consumed field was forwarded to run_project, which does not take it"
)
assert set(consumed) == set(hosting._CONSUMED_FIELDS)
parameters = inspect.signature(run.run_project).parameters
assert set(hosting._CONSUMED_FIELDS).isdisjoint(parameters), (
"a CONSUMED field is a run_project parameter — it belongs in the forwarded half"
)
for name in (*hosting._REQUIRED_FIELDS, *hosting._OPTIONAL_FIELDS):
# project_id is positional; every other forwarded field must be a real keyword.
assert name in parameters, f"whitelisted field {name!r} is not a run_project parameter"
@pytest.mark.asyncio
@pytest.mark.parametrize(
("payload", "expected"),
[
pytest.param(
{"bundle_dir": str(_BUNDLE_DIR), "explore_contract": dict(_CONTRACT_JSON)},
"explore_prompt",
id="bounds-without-an-exploration",
),
pytest.param(
{"bundle_dir": str(_BUNDLE_DIR), "explore_prompt": "p"},
"explore_contract",
id="exploration-without-bounds",
),
pytest.param(
{"explore_prompt": "p", "explore_contract": dict(_CONTRACT_JSON)},
"bundle_dir",
id="exploration-without-a-knowledge-base",
),
pytest.param(
{
"bundle_dir": str(_BUNDLE_DIR),
"explore_prompt": "p",
"explore_contract": {**_CONTRACT_JSON, "enable_plan_review": True},
},
"enable_plan_review",
id="a-review-nobody-can-answer",
),
],
)
async def test_the_hosted_door_refuses_by_name_on_the_callers_channel(payload, expected) -> None:
"""H3: each hosted refusal names the field, and each is a ``ValueError`` — the 400 arm.
``enable_plan_review`` is the one that had to be refused HERE rather than in ``explore()``:
``ExplorationError`` is a ``RuntimeError``, so leaving it to the loop would answer a caller's
configuration mistake on the crash channel (500), which is where a fallen-over endpoint lives.
A synchronous plan review would also block the HTTP request on a reviewer that does not exist.
Detach point: drop any one of the four guards RED.
"""
with pytest.raises(ValueError) as excinfo:
await hosting.invoke(_hosted_payload(**payload))
assert expected in str(excinfo.value)
# ---------------------------------------------------------------------------------------------
# 5. The demo scenario — a THIRD entry, reachable only by name
# ---------------------------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_the_demo_scenario_lets_a_shaped_direction_reach_the_hypothesis(tmp_path) -> None:
"""S1: the offline walkthrough of U4 — a prompt and a knowledge base become a mandate, and the
direction the loop shaped reaches the proposer VERBATIM.
The same honesty limit the rest of the demo carries applies here and is worth restating: this
proves the plumbing and that the data flow closes, NOT that a live model would shape a good
direction. Every reply is scripted.
Detach point: drop ``mandate=`` from the scenario's ``run_project`` call → RED.
"""
result = await simulation.simulate_exploration(str(_BUNDLE_DIR), str(tmp_path), max_rounds=3)
assert [a.label for a in result.exploration.mandate.approaches] == [result.label]
assert result.label_in_generation_prompt, (
"the shaped direction never reached the hypothesis prompt — the demo would show a mandate "
"the pipeline ignored"
)
assert result.trace.ledger, "the exploration recorded no rounds"
def test_an_outbox_without_a_run_id_is_refused_before_the_exploration_spends_anything(
tmp_path, _explored_main
) -> None:
"""T16: an argv that cannot finish is refused BEFORE the loop costs anything.
``run_project`` refuses ``outbox_dir`` without ``run_id`` at its very first statement, which is
early enough for every path that existed before U4. The exploration runs AHEAD of that call, so
without this guard the run spends its whole exploration budget on model calls and only then
refuses and the artefact write is skipped too, so not even the evidence of what was spent
survives. Exactly the hoist ``main()`` already performs twice ("an incomplete argv is refused
BEFORE the honesty banner could claim a scripted run happened").
The assertion is that NO model call happened, not merely that rc is 1: a refusal that arrives
after the spend looks identical at the exit code.
Detach point: drop the guard from the exploration block RED.
"""
rc = run.main(_base_argv(tmp_path) + ["--outbox-dir", str(tmp_path / "outbox")])
assert rc == 1
assert not _explored_main, (
"the exploration made model calls before the run was refused — the budget was spent on an "
"argv that could never finish"
)
@pytest.mark.asyncio
async def test_a_direction_the_base_already_states_is_refused_as_vacuous(tmp_path) -> None:
"""S2: a label the knowledge base ALREADY contains is refused, not demonstrated.
Exactly the guard ``simulate_learning_loop`` raises on when its two markers coincide: the
scenario's whole claim is that the direction came from the LOOP, and a label the bundle states
on its own would reach the prompt as ordinary context a demonstration that demonstrates
nothing, which is this repo's vacuous-gate class in demo form.
Detach point: drop the guard RED.
"""
stated = "LED-retrofit" # present in the bundle's own text
with pytest.raises(ValueError) as excinfo:
await simulation.simulate_exploration(str(_BUNDLE_DIR), str(tmp_path), label=stated)
assert stated in str(excinfo.value)

View file

@ -0,0 +1,992 @@
"""U4 + U13-synchronous (økt 56) — the Magentic exploration loop as a MANDATE-FORMER.
**What this loop is, and what it deliberately is not.** ``explore()`` puts a Magentic manager
*over* the normative pipeline, never inside it: the manager is free to choose which knowledge base
to open and which hypothesis to shape next, and what leaves that freedom is a
``mandate.Mandate`` a list of approaches worth *testing*. It is never a proposal. Every number
that survives is still gated by ``validate_proposal`` inside ``run_project``, in the same blocking
gate as today, and the exploration itself can write to neither the outbox nor the wiki. Step 3's
maker-checker debate is untouched (``shared/method-spec.md`` §3 is commons-owned and normative).
**Everything asserted here was measured before it was built** (plan
``docs/plan/2026-08-23-magentic-utforskningssloeyfe.md`` § F, spikes S0S6 in økt 54, plus three
probes run at the head of økt 56):
* a plan-review ``revise`` costs two manager calls, **zero** rounds, and asks *again* so an
always-revising expert is unbounded spend under a round cap that never ticks. That is the whole
reason ``max_plan_revisions`` is a required contract field rather than a nicety.
* the round cap and the reset cap **raise nothing**. Both end the run with a canonical assistant
message and a normal-looking result (measured: ``max_round_count=2`` two ledger events and
``'Workflow terminated due to reaching maximum round count.'``; a stalling ledger with
``max_reset_count=1`` one ``REPLANNED`` event and ``'…maximum reset count.'``). At the
transport both are indistinguishable from success, so this layer produces the typed stop itself.
* a ``next_speaker`` naming nobody produces a **silent final answer with zero participant work**
(``_magentic.py:1128-1131``) a plausible answer produced by no work at all, which is the
hazard class E2 was retired for. The names are therefore validated, never assumed.
**The client is the repo's own ``ScriptedChatClient``.** A bare ``BaseChatClient`` silently no-ops
``BudgetMiddleware`` (measured, ``simulation.py:373-375``), so a budget claim proved against one
would prove nothing.
"""
from __future__ import annotations
import asyncio
import json
import os
import shutil
import subprocess
import sys
from collections.abc import Callable
from pathlib import Path
from typing import Any
import pytest
from agent_framework import BaseChatClient
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
from pydantic import ValidationError
import portfolio_optimiser
from portfolio_optimiser import explore, okf
from portfolio_optimiser.budget import Budget, BudgetExceeded, TokenMeter
from portfolio_optimiser.explore import ExplorationContract
from portfolio_optimiser.mandate import Approach
from portfolio_optimiser.simulation import ScriptedChatClient
# ---------------------------------------------------------------------------------------------
# C.3 — the contract: an exploration without stated bounds refuses to start
# ---------------------------------------------------------------------------------------------
_FULL_CONTRACT = {
"max_rounds": 4,
"max_tokens": 5_000,
"max_stall_count": 2,
"max_reset_count": 1,
"max_plan_revisions": 1,
"enable_plan_review": True,
}
@pytest.mark.parametrize("omitted", sorted(_FULL_CONTRACT))
def test_every_bound_is_required_with_no_default(omitted: str) -> None:
"""T1: each of the six fields is REQUIRED — dropping any one refuses construction.
Not a style point. ``MagenticBuilder`` defaults ``max_round_count`` to ``None`` (unbounded)
and ``max_reset_count`` to ``None`` (unlimited), and inheriting either would give this repo
the one thing ``shared/method-spec.md`` §8 forbids outright: a loop with no stated end. A
default here would also be a claim about the operator's intent that nobody made — the same
ground on which ``ProvenanceStamp.cost_baseline_anchored`` is required without one.
"""
payload = {k: v for k, v in _FULL_CONTRACT.items() if k != omitted}
with pytest.raises(ValidationError):
ExplorationContract(**payload)
def test_full_contract_constructs() -> None:
"""T2: the control for T1 — the complete payload IS valid.
Without it, T1 would pass on a model that refuses everything, which is the vacuous-gate class
this repo has paid for six times.
"""
contract = ExplorationContract(**_FULL_CONTRACT)
assert contract.max_rounds == 4
assert contract.enable_plan_review is True
def test_a_revision_cap_without_plan_review_is_refused_not_ignored() -> None:
"""T3: ``max_plan_revisions > 0`` with ``enable_plan_review=False`` refuses.
A plan revision can only arise from a plan review with the review off, the cap bounds an
event that cannot occur, and a caller who set it believes they bounded something. This repo
refuses a setting that cannot take effect rather than dropping it silently (the same partition
``--embedder-config requires --semantic-retrieval`` enforces on the CLI).
"""
with pytest.raises(ValidationError):
ExplorationContract(**{**_FULL_CONTRACT, "enable_plan_review": False})
def test_review_off_with_zero_revisions_is_the_coherent_form() -> None:
"""T4: the control for T3 — review off and the cap at ``0`` is a consistent statement, and
must construct. Without this arm T3 would pass on a model that simply forbade
``enable_plan_review=False`` outright, which is a different (and wrong) rule.
"""
contract = ExplorationContract(
**{**_FULL_CONTRACT, "enable_plan_review": False, "max_plan_revisions": 0}
)
assert contract.enable_plan_review is False
assert contract.max_plan_revisions == 0
# ---------------------------------------------------------------------------------------------
# The scripted stand-ins. ScriptedChatClient, never a bare BaseChatClient: the latter no-ops
# BudgetMiddleware (measured, simulation.py:373-375), so a budget assertion made against one
# would assert nothing.
# ---------------------------------------------------------------------------------------------
PROMPT = "Find the cheapest saving available in the energy bundle."
def _ledger_json(
*, satisfied: bool, speaker: str, instruction: str = "Shape one hypothesis."
) -> str:
"""A progress ledger naming ``speaker``.
The name is a PARAMETER, never a literal, because a ``next_speaker`` matching no participant
is the measured footgun this module defends against: the orchestrator does not error, it
quietly emits a final answer having asked nobody (``_magentic.py:1128-1131``).
"""
return json.dumps(
{
"is_request_satisfied": {"reason": "r", "answer": satisfied},
"is_in_loop": {"reason": "r", "answer": False},
"is_progress_being_made": {"reason": "r", "answer": True},
"next_speaker": {"reason": "r", "answer": speaker},
"instruction_or_question": {"reason": "r", "answer": instruction},
}
)
def _stalling_ledger_json(speaker: str) -> str:
"""A ledger reporting NO progress and a loop — the two flags that drive ``stall_count`` up."""
return json.dumps(
{
"is_request_satisfied": {"reason": "r", "answer": False},
"is_in_loop": {"reason": "circles", "answer": True},
"is_progress_being_made": {"reason": "none", "answer": False},
"next_speaker": {"reason": "r", "answer": speaker},
"instruction_or_question": {"reason": "r", "answer": "Try again."},
}
)
def _manager_script(
ledgers: list[str], calls: list[str] | None = None
) -> Callable[[str, str], str]:
"""Route a manager prompt blob to its scripted reply, consuming ``ledgers`` in order.
The ORDER of these tests is load-bearing and was measured (§ F, A6): the selector receives the
CONCATENATION of every message in the call, so a later-stage prompt still carries the earlier
stage's text — one manager call in five carries two markers. Testing the later stage FIRST is
what resolves it; reversing two of these silently reattributes a reply to the wrong stage.
"""
def _select(blob: str, _role: str) -> str:
if calls is not None:
calls.append(blob[:40])
if "provide the final answer" in blob:
return "FINAL: exploration done."
if "pure JSON format" in blob:
return ledgers.pop(0) if ledgers else _ledger_json(satisfied=True, speaker="navigator")
if "went wrong on this last run" in blob:
return "PLAN-UPDATE: revised plan."
if "rewrite the following fact sheet" in blob:
return "FACTS-UPDATE: revised facts."
if "bullet-point plan" in blob:
return "PLAN: - ask the hypothesiser"
if "pre-survey" in blob:
return "FACTS: the bundle is anchored."
return "{}"
return _select
def _factory(
*, ledgers: list[str], hypothesiser: list[str], navigator: str = "NAVIGATOR: index read."
) -> Callable[[str], BaseChatClient]:
"""One fresh ``ScriptedChatClient`` per role, exactly as the real factory hands out one per
role. ``hypothesiser`` is a list consumed in order, so a run can shape several candidates."""
def factory(role: str) -> BaseChatClient:
if role == explore.MANAGER_ROLE:
return ScriptedChatClient(reply_selector=_manager_script(ledgers), role=role)
if role == explore.HYPOTHESISER_ROLE:
replies = list(hypothesiser)
def _hyp(_blob: str, _role: str) -> str:
return replies.pop(0) if replies else "nothing further."
return ScriptedChatClient(reply_selector=_hyp, role=role)
return ScriptedChatClient(navigator, role=role)
return factory
def _hypothesis_line(label: str, rationale: str) -> str:
return f"{explore.HYPOTHESIS_MARKER} " + json.dumps({"label": label, "rationale": rationale})
#: The no-review base every stop test derives from. ``enable_plan_review`` and
#: ``max_plan_revisions`` move together — ``ExplorationContract`` refuses them apart — so a test
#: about round or stall behaviour has to say so explicitly rather than inherit ``_FULL_CONTRACT``.
_NO_REVIEW = {**_FULL_CONTRACT, "enable_plan_review": False, "max_plan_revisions": 0}
_CONTRACT = ExplorationContract(
max_rounds=6,
max_tokens=100_000,
max_stall_count=2,
max_reset_count=1,
max_plan_revisions=0,
enable_plan_review=False,
)
# ---------------------------------------------------------------------------------------------
# C.0 / C.6 — the exploration is a MANDATE-FORMER, and a seed never disappears
# ---------------------------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_hypotheses_become_the_mandate_in_the_order_they_were_shaped() -> None:
"""T5: what the hypothesiser MARKED becomes ``Mandate.approaches``, rationale VERBATIM.
The rationale is the half a model cannot re-derive from cost data ``mandate.Approach``
already feeds ``description`` to the proposer verbatim (``generate._build_messages``), so
paraphrasing it here would drop precisely the part the exploration exists to carry forward.
"""
ledgers = [
_ledger_json(satisfied=False, speaker=explore.HYPOTHESISER_ROLE),
_ledger_json(satisfied=True, speaker=explore.HYPOTHESISER_ROLE),
]
result = await explore.explore(
PROMPT,
contract=_CONTRACT,
bundle_dirs=(),
client_factory=_factory(
ledgers=ledgers,
hypothesiser=[
"Looking at the bundle.\n"
+ _hypothesis_line("LED retrofit", "the fixtures are 1990s fluorescent")
],
),
)
assert [a.label for a in result.mandate.approaches] == ["LED retrofit"]
assert result.mandate.approaches[0].description == "the fixtures are 1990s fluorescent"
assert result.mandate.objective == PROMPT
assert result.stop is None
@pytest.mark.asyncio
async def test_a_seed_approach_survives_whatever_the_manager_found() -> None:
"""T6: an expert's own hypothesis is in the output mandate, FIRST, untouched.
Door 1 of § C.6, and the ``not_evaluated`` rule applied one stage earlier: a direction the
domain expert asked for may never vanish because an autonomous loop preferred its own. Seeds
lead so the pipeline reaches them before spending its budget on discovered ones.
"""
seed = Approach(id="fagperson-1", label="Night setback", description="the expert's own words")
ledgers = [
_ledger_json(satisfied=False, speaker=explore.HYPOTHESISER_ROLE),
_ledger_json(satisfied=True, speaker=explore.HYPOTHESISER_ROLE),
]
result = await explore.explore(
PROMPT,
contract=_CONTRACT,
bundle_dirs=(),
seed_approaches=(seed,),
client_factory=_factory(
ledgers=ledgers,
hypothesiser=[_hypothesis_line("LED retrofit", "fluorescent fixtures")],
),
)
assert [a.id for a in result.mandate.approaches] == ["fagperson-1", "hypothesis-1"]
assert result.mandate.approaches[0] == seed
@pytest.mark.asyncio
async def test_an_exploration_that_shaped_nothing_still_returns_the_seeds() -> None:
"""T7: the control for T6 — with the hypothesiser silent, the seed is still the mandate.
This is what makes T6 a statement about PRESERVATION rather than about ordering: a test that
only ever saw seeds alongside discoveries could not tell "seeds are kept" from "seeds sort
first".
"""
seed = Approach(id="fagperson-1", label="Night setback", description="the expert's own words")
result = await explore.explore(
PROMPT,
contract=_CONTRACT,
bundle_dirs=(),
seed_approaches=(seed,),
client_factory=_factory(
ledgers=[_ledger_json(satisfied=True, speaker=explore.NAVIGATOR_ROLE)],
hypothesiser=[],
),
)
assert result.mandate.approaches == (seed,)
assert result.mandate.allow_own_proposals is True
def test_zero_resets_is_refused_because_it_silently_explores_nothing() -> None:
"""T8: ``max_reset_count=0`` refuses — MEASURED, not reasoned.
The orchestrator's limit check is ``reset_count >= max_reset_count`` (``_magentic.py:1243``)
and ``reset_count`` starts at zero, so a cap of zero is already met before the first round.
Measured against the installed stack: the run makes only the ``facts`` and ``plan`` manager
calls, emits **zero** progress-ledger events, and returns
``'Workflow terminated due to reaching maximum reset count.'`` an exploration that explored
nothing, reported as a stall that never happened. An operator writing "allow no resets" would
get "do no work", quietly. So it is refused at construction, where the reason can be said.
"""
with pytest.raises(ValidationError):
ExplorationContract(**{**_FULL_CONTRACT, "max_reset_count": 0})
def test_zero_stalls_is_allowed_because_it_means_something() -> None:
"""T9: the control for T8 — ``max_stall_count=0`` is a real setting and must construct.
The stall check is STRICT (``stall_count > max_stall_count``, ``:1118``) and the counter is
incremented before it, so zero means "reset on the first round that reports no progress".
That is strictness, not self-defeat, and refusing both zeroes on symmetry would have banned a
usable configuration on the strength of a measurement about a different field.
"""
contract = ExplorationContract(**{**_FULL_CONTRACT, "max_stall_count": 0})
assert contract.max_stall_count == 0
# ---------------------------------------------------------------------------------------------
# C.2 / C.3 — three endings the orchestration reports as if they were success
# ---------------------------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_the_round_cap_leaves_as_a_typed_budget_stop() -> None:
"""T10: the round cap becomes ``BudgetExceeded(kind="exploration_rounds")``.
Measured (§ F, E5, re-measured at the head of this økt): ``max_round_count`` raises NOTHING.
The run ends with the assistant message ``'Workflow terminated due to reaching maximum round
count.'`` and a result that ``get_outputs()`` answers like any other — at the transport it is
indistinguishable from a finished exploration. Left alone, a caller would read a run that
explored two rounds of a six-round question as a completed answer. The triple is the one
-(y) defends: WHICH cap bound, what it was, and how far the run actually got.
"""
contract = ExplorationContract(**{**_NO_REVIEW, "max_rounds": 2})
with pytest.raises(BudgetExceeded) as excinfo:
await explore.explore(
PROMPT,
contract=contract,
bundle_dirs=(),
client_factory=_factory(
ledgers=[_ledger_json(satisfied=False, speaker=explore.HYPOTHESISER_ROLE)] * 4,
hypothesiser=[_hypothesis_line("LED", "worth a look")] * 4,
),
)
assert excinfo.value.kind == "exploration_rounds"
assert excinfo.value.limit == 2
assert excinfo.value.observed == 2
@pytest.mark.asyncio
async def test_a_request_satisfied_on_the_last_allowed_round_is_success() -> None:
"""T11: the discriminator for T10 — reaching the cap is not the same as being cut off by it.
Both runs end with exactly ``max_rounds`` progress-ledger events, so a check written on the
count alone would raise on this one too and turn a completed exploration into a budget error.
What separates them is the LAST ledger's ``is_request_satisfied``, which is also what the
orchestrator itself branches on (``:1106``). Without this arm, T10 would pass on an
implementation that refuses every exploration that uses its whole allowance.
"""
contract = ExplorationContract(**{**_NO_REVIEW, "max_rounds": 2})
result = await explore.explore(
PROMPT,
contract=contract,
bundle_dirs=(),
client_factory=_factory(
ledgers=[
_ledger_json(satisfied=False, speaker=explore.HYPOTHESISER_ROLE),
_ledger_json(satisfied=True, speaker=explore.HYPOTHESISER_ROLE),
],
hypothesiser=[_hypothesis_line("LED", "worth a look")],
),
)
assert len(result.ledger_log) == contract.max_rounds
assert result.stop is None
assert [a.label for a in result.mandate.approaches] == ["LED"]
@pytest.mark.asyncio
async def test_stalling_out_is_a_typed_value_never_an_exception() -> None:
"""T12: stall → reset → out of resets is ``stop="stalled"``, and the run still returns.
Kept as a VALUE while the round cap RAISES, and the split is S3.4's, not a preference: a
stalled exploration is an outcome (the manager tried and got nowhere), whereas an exhausted
round or token cap is resource exhaustion. Fusing them would leave a caller unable to tell
"there was nothing here" from "we could not afford to look".
"""
contract = ExplorationContract(
**{**_NO_REVIEW, "max_rounds": 6, "max_stall_count": 1, "max_reset_count": 1}
)
result = await explore.explore(
PROMPT,
contract=contract,
bundle_dirs=(),
client_factory=_factory(
ledgers=[_stalling_ledger_json(explore.HYPOTHESISER_ROLE)] * 6,
hypothesiser=["still nothing."] * 6,
),
)
assert result.stop == "stalled"
assert len(result.ledger_log) < contract.max_rounds
assert all(entry.is_in_loop for entry in result.ledger_log)
@pytest.mark.asyncio
async def test_a_ledger_naming_nobody_withholds_what_the_run_produced() -> None:
"""T13: a ``next_speaker`` matching no participant stops the exploration and drops its finds.
The measured footgun (``_magentic.py:1128-1131``): the orchestrator neither raises nor retries
on an unknown speaker it logs a warning and jumps to ``_prepare_final_answer``. The run
therefore returns a plausible answer that no participant was asked for. This is the shape E2
was retired over ("a plausible verdict produced by zero work"), so the mandate is NOT built
from what such a run said it found.
The scripted run reaches the bad ledger on round TWO, after a good round in which the
hypothesiser really did commit to a direction. That ordering is what makes the assertion
sharp: with the bad ledger first, nobody would ever have spoken and "nothing was carried
forward" would be true of any implementation at all.
"""
seed = Approach(id="fagperson-1", label="Night setback", description="expert's own")
result = await explore.explore(
PROMPT,
contract=_CONTRACT,
bundle_dirs=(),
seed_approaches=(seed,),
client_factory=_factory(
ledgers=[
_ledger_json(satisfied=False, speaker=explore.HYPOTHESISER_ROLE),
_ledger_json(satisfied=False, speaker="a-name-nobody-answers-to"),
],
hypothesiser=[_hypothesis_line("LED retrofit", "fluorescent fixtures")],
),
)
assert result.stop == "unknown_speaker"
assert result.ledger_log[0].speaker_known is True
assert result.ledger_log[-1].speaker_known is False
# The seed survives — preservation is unconditional (§ C.6 door 1) — while the loop's own
# find does not, because nothing stands behind the turn that ended the run.
assert result.mandate.approaches == (seed,)
# ---------------------------------------------------------------------------------------------
# C.2 — the token cap covers the MANAGER, which is the loop's most talkative agent
# ---------------------------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_the_token_cap_binds_the_manager_before_any_participant_speaks() -> None:
"""T14: a one-token budget stops the exploration on the MANAGER's own first call.
Agent-level ``ChatMiddleware`` does fire on the manager's calls (§ F, A1, measured green), and
the manager talks more than anyone else in a Magentic loop it extracts facts, writes the
plan, and writes a progress ledger every single round. A cap fastened only to the participants
would be a cap in name.
The assertion is deliberately not "something raised". ``kind == "tokens"`` separates it from
the round-cap stop, ``meter.tokens == 8`` shows the charge came from a call that was actually
made and metered, and the EMPTY ledger log shows it landed before the loop had run a single
round which is exactly what a manager-attached middleware does and a participant-only one
cannot.
"""
meter = TokenMeter(Budget(max_tokens=1, max_rounds=6))
contract = ExplorationContract(**{**_NO_REVIEW, "max_tokens": 1})
with pytest.raises(BudgetExceeded) as excinfo:
await explore.explore(
PROMPT,
contract=contract,
bundle_dirs=(),
meter=meter,
client_factory=_factory(
ledgers=[_ledger_json(satisfied=True, speaker=explore.NAVIGATOR_ROLE)],
hypothesiser=[],
),
)
assert excinfo.value.kind == "tokens"
assert meter.tokens == 8, "the manager's own call must have been charged to the meter"
# ---------------------------------------------------------------------------------------------
# C.5 / U13 — the synchronous plan review, and the cap the measurement forced
# ---------------------------------------------------------------------------------------------
def _reviewer(script: list[explore.PlanReviewDecision], seen: list[explore.PlanReviewRequest]):
def review(request: explore.PlanReviewRequest) -> explore.PlanReviewDecision:
seen.append(request)
return script.pop(0) if script else explore.PlanReviewDecision.approve()
return review
@pytest.mark.asyncio
async def test_a_revision_reaches_the_manager_and_the_review_is_asked_again() -> None:
"""T15: revise → replan → asked AGAIN → approve → the loop runs.
This is målbilde's "ask the question, use the answer, carry on" on the installed stack: the
expert's words go into the manager's history, the manager replans, and the human is asked to
sign off on the NEW plan rather than the old one. Both round trips are recorded, in order,
with the feedback verbatim an audit of what a human actually told an autonomous loop is
worth nothing paraphrased.
"""
seen: list[explore.PlanReviewRequest] = []
contract = ExplorationContract(
**{**_FULL_CONTRACT, "enable_plan_review": True, "max_plan_revisions": 2}
)
result = await explore.explore(
PROMPT,
contract=contract,
bundle_dirs=(),
plan_reviewer=_reviewer(
[explore.PlanReviewDecision.revise("Also test night setback.")], seen
),
client_factory=_factory(
ledgers=[
_ledger_json(satisfied=False, speaker=explore.HYPOTHESISER_ROLE),
_ledger_json(satisfied=True, speaker=explore.HYPOTHESISER_ROLE),
],
hypothesiser=[_hypothesis_line("Night setback", "the expert asked for it")],
),
)
assert [r.decision for r in result.plan_reviews] == ["revise", "approve"]
assert result.plan_reviews[0].feedback == "Also test night setback."
assert len(seen) == 2, "a revision must produce a SECOND review, not resume silently"
assert seen[1].plan != "", "the second review must show the revised plan"
assert result.stop is None
assert [a.label for a in result.mandate.approaches] == ["Night setback"]
@pytest.mark.asyncio
async def test_an_always_revising_reviewer_is_stopped_by_the_cap() -> None:
"""T16: the cap terminates a reviewer that never signs off — the reason it exists.
Measured (§ F, A3): a revise costs two manager calls, emits NO progress ledger and consumes
NO round, then asks again. The round cap therefore never ticks, and without
``max_plan_revisions`` this is an unbounded spend under caps that all look satisfied
precisely what ``shared/method-spec.md`` §8 forbids. The stop is typed and the exploration
still returns; the reviewer's last (refused) revision is recorded, because the record is of
what the human decided and ``stop`` is what says it was not applied.
"""
seen: list[explore.PlanReviewRequest] = []
always_revise = [explore.PlanReviewDecision.revise(f"Again #{n}.") for n in range(10)]
contract = ExplorationContract(
**{**_FULL_CONTRACT, "enable_plan_review": True, "max_plan_revisions": 1}
)
result = await explore.explore(
PROMPT,
contract=contract,
bundle_dirs=(),
plan_reviewer=_reviewer(always_revise, seen),
client_factory=_factory(
ledgers=[_ledger_json(satisfied=True, speaker=explore.NAVIGATOR_ROLE)],
hypothesiser=[],
),
)
assert result.stop == "plan_revisions_exhausted"
assert [r.decision for r in result.plan_reviews] == ["revise", "revise"]
assert result.ledger_log == (), "the loop must never have run: the plan was never approved"
@pytest.mark.asyncio
async def test_a_reviewer_that_signs_off_at_once_is_not_capped() -> None:
"""T17: the control for T16 — the same cap, a reviewer that approves, and no stop.
Without it, T16 would pass on an implementation that refuses every plan review it is given,
which would stop the runaway loop and every legitimate one with it.
"""
seen: list[explore.PlanReviewRequest] = []
contract = ExplorationContract(
**{**_FULL_CONTRACT, "enable_plan_review": True, "max_plan_revisions": 1}
)
result = await explore.explore(
PROMPT,
contract=contract,
bundle_dirs=(),
plan_reviewer=_reviewer([], seen),
client_factory=_factory(
ledgers=[_ledger_json(satisfied=True, speaker=explore.NAVIGATOR_ROLE)],
hypothesiser=[],
),
)
assert result.stop is None
assert [r.decision for r in result.plan_reviews] == ["approve"]
assert len(result.ledger_log) >= 1, "an approved plan must let the loop actually run"
# ---------------------------------------------------------------------------------------------
# C.0 level 3 — the exploration has no write access, and level 1 is advisory
# ---------------------------------------------------------------------------------------------
def _tree(root: Path) -> dict[str, bytes]:
return {
str(p.relative_to(root)): p.read_bytes() for p in sorted(root.rglob("*")) if p.is_file()
}
@pytest.mark.asyncio
async def test_an_exploration_leaves_the_knowledge_base_byte_identical(tmp_path: Path) -> None:
"""T18: ``explore()`` writes NOTHING — not to the base, not anywhere under it.
Level 3 of the guarantee table: only the pipeline may write an outbox artefact, and only the
gated ``promote_verdict`` may write to the wiki. An exploration that could write would be a
route around the gate that makes an answer checkable and, promoting into the base it reads,
the self-contamination loop the Step-8 gate exists to prevent.
Compared BYTE for byte over the whole subtree rather than by listing names, so a rewritten
``index.md`` of the same length would still fail.
**The tools are exercised DIRECTLY, and that is a correction, not thoroughness.** A first
version of this test drove only ``explore()`` and a mutation that made ``read_bundle`` write
a file into the base it reads left the WHOLE suite green (measured: 974 passed). A
``ScriptedChatClient`` returns text and never emits a tool call, so no scripted run reaches a
tool body: the read surface, which is the only place a write could plausibly come from, was
outside the gate entirely.
"""
base = tmp_path / "bygg-energi-baseline-mikro"
shutil.copytree(
Path(portfolio_optimiser.__file__).parent / "data" / "bundles" / base.name, base
)
before = _tree(tmp_path)
result = await explore.explore(
PROMPT,
contract=_CONTRACT,
bundle_dirs=(str(base),),
client_factory=_factory(
# Three ledgers, and the third is what makes the second one matter: the orchestrator
# tests ``is_request_satisfied`` BEFORE it reads ``next_speaker`` (``:1106``), so a
# satisfied ledger naming the hypothesiser never actually asks it anything.
ledgers=[
_ledger_json(satisfied=False, speaker=explore.NAVIGATOR_ROLE),
_ledger_json(satisfied=False, speaker=explore.HYPOTHESISER_ROLE),
_ledger_json(satisfied=True, speaker=explore.HYPOTHESISER_ROLE),
],
hypothesiser=[_hypothesis_line("LED retrofit", "fluorescent fixtures")],
),
)
assert result.mandate.approaches[0].label == "LED retrofit"
# Every read tool, called on the same base, with model-shaped arguments.
tools = {t.name: t for t in explore.navigator_tools((str(base),))}
assert tools["list_bundles"].func()[0]["id"] == base.name
assert tools["read_bundle"].func(bundle_id=base.name) != ""
assert tools["read_file"].func(bundle_id=base.name, path="index.md") != ""
explore.quick_validate_tool((str(base),)).func(
bundle_id=base.name, proposal_json=json.dumps(_micro_projection())
)
assert _tree(tmp_path) == before
def _micro_bundle_dir() -> str:
return str(
Path(portfolio_optimiser.__file__).parent
/ "data"
/ "bundles"
/ "bygg-energi-baseline-mikro"
)
def _micro_projection() -> dict[str, Any]:
projection = dict(okf.load_ir_projection(_micro_bundle_dir()))
projection.pop("_note", None)
return projection
def test_quick_validate_reports_the_real_verdict_and_says_whether_it_was_anchored() -> None:
"""T19: the in-loop check is the SAME validator, and it declares its own anchoring.
Level 1 is advisory but never fake: it runs ``validate_proposal`` against the base's own
``cost-baseline.json``, so stage 0 reconciliation is live and a fabricated cost line is caught
in the loop rather than three steps later. ``anchored`` rides along for the reason
``ProvenanceStamp.cost_baseline_anchored`` is a required field a verdict reached without the
project's real cost lines is a weaker claim, and one that does not say so is a silence.
"""
base = _micro_bundle_dir()
projection = _micro_projection()
validate = explore.quick_validate_tool((base,))
honest = validate.func(
bundle_id="bygg-energi-baseline-mikro", proposal_json=json.dumps(projection)
)
assert honest["decision"] == "validated"
assert honest["anchored"] is True
assert honest["p90"] >= honest["p50"] >= honest["p10"]
# A cost code the project does not have is refused by stage 0 — the one stage that can tell a
# fabricated line from a real one, and the reason `anchored` is worth reporting at all.
invented = dict(projection)
invented["affected_items"] = [
{**dict(projection["affected_items"][0]), "code": "CODE-THAT-DOES-NOT-EXIST"}
]
fabricated = validate.func(
bundle_id="bygg-energi-baseline-mikro", proposal_json=json.dumps(invented)
)
assert fabricated["decision"] == "rejected"
assert "CODE-THAT-DOES-NOT-EXIST" in fabricated["reason"]
def test_an_unknown_knowledge_base_is_refused_by_name() -> None:
"""T20: a tool call naming a base nobody configured refuses, and says what IS configured.
Model-chosen arguments are untrusted input. Answering an unknown id with an empty result would
let the manager conclude the base is empty rather than absent the fourth face of the
verification law, arrived at through a tool rather than a query.
"""
validate = explore.quick_validate_tool(("/tmp/base-a",))
with pytest.raises(explore.ExplorationError) as excinfo:
validate.func(bundle_id="base-b", proposal_json="{}")
assert "base-a" in str(excinfo.value)
def test_two_bases_with_the_same_name_are_refused() -> None:
"""T21: duplicate ids refuse at construction — the S3.2 key-collision class, one layer up.
The id is how the manager names a base. Two bases answering to one name would let it read A
while believing it read B, and every quotation it produced afterwards would be attributed to
the wrong project.
"""
with pytest.raises(explore.ExplorationError):
explore.navigator_tools(("/tmp/one/shared-name", "/tmp/two/shared-name"))
# ---------------------------------------------------------------------------------------------
# U14 — the three events the tracing seam was landed for, now that they have a call site
# ---------------------------------------------------------------------------------------------
def _recording_tracer() -> tuple[Any, InMemorySpanExporter]:
"""A REAL OpenTelemetry tracer over an in-memory exporter — not a spy.
A recorder standing in for ``add_event`` would prove that this module calls something shaped
like OTel; this proves the events survive the actual SDK, with the attribute types it will
accept. The provider is LOCAL and is never installed globally, so the pytest process keeps
whatever tracing configuration it had (the same restraint U14's own tests exercise).
"""
provider = TracerProvider()
exporter = InMemorySpanExporter()
provider.add_span_processor(SimpleSpanProcessor(exporter))
return provider.get_tracer("test"), exporter
@pytest.mark.asyncio
async def test_the_three_orchestrator_events_reach_the_trace(monkeypatch: Any) -> None:
"""T22: ``plan_created``, ``replanned`` and ``progress_ledger_updated`` are recorded.
These are the events U14 deliberately did NOT build in økt 55 "an emitter with no call site
is a shape guessed instead of measured". This is the call site. A Magentic manager decides
which base to open and who speaks next; without these, the only trace of that reasoning is
MAF's own ``invoke_agent`` spans, which say a call happened and nothing about what it decided.
The ledger event carries the decision fields rather than a rendered sentence, for the reason
``SkippedLink`` is structured and ``BudgetExceeded`` carries three fields: "who was asked" and
"was the request satisfied" are separate operative questions, and a reader who has to re-parse
prose to tell them apart has a trace they cannot query.
"""
tracer, exporter = _recording_tracer()
# Patched where the name is BOUND (the ``hosting.run_project`` precedent): ``explore``
# imports it by name, so patching ``tracing`` would leave that binding untouched and this
# test would quietly measure nothing.
monkeypatch.setattr(explore, "exploration_tracer", lambda: tracer)
seen: list[explore.PlanReviewRequest] = []
contract = ExplorationContract(
**{**_FULL_CONTRACT, "enable_plan_review": True, "max_plan_revisions": 2}
)
await explore.explore(
PROMPT,
contract=contract,
bundle_dirs=(),
plan_reviewer=_reviewer([explore.PlanReviewDecision.revise("Test night setback.")], seen),
client_factory=_factory(
ledgers=[
_ledger_json(satisfied=False, speaker=explore.HYPOTHESISER_ROLE),
_ledger_json(satisfied=True, speaker=explore.HYPOTHESISER_ROLE),
],
hypothesiser=[_hypothesis_line("Night setback", "the expert asked")],
),
)
spans = exporter.get_finished_spans()
assert [s.name for s in spans] == [explore.EXPLORATION_SPAN]
events = [(e.name, dict(e.attributes or {})) for e in spans[0].events]
names = [name for name, _ in events]
assert names.count("plan_created") == 1
assert names.count("replanned") == 1, "the human's revision must be visible in the trace"
assert names.count("progress_ledger_updated") == 2
ledger_events = [attrs for name, attrs in events if name == "progress_ledger_updated"]
assert [a["round_index"] for a in ledger_events] == [1, 2]
assert [a["next_speaker"] for a in ledger_events] == [explore.HYPOTHESISER_ROLE] * 2
assert [a["is_request_satisfied"] for a in ledger_events] == [False, True]
assert all(a["speaker_known"] for a in ledger_events)
#: A complete exploration in a CHILD interpreter. The stdout/stderr question cannot be answered
#: in-process: ``ConsoleSpanExporter``'s ``out`` default is bound when
#: ``opentelemetry.sdk.trace.export`` is first imported, so under pytest it is whatever stdout was
#: at COLLECTION time — and ``capsys``, which replaces ``sys.stdout`` later, never sees it. That is
#: not a testing quirk to work around; it is precisely the fact U14 exists for, and the reason
#: ``configure_tracing`` passes ``out=`` explicitly instead of trusting the default. Measured: a
#: mutation routing exploration spans to that default left an in-process ``capsys`` assertion
#: GREEN while the spans really were on stdout.
_CHILD_EXPLORATION = """
import asyncio, json, sys
from portfolio_optimiser import explore
from portfolio_optimiser.simulation import ScriptedChatClient
from portfolio_optimiser.tracing import configure_tracing
configure_tracing()
LEDGER = json.dumps({
"is_request_satisfied": {"reason": "r", "answer": True},
"is_in_loop": {"reason": "r", "answer": False},
"is_progress_being_made": {"reason": "r", "answer": True},
"next_speaker": {"reason": "r", "answer": "navigator"},
"instruction_or_question": {"reason": "r", "answer": "none"},
})
def _select(blob, _role):
if "provide the final answer" in blob:
return "FINAL: done."
if "pure JSON format" in blob:
return LEDGER
if "bullet-point plan" in blob:
return "PLAN: - ask the navigator"
if "pre-survey" in blob:
return "FACTS: none."
return "{}"
def factory(role):
if role == explore.MANAGER_ROLE:
return ScriptedChatClient(reply_selector=_select, role=role)
return ScriptedChatClient("ok", role=role)
contract = explore.ExplorationContract(
max_rounds=4, max_tokens=100000, max_stall_count=2,
max_reset_count=1, max_plan_revisions=0, enable_plan_review=False,
)
result = asyncio.run(
explore.explore("probe", contract=contract, bundle_dirs=(), client_factory=factory)
)
assert result.stop is None, result.stop
print("EXPLORATION-OK", file=sys.stderr)
"""
def _run_child(**env: str) -> subprocess.CompletedProcess[str]:
return subprocess.run(
[sys.executable, "-c", _CHILD_EXPLORATION],
capture_output=True,
text=True,
cwd=str(Path(__file__).resolve().parent.parent),
env={**os.environ, **env},
)
def test_an_untraced_exploration_writes_nothing_to_stdout_or_stderr() -> None:
"""T23: in a real process, with tracing off, an exploration prints NOTHING.
A subprocess and not ``capsys``, for the reason recorded above ``_CHILD_EXPLORATION`` and the
stakes are the pinned artefacts: ``tests/golden/demo-transcript.stdout`` is byte-fixed and the
demo's stderr is fixed at four lines, so one stray span dump would break both.
``EXPLORATION-OK`` on stderr is the control. Without it, "stdout was empty" would be equally
true of a child that crashed on import, which is the fourth face of the verification law: an
absence is only evidence once you have shown the measurement could have found something.
"""
proc = _run_child(PORTFOLIO_OTEL="")
assert proc.returncode == 0, proc.stderr
assert "EXPLORATION-OK" in proc.stderr, "the child must really have run an exploration"
assert proc.stdout == ""
# Not an exact-equality assertion on stderr: MAF emits two ``ExperimentalWarning`` lines while
# importing, under every run form, and they are the same pair the demo's pinned stderr already
# carries. What must be absent is TRACE data, so that is what is asserted.
assert '"name": "exploration"' not in proc.stderr
assert "progress_ledger_updated" not in proc.stderr
def test_a_traced_exploration_puts_its_span_on_stderr_and_leaves_stdout_clean() -> None:
"""T24: the positive arm — ``PORTFOLIO_OTEL=console`` and the exploration span is on STDERR.
This is what the whole U14 seam was landed for, now carrying the events U4 gave it a call site
for. Both halves are asserted: the span and its ``progress_ledger_updated`` event ARE exported
(so tracing is real), and stdout is STILL empty (so the byte-pinned transcript survives a
traced run). Asserting only the first would pass on an exporter writing to stdout which is
OpenTelemetry's own default, and therefore the mistake actually available to make.
"""
proc = _run_child(PORTFOLIO_OTEL="console")
assert proc.returncode == 0, proc.stderr
assert "EXPLORATION-OK" in proc.stderr
assert proc.stdout == "", "a traced run must not put one byte on stdout"
assert '"name": "exploration"' in proc.stderr
assert "progress_ledger_updated" in proc.stderr
def test_a_marked_line_that_will_not_parse_is_a_hard_error() -> None:
"""T24: the marker is what makes fail-closed affordable here.
Most hypothesiser turns legitimately are not hypotheses the agent reasons out loud so
"parse every turn or fail" would refuse a normal exploration. The marker separates a turn that
is not a claim from a claim that cannot be read. The second is the run's own product coming
back unreadable, so it raises (``write_concept_file``'s rule: validation, never repair) rather
than following the tolerant RAW-inbox rule, which belongs to folders anyone may drop files in.
"""
with pytest.raises(explore.HypothesisParseError):
explore._parse_hypotheses([f"{explore.HYPOTHESIS_MARKER} not json at all"], ())
with pytest.raises(explore.HypothesisParseError):
explore._parse_hypotheses([f'{explore.HYPOTHESIS_MARKER} {{"label": "no rationale"}}'], ())
def test_unmarked_prose_is_not_a_failure() -> None:
"""T25: the control for T24 — ordinary reasoning yields no hypothesis and no error.
Without it, T24 would pass on an implementation that refused every hypothesiser turn that was
not a hypothesis, which would make the loop unusable and the strictness meaningless.
"""
assert (
explore._parse_hypotheses(["I looked at the index and nothing stands out yet."], ()) == []
)
def test_a_review_nobody_can_answer_is_refused_before_the_first_model_call() -> None:
"""T26: plan review without a reviewer refuses; a reviewer without plan review refuses too.
The first would hang: the workflow stops at a ``request_info`` and nothing ever answers it, and
a hang is the one failure mode that reports nothing at all. The second is the silent-ignore the
repo's flag partition forbids — a caller who supplied a reviewer believes a human is in the
loop. Both are refused BEFORE anything is built, so neither costs a model call.
"""
with pytest.raises(explore.ExplorationError):
asyncio.run(
explore.explore(
PROMPT,
contract=ExplorationContract(**_FULL_CONTRACT),
bundle_dirs=(),
client_factory=_factory(ledgers=[], hypothesiser=[]),
)
)
with pytest.raises(explore.ExplorationError):
asyncio.run(
explore.explore(
PROMPT,
contract=ExplorationContract(**_NO_REVIEW),
bundle_dirs=(),
plan_reviewer=lambda _r: explore.PlanReviewDecision.approve(),
client_factory=_factory(ledgers=[], hypothesiser=[]),
)
)

View file

@ -0,0 +1,86 @@
"""Fase 1b, last step — GATED live run over the WHOLE ``run_project`` path (måleprotokoll §4.4).
NOT default CI, and NOT gated like its two siblings. This is the expensive arm: it drives the
complete vertical slice bundle navigation, the maker/checker debate, generation under
``response_format``, and the deterministic validator against a real Foundry deployment.
**Why a THIRD environment variable, and why it is load-bearing.** ``test_foundry_profile_live.py``
(client-level probe) and ``test_portfolio_live.py`` (``run_portfolio`` fan-out) both skip on exactly
``PORTFOLIO_FOUNDRY_PROJECT_ENDPOINT`` + ``PORTFOLIO_FOUNDRY_DEPLOYMENT``. Reusing that pair here
would mean the moment an operator exports the two variables to run the CHEAP one-word probe, this
full run fires too collapsing the måleprotokoll's whole point (§1: *"bevis så mye som mulig før
det dyre trinnet, en feil er attribuerbar"*) into a single step, and spending money on a rung
whose predecessors have not been shown green. ``PORTFOLIO_LIVE_FULL_RUN`` is therefore a separate,
deliberate opt-in, read on **truthiness, not presence** (the Fase 4b invariant: an exported-but-empty
value is a shell accident, not a decision).
``PORTFOLIO_MODEL_MAP`` is part of the skip condition for a different reason attribution. ``run.py``
stamps provenance with the deployment NAME before any client is built (målt 4e), so without the map
the run fails for a CONFIGURATION reason while looking exactly like a model failure. Skipping is
honest; failing there would misattribute.
**What this asserts is narrow on purpose** see ``conftest.assert_full_run_contract``. The claim
being felled is *"the emitted structured schema is accepted by the live endpoint"* (økt 37's stated
honesty limit), NOT "the model proposes well". A validator REJECTION passes this test: the run
reached the deterministic gate with a parsed candidate, which is the whole question. The contract's
ability to discriminate is proven offline and for free by
``tests/test_live_full_run_contract.py`` the paid call here is the measurement, not the proof that
the instrument works.
Outcomes are pre-registered in ``docs/2026-08-14-fase1b-forste-levende-kjoring.md`` §5, written
BEFORE the run, so the write-up cannot be negotiated after the fact.
The round/token caps are the SAME ones the first live run died on. They are deliberately not
raised: if the ledger fires again that is information, and raising it spends more on a path that may
still be broken.
"""
from __future__ import annotations
import os
from pathlib import Path
import pytest
from conftest import assert_full_run_contract
from portfolio_optimiser.run import RunResult, run_project
from portfolio_optimiser.verdicts import VerdictStore
BUNDLE_DIR = Path(__file__).resolve().parents[1] / "shared" / "examples" / "bygg-energi-mikro"
_PROJECT_ID = "BYGG-KONTOR-NORD"
_ENDPOINT = os.environ.get("PORTFOLIO_FOUNDRY_PROJECT_ENDPOINT")
_DEPLOYMENT = os.environ.get("PORTFOLIO_FOUNDRY_DEPLOYMENT")
_MODEL_MAP = os.environ.get("PORTFOLIO_MODEL_MAP")
#: Truthiness, not presence (Fase 4b): ``PORTFOLIO_LIVE_FULL_RUN=`` must NOT arm a paid run.
_OPTED_IN = bool(os.environ.get("PORTFOLIO_LIVE_FULL_RUN"))
_SKIP = not (_ENDPOINT and _DEPLOYMENT and _MODEL_MAP and _OPTED_IN)
@pytest.mark.skipif(
_SKIP,
reason=(
"paid full run not armed (set PORTFOLIO_LIVE_FULL_RUN=1 alongside "
"PORTFOLIO_FOUNDRY_PROJECT_ENDPOINT + PORTFOLIO_FOUNDRY_DEPLOYMENT + PORTFOLIO_MODEL_MAP)"
),
)
async def test_full_run_reaches_the_validator_on_a_live_model(tmp_path: Path) -> None:
"""The whole slice against a real deployment: every generation reply must come back in the
requested shape, and the deterministic validator must decide on it."""
outbox_dir = tmp_path / "outbox"
run_id = "live-full-001"
result = await run_project(
_PROJECT_ID,
"azure",
docs_dir=str(BUNDLE_DIR),
bundle_dir=str(BUNDLE_DIR),
verdict_input={"decision": "approved", "rationale": "expert reviewed (live 1b)"},
store=VerdictStore(verdicts=[]),
outbox_dir=str(outbox_dir),
run_id=run_id,
)
assert isinstance(result, RunResult)
assert_full_run_contract(result, outbox_dir, run_id)

Some files were not shown because too many files have changed in this diff Show more