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

View file

@ -42,6 +42,22 @@ class LedgerEntry(BaseModel):
provenance: str # lightweight who/experiment/when string
def to_ore(nok: float) -> int:
"""The framework's ONE NOK -> integer-øre conversion (Kø-(p)).
Via ``Decimal`` to avoid binary-float error: ``12345.67`` NOK -> ``1234567`` øre exactly
(a raw ``float * 100`` would drift to ``...66.9999``). Half øre round HALF UP.
**Apply this PER money amount, then sum the integers never sum floats and convert the
total.** The two orders disagree (three ``60000.005`` NOK lines are ``18000003`` øre
quantized first, ``18000001`` summed first), and each cost line is itself a real amount, so
the per-line value is the one that exists. Integer addition is also associative, which keeps
every total order-independent under the D-D wave model. ``run.py``'s goal baselines import
THIS function rather than re-implementing it: two copies of a money conversion drift, and a
drifted copy would put the two sides of a goal comparison on different scales."""
return int((Decimal(str(nok)) * 100).quantize(Decimal("1"), rounding=ROUND_HALF_UP))
def stamp(*, approver: str, experiment: str, timestamp: str) -> str:
"""A lightweight who/experiment/when provenance string for a ledger entry. ``timestamp`` is a
required keyword no wall-clock default so a stamped entry is deterministic and its provenance
@ -201,19 +217,14 @@ def realize(
``project_id`` and ``dimension`` are required keywords: a ``LedgerEntry`` is scoped to a project
and a dimension, and neither ``features`` nor ``verdict`` carries them.
NOK->øre conversion happens HERE and only here, via ``Decimal`` to avoid binary-float error:
``12345.67`` NOK -> ``1234567`` øre exactly (a raw ``float * 100`` would drift to ...66.9999).
NOK->øre conversion goes through ``to_ore``, the framework's ONE conversion (Kø-(p)).
``timestamp`` is a required keyword (no wall-clock default), so the entry is deterministic."""
if verdict.decision not in _APPROVED_DECISIONS:
raise RealizationRefused(
f"refusing to realize a non-approved verdict (decision={verdict.decision!r}); "
"only human/persona-approved savings enter the ledger (SC5)"
)
amount_ore = int(
(Decimal(str(features.claimed_saving_nok)) * 100).quantize(
Decimal("1"), rounding=ROUND_HALF_UP
)
)
amount_ore = to_ore(features.claimed_saving_nok)
entry = LedgerEntry(
project_id=project_id,
dimension=dimension,

View file

@ -26,9 +26,8 @@ durable learned verdict captured out-of-band in the VerdictStore (D7-portable).
from __future__ import annotations
import asyncio
from collections.abc import Callable, Sequence
from collections.abc import Callable, Iterable, Sequence
from dataclasses import dataclass, replace
from decimal import ROUND_HALF_UP, Decimal
from typing import Any, Literal, cast
from agent_framework import BaseChatClient, SessionContext
@ -43,7 +42,7 @@ from portfolio_optimiser.budget import (
TokenMeter,
)
from portfolio_optimiser.contracts import GoalConfig, GoalContract, load_contracts, load_goal_config
from portfolio_optimiser.ledger import SavingsLedger
from portfolio_optimiser.ledger import SavingsLedger, to_ore
from portfolio_optimiser.datasource import (
bundle_citations,
chunk_dict_to_citation,
@ -596,9 +595,17 @@ def _aggregate(runs: tuple[RunResult, ...], store: VerdictStore) -> PortfolioRes
)
def _to_ore(nok: float) -> int:
"""NOK float -> integer øre, deterministically (Decimal, mirrors ``ledger.realize``)."""
return int((Decimal(str(nok)) * 100).quantize(Decimal("1"), rounding=ROUND_HALF_UP))
def _baseline_ore(projects: Iterable[Project]) -> int:
"""Addressable baseline in øre, quantized PER COST LINE and summed as integers (Kø-(p)).
Both sides of the goal comparison must be computed in the same order. ``observed_ore`` is
``SavingsLedger``'s sum of per-candidate integer øre; a baseline that summed
``Project.total_cost`` floats and quantized the total ONCE put the threshold on a different
scale three ``60000.005`` NOK lines are ``18000003`` øre per line but ``18000001`` summed
first, enough to flip a percent goal. Each cost line is a real amount, so the per-line value
is the one that exists; integer addition also keeps the total order-independent, which
``Project.total_cost``'s float fold is not under the D-D wave model."""
return sum(to_ore(item.total_cost) for p in projects for item in p.cost_items)
def _goal_limit_if_reached(goal: GoalContract, observed_ore: int, baseline_ore: int) -> int | None:
@ -848,7 +855,7 @@ async def run_portfolio(
# assignment outlived the pass; forwarding keeps the opt-in scoped to each run's own retrievals.
ledger = ledger if ledger is not None else SavingsLedger(entries=[])
goals = goals if goals is not None else GoalConfig()
portfolio_baseline_ore = _to_ore(sum(projects[p].total_cost for p in ids if p in projects))
portfolio_baseline_ore = _baseline_ore(projects[p] for p in ids if p in projects)
runs: list[RunResult] = []
failures: list[RunFailure] = []
@ -881,7 +888,7 @@ async def run_portfolio(
if per_project_goal is not None:
observed = ledger.per_project_total(pid)
limit = _goal_limit_if_reached(
per_project_goal, observed, _to_ore(project.total_cost)
per_project_goal, observed, _baseline_ore((project,))
)
if limit is not None:
if stop_reason is None:

View file

@ -0,0 +1,216 @@
"""Kø-(p) LOAD-BEARING: money is quantized to øre in ONE order, from ONE source.
Two orders existed. ``ledger`` quantizes EVERY realized candidate to integer øre and sums the
ints (``ledger.py`` documents ``amount_ore`` as "exact, order-independent sums"); ``run.py``'s
goal baselines summed ``Project.total_cost`` FLOATS across items and projects and quantized the
total ONCE. The two met at exactly one place ``_goal_limit_if_reached``, where a percent goal
compares the ledger's quantize-first ``observed_ore`` against a threshold derived from the
sum-first baseline. A comparison whose two sides are computed in different orders decides whether
a portfolio pass stops early, so the divergence is not cosmetic.
DECISION (-(p)): **quantize each cost line, then sum integers.** Each ``CostItem`` IS a money
amount ("One cost line in a project's estimate"), and S4.0 made per-line ``quantity``/``unit_cost``
the validator's ground truth — a real amount has a real number of øre. Float summation is also
order-dependent, which is the nondeterminism class ``_wave_snapshot`` already eliminates elsewhere.
The fixture makes the two orders disagree by construction: three lines of ``60000.005`` NOK each.
Quantize-first gives ``6000001`` øre per line -> ``18000003``; the float sum drifts to
``180000.01499999998`` and quantize-last gives ``18000001``. A ledger observation of ``18000002``
øre sits STRICTLY BETWEEN the two thresholds, so a 100 % portfolio goal stops the pass under the
old order and does not under the new one RED in either detach direction.
"""
from __future__ import annotations
from portfolio_optimiser import ledger as ledger_mod
from portfolio_optimiser import run as run_mod
from portfolio_optimiser.contracts import GoalConfig, GoalContract
from portfolio_optimiser.ledger import SavingsLedger, realize
from portfolio_optimiser.reference_domain import CostItem, Project
from portfolio_optimiser.run import run_portfolio
from portfolio_optimiser.verdicts import ProposalFeatures, capture_verdict
# Three lines whose per-line øre value ends in a half øre (60000.005 NOK = 6000000.5 øre).
_HALF_ORE_UNIT_COST = 60000.005
_QUANTIZE_FIRST_ORE = 18_000_003 # 3 x ROUND_HALF_UP(6000000.5) = 3 x 6000001
_SUM_FIRST_ORE = 18_000_001 # ROUND_HALF_UP(100 x 180000.01499999998)
_OBSERVED_ORE = 18_000_002 # strictly between the two -> discriminates the orders
_OBSERVED_NOK = 180_000.02
# Whole-øre control lines: the two orders agree, so the outcome must NOT flip.
_WHOLE_ORE_UNIT_COST = 60_000.00
# A proposal reconciling with the fixture's first cost line (S4.0 baseline gate). Degenerate
# Monte Carlo: P90 = 0.30 x 60000.005 = 18000.0015 >= claimed 15000 -> validates.
_ALIGNED_REPLY = (
'{"measure":"LED-retrofit av kontorbelysning","affected_items":'
'[{"code":"ORE-A","quantity":1.0,"unit_cost":60000.005}],"claimed_saving_nok":15000}'
)
def _make_docs(tmp_path) -> str:
"""A tmp docs folder with citable content, so the road path retrieval is non-empty."""
d = tmp_path / "ore-docs"
d.mkdir()
(d / "cost.txt").write_text(
"Lighting retrofit reduced the electricity cost on the office stretch.", encoding="utf-8"
)
return str(d)
def _project(tmp_path, *, unit_cost: float) -> Project:
return Project(
id="ORE-P",
name="Øre project",
description="a project whose cost lines expose the quantization order",
currency="NOK",
cost_items=tuple(
CostItem(
code=code,
description=f"line {code}",
quantity=1.0,
unit="stk",
unit_cost=unit_cost,
)
for code in ("ORE-A", "ORE-B", "ORE-C")
),
docs_dir=_make_docs(tmp_path),
verdict_input={"decision": "approved", "rationale": "reviewed (sim)"},
bundle_dir=None,
verdict_dir=None,
)
def _ledger_observing(amount_nok: float) -> SavingsLedger:
"""A ledger holding ONE realized entry worth ``amount_nok`` — the quantize-first side of the
comparison, built through the production ``realize`` path rather than a hand-made entry."""
ledger = SavingsLedger(entries=[])
features = ProposalFeatures(
affected_codes=frozenset({"ORE-A"}),
measure_type="lighting retrofit",
claimed_saving_nok=amount_nok,
)
realize(
ledger,
features,
capture_verdict(features, "approved", "realized out of band"),
project_id="ORE-P",
dimension="kostnad",
approver="persona",
experiment="ko-p",
timestamp="2026-08-03T00:00:00Z",
)
return ledger
def test_the_fixture_actually_discriminates_the_two_orders() -> None:
"""The premise itself, MEASURED — not assumed. If these constants ever stop disagreeing the
load-bearing test below would pass vacuously, so the disagreement is asserted first."""
lines = [1.0 * _HALF_ORE_UNIT_COST] * 3
assert sum(ledger_mod.to_ore(x) for x in lines) == _QUANTIZE_FIRST_ORE
assert ledger_mod.to_ore(sum(lines, 0.0)) == _SUM_FIRST_ORE
assert _SUM_FIRST_ORE < _OBSERVED_ORE < _QUANTIZE_FIRST_ORE
assert ledger_mod.to_ore(_OBSERVED_NOK) == _OBSERVED_ORE
def test_nok_to_ore_conversion_has_exactly_one_source() -> None:
"""ONE source, not two copies that drift (the S4.0 ``REPLIES`` precedent). ``run`` must use
``ledger``'s conversion by IDENTITY — a re-implementation that happens to agree today is the
defect class, not the fix. Reintroduce a private copy in ``run.py`` -> RED."""
assert run_mod.to_ore is ledger_mod.to_ore, (
"run.py no longer shares ledger.py's NOK->øre conversion — two copies of a money "
"conversion is exactly the drift Kø-(p) closed"
)
assert not hasattr(run_mod, "_to_ore"), "the private duplicate in run.py must be gone"
async def test_goal_baseline_is_quantized_per_cost_line_not_after_summing(
tmp_path, monkeypatch, make_recording_client_factory
) -> None:
"""LOAD-BEARING: the portfolio goal baseline is quantize-first, so BOTH sides of
``_goal_limit_if_reached`` are computed in the same order.
Observed = 18000002 øre. Quantize-first baseline 18000003 -> a 100 % goal is NOT reached ->
the project runs. Revert the baseline to ``to_ore(sum(total_cost))`` -> baseline 18000001 ->
18000002 >= 18000001 -> hard stop before any project runs -> RED."""
project = _project(tmp_path, unit_cost=_HALF_ORE_UNIT_COST)
monkeypatch.setattr("portfolio_optimiser.run.load_reference_projects", lambda: (project,))
factory, _recorded = make_recording_client_factory(_ALIGNED_REPLY)
result = await run_portfolio(
profile="local",
client_factory=factory,
ledger=_ledger_observing(_OBSERVED_NOK),
goals=GoalConfig(portfolio=GoalContract(percent=100.0, mode="hard")),
)
assert result.stopped_early is False, (
"the pass stopped early: the goal threshold was derived from a sum-first baseline "
f"({_SUM_FIRST_ORE} øre) instead of the quantize-first one ({_QUANTIZE_FIRST_ORE} øre)"
)
assert len(result.runs) == 1, "the single project did not run"
async def test_per_project_goal_baseline_is_also_quantized_per_cost_line(
tmp_path, monkeypatch, make_recording_client_factory
) -> None:
"""LOAD-BEARING (second call site): the PER-PROJECT goal baseline is quantize-first too.
The portfolio and per-project baselines are separate call sites, and a fix applied to only one
leaves the other on the old order measured: mutating this call site alone survived the whole
suite before this test existed. A hard per-project goal ``continue``s past its own pid rather
than stopping the pass, so the tell is that the project never runs. Revert this call site to
``to_ore(project.total_cost)`` -> baseline 18000001 -> the goal reads as reached -> RED."""
project = _project(tmp_path, unit_cost=_HALF_ORE_UNIT_COST)
monkeypatch.setattr("portfolio_optimiser.run.load_reference_projects", lambda: (project,))
factory, _recorded = make_recording_client_factory(_ALIGNED_REPLY)
result = await run_portfolio(
profile="local",
client_factory=factory,
ledger=_ledger_observing(_OBSERVED_NOK),
goals=GoalConfig(per_project={"ORE-P": GoalContract(percent=100.0, mode="hard")}),
)
assert result.stop_reason is None, (
"the per-project goal read as reached: its threshold came from a sum-first baseline "
f"({_SUM_FIRST_ORE} øre) instead of the quantize-first one ({_QUANTIZE_FIRST_ORE} øre)"
)
assert len(result.runs) == 1, "the project was skipped by a goal that should not have fired"
def test_realize_converts_through_the_exact_decimal_conversion() -> None:
"""``realize`` must go through ``to_ore``, not a raw ``float * 100``. ``1.13`` NOK is ``113``
øre, but ``int(1.13 * 100)`` truncates the binary-float representation to ``112`` an øre lost
per realized candidate. Measured: bypassing ``to_ore`` in ``realize`` survived the whole suite
before this test existed. Replace the call with ``int(claimed * 100)`` -> RED."""
ledger = _ledger_observing(1.13)
assert ledger.entries[0].amount_ore == 113, (
"realize lost an øre to binary-float drift — its NOK->øre conversion is not going through "
"to_ore's Decimal path"
)
assert ledger.portfolio_total() == 113
async def test_control_whole_ore_lines_do_not_flip_the_outcome(
tmp_path, monkeypatch, make_recording_client_factory
) -> None:
"""CONTROL (causality): with whole-øre cost lines the two orders AGREE, so the same observation
and goal must reach the goal and stop. Proves the flip above comes from the quantization order
not from the goal machinery merely being present."""
project = _project(tmp_path, unit_cost=_WHOLE_ORE_UNIT_COST)
monkeypatch.setattr("portfolio_optimiser.run.load_reference_projects", lambda: (project,))
factory, _recorded = make_recording_client_factory(_ALIGNED_REPLY)
result = await run_portfolio(
profile="local",
client_factory=factory,
ledger=_ledger_observing(_OBSERVED_NOK),
goals=GoalConfig(portfolio=GoalContract(percent=100.0, mode="hard")),
)
assert result.stopped_early is True, (
"whole-øre lines give baseline 18000000 øre under BOTH orders, and observed 18000002 "
"exceeds it — the goal must be reached regardless of quantization order"
)