portfolio-optimiser/docs/bestille-en-kjoring.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

8.3 KiB

Bestille en kjøring

Dette dokumentet er for deg som bestiller en kjøring — fagpersonen som vet hvilke tilnærminger det er verdt å prøve på et prosjekt, og som vil vite nøyaktig hva systemet skal gjøre før det gjør det. Søsterdokumentet ekspert-svar.md handler om det motsatte enden: å avgi dommen etterpå.

Ærlighetsmerking. Bestillingen styrer hva som vurderes. Den styrer aldri hva som godkjennes. Den deterministiske validatoren gjelder uendret for en tilnærming du selv har bedt om — ber du om noe tallene ikke bærer, blir det avvist, og avvisningen kommer tilbake til deg med begrunnelsen.

Oppdragsfila

En bestilling er én JSON-fil. Du gir den til kjøringen med --mandate.

{
  "objective": "Kutt energikostnad i BYGG-KONTOR-NORD uten ombygging, med tiltak som kan gjennomføres i 2026.",
  "approaches": [
    {
      "id": "led-retrofit",
      "label": "LED-retrofit av kontorbelysning",
      "description": "Drift mener armaturene er originale og har gått langt forbi oppgitt levetid."
    },
    {
      "id": "driftsavtale",
      "label": "Reforhandling av driftsavtale",
      "description": "Avtalen er ikke justert siden 2019 og dekker areal vi ikke lenger disponerer."
    }
  ],
  "allow_own_proposals": true,
  "success_criteria": "Minst ett tiltak som passerer validatoren og som drift kan stå inne for."
}
Felt Hva det gjør
objective Hva kjøringen er til for, i klartekst. Påkrevd — en kjøring uten uttalt hensikt er nettopp det denne fila finnes for å hindre.
approaches Tilnærmingene du vil ha vurdert. Hver får sin egen vurdering og sin egen linje i oppgjøret.
allow_own_proposals true (standard): systemet foreslår også sitt eget, i tillegg til dine. false: kun dine.
success_criteria Hva du vil regne som vellykket. Gjengis ordrett i kunngjøringen; ingenting tolker den maskinelt.

Om id: den er nøkkelen tilnærmingen rapporteres under. To tilnærminger kan ikke dele id (da ville de kollapset til én linje i oppgjøret), og own-proposal er reservert for systemets eget forslag.

Om description: den mates ordrett inn til modellen. Det er her fagkunnskapen din faktisk gjør en forskjell — hvorfor du tror tilnærmingen er verdt å prøve er nøyaktig det systemet ikke kan lese seg til av kostnadstallene.

Enten tilnærminger eller egne forslag. En fil med tom approaches og allow_own_proposals: false blir avvist: da har kjøringen ingenting å gjøre.

Å kjøre den

uv run python -m portfolio_optimiser.run BYGG-KONTOR-NORD \
  --docs-dir shared/examples/bygg-energi-mikro \
  --bundle-dir shared/examples/bygg-energi-mikro \
  --mandate oppdrag.json \
  --scripted-replies replies.json

--mandate virker i begge moduser. I porteføljemodus (--portfolio) gjelder bestillingen hvert prosjekt i passet, og hvert prosjekt gjør opp for seg.

Vil du se hva kjøringen ville gjort, uten å bruke penger? Legg til --live-dry-run. Da skrives kunngjøringen, og kjøringen stopper før første modellkall.

Fila leses strengt. Mangler den, eller er den feilformet, blir kjøringen nektet (rc 1) — den går aldri videre på en halvlest bestilling. Det er med vilje: et oppgjør som beskriver arbeid ingen har bestilt, er verre enn ingen bestilling.

Hva du får: kunngjøringen

Før noe koster penger, skriver kjøringen hva den skal gjøre:

Run mandate for BYGG-KONTOR-NORD
  Objective:   Kutt energikostnad i BYGG-KONTOR-NORD uten ombygging, ...
  Evaluates:   2 expert-proposed approach(es) + the system's own proposals
                 1. led-retrofit — LED-retrofit av kontorbelysning
                 2. driftsavtale — Reforhandling av driftsavtale
  Stops at:    3 rounds / 100000 tokens
  Contacts:    no external services
  Success:     Minst ett tiltak som passerer validatoren ...

Kjører du med --dimension-config, kommer det en Scoped to:-linje til. Linjer for ting kjøringen ikke har — avgrensning, tallmål — utelates i stedet for å stå tomme: en tom linje ville lest som en beslutning ingen har tatt.

Contacts er egress-erklæringen: en kjøring når aldri en ekstern tjeneste den ikke har navngitt her. Stops at er takene — systemet kjører aldri en ubegrenset løkke.

Hva du får: oppgjøret

Etter kjøringen svarer den for bestillingen, én linje per tilnærming:

Mandate outcome
  led-retrofit         VALIDATED          30000 NOK   LED-retrofit av kontorbelysning
  driftsavtale         REJECTED      claimed 240000 exceeds feasible 90000
  own-proposal         NOT EVALUATED budget exhausted before this approach was evaluated
  Validated: 1 of 3 approaches — best 30000 NOK (the outcome this run carries)

De tre statusene betyr forskjellige ting, og forskjellen er poenget:

  • VALIDATED — tallene holdt. Beløpet er det validatoren slapp gjennom.
  • REJECTED — tilnærmingen ble vurdert, og falt på tallene. Grunnen står der; det er den du kan gjøre noe med (ofte er den «regnet mot feil grunnlag», ikke «dårlig idé»).
  • NOT EVALUATED — kjøringen rakk den aldri. Den er ikke avvist, og den er ikke glemt.

En tilnærming du bestilte forsvinner aldri i stillhet. Det er hele grunnen til at oppgjøret finnes.

Tilnærmingene summeres ikke. Flere av dem angriper som regel samme kostnadslinje, så de er alternativer — ikke besparelser som legges oppå hverandre. Derfor står det hvor mange som holdt og hvilken kjøringen bærer videre, aldri en sum. (Dette ble oppdaget ved å faktisk kjøre den: tre tilnærminger mot samme linje ga «totalt 90 000», som ingen av dem kunne innfri.)

Hver tilnærming kan dømmes for seg

Kjører du med --outbox-dir (og --run-id), legger kjøringen igjen én artefakt per vurdert tilnærmingrun-001-led-retrofit-proposal.json, run-001-driftsavtale-proposal.json, og så videre — ikke bare for den kjøringen bar videre.

Det er ikke en bokføringsdetalj. Uten det kunne bare den valgte tilnærmingen få en fagdom, og de andre du bestilte ville aldri nådd læringssløyfa: de ble vurdert, rapportert i oppgjøret, og deretter glemt. Nå står de hver for seg i køen din:

uv run python -m portfolio_optimiser.hitl pending --outbox-dir utboks --verdict-dir innboks
run-001 c91cb2fe1aa139a9 validated [led-retrofit]
run-001 4f0a1d77b2e5c318 rejected [driftsavtale]

Hvordan du svarer på dem står i ekspert-svar.md. En tilnærming med status NOT EVALUATED har ingen artefakt — kjøringen produserte aldri et forslag for den, så det er ingenting å dømme.

Hva bestillingen ikke gjør

  • Den overstyrer ikke validatoren. Se ærlighetsmerkingen øverst.
  • Den avgjør ikke om et tiltak skal gjennomføres. Det gjør et menneske, etterpå — se ekspert-svar.md.
  • Den setter ikke et tallmål. Sparemålet har ett hjem, målkonfigurasjonen (--goals, som gjelder porteføljemodus). To kopier av samme tall drifter fra hverandre, så oppdraget bærer hensikten i ord og gjengir tallet hvis det finnes.
  • Den lagrer seg ikke som kunnskap. Bestillingen er en instruks for én kjøring. Det som lærer systemet noe, er dommen du avgir etterpå.

Hvis kjøringen skal spørre en ekstern tjeneste

Skal agentene kunne slå opp i en tjeneste dere allerede har — et prisregister, målerdata, et avtaleregister — settes den opp med --mcp-config:

{"servers": [
  {"name": "prisregister", "transport": "http", "url": "https://intern.example/mcp",
   "allowed_tools": ["lookup_unit_price"], "timeout_seconds": 15,
   "credential_env": "PRISREGISTER_TOKEN"}
]}

Tre ting er verdt å vite som bestiller:

  • Du må navngi hvilke verktøy som er tillatt. Tom liste er ikke lov. Uten det ville tjenesten selv bestemt hva agentene får lov til å kalle.
  • Alt som vil bli kontaktet, står i kunngjøringen — før første kall, på Contacts:-linja, med både servernavn og tillatte verktøy. En kjøring når aldri en tjeneste den ikke har navngitt. Det gjelder også når du ikke bruker --mandate.
  • Passord og nøkler skal aldri i fila. credential_env navngir en miljøvariabel; verdien leses derfra. Er variabelen ikke satt, blir kjøringen nektet — heller det enn å ringe tjenesten anonymt og få svar med feil tilgang.

Uten --mcp-config gjøres ingen nettverkskall i det hele tatt.