portfolio-optimiser/docs/2026-09-03-forslag-fra-mandat.md
Kjell Tore Guttormsen 19badcd0fe docs(s7b): forslaget ble ALDRI lest fra validator-input.json - symptomet stemmer, diagnosen ikke
Ordrens fase 1: mal hvor SavingsProposal-feltene kommer fra i dag, og hvilke som
kan avledes deterministisk fra mandatet, den deriverte baselinen eller den rutede
tilnaermingen.

PREMISSET ER FELT FOER NOE BLE BYGGET PAA DET. Symptomet er bekreftet - en base
uten validator-input.json nekter med FileNotFoundError foer foerste modellkall.
Men forslaget har aldri blitt lest fra den fila: SavingsProposal konstrueres av
generate._parse_ir fra MODELLENS svar. Fila er en fasit ved siden av kjoerestien.
Det som blokkerer er at den er en paakrevd inngangsbetingelse for prosjekt-
IDENTITETEN. Skillet avgjoer at S7b er TO soemmer, ikke en.

NEVNEREN: tre kallsteder leser IR-projeksjonen, ikke ett - run.py:453
(hver bundle-kjoering), verdicts.py:507 (kun naar storen er ikke-tom) og
run.py:1662, dispatcherens rutingsnoekkel, som bevisst ikke tar project_id fordi
den leser den derfra. En ingestert base kan derfor ikke rutes i det hele tatt.

MAALT som IKKE i veien: derive_cost_baseline trenger ingen validator-input.json
(3 linjer ut av MAJOR-4-fixturen, 2 ut av en syntetisk base uten fila; det
uprisede skjemaet nekter fortsatt i sin helhet). Forankringen er paa plass; det
som mangler er en kandidat aa forankre.

TABELLEN tvinger fram tre ting fase 2 maa avgjoere: anslaget og kostkodene
finnes IKKE paa Approach eller Mandate i dag (maalt: fire hhv. fire felt, null
tallfelt); measure er ikke bare prosa men METHOD_CAPS-oppslagsnoekkelen, saa en
label treffer aldri metode-cap-en; og tom assumptions gjoer Monte Carlo inert
(P10 == P50 == P90) mens persentiler fortsatt printes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 20:23:49 +02:00

154 lines
9.2 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Hvor kommer forslaget fra? — måling før S7b
**Dato:** 2026-09-03 · **Ordre:** `20260903T014107Z-72282048-from-.claude` (S7b-forberedelse), fase 1
**Status:** måling. Ingen kode endret av dette dokumentet.
Bruksscenarioet ordren stiller opp er «dokumenter + konkret oppgave → forslag». Ordren beskriver
hindringen slik: *forslaget leses fra en håndskrevet `validator-input.json`*. Dette dokumentet måler
den påstanden mot koden før noe bygges på den, og legger fram tabellen ordren ber om: hvert felt i
`SavingsProposal` → hvor det kommer fra i dag → om det kan avledes deterministisk fra mandatet, den
deriverte kostbaselinen eller den rutede tilnærmingen.
---
## 1. Premisset er felt: symptomet stemmer, diagnosen ikke
Ordrens symptom er **bekreftet**. En kunnskapsbase uten `validator-input.json` nekter, og nekten kommer
før første modellkall:
```
$ run_project('P1', docs_dir=<base>, bundle_dir=<base>, live_dry_run=True)
FileNotFoundError: IR projection not found in bundle: 'validator-input.json'
```
Diagnosen stemmer derimot ikke. **Forslaget har aldri blitt lest fra den fila.** Målt i kilden:
* `SavingsProposal` konstrueres av `generate._parse_ir` fra **modellens svar**
(`generate.py`, `generate_via_llm` → `_fetch_parsed` → `_parse_ir`). Ingen kodesti leser et forslag
fra `validator-input.json`.
* `run._project_from_bundle` (`run.py:444`) leser IR-projeksjonen til nøyaktig **to** ting: en
fail-fast av kjøringens `project_id` mot basens, og et fallback for prosjektnavnet — navnet hentes
primært fra `type: project`-konseptets `title`-frontmatter, og IR-en brukes ikke til det i det hele
tatt.
At `validator-input.json` er en komplett håndskrevet `SavingsProposal` (den er det — se
`shared/examples/tunnel-hauglia/validator-input.json`) gjør den til en **fasit ved siden av**
kjørestien, ikke til kjørestiens inngang. Det som faktisk blokkerer, er at fila er en **påkrevd
inngangsbetingelse for prosjekt-identiteten**.
Skillet er ikke pedanteri: det avgjør at S7b er **to sømmer, ikke én**.
| | Hva som er i veien | Hva som må til |
|---|---|---|
| **Søm 1 — prosjekt-identitet** | `load_ir_projection` er fail-fast påkrevd på tre kallsteder | fravær tolereres; en fil som FINNES nekter fortsatt ved avvik |
| **Søm 2 — kandidat-kilde** | den eneste kandidat-kilden er `generate_via_llm` | en deterministisk kilde ved siden av, eksplisitt bestilt |
### 1.1 Nevneren for søm 1: tre kallsteder, ikke ett
```
src/portfolio_optimiser/run.py:453 _project_from_bundle (prosjekt + fail-fast)
src/portfolio_optimiser/run.py:1662 run_mandate_across_bundles (dispatcherens rutingsnøkkel)
src/portfolio_optimiser/verdicts.py:507 bundle_candidate_features (ExpeL-nøkkelen)
```
Alle tre er målt til å reise samme `FileNotFoundError` mot en base uten fila. De er ulike i når de
fyrer:
* **`run.py:453`** fyrer på hver bundle-kjøring — dette er nekten ordren observerte.
* **`verdicts.py:507`** fyrer bare når storen er ikke-tom (Steg-1-folden er gated på det), altså først
når basen har lært noe. En kjøring uten dommer merker den ikke.
* **`run.py:1662`** er den som betyr mest for S7b: multi-base-dispatchen tar bevisst **ingen**
`project_id`-parameter, fordi hver bases prosjekt leses fra DEN basens egen IR-projeksjon
(CLAUDE.md, multi-base-raden). En ingestert base uten fila kan derfor ikke rutes i det hele tatt —
og etter S7a-3 finnes det en åpenbar erstatning: basens **erklærte `bundle_id`**.
### 1.2 Det som IKKE er i veien
`derive_cost_baseline` trenger ingen `validator-input.json`. Målt mot den samme base som nektet over,
og mot MAJOR-4-fixturparet:
| base | utfall |
|---|---|
| syntetisk to-linjers base (uten IR-projeksjon) | `A1: 100 × 50`, `A2: 20 × 250` |
| `tests/fixtures/k2-prisskjema-SYNTETISK` | `21.1: 1250 × 850`, `24.2: 48000 × 42.5`, `36.1: 4 × 187500` |
| `tests/fixtures/k2-prisskjema-uprisert-SYNTETISK` | `CostBaselineDerivationError` (uprisert skjema nekter i sin helhet) |
Forankringen er altså allerede på plass for et ingestert korpus. Det som mangler, er en kandidat å
forankre.
---
## 2. Tabellen: felt → kilde → skjønn
`SavingsProposal` har fem felt (målt: `['project_id', 'measure', 'affected_items',
'claimed_saving_nok', 'assumptions']`).
| felt | hvor det kommer fra i dag | (a) mandatet | (b) `derive_cost_baseline` | (c) rutet approach | skjønn? |
|---|---|---|---|---|---|
| `project_id` | modellens svar, med `project.id` som default (`_parse_ir`) | — | `CostBaseline.project_id` — men den er et **påkrevd argument**, ikke en avledning | — | **NEI** — kjøringen vet allerede hvilket prosjekt den kjører |
| `measure` | modellens prosa | — | — | `Approach.label` (ev. + `description`), VERBATIM | **NEI** — men se § 2.1 |
| `affected_items` | modellen velger både koder og tall | — | `items[code] → (quantity, unit_cost)` for hver kode | approachen må **navngi kodene** | **JA** hvis kodene ikke er navngitt → må nekte, ikke gjette |
| `claimed_saving_nok` | modellens tall | **ingen plass i dag** | kan ikke avledes — baselinen er en KOSTNAD, ikke en besparelse | **ingen plass i dag** | **JA** → må komme fra eksperten, eller nekt ved navn |
| `assumptions` | modellens bånd | — | kan ikke avledes — én pris, ikke et bånd | valgfritt eksplisitt | **JA** → tomt er lovlig, men se § 2.2 |
Målt: `Approach` bærer `['id', 'label', 'description', 'bundle_id']` og `Mandate` bærer
`['objective', 'approaches', 'allow_own_proposals', 'success_criteria']`. **Ingen av dem har et
tallfelt.** De to feltene tabellen krever av eksperten — anslaget og kostkodene — finnes altså ikke,
og må legges til i `mandate.py`.
### 2.1 `measure` er ikke bare prosa — den er en oppslagsnøkkel
`validate_proposal` stage 5 slår opp `METHOD_CAPS[proposal.measure]` (`validator.py:248`). Registeret
har i dag én oppføring, `energy_efficiency` → 0,15. En `measure` satt til ekspertens `label` treffer
den derfor **aldri**, med mindre labelen ordrett er `energy_efficiency`.
Det er ikke en defekt, men det er en grense som må sies: en mandat-avledet kandidat dømmes av den
generiske 30 %-cap-en (`MAX_SAVING_FRACTION`), ikke av en metode-spesifikk. Metode-cap-en er
opt-in-data (F8), og en mandat-vei som skal treffe den, må la eksperten navngi metoden — det er en
egen beslutning, ikke noe denne veien kan avlede.
### 2.2 Tom `assumptions` gjør den stokastiske falsifisereren inert
`validator._monte_carlo` faller tilbake på `item.unit_cost` for hver kode uten bånd. Uten bånd i det
hele tatt er alle samples identiske og **P10 == P50 == P90**. Det er nøyaktig faren
`generate._ASSUMPTIONS_WIRE_NODE` ble skrevet for å unngå på LLM-stien.
Konsekvensen for en mandat-avledet kandidat: stage 3 rapporterer fortsatt persentiler, men de bærer
ingen usikkerhet. Kandidaten dømmes reelt av stage 0 (avstemming mot baselinen), stage 2/4b (CBC-solve
mot 30 %-cap-en) og pydantics `claimed ≤ total`. **Dette skal stå som en uttalt ærlighets-grense**, ikke
skjules bak at persentiler blir printet.
### 2.3 Hva som gjør en mandat-kandidat ikke-triviell å gjøre grønn
Kopieres `affected_items` fra den deriverte baselinen, avstemmer stage 0 med 0 % avvik ved
konstruksjon. Det er verdt å si høyt, fordi det betyr at en test som bare sjekker «validatoren kjørte»
er grønn ved konstruksjon — repoets vakuøs-gate-klasse.
Den levende beskrankningen er anslaget: `MAX_SAVING_FRACTION = 0.30`. Et anslag over 30 % av de
berørte linjenes total blir **avvist av den ordinære gaten**, uten at kandidat-veien vet noe om det.
Det er den skarpe diskriminatoren en test må bruke for å vise at kandidaten går *gjennom*
`validate_proposal` og ikke rundt den.
---
## 3. Hva som følger av dette
Fase 2 av ordren bygger på tre beslutninger tabellen tvinger fram:
1. **Anslaget og kostkodene bor på `Approach`, ikke på `Mandate`.** `mandate.settle` sier selv at
tilnærminger er ALTERNATIVER og aldri summeres, så ett tall på mandatnivå ville vært tvetydig over
N tilnærminger. `Mandate`-docstringen nekter å bære *målet* (det eier `contracts.GoalContract`) —
et per-kandidat-anslag er en annen ting enn et mål for kjøringen, og skillet må skrives ned der
feltet innføres, ellers leses det som nøyaktig den driften modulen forbyr.
2. **Hver ny nekt subklasser `ValueError`** — `MandateRoutingError`/`BundleIdMismatch`/
`CostBaselineDerivationError`-presedensen — så den lander på CLI-ens nekt-tuppel og hostings
400-arm, aldri krasj-kanalen.
3. **`allow_own_proposals` har ingen deterministisk motpart.** En «egen» kandidat krever en modell. Den
raden kan derfor ikke fylles, og skal rapporteres som `not_evaluated` med en grunn ved navn —
`ApproachOutcome`s egen regel: en rad som utelates er umulig å skille fra en tilnærming ingen
bestilte. Å nekte hele kjøringen ville vært feil, siden feltet defaulter til `True` og hvert
eksisterende mandat bærer det.
**Ærlighets-grenser i denne målingen:** ingen levende K2-fil er lest (fixturene er syntetiske, som i
MAJOR-4); `run.py:1662`-konsekvensen for dispatchen er lest i kilden, ikke kjørt mot en base uten
IR-projeksjon; og tabellen beskriver hva som KAN avledes, ikke at en ekspert vil fylle feltene godt.