portfolio-optimiser/docs/ekspert-svar.md
Kjell Tore Guttormsen 455d93d33e feat(outbox): every evaluated approach becomes something an expert can judge
A run commissioned to evaluate three approaches wrote ONE proposal artefact, so
only the approach it selected could ever receive a verdict. The other two were
evaluated, reported in the settlement, and then taught the learning loop nothing.

The defect class is a key collapse, and it had two halves — fixing either alone
leaves it intact:

* the WRITER wrote one pair per run, so the non-selected approaches never existed
  on disk;
* the READER (hitl._read_outbox_proposals) joins proposal to outcome on the
  run_id FIELD read from file CONTENT, never the filename. Three files sharing
  one run_id collapse onto one dict key, last write wins — so widening only the
  filename would have produced three artefacts and still one pending row. This is
  the S3.2 collision class: two rows under one key silently become one.

Artefacts are now keyed {run_id}-{approach_id}-*.json AND carry approach_id in the
payload; the join key is (run_id, approach_id). Two properties make them genuinely
judgeable rather than merely present:

* verdict_id is minted per approach (verdicts.verdict_key, the S3.2 content hash)
  — reusing the run's single id would let one delivered verdict clear all three
  from the queue;
* provenance.validator_decision follows ITS OWN approach — the run's stamp would
  report a rejected candidate as validated, and nothing downstream could correct it.

verdicts.verdict_key is public so a run can stamp the key a verdict WILL arrive
under without capturing a decision nobody has made; it delegates to _mint_id
rather than restating the hash (the (p) rule: one keying rule, one copy).

The per-approach set REPLACES the run-level pair rather than joining it — the
selected approach is already among them, and writing both would count it twice in
hitl pending. The selected one carries the run's final outcome, so the outbox can
never disagree with the RunResult; the others carry the validator's verdict, the
only falsifier that ran on them.

mandate.py is deliberately untouched: hanging a ValidatedProposal off a coverage
row would drag validator — and pulp — into a module kept to pydantic+stdlib for
D7 portability, so _evaluate_mandate returns the evaluated outcomes alongside.

Ran it, not just tested it: a real CLI run wrote six artefacts and hitl pending
listed three rows. It also showed the honest edge — three approaches that produce
an identical candidate share one content-hash key, so one verdict settles all
three. That is correct (they were one candidate), and it is now documented.

Load-bearing MEASURED (tests/test_a5_per_approach_artifacts_loadbearing.py) against
the whole 750-test suite, five mutations all red: detach the per-approach writer ·
drop approach_id from the join key · reuse the run's verdict id · reuse the run's
provenance stamp · widen the filename but not the payload. Control: on a full
detach exactly the 5 new tests fail and 745 pre-existing ones stay green — the
no-mandate path is inert, and writes neither the filename segment nor the field.

Docs: bestille-en-kjoring.md (what the commissioner gets) + ekspert-svar.md (what
the expert's queue looks like, and that "rejected" is the validator's verdict on
the numbers, never a professional judgement of the idea).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VtRd8y1PDPGwkrRXFhubqr
2026-08-05 21:12:09 +02:00

9.9 KiB

Limbare ekspert-svar

Dette dokumentet er for deg som skal avgi dommen i loopen — fageksperten som får et validert forslag i fanget og skal si ja, ja-med-forbehold eller nei. Det gir deg ferdige svar du kan lime rett inn, og forklarer hvor de skal limes.

Ærlighetsmerking. Svarteksten under er AI-forfattet og er ikke verifisert fagdom. Den er laget for å vise formen et ekspertsvar har og for å gjøre gjennomkjøringen mulig uten en levende ekspert. Tallene og resonnementene er forankret i kildene som ligger i kunnskapsbasen (shared/examples/bygg-energi-mikro/kilder-realiseringsgap.md), men ingen fagperson har godkjent dem. Skal en dom telle som fagdom, må en fagperson skrive den selv.

De to formene, og når du bruker hvilken

Systemet tar imot en ekspertdom på to steder, og de gjør forskjellige ting:

Form A — begrunnelses-streng Form B — dom-fil i innboksen
Hvor --decision / --rationale på kommandolinjen en JSON-fil i mappa --verdict-dir peker på
Når du er til stede mens kjøringen skjer du dømmer etterpå, kanskje dager senere
Rekkevidde denne ene kjøringen alle senere kjøringer som leser innboksen
Hva du skriver bare begrunnelsen (én streng) hele dommen som JSON-dokument

Form B er den som lærer systemet noe. Form A er en synkron snarvei.

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 kan telles.

Form A — begrunnelses-streng på kommandolinjen

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 "Godkjent med realiseringskorreksjon. Besparelsen er teknisk korrekt utledet, men i drift realiseres erfaringsvis ~79 % av en timeplan-stipulert LED-besparelse i kontorbygg; forventet faktisk besparelse ca 23 700 NOK/aar."

Form B — dom-fil i innboksen

Slik finner du feltene du må fylle ut

Kjør med --outbox-dir (som krever --run-id), så legger kjøringen igjen artefaktene sine:

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 \
  --outbox-dir utboks --run-id run-001

uv run python -m portfolio_optimiser.hitl pending --outbox-dir utboks --verdict-dir innboks

pending skriver én linje per forslag som fortsatt venter på en dom:

run-001 c91cb2fe1aa139a9 validated

Midterste kolonne er id-en dommen din må bære. Den er en innholds-hash av forslagets egenskaper, ikke et løpenummer — den er nøkkelen en senere kjøring bruker for å finne igjen dommen din, så den må gjengis ordrett. Resten av feltene ligger i utboks/run-001-proposal.json: measuremeasure_type, hver affected_items[].codeaffected_codes, og claimed_saving_nok uendret.

Har du bestilt flere tilnærminger?

Da får hver vurderte tilnærming sin egen artefakt — ellers kunne du bare dømt den ene kjøringen valgte, og de andre du bestilte ville ikke lært systemet noe. Filene heter utboks/run-001-<tilnærming>-proposal.json, og pending lister dem hver for seg med tilnærmingen i klammer:

run-001 c91cb2fe1aa139a9 validated [behovsstyrt-lys]
run-001 4f0a1d77b2e5c318 rejected [aggregat-bytte]
run-001 9b31e0c4a7d6f025 validated [own-proposal]

own-proposal er systemets eget forslag, ikke et av dine. Hver linje dømmes for seg, med sin egen id — akkurat som over. To tilnærminger som endte på nøyaktig samme forslag deler id (den er en innholds-hash), og da gjør én dom opp for begge; det er ikke en feil, det er at de var samme forslag.

Merk hva artefaktene ikke sier: en tilnærming som står som rejected, er avvist av den deterministiske validatoren — altså på tallene. Det er ikke en fagdom over ideen. Det er nettopp derfor den fortsatt havner i pending og venter på deg.

Utboks og innboks skal være to forskjellige mapper. De har motsatt eierskap: systemet skriver utboksen, du skriver innboksen. (Målt: å peke dem på samme mappe ødelegger ingenting i dag — utboksens filer heter run-001-*.json og mangler dom-feltene, så innboks-lasteren hopper over dem. Men den dagen noe skriver en dom-formet fil i utboksen, blir rå agent-output lest inn som om en ekspert hadde godkjent den. Hold dem adskilt.)

Fila legges i innboksen som <id>.json, altså innboks/c91cb2fe1aa139a9.json.


Svar 1 — godkjent med realiseringskorreksjon

Det vanligste ekte svaret på et energitiltak: regnestykket stemmer, men drift leverer mindre enn modellen lovte. Dette er dommen som bærer mest læring, fordi den forteller systemet noe det ikke kunne lest ut av tallene selv.

{
  "id": "c91cb2fe1aa139a9",
  "decision": "approved",
  "rationale": "Godkjent med realiseringskorreksjon. Den modellerte besparelsen er teknisk korrekt utledet fra parameterne, og validatoren bekrefter at den ligger innenfor feasibelt omraade. Men i drift realiseres erfaringsvis ~79 % av en timeplan-stipulert LED-besparelse i kontorbygg (realiseringsgrad=0.79), fordi driftstimene er overestimerte og in-service rate er under 1. Forventet faktisk besparelse er ca 23 700 NOK/aar. Tiltaket gjennomfoeres, men gevinsten foeres opp med det korrigerte tallet.",
  "proposal_features": {
    "affected_codes": ["ENERGI-TOTAL-EL"],
    "measure_type": "LED-retrofit av kontorbelysning",
    "claimed_saving_nok": 30000.0,
    "description": ""
  }
}

Svar 2 — godkjent uten forbehold

Når du faktisk mener tallet står seg som det er. Kort er riktig her; en lang begrunnelse for et enkelt ja gir systemet støy å lære av.

{
  "id": "c91cb2fe1aa139a9",
  "decision": "approved",
  "rationale": "Godkjent. Effekt foer/etter og antall armaturer er verifisert mot anleggets egen dokumentasjon, og driftstimene er metret, ikke stipulert. Det vanlige realiseringsavviket gjelder derfor ikke her, og besparelsen foeres opp uendret.",
  "proposal_features": {
    "affected_codes": ["ENERGI-TOTAL-EL"],
    "measure_type": "LED-retrofit av kontorbelysning",
    "claimed_saving_nok": 30000.0,
    "description": ""
  }
}

Svar 3 — avvist: tiltaket er ikke gjennomførbart her

Den viktigste dommen å ha et eksempel på, fordi den er den eneste som stopper noe. Validatoren har allerede sagt at tallene holder — du sier at virkeligheten rundt tallene ikke gjør det. Det er en dom bare et menneske kan avgi.

{
  "id": "c91cb2fe1aa139a9",
  "decision": "rejected",
  "rationale": "Avvist. Regnestykket er internt konsistent, men forutsetningen om 3 000 driftstimer holder ikke for dette bygget: belysningen staar paa naermest kontinuerlig i fellesarealene og er allerede dagslysstyrt i kontorsonene, saa faktisk brenntid ligger langt fra timeplanen tiltaket er regnet paa. I tillegg ble armaturene skiftet i 2023 og er ikke avskrevet. Et nytt bytte naa gir en gevinst som ikke forsvarer kostnaden, uavhengig av at den modellerte besparelsen er feasibel.",
  "proposal_features": {
    "affected_codes": ["ENERGI-TOTAL-EL"],
    "measure_type": "LED-retrofit av kontorbelysning",
    "claimed_saving_nok": 30000.0,
    "description": ""
  }
}

Svar 4 — avvist: feil kostnadsgrunnlag

Bruk denne når tiltaket i seg selv er fornuftig, men det er regnet mot feil tall. Her sier du noe om grunnlaget, ikke om tiltaket — og det er en annen lærdom for systemet.

{
  "id": "c91cb2fe1aa139a9",
  "decision": "rejected",
  "rationale": "Avvist paa grunnlaget, ikke paa tiltaket. Besparelsen er regnet mot byggets totale elforbruk, men belysning utgjoer bare ~18 % av det forbruket. Den paastaatte besparelsen tilsvarer over halvparten av hele belysningsposten, noe som ikke er oppnaaelig med et rent armaturbytte. Send tiltaket inn paa nytt regnet mot belysningsposten alene, saa er det sannsynligvis godkjennbart i redusert form.",
  "proposal_features": {
    "affected_codes": ["ENERGI-TOTAL-EL"],
    "measure_type": "LED-retrofit av kontorbelysning",
    "claimed_saving_nok": 30000.0,
    "description": ""
  }
}

Hva systemet gjør med dommen din

Legg fila i innboksen og kjør på nytt med --verdict-dir:

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 \
  --verdict-dir innboks

Dommen slås sammen inn i minnet før hypotesen lages, så neste forslag ser hva du sa forrige gang. hitl pending er tom etterpå — forslaget er dømt.

Dommen din blir liggende i råt-laget: den er lest, men den er ikke skrevet inn i kunnskapsbasen. Det siste skrittet er bevisst atskilt og krever en godkjenning (verdicts.promote_verdict), slik at bare kunnskap et menneske har stått inne for havner i wikien. En avvist dom kan ikke promoteres i det hele tatt — porten er lukket den veien, så rå agent-output aldri kan skrive seg selv inn som lærdom.

Om du skriver dine egne

  • id gjengis ordrett fra hitl pending. Endrer du den, blir dommen din hengende på et forslag som ikke finnes.
  • decision er approved eller rejected. Ingen tredje verdi på denne stien.
  • proposal_features skal beskrive forslaget du dømte, ikke det du mener det burde vært. Feltene kopieres fra run-001-proposal.json.
  • Begrunnelsen er det eneste som bærer fagkunnskap videre. Skriv hvorfor, ikke hva — «avvist, ikke feasibelt» lærer systemet ingenting det ikke allerede visste.
  • En ufullstendig eller halvskrevet fil hoppes stille over, ikke feiler. Sjekk med hitl pending at forslaget faktisk forsvant fra lista.