fix(money): quantize NOK to øre in one order, from one source (kø-p)

Two quantization orders existed and met at exactly one comparison.
SavingsLedger quantizes every realized candidate to integer øre and sums the
ints; run.py's goal baselines summed Project.total_cost FLOATS across items and
projects and quantized the total once. _goal_limit_if_reached compared the
former against a threshold derived from the latter — so whether a portfolio pass
stops early was decided by two differently-computed sides.

Measured divergence: three 60000.005 NOK lines are 18000003 øre quantized first
but 18000001 summed first (the float sum drifts to 180000.01499999998).

Decision: quantize per cost line, then sum integers. Each CostItem IS a money
amount — S4.0 made per-line quantity/unit_cost the validator's ground truth — and
integer addition is associative, keeping totals order-independent under the D-D
wave model, which the float fold is not.

ledger.to_ore is now the framework's one NOK->øre conversion; run.py imports it
rather than keeping a private copy (the S4.0 REPLIES precedent).

Measuring the mutations found two further gaps, both now closed: the per-project
baseline is a SECOND call site whose mutation survived the whole suite, and
realize bypassing to_ore with a raw float*100 was caught by nothing.

Load-bearing MEASURED (tests/test_money_quantization_loadbearing.py), five
mutations all red: detach the portfolio baseline · detach the per-project
baseline · reintroduce a private copy in run.py · change the rounding mode · let
realize bypass to_ore. 615 -> 621 tests.

Honesty boundary: sum_claimed_saving_nok (run.py:_aggregate) is deliberately
untouched — a float NOK reporting field that is never quantized and never
compared against the ledger, hence outside the ordering defect.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WiY53sm8JFqk7NN75g5wRS
This commit is contained in:
Kjell Tore Guttormsen 2026-08-03 20:08:59 +02:00
commit 756e8f8259
4 changed files with 267 additions and 15 deletions

View file

@ -181,6 +181,24 @@ Python ≥3.10. MAF (`agent-framework-core` 1.9.0). Pakkehåndtering: `uv`. To b
Load-bearing MÅLT (`tests/test_portfolio_budget_loadbearing.py` + `tests/test_budget.py`), seks
mutasjoner alle røde: detach wave-sjekken · detach pre-call-guarden · detach bølge-reservasjonen ·
sjekk run-taket før global kreditering · detach oppstartsnekten · gjør `read_spend` tolerant.
- **Pengetall kvantiseres i ÉN orden, fra ÉN kilde (kø-(p)):** `ledger.to_ore` er rammeverkets ENE
NOK→øre-konvertering (Decimal, ROUND_HALF_UP), og den brukes **per pengebeløp — deretter summeres
HELTALL**. `run.py` importerer den; aldri en egen kopi (to kopier av en penge-konvertering drifter,
og en driftet kopi setter de to sidene av en mål-sammenligning på hver sin skala — S4.0s
`REPLIES`-presedens). Før dette summerte `run.py`s mål-baseline `Project.total_cost`-FLOATS og
kvantiserte totalen ÉN gang, mens `SavingsLedger` summerte per-kandidat-heltall — og de to ordenene
møttes i nøyaktig ETT punkt: `_goal_limit_if_reached`, der et prosent-mål avgjør om et
porteføljepass stopper tidlig. **Målt divergens:** tre linjer à `60000.005` NOK er `18000003` øre
kvantisert først, men `18000001` summert først (float-drift til `180000.01499999998`) — nok til å
vippe et mål. **Kvantiser-først valgt fordi hver `CostItem` ER et beløp** (S4.0 gjorde per-linje
`quantity`/`unit_cost` til validatorens grunnsannhet), og fordi heltallsaddisjon er assosiativ →
rekkefølge-uavhengig under D-D-bølgemodellen, som float-folden ikke er. **To kallsteder, ikke ett:**
portefølje- og per-prosjekt-baselinen er separate, og en fiks på bare den ene OVERLEVDE hele suiten
(målt). Load-bearing MÅLT (`tests/test_money_quantization_loadbearing.py`), fem mutasjoner alle
røde: detach portefølje-baselinen · detach per-prosjekt-baselinen · gjeninnfør en privat kopi i
`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.
- **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