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:
parent
9dc3722161
commit
756e8f8259
4 changed files with 267 additions and 15 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue