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
18
CLAUDE.md
18
CLAUDE.md
|
|
@ -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
|
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 ·
|
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.
|
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.
|
- **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
|
- **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
|
IKKE MAF mot ekte modell (verken Azure/Foundry eller Ollama — API for begge repoene er for kostbart
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,22 @@ class LedgerEntry(BaseModel):
|
||||||
provenance: str # lightweight who/experiment/when string
|
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:
|
def stamp(*, approver: str, experiment: str, timestamp: str) -> str:
|
||||||
"""A lightweight who/experiment/when provenance string for a ledger entry. ``timestamp`` is a
|
"""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
|
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
|
``project_id`` and ``dimension`` are required keywords: a ``LedgerEntry`` is scoped to a project
|
||||||
and a dimension, and neither ``features`` nor ``verdict`` carries them.
|
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:
|
NOK->øre conversion goes through ``to_ore``, the framework's ONE conversion (Kø-(p)).
|
||||||
``12345.67`` NOK -> ``1234567`` øre exactly (a raw ``float * 100`` would drift to ...66.9999).
|
|
||||||
``timestamp`` is a required keyword (no wall-clock default), so the entry is deterministic."""
|
``timestamp`` is a required keyword (no wall-clock default), so the entry is deterministic."""
|
||||||
if verdict.decision not in _APPROVED_DECISIONS:
|
if verdict.decision not in _APPROVED_DECISIONS:
|
||||||
raise RealizationRefused(
|
raise RealizationRefused(
|
||||||
f"refusing to realize a non-approved verdict (decision={verdict.decision!r}); "
|
f"refusing to realize a non-approved verdict (decision={verdict.decision!r}); "
|
||||||
"only human/persona-approved savings enter the ledger (SC5)"
|
"only human/persona-approved savings enter the ledger (SC5)"
|
||||||
)
|
)
|
||||||
amount_ore = int(
|
amount_ore = to_ore(features.claimed_saving_nok)
|
||||||
(Decimal(str(features.claimed_saving_nok)) * 100).quantize(
|
|
||||||
Decimal("1"), rounding=ROUND_HALF_UP
|
|
||||||
)
|
|
||||||
)
|
|
||||||
entry = LedgerEntry(
|
entry = LedgerEntry(
|
||||||
project_id=project_id,
|
project_id=project_id,
|
||||||
dimension=dimension,
|
dimension=dimension,
|
||||||
|
|
|
||||||
|
|
@ -26,9 +26,8 @@ durable learned verdict captured out-of-band in the VerdictStore (D7-portable).
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
from collections.abc import Callable, Sequence
|
from collections.abc import Callable, Iterable, Sequence
|
||||||
from dataclasses import dataclass, replace
|
from dataclasses import dataclass, replace
|
||||||
from decimal import ROUND_HALF_UP, Decimal
|
|
||||||
from typing import Any, Literal, cast
|
from typing import Any, Literal, cast
|
||||||
|
|
||||||
from agent_framework import BaseChatClient, SessionContext
|
from agent_framework import BaseChatClient, SessionContext
|
||||||
|
|
@ -43,7 +42,7 @@ from portfolio_optimiser.budget import (
|
||||||
TokenMeter,
|
TokenMeter,
|
||||||
)
|
)
|
||||||
from portfolio_optimiser.contracts import GoalConfig, GoalContract, load_contracts, load_goal_config
|
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 (
|
from portfolio_optimiser.datasource import (
|
||||||
bundle_citations,
|
bundle_citations,
|
||||||
chunk_dict_to_citation,
|
chunk_dict_to_citation,
|
||||||
|
|
@ -596,9 +595,17 @@ def _aggregate(runs: tuple[RunResult, ...], store: VerdictStore) -> PortfolioRes
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _to_ore(nok: float) -> int:
|
def _baseline_ore(projects: Iterable[Project]) -> int:
|
||||||
"""NOK float -> integer øre, deterministically (Decimal, mirrors ``ledger.realize``)."""
|
"""Addressable baseline in øre, quantized PER COST LINE and summed as integers (Kø-(p)).
|
||||||
return int((Decimal(str(nok)) * 100).quantize(Decimal("1"), rounding=ROUND_HALF_UP))
|
|
||||||
|
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:
|
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.
|
# 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=[])
|
ledger = ledger if ledger is not None else SavingsLedger(entries=[])
|
||||||
goals = goals if goals is not None else GoalConfig()
|
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] = []
|
runs: list[RunResult] = []
|
||||||
failures: list[RunFailure] = []
|
failures: list[RunFailure] = []
|
||||||
|
|
@ -881,7 +888,7 @@ async def run_portfolio(
|
||||||
if per_project_goal is not None:
|
if per_project_goal is not None:
|
||||||
observed = ledger.per_project_total(pid)
|
observed = ledger.per_project_total(pid)
|
||||||
limit = _goal_limit_if_reached(
|
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 limit is not None:
|
||||||
if stop_reason is None:
|
if stop_reason is None:
|
||||||
|
|
|
||||||
216
tests/test_money_quantization_loadbearing.py
Normal file
216
tests/test_money_quantization_loadbearing.py
Normal 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 (Kø-(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"
|
||||||
|
)
|
||||||
Loading…
Add table
Add a link
Reference in a new issue