Compare commits

..

No commits in common. "main" and "v0.1.0" have entirely different histories.

137 changed files with 240 additions and 28376 deletions

View file

@ -5,151 +5,6 @@ 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/),
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
First tagged release. There is no prior release, so the entries below describe what this version

853
CLAUDE.md
View file

@ -1,15 +1,5 @@
# 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
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.
@ -24,16 +14,10 @@ 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.
- Metode kodifiseres som **Agent Skill** (`agentskills.io`: `SKILL.md` + `scripts/` + `references/`).
- Datatilgang: in-process `FunctionTool` er default-sømmen i kjørestien. **MCP er wiret som opt-in i kjørestien** (`mcp_tools.py` + `--mcp-config`, Trekk B 2026-08-05): konkrete eksterne servere blir verktøy agentene kan kalle UNDER debatten. Uten konfig gjøres null nettverkskall og verktøylista er uendret. Tre regler er load-bearing: **allowlist er påkrevd** (tom liste ville latt motparten bestemme hva agentene får kalle), **hver server og hvert tillatte verktøy navngis i kunngjøringen før første kall** (også uten `--mandate` — ingen udeklarert egress), og `--live-dry-run` åpner **ingenting**. Egen søm fra `ingest_mcp.py` (kildedokumenter FØR kjøring, null-argument-tools) — samme protokoll, ulik jobb. `build_mcp_server` (`datasource.py`) er fortsatt kun demo. Data-source-konfig JSON-Schema-validert, fail-fast.
- `shared/` er en **git subtree** av [`portfolio-optimiser-commons`](https://git.fromaitochitta.com/open/portfolio-optimiser-commons) (source of truth, R1 realisert 2026-07-03; publisert i `open/` 2026-08-04 — `commons`-remoten peker fortsatt på 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.
- `shared/` er en **git subtree** av [`portfolio-optimiser-commons`](https://git.fromaitochitta.com/open/portfolio-optimiser-commons) (source of truth, R1 realisert 2026-07-03; publisert i `open/` 2026-08-04 — `commons`-remoten peker fortsatt på `ktg/` og virker uendret). Synk er **pull-only**: endringer committes i commons og hentes med `git subtree pull --prefix=shared commons main --squash`. ALDRI `git subtree push` fra konsument — re-split lekker hele konsument-historikken inn i commons (observert + opprydd 2026-07-03). Se `shared/README.md`.
## Kommandoer
- Sync: `uv sync` — 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.
- Sync: `uv sync`
- Test: `uv run pytest`
- Lint: `uv run ruff check .` + `uv run ruff format .`
- Type: `uv run mypy src`
@ -63,47 +47,12 @@ 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å
fasit): `test_nav_golden_hierarchy_*` (positiv) + `test_nav_golden_escape_*` (negativ — en gate
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`
er repoets ene authoring-primitiv som materialiserer en konseptfil fra CALLER-oppgitt frontmatter,
og avviser derfor det KOMPLETTE eierskaps-stempelet (`generated: true` + `ingest_manifest`) med
`IngestStampError` — mens hver halvdel alene er lovlig (kuratert innhold kan bære ett
provenance-felt). Validering, ALDRI reparasjon: ingenting skrives. Uten dette kunne en kuratert fil
bli stille slettet av en senere re-materialisering, som fjerner nøyaktig det som bærer stempelet.
**`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
`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`,
@ -142,165 +91,6 @@ 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 ·
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.
- **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).
- **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
@ -322,23 +112,6 @@ 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:
`tests/test_step5_refine_loadbearing.py` blir rød når reason-injeksjonen detaches (utfallet
flipper aldri + reason-verbatim-asserten faller); kontrollen beviser at løkka forblir bundet.
- **Falsifiserings-historikken FORLATER generate-løkka som typet returverdi (Steg 5, del 2):**
`generate_via_llm` returnerer `GenerationResult(outcome, refinements)` — ikke lenger bare
`ValidatedProposal | Rejection`. Før dette forbrukte løkka hver `Rejection` internt (`last`) og
DROPPET den, så Steg 5 var det ene av åtte steg uten observerbart utfall. **Returverdi, ikke
out-parameter/callback:** en returnert verdi kan ikke bli stille tapt av en kaller som glemmer å
sende en samler, og mypy tvinger hvert kallsted til å ta stilling. **`refinements` bærer KUN
avvisninger som faktisk ble matet tilbake** i et senere forsøks prompt — ved uttømt budsjett ER
den siste avvisningen `outcome`, den informerte ingenting, og å telle den med ville vært
dobbeltføring (en «samle alt»-implementasjon består den positive testen og faller på kontrollen).
Taket er URØRT: `max_attempts` + `meter.tick_round` står, og `last` driver fortsatt prompten alene
(prompt-veksten er uendret). `run.py` akkumulerer på tvers av `_evaluate`-kallene, så
`_evaluate_mandate` er urørt; `RunResult.refinements` er defaultet (`coverage`-presedensen), og
med mandat er den KONKATENERT på tvers av tiltak, ikke nøklet per tiltak (uttalt ærlighets-grense).
`scripted_factory` tar nå `str | reply_selector` per rolle, så simuleringens proposer korrigerer
seg innholds-nøklet uten en andre scriptet kropp. Load-bearing MÅLT
(`tests/test_step5_history_loadbearing.py`), fire mutasjoner: detach returneringen · samle-alt ·
detach run-wiringen · reverter simuleringens proposer til konstant svar.
- **Lang/async fil-løkke (Steg 7, målbilde §3/§7):** `run_project(verdict_dir=...)` er den lange
tilbakemeldings-tidsskalaen — en ekspert/persona dropper en verdict-fil (vanlig JSON, RAW-laget
per §10 R2) i en inbox-mappe ETTER en kjøring, og en separat, senere kjøring `load_verdicts_from_dir`
@ -409,26 +182,6 @@ 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
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.
**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` +
`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:
@ -484,35 +237,6 @@ 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:**
`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.
- **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.
- **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
@ -526,103 +250,6 @@ 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
(`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.
- **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
`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
@ -637,482 +264,6 @@ 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,
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).
- **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.
- Prosess: Voyage-plugin (`/trekbrief → /trekplan → /trekexecute → /trekreview`) per større fase.

232
DEPLOY.md
View file

@ -1,232 +0,0 @@
# 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,31 +12,6 @@ generates candidate measures; a **mandatory deterministic validator** (solver +
decides the numbers; domain experts judge the outcomes (human-in-the-loop); and the system
**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
Python ≥3.10, with [`uv`](https://docs.astral.sh/uv/). The package is not published to a package
@ -48,34 +23,8 @@ cd portfolio-optimiser
uv sync
```
The clone is the documented path because the walkthrough below points at files in the tree. It is
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.
Clone rather than install into an existing environment: the shared spec, the persona skill and the
example bundles under [`shared/`](shared/README.md) are read from the working tree at run time.
Verify the install by running the whole suite from the clean clone:
@ -105,24 +54,11 @@ ls shared/examples/bygg-energi-mikro/
demonstrably informed by the first:
```bash
uv run python -m portfolio_optimiser.simulation # or: uv run portfolio-optimiser-demo
uv run python -m portfolio_optimiser.simulation
```
Each run shows the refinement step: the proposer's first claim is falsified by the deterministic
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.
The trace ends with the approved verdict's marker present in Run B's prompt and absent from Run A's
— knowledge crossing runs purely through the file-backed wiki (promote → re-seed → fold).
**3 — Run the loop over a knowledge base, with answers you supply.** Write the stand-in replies,
then point the CLI at the bundle:
@ -154,28 +90,11 @@ 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
argued with decides.
Read that summary line carefully. Nobody reviewed this run, so it says exactly that:
`Rejection (no expert verdict given; verdict key=…)`. `Rejection` is the **validator's** outcome;
the second half is about the **human**, and there was no human here. The key it quotes is the id
under which an expert verdict on this candidate will arrive later — your join back into the
[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.
Read that summary line carefully: `Rejection (verdict id=…, decision=approved)` is not a
contradiction. `Rejection` is the **validator's** outcome, while `decision=` echoes the
**human's** recorded verdict — here the `--decision` default, since nobody reviewed this run.
The two are deliberately separate: a machine gate that blocks, and a human judgement that
approves, are different questions and are never collapsed into one field.
**5 — See what it would cost with a real model**, before spending anything:
@ -278,24 +197,13 @@ per amount, before anything is summed.
- **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.
> **Status:** the full 8-step agentic loop is wired and proven with load-bearing tests. The
> end-to-end proof is primarily an **offline simulation** with a scripted stand-in client, but one
> **live run** against a real endpoint (`gpt-4.1-mini`, 2026-08-14) has also completed: it ended
> in a correct `rejected` outcome — the deterministic validator caught a cost line the model had
> invented outright (a code absent from the knowledge base), on the tolerance gate rather than the
> stricter existence gate, because the bundle it ran against ships no cost baseline to anchor
> 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.
> **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 — 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. A sibling implementation of
> the same method on the **Claude Agents SDK** is built in parallel from the same shared spec.
> **Disclaimer — technical framework only.** Deploying organizations own their processing
> purposes and assessments (DPIA, risk/ROS, security review). The framework ships the technical
@ -325,13 +233,6 @@ 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
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
A traditional wiki is built for *people* — optimized for humans finding and reading
@ -361,9 +262,7 @@ 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
lines it claims to save against.
5. **Refine** — a rejected attempt retries *informed* by the rejection reason, under hard
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.
attempt and token caps. Unbounded loops are forbidden everywhere.
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
later run picks it up. Fully resumable; no live session assumed.
@ -388,30 +287,6 @@ 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
implemented and exercised against committed fixtures — no bundle has yet been materialized from
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
cannot exercise every flag:
- **Single-project**`PROJECT_ID --docs-dir <dir>`, plus optional `--bundle-dir`,
@ -419,9 +294,7 @@ when the seam is detached, so the loop cannot silently degrade into theater.
`--semantic-retrieval`, `--decision`/`--rationale`, `--live-dry-run`, and
`--scripted-replies <file>` (the offline whole-loop door — see
[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), and
`--explore "<prompt>" --explore-config <file>` (opt-in: run an exploration first and let it
shape the mandate this run evaluates — see below).
`--live-dry-run`, which stops before the first model call rather than answering it).
- **Portfolio**`--portfolio`, plus optional `--goals`, `--ledger`, `--dimension-config`,
`--semantic-retrieval`; it stops early and prints a `goal reached: …` line when the
accumulated ledger meets a goal.
@ -441,104 +314,6 @@ when the seam is detached, so the loop cannot silently degrade into theater.
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
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
@ -578,19 +353,9 @@ 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
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
plain `--docs-dir`-only run is single-shot (no fold). `--decision`/`--rationale` apply to the
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
single-project path only and are inert in portfolio mode. **`--outbox-dir` must differ from
`--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
not CLI-enforced. Stop criteria and budget caps are required at startup. Try the offline
@ -625,57 +390,6 @@ only an expert has:
- **Vendor-neutral knowledge** — the same bundles drive two different agent stacks; switching
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
- [Bestille en kjøring](docs/bestille-en-kjoring.md) *(norsk)* — for the domain expert who
@ -690,11 +404,6 @@ whether the first deployment works. Gated by `tests/test_handover_package_loadbe
- [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
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
knowledge architecture (north star).
- [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
Email: security@fromaitochitta.com
Email: hello@fromaitochitta.com
Include:
- Description of the vulnerability
@ -24,31 +24,10 @@ Include:
## Supported Versions
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.
| 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.
| Version | Supported |
| ------- | ------------------ |
| latest | :white_check_mark: |
| < latest| :x: |
## Security Best Practices

View file

@ -42,41 +42,6 @@ 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`
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
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

@ -1,196 +0,0 @@
# 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

@ -1,125 +0,0 @@
# 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

@ -1,438 +0,0 @@
# 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

@ -1,450 +0,0 @@
<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

@ -1,247 +0,0 @@
# 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

@ -1,199 +0,0 @@
# 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

@ -1,327 +0,0 @@
# 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

@ -1,283 +0,0 @@
# 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

@ -1,120 +0,0 @@
# 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

@ -1,230 +0,0 @@
# 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,15 +23,6 @@ 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.
**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
«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

View file

@ -74,10 +74,9 @@ 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
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
is `shared/examples/bygg-energi-mikro/validator-input.json`; for the cost baseline it is
`shared/examples/veglys-fv-soer/cost-baseline.json`**two bundled examples ship one** (that one
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
is `shared/examples/bygg-energi-mikro/validator-input.json`; **no bundled example ships a
`cost-baseline.json`** (checked), so its shape — `{code: {quantity, unit_cost}}` — comes from
`ir.CostBaseline` and the README. Writing them from ingested content is
unbuilt, and is not on the 90 %-principle side of the line: what candidate to propose is the
agents' job, not the connector's.

View file

@ -45,15 +45,6 @@ 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
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
Bundles are plain markdown, so **Obsidian or VS Code** are enough to read and navigate them — no

View file

@ -1,611 +0,0 @@
<!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

@ -1,623 +0,0 @@
# 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]**
**Mål:** Samme kontrakt konsumert uendret fra commons i søskenrepoet
(`../portfolio-optimiser-claude`) — beviser at ingest-spec-en faktisk er delt.
(`/Users/ktg/repos/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
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 |
| A2 | `portfolio-optimiser-claude/src/portfolio_optimiser_claude/okf.py` | Py, 134 l | **kun les** (mangler skrive-halvdelen/Steg 8-primitiver) |
| B1 | `plugin-marketplace/okr/` (lib+scripts `.mjs`) | JS | index-generering + validering |
| B2 | `plugin-marketplace/linkedin-studio/scripts/brain/` | TS | emit + conformance-test |
| B1 | `ktg-plugin-marketplace/okr/` (lib+scripts `.mjs`) | JS | index-generering + validering |
| B2 | `ktg-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) |
| 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 |
| 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 |
| B1/B2 | `plugin-marketplace/` (`.mjs`/`.ts`) | JS/TS | index-generering + conformance |
| B1/B2 | `ktg-plugin-marketplace/` (`.mjs`/`.ts`) | JS/TS | index-generering + conformance |
≥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
@ -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.
3. **`claude-code-llm-wiki` (C1)** → senere; produsent-stien (ingest→bundle) legges på
`okf-core` + fabrikken.
4. **`plugin-marketplace` (B1/B2)** → forblir JS/TS (kan ikke dele Python-kode), men
4. **`ktg-plugin-marketplace` (B1/B2)** → forblir JS/TS (kan ikke dele Python-kode), men
konsumerer **formatprøven** som delt data.
5. **`llm-ingestion-guard` (C2)** → migreres IKKE; forblir eget repo og trekkes inn som
**avhengighet** i fabrikkens sikkerhetsvask.

View file

@ -1,18 +1,5 @@
# 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
> 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`.

View file

@ -1,136 +0,0 @@
# 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

@ -1,311 +0,0 @@
# 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

@ -1,146 +0,0 @@
# 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

@ -1,705 +0,0 @@
# 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

@ -1,142 +0,0 @@
# 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

@ -1,206 +0,0 @@
# 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

@ -1,394 +0,0 @@
# 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

@ -1,652 +0,0 @@
# 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

@ -1,475 +0,0 @@
<!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

@ -1,609 +0,0 @@
<!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.
**Konfig-diff mellom feilet og vellykket kjøring (publisert):**
`git -C ../portfolio-optimiser-claude diff 0238507..7637c6f --stat` viser 7 filer
`git -C /Users/ktg/repos/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`
+ 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
@ -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,
jf. §4.2/§4.5); **disclosed-non-reproducible** = kilden er erklært og ikke-reproduserbar.
Kommandoer kjøres fra MAF-repoets rot (dette repoet); søskenet er sjekket ut som nabokatalog
(`../portfolio-optimiser-claude`).
Kommandoer kjøres fra MAF-repoets rot (`/Users/ktg/repos/portfolio-optimiser`); søskenet er
`/Users/ktg/repos/portfolio-optimiser-claude`.
| # | Påstand | Kommando / kilde | Klasse |
|---|---|---|---|
@ -278,7 +278,7 @@ Kommandoer kjøres fra MAF-repoets rot (dette repoet); søskenet er sjekket ut s
python3 - <<'EOF'
import json, pathlib
g = json.loads(pathlib.Path("shared/examples/bygg-energi-mikro/golden.json").read_text())["validator"]
r = json.loads(pathlib.Path("../portfolio-optimiser-claude/runs/s10/run_result.json").read_text())["outcome"]
r = json.loads(pathlib.Path("/Users/ktg/repos/portfolio-optimiser-claude/runs/s10/run_result.json").read_text())["outcome"]
for k in ("p10", "p50", "p90"):
assert g[k] == r[k], (k, g[k], r[k])
print("OK: golden == run_result (bit-for-bit p10/p50/p90)")

View file

@ -3,7 +3,7 @@
**Dato:** 2026-07-04
**Program:** ingest-konnektorer for portfolio-optimiser, avsluttende steg (I7).
**Omfang:** begge referanseimplementasjonene — MAF (dette repoet) og Claude Agent
SDK-søsknet (D7, [`portfolio-optimiser-claude`](https://git.fromaitochitta.com/open/portfolio-optimiser-claude),
SDK-søsknet (D7, [`portfolio-optimiser-claude`](https://git.fromaitochitta.com/ktg/portfolio-optimiser-claude),
sitert *read-only*).
**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

View file

@ -18,26 +18,5 @@ PORTFOLIO_LOCAL_API_KEY=ollama # local servers accept any
# --- AZURE / Foundry profile (targeted verification only — D6) ---
# Deployment (model) names are tenant-specific and live in data/model_map.json (azure.*).
# 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.
# Credential resolves via Azure DefaultAzureCredential (`az login` / managed identity).
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

View file

@ -1,33 +0,0 @@
# 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
View file

@ -1,14 +0,0 @@
"""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]
name = "portfolio-optimiser"
version = "1.1.0"
version = "0.1.0"
description = "Generic framework on Microsoft Agent Framework for per-project cost-savings optimization"
readme = "README.md"
requires-python = ">=3.10"
@ -28,38 +28,13 @@ dependencies = [
# `uv run mypy src` outright. Raise both floors together or not at all.
"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-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,
# 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.
[tool.uv.sources]
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`),
# so the documented bare `uv sync` + `uv run pytest` workflow installs it without `--extra`.
@ -85,14 +60,6 @@ build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
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]
line-length = 100
src = ["src", "tests", "spikes"]
@ -107,10 +74,5 @@ python_version = "3.10"
# Third-party libs without bundled type stubs (py.typed) — analysed as untyped, not errors.
[[tool.mypy.overrides]]
module = ["pulp.*", "agent_framework_foundry.*", "llm_ingestion_guard.*"]
module = ["pulp.*", "agent_framework_foundry.*"]
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

@ -1,35 +0,0 @@
#!/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

@ -1,151 +0,0 @@
# 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

@ -1,10 +0,0 @@
{
"_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

@ -1,95 +0,0 @@
---
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

@ -1,88 +0,0 @@
---
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

@ -1,221 +0,0 @@
---
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

@ -1,98 +0,0 @@
---
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

@ -1,81 +0,0 @@
---
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

@ -1,167 +0,0 @@
---
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

@ -1,16 +0,0 @@
{
"_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

@ -1,138 +0,0 @@
---
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

@ -1,10 +0,0 @@
{
"_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

@ -1,71 +0,0 @@
---
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

@ -1,142 +0,0 @@
---
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

@ -1,81 +0,0 @@
---
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

@ -1,96 +0,0 @@
---
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

@ -1,132 +0,0 @@
---
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

@ -1,16 +0,0 @@
{
"_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

@ -1,81 +0,0 @@
---
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

@ -1,97 +0,0 @@
---
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,8 +31,7 @@ spec, the golden suite, or agent behaviour.
connector) does not require any change to this spec, and NOT implementing it does not break
conformance.
- **Honesty rule (unwaivable, method spec §1):** a machine-generated bundle is labelled as
such (`generated.by` naming the ingest actor, plus a manifest reference, §7) everywhere it is
presented.
such (`generated: true` plus a manifest reference, §7) everywhere it is presented.
- **Boundary:** the deploying organisation owns processing purposes and impact assessments;
ingest provides only the technical prerequisites (local-only default, provenance, no silent
egress).
@ -68,10 +67,9 @@ 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
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
the ingest stamp (`generated.by` equal to the ingest actor plus an `ingest_manifest`
reference, §7) and MUST NOT touch curated or promoted files. If a generated filename
collides with an existing file that does NOT carry the stamp, materialization MUST fail —
never overwrite curated content.
the ingest stamp (`generated: true` plus an `ingest_manifest` reference, §7) and MUST NOT
touch curated or promoted files. If a generated filename 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
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,
@ -81,11 +79,10 @@ 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
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*
ownership stamp — `generated.by` equal to the ingest actor together with an `ingest_manifest`
reference — while permitting either field alone (curated content may legitimately carry a
single provenance field). The check is on the complete stamp, never on the individual field
names, so a legitimate verbatim round-trip is preserved; it is a **validation, never a
repair**.
ownership stamp — `generated: true` together with an `ingest_manifest` reference — while
permitting either field alone (curated content may legitimately carry a single provenance
field). The check is on the complete stamp, never on the individual field names, so a
legitimate verbatim round-trip is preserved; it is a **validation, never a repair**.
## 4. The ingest manifest (the contract)
@ -214,7 +211,7 @@ that are never mixed — the same discipline as the two falsifiers.
| `source_query` | The query that fetched the content (whitespace-collapsed, §5). |
| `ingested_at` | The explicit timestamp argument, verbatim (§5). |
| `ingest_manifest` | The manifest reference `{stem}@{hash16}` (§5). |
| `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. |
| `generated` | Literally `true` — the machine-generated marker (§1 honesty rule). |
- OKF consumers preserve unknown frontmatter fields, so this layer rides through navigation
and context rendering unchanged.
@ -275,7 +272,7 @@ spec §11 regime):
| Seam | The test MUST fail when… |
|---|---|
| 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.by` equal to the ingest actor, 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: true` 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 |
| 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) |

View file

@ -1,5 +1,5 @@
{
"decision": "approved",
"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 tilsvarende anlegg (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 kontorbygg (realiseringsgrad=0.79) pga. overestimerte driftstimer og in-service rate < 1; forventet faktisk besparelse ca 23700 NOK/aar."
}

View file

@ -1,646 +0,0 @@
"""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

@ -1,39 +0,0 @@
"""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,
)
__version__ = "1.1.0"
__version__ = "0.1.0"
__all__ = [
# Portfolio orchestration

View file

@ -37,34 +37,6 @@ _MODEL_MAP_ENV = "PORTFOLIO_MODEL_MAP"
_PLACEHOLDER_PREFIX = "REPLACE-WITH-"
# 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"
# 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]:
@ -128,26 +100,20 @@ class AzureFoundryBackend:
profile = Profile.AZURE
def create_chat_client(self, *, model: str) -> BaseChatClient:
endpoint = _resolve_endpoint()
endpoint = os.environ.get("PORTFOLIO_FOUNDRY_PROJECT_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
# 1.8.2 — it raises ``ValueError`` without one; there is NO lazy DefaultAzureCredential
# default). Lazy import so the LOCAL path never pulls azure.identity.
#
# Fase 4b — the credential is chosen by ENVIRONMENT, because the two environments have
# different identities available:
# * 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
# default). Lazy import so the LOCAL path never pulls azure.identity. AzureCliCredential is
# 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:
# docs/2026-07-15-foundry-auth-recipe.md.
from azure.identity.aio import AzureCliCredential
credential = ManagedIdentityCredential() if _is_hosted() else AzureCliCredential()
return FoundryChatClient(project_endpoint=endpoint, model=model, credential=credential)
return FoundryChatClient(
project_endpoint=endpoint, model=model, credential=AzureCliCredential()
)
class LocalBackend:

View file

@ -107,11 +107,7 @@ class Contracts(BaseModel):
data_source: DataSourceContract
model_map: ModelMapContract
termination: TerminationContract
#: ``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
feedback: FeedbackContract
def _bundled_model_map() -> dict[str, Any]:
@ -123,19 +119,17 @@ def _bundled_model_map() -> dict[str, Any]:
def load_contracts(
data_source: dict[str, Any],
termination: dict[str, Any],
feedback: dict[str, Any] | None,
feedback: dict[str, Any],
*,
model_map: dict[str, Any] | None = None,
) -> Contracts:
"""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
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)."""
bundled ``data/model_map.json`` (the same file Step 8 ships)."""
raw_map = _bundled_model_map() if model_map is None else model_map
return Contracts(
data_source=DataSourceContract(**data_source),
model_map=ModelMapContract(**raw_map),
termination=TerminationContract(**termination),
feedback=None if feedback is None else FeedbackContract(**feedback),
feedback=FeedbackContract(**feedback),
)

File diff suppressed because it is too large Load diff

View file

@ -15,19 +15,17 @@ Two entry points, because the LLM call is async while ``validator.self_repair``
attempts. Used for deterministic candidate sources.
* ``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``).
Returns a ``GenerationResult`` (the outcome PLUS the falsifications that informed it); never a
malformed proposal; raises ``BudgetExceeded`` when the meter cap is crossed.
Returns ``ValidatedProposal | Rejection``; never a malformed proposal; raises
``BudgetExceeded`` when the meter cap is crossed.
"""
from __future__ import annotations
import json
from collections.abc import Callable, Mapping
from dataclasses import dataclass, field
from typing import Any
from collections.abc import Callable
from agent_framework import BaseChatClient, Message
from pydantic import BaseModel, ValidationError
from pydantic import ValidationError
from portfolio_optimiser.budget import TokenMeter
from portfolio_optimiser.ir import CostBaseline, SavingsProposal
@ -45,234 +43,6 @@ class GenerationError(RuntimeError):
"""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(
project: Project,
context: str,
@ -326,37 +96,6 @@ def _build_messages(
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:
"""Parse the model's structured reply into the typed IR. Raises on malformed/text-leaked
output (JSON error or Pydantic ``ValidationError``)."""
@ -364,7 +103,6 @@ def _parse_ir(text: str, project: Project) -> SavingsProposal:
if not isinstance(data, dict):
raise ValueError("reply is not a JSON object")
data.setdefault("project_id", project.id)
_normalise_assumptions(data)
return SavingsProposal(**data)
@ -402,8 +140,7 @@ async def generate_via_llm(
max_attempts: int = 3,
baseline: CostBaseline | None = None,
approach: Approach | None = None,
parse_failures: list[ParseFailure] | None = None,
) -> GenerationResult:
) -> ValidatedProposal | Rejection:
"""Async LLM path: non-streaming chat -> parse -> validate, with TWO bounded retry kinds,
the meter checked in this loop:
@ -428,71 +165,32 @@ async def generate_via_llm(
``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
through the SAME informed-refinement path (Step 5), which is why no new loop appears here.
``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``
Returns
``ValidatedProposal | Rejection``; never a malformed proposal; raises ``BudgetExceeded``
when the meter cap is crossed."""
async def _fetch_parsed(messages: list[Message]) -> SavingsProposal:
# Parse-robust: a malformed/text-leaked reply is retried; the meter caps total work.
while True:
meter.tick_round() # between-attempt bound (BudgetExceeded over cap)
# 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()}
)
reply = await chat_client.get_response(messages) # non-streaming
_charge_usage(meter, reply)
try:
return _parse_ir(reply.text, project)
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}")
)
except (ValidationError, ValueError, TypeError):
continue
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):
# 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
# overwritten each round -> only the most-recent falsification ("forrige"), never an
# accumulated history (bounded prompt growth).
if last is not None:
fed_back.append(last)
messages = _build_messages(project, context, prior_rejection=last, approach=approach)
candidate = await _fetch_parsed(messages)
result = validate_proposal(candidate, baseline=baseline)
if isinstance(result, ValidatedProposal):
return GenerationResult(outcome=result, refinements=tuple(fed_back))
return result
last = result
assert last is not None # max_attempts >= 1, so at least one validation ran
# 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))
return last # validation never passed within the attempt budget -> typed Rejection

View file

@ -165,138 +165,6 @@ 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))
# --- 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) ----------------------------
# 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``

View file

@ -1,358 +0,0 @@
"""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,17 +17,13 @@ it re-exports the library's typed surface and keeps the historical ``materialize
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").**
The library writes what it is given, so this module owns both gates Door A has:
- NETWORK (§8, no silent egress) ``allow_network`` (default ``False``): an ``http`` source is
refused fail-fast unless a run explicitly opts in, so the manifest can never grant itself
network access.
- CONTENT (P2/S1.b) :func:`materialize_gated` scans every concept an ingest run generates,
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).
Door A calls no guard function before writing to disk ``materialize_bundle`` writes what it
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
unless a run explicitly opts in the manifest can never grant itself network (§8, no silent
egress). Untrusted-content scanning remains the separate, still-planned ``llm-ingestion-guard``
wiring (see ``docs/plan/2026-07-16-llm-ingestion-guard-inclusion.md``); adopting this library
does NOT provide it.
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
@ -37,11 +33,8 @@ dependencies and imports no ``agent_framework`` / ``mcp``. Guarded by
from __future__ import annotations
import hashlib
import shutil
import socket
import tempfile
from pathlib import Path
from typing import Any
from llm_ingestion_okf import (
Extraction,
@ -69,12 +62,6 @@ from llm_ingestion_okf.connectors import (
)
from llm_ingestion_okf.manifest import generated_filename
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
# survive the adoption unchanged. `_urllib_get` backs an identity assertion that the default http
@ -98,8 +85,6 @@ HTTP_TIMEOUT_SECONDS = 30.0
__all__ = [
"HTTP_TIMEOUT_SECONDS",
"Channel",
"ContentGateRefused",
"Extraction",
"FileSource",
"HttpGet",
@ -111,7 +96,6 @@ __all__ = [
"ManifestV1",
"MaterializationError",
"NetworkGateError",
"Origin",
"RenderError",
"SourceError",
"SqlSource",
@ -119,7 +103,6 @@ __all__ = [
"load_manifest",
"materialize",
"materialize_bundle",
"materialize_gated",
"read_csv",
"read_http",
"read_sql",
@ -217,143 +200,3 @@ def materialize(
http_get=transport,
).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,7 +29,6 @@ Two refusals are load-bearing, both at construction time:
from __future__ import annotations
from collections.abc import Sequence
from dataclasses import dataclass
from pathlib import Path
from typing import Literal
@ -51,14 +50,6 @@ class Approach(BaseModel):
id: str = Field(min_length=1)
label: str = Field(min_length=1)
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):
@ -97,85 +88,6 @@ class Mandate(BaseModel):
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)
class ApproachOutcome:
"""What became of ONE commissioned approach — one row of the run's coverage report.

View file

@ -19,12 +19,6 @@ 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
stdlib): each cross-link is canonicalised and boundary-checked against the bundle dir, fail-closed
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
@ -34,7 +28,7 @@ import posixpath
import re
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Literal
from typing import Any
from portfolio_optimiser.ir import CostBaseline
from portfolio_optimiser.retrieval import PathSecurityError, safe_resolve
@ -100,55 +94,12 @@ class BundleFile:
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)
class Bundle:
"""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."""
"""A navigated OKF bundle: ``index.md`` plus every cross-linked file that resolves."""
dir: str
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
def index_summary(self) -> str:
@ -214,54 +165,29 @@ def _resolve_target(bundle_dir: str, from_name: str, target: str) -> tuple[str,
return None
def _walk(
bundle_dir: str,
current: BundleFile,
files: list[BundleFile],
seen: set[str],
skipped: list[SkippedLink],
) -> None:
def _walk(bundle_dir: str, current: BundleFile, files: list[BundleFile], seen: set[str]) -> None:
"""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
``./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."""
``./a.md``, ``a.md`` and ``/a.md`` are one entry), which is also what terminates cycles."""
for target in _LINK_RE.findall(current.body):
resolved = _resolve_target(bundle_dir, current.name, target)
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
rel, canonical = resolved
if canonical in seen:
continue # de-duplication / cycle termination: correct navigation, NOT a skip
continue
seen.add(canonical)
linked = _load_file(bundle_dir, rel)
if linked is None:
# 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
continue # broken link: tolerated, never raised (OKF §4)
files.append(linked)
_walk(bundle_dir, linked, files, seen, skipped)
_walk(bundle_dir, linked, files, seen)
def navigate_bundle(bundle_dir: str) -> 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
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.
file's frontmatter + body. Fully deterministic. Broken / escaping links are skipped (§4).
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
@ -271,11 +197,10 @@ def navigate_bundle(bundle_dir: str) -> Bundle:
if index is None:
raise ValueError(f"OKF bundle has no readable {_INDEX_NAME}: {bundle_dir!r}")
files: list[BundleFile] = [index]
skipped: list[SkippedLink] = []
root = _resolve_target(bundle_dir, _INDEX_NAME, _INDEX_NAME)
seen = {root[1]} if root is not None else set()
_walk(bundle_dir, index, files, seen, skipped)
return Bundle(dir=bundle_dir, files=tuple(files), skipped=tuple(skipped))
_walk(bundle_dir, index, files, seen)
return Bundle(dir=bundle_dir, files=tuple(files))
def bundle_context(bundle: Bundle, *, dimension: str | None = None) -> str:
@ -326,30 +251,17 @@ class IngestStampError(ValueError):
forged it could be silently deleted by a later ingest run."""
_YAML_TRUE_LITERALS = frozenset({"true", "yes", "on"})
"""Every scalar a real YAML reader parses to boolean ``True`` (measured with PyYAML's ``safe_load``
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."""
def _carries_complete_ingest_stamp(frontmatter: dict[str, str]) -> bool:
"""Whether ``frontmatter`` carries BOTH halves of the ingest ownership stamp: a ``generated``
value a YAML reader would read as boolean ``True`` (``_YAML_TRUE_LITERALS``) together with a
non-empty ``ingest_manifest`` reference (ingest-spec §7).
"""Whether ``frontmatter`` carries BOTH halves of the ingest ownership stamp: ``generated:
true`` 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."""
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()
manifest = str(frontmatter.get("ingest_manifest", "")).strip().strip('"')
return generated in _YAML_TRUE_LITERALS and bool(manifest)
return generated == "true" and bool(manifest)
def write_concept_file(bundle_dir: str, name: str, frontmatter: dict[str, str], body: str) -> Path:

View file

@ -31,7 +31,6 @@ called by ``run_project``, not a public authoring API (contrast ``verdicts.write
from __future__ import annotations
import json
from collections.abc import Mapping, Sequence
from pathlib import Path
from typing import TYPE_CHECKING, Any
@ -89,33 +88,9 @@ def write_outbox(
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):
return {
outcome_payload: dict[str, Any] = {
**keys,
"outcome_type": "validated",
"p10": outcome.p10,
"p50": outcome.p50,
@ -124,98 +99,18 @@ def outcome_payload(
"checker_verdict": checker_verdict,
"verdict_id": verdict_id,
}
return {
"outcome_type": "rejected",
"reason": outcome.reason,
"checker_verdict": checker_verdict,
"verdict_id": verdict_id,
}
else:
outcome_payload = {
**keys,
"outcome_type": "rejected",
"reason": outcome.reason,
"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")
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
return proposal_path, outcome_path
def write_run_config(

View file

@ -2,11 +2,9 @@
call before the operator pays for one.
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) an endpoint is set
under EITHER name the run path accepts (``PORTFOLIO_FOUNDRY_PROJECT_ENDPOINT`` first, then the
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
(config/string/env only NO client construction, NO network, NO auto-login): (1) the endpoint env
``PORTFOLIO_FOUNDRY_PROJECT_ENDPOINT`` is set and 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
still a ``REPLACE-WITH-*`` placeholder (via ``resolve_model`` the SAME seam the run path uses, so
preflight and run never validate different maps).
@ -28,14 +26,10 @@ from dataclasses import dataclass
from pydantic import ValidationError
from portfolio_optimiser.backends import _ENDPOINT_ENVS, Profile, _load_effective_map, resolve_model
from portfolio_optimiser.backends import Profile, _load_effective_map, resolve_model
from portfolio_optimiser.contracts import ModelMapContract
# 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]
_ENDPOINT_ENV = "PORTFOLIO_FOUNDRY_PROJECT_ENDPOINT"
_FOUNDRY_HOST_SUFFIX = ".services.ai.azure.com"
_ROLES = ("default", "proposer", "checker")
# Exact operator-facing disclaimer marker (Norwegian, per docs-language convention). The docs note
@ -58,28 +52,12 @@ class PreflightRefusal:
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:
"""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)."""
endpoint = _resolve_endpoint()
endpoint = os.environ.get(_ENDPOINT_ENV)
if not endpoint:
# 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)"
)
return f"{_ENDPOINT_ENV} er ikke satt (påkrevd for azure-profilen)"
if not endpoint.startswith("https://"):
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

View file

@ -57,20 +57,6 @@ class ProvenanceStamp(BaseModel):
role: str
validator_decision: Literal["validated", "rejected"]
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
#: this process was contacted" — not an absent field, which is why it is always serialized.
external_calls: list[ExternalCall] = Field(default_factory=list)

View file

@ -45,12 +45,7 @@ class Project:
currency: str
cost_items: tuple[CostItem, ...]
docs_dir: str # absolute path to this project's bundled cost-docs folder (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
verdict_input: dict[str, str] # SYNTHETIC Layer-2 expert decision/rationale (config-driven)
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)
@ -64,9 +59,8 @@ def load_reference_projects() -> tuple[Project, ...]:
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
Layer-2 expert decision/rationale when the row declares one, and is ``None`` when it does not
(F2). Missing keys raise ``KeyError`` (fail-fast, matching the existing loader contract)
``verdict_input`` is deliberately NOT among them."""
Layer-2 expert decision/rationale. Missing keys raise ``KeyError`` (fail-fast, matching the
existing loader contract)."""
resource = files("portfolio_optimiser").joinpath(_DATA_RESOURCE)
raw = json.loads(resource.read_text(encoding="utf-8"))
@ -93,9 +87,7 @@ def load_reference_projects() -> tuple[Project, ...]:
for c in p["cost_items"]
),
docs_dir=str(files("portfolio_optimiser").joinpath(f"data/{p['docs_dir']}")),
# ``.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"),
verdict_input=p["verdict_input"],
bundle_dir=_resolve(p.get("bundle_dir")),
verdict_dir=_resolve(p.get("verdict_dir")),
)

File diff suppressed because it is too large Load diff

View file

@ -1,15 +1,10 @@
"""Resolver for the location of the shared framework-neutral core (S3, R1-forberedelse; 4a pakket).
"""Resolver for the location of the shared framework-neutral core (S3, R1-forberedelse).
``shared/`` is a pull-only subtree of the commons repo at the repository root. Every MAF-side
runtime consumer (``persona``, ``simulation``) resolves its location through this ONE seam, so a
re-point is an env var, not a code change. Since Fase 4a the wheel also carries a byte-identical
mirror of the tree as packaged data (``portfolio_optimiser/_shared/``, hatchling force-include in
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.
``shared/`` is today an in-repo directory, but is slated for extraction into its own commons repo
(R1/S4). Every MAF-side runtime consumer (``persona``, ``simulation``) resolves its location through
this ONE seam so the extraction is a re-point (env var), not a code change. 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
@ -19,20 +14,12 @@ from pathlib import Path
ENV_VAR = "PORTFOLIO_SHARED_ROOT"
_WORKTREE = Path(__file__).resolve().parents[2] / "shared"
_PACKAGED = Path(__file__).resolve().parent / "_shared"
_DEFAULT = Path(__file__).resolve().parents[2] / "shared"
def shared_root() -> Path:
"""Resolve the shared-core root at CALL time: ``PORTFOLIO_SHARED_ROOT`` if set (non-empty),
else the working tree's ``shared/`` when it exists (a checkout), else the packaged copy
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."""
else the in-repo default. The call-time read is what keeps the override testable and the S4
extraction re-pointable without touching consumers."""
override = os.environ.get(ENV_VAR)
if override:
return Path(override)
if _WORKTREE.is_dir():
return _WORKTREE
return _PACKAGED
return Path(override) if override else _DEFAULT

File diff suppressed because it is too large Load diff

View file

@ -1,265 +0,0 @@
"""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,13 +10,11 @@ from __future__ import annotations
import json
from collections.abc import Callable, Sequence
from pathlib import Path
import pytest
from agent_framework import BaseChatClient
from portfolio_optimiser.reference_domain import load_reference_projects
from portfolio_optimiser.run import RunResult
from portfolio_optimiser.simulation import ScriptedChatClient
from portfolio_optimiser.verdicts import VerdictStore, seed_store
@ -194,50 +192,3 @@ def docs_dir(tmp_path) -> str:
encoding="utf-8",
)
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

@ -1,4 +0,0 @@
<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

@ -1,61 +0,0 @@
==============================================================================
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

@ -1,260 +0,0 @@
"""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

@ -1,188 +0,0 @@
"""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

@ -1,689 +0,0 @@
"""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,10 +59,7 @@ def test_local_backend_returns_client_no_network(monkeypatch: pytest.MonkeyPatch
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.
# 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("FOUNDRY_PROJECT_ENDPOINT", raising=False)
with pytest.raises(ValueError):
get_backend("azure").create_chat_client(model="dummy-deployment")

View file

@ -1,327 +0,0 @@
"""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

@ -1,183 +0,0 @@
"""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

@ -1,87 +0,0 @@
"""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

@ -1,163 +0,0 @@
"""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

@ -1,116 +0,0 @@
"""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,7 +52,6 @@ _LIVE_DOCS = (
"docs/ekspert-svar.md",
"docs/bestille-en-kjoring.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

View file

@ -1,766 +0,0 @@
"""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

@ -1,992 +0,0 @@
"""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

@ -1,86 +0,0 @@
"""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