portfolio-optimiser/docs/2026-09-03-forslag-fra-mandat.md
Kjell Tore Guttormsen 26d08e7a96 docs(s7b): maaledokumentet baerer funnet CLAUDE.md-raden peker paa
--mandate mangler i report_forbidden. Maalt under fase 2, rapportert her, ikke
fikset: utenfor ordren, og eldre enn den.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 22:40:56 +02:00

162 lines
9.7 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.
### 3.1 Målt underveis, rapportert, IKKE fikset (utenfor ordren)
`--mandate` står **ikke** i `main()`s `report_forbidden`-partisjon. Report-modus returnerer FØR
kjøre-dispatchen, så `--report --ledger X --mandate Y` **dropper kommisjonen i stillhet** i stedet
for å nekte — nøyaktig den gap-klassen F4 målte på `--plan-review`, på et flagg som er eldre enn
denne ordren. `--proposals-from-mandate` er lagt inn i partisjonen (og gatet av M11); `--mandate`
selv er ikke rørt, fordi den er utenfor det ordren ba om.
**Æ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.