feat(ledger): K1 — savings ledger + goal contract (parity rows 2-3)
New ledger.py: typed SavingsLedger; realize is fail-closed on an APPROVED FeedbackContract + a named expert + an explicit timestamp (the §6 determinism rule — no wall-clock default). The sum key is DIMENSION-FREE (the dimension label is annotation only and never participates in the mint), so the same realized saving surfaced via two dimensions lands in one first-write-wins slot and is never double-counted. Deterministic JSON persistence (sort_keys, indent 2, LF, trailing newline), schema-validated on load. New goals.py: GoalContract (absolute target, hard/soft, fail-fast §10). A hard goal reached raises GoalReached, a structured stop event carrying target + observed — never a silent stop; soft flags without stopping. The percent-goal baseline is D-E-gated: the field is reserved and construction refuses with an explicit NotImplementedError. Semantics are marked STACK-LOCAL in the docstrings — mirrored from the MAF plan's capability description, never from MAF code; format shareability stays a proposed decision point in the brief. Two detach proofs delivered (decision gate removed -> red; dimension into the key mint -> the double-counting test red). 400 -> 426 tests; README synced (test count + a Value layer module block). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
d4efdd9a35
commit
698e8f21dd
5 changed files with 445 additions and 2 deletions
14
README.md
14
README.md
|
|
@ -13,7 +13,7 @@ human-in-the-loop, and the system learns from the verdicts.
|
||||||
> **Status:** the D7 build (S5–S10) is complete, and the deterministic **ingest layer**
|
> **Status:** the D7 build (S5–S10) is complete, and the deterministic **ingest layer**
|
||||||
> (CSV and SQL source types) has since been added in front of the loop. The deterministic
|
> (CSV and SQL source types) has since been added in front of the loop. The deterministic
|
||||||
> backbone, the agentic loop, the learning loop, and the ingest connectors are wired seam by
|
> backbone, the agentic loop, the learning loop, and the ingest connectors are wired seam by
|
||||||
> seam, each proven by load-bearing tests (400 tests, all running offline without an API
|
> seam, each proven by load-bearing tests (426 tests, all running offline without an API
|
||||||
> key). The programme's single budgeted **live model run has been executed and validated** —
|
> key). The programme's single budgeted **live model run has been executed and validated** —
|
||||||
> its artifacts are committed under [`runs/s10/`](runs/s10/) (see below).
|
> its artifacts are committed under [`runs/s10/`](runs/s10/) (see below).
|
||||||
|
|
||||||
|
|
@ -73,6 +73,16 @@ offline. Module by module:
|
||||||
[`shared/skills/expert-reviewer/`](shared/skills/expert-reviewer/) at call time, so the
|
[`shared/skills/expert-reviewer/`](shared/skills/expert-reviewer/) at call time, so the
|
||||||
shared persona is genuinely consumed and cannot rot silently.
|
shared persona is genuinely consumed and cannot rot silently.
|
||||||
|
|
||||||
|
**Value layer** (stack-local contract — mirrored from the sibling plan's capability
|
||||||
|
description, never from its code)
|
||||||
|
- `ledger.py` — the typed savings ledger: realized savings enter the book **only** through
|
||||||
|
the fail-closed expert gate (an approved verdict + a named expert + an explicit
|
||||||
|
timestamp), and the sum key is dimension-free, so the same realized saving surfaced via
|
||||||
|
two dimensions is never double-counted. Persistence is deterministic JSON.
|
||||||
|
- `goals.py` — the goal contract: absolute savings target, hard/soft. A hard goal reached
|
||||||
|
raises a structured stop event, never a silent stop; the percent-goal baseline is
|
||||||
|
D-E-gated and refused explicitly.
|
||||||
|
|
||||||
**Run layer** (the only part that touches the network)
|
**Run layer** (the only part that touches the network)
|
||||||
- `sdk_client.py` — the Claude Agent SDK client, isolated from local configuration
|
- `sdk_client.py` — the Claude Agent SDK client, isolated from local configuration
|
||||||
(`setting_sources=[]`) so no user/project config can leak into a run.
|
(`setting_sources=[]`) so no user/project config can leak into a run.
|
||||||
|
|
@ -158,7 +168,7 @@ Python ≥3.10 · [`claude-agent-sdk`](https://pypi.org/project/claude-agent-sdk
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
uv sync # install dependencies
|
uv sync # install dependencies
|
||||||
uv run pytest # 400 tests — run without any API key and without network
|
uv run pytest # 426 tests — run without any API key and without network
|
||||||
uv run ruff check . && uv run ruff format --check .
|
uv run ruff check . && uv run ruff format --check .
|
||||||
uv run mypy src # strict
|
uv run mypy src # strict
|
||||||
```
|
```
|
||||||
|
|
|
||||||
53
src/portfolio_optimiser_claude/goals.py
Normal file
53
src/portfolio_optimiser_claude/goals.py
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
"""The goal contract (parity row 2) — absolute savings target, hard/soft.
|
||||||
|
|
||||||
|
Distinct from the token budget (§8): the budget bounds SPEND, the goal bounds
|
||||||
|
ACHIEVEMENT. Fail-fast construction (§10-style). A HARD goal reached raises
|
||||||
|
``GoalReached``, a structured stop event carrying target + observed — never a
|
||||||
|
silent stop; a SOFT goal reached is a flag without stopping. The percent-goal
|
||||||
|
baseline is D-E-gated: the field is reserved and construction refuses with an
|
||||||
|
explicit ``NotImplementedError`` — never silent semantics.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field, model_validator
|
||||||
|
|
||||||
|
GoalMode = Literal["hard", "soft"]
|
||||||
|
|
||||||
|
|
||||||
|
class GoalReached(Exception):
|
||||||
|
"""The structured stop event: hard target reached, carrying target + observed."""
|
||||||
|
|
||||||
|
def __init__(self, target_nok: float, observed_nok: float) -> None:
|
||||||
|
super().__init__(
|
||||||
|
f"goal reached: realized {observed_nok} NOK >= hard target {target_nok} NOK"
|
||||||
|
)
|
||||||
|
self.target_nok = target_nok
|
||||||
|
self.observed_nok = observed_nok
|
||||||
|
|
||||||
|
|
||||||
|
class GoalContract(BaseModel):
|
||||||
|
"""Absolute savings goal, hard/soft — fail-fast, percent baseline D-E-gated."""
|
||||||
|
|
||||||
|
target_nok: float = Field(gt=0, allow_inf_nan=False)
|
||||||
|
mode: GoalMode
|
||||||
|
# Reserved field — percent-goal baseline semantics are gated on decision D-E.
|
||||||
|
target_percent: float | None = None
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def _refuse_percent_goal(self) -> GoalContract:
|
||||||
|
if self.target_percent is not None:
|
||||||
|
raise NotImplementedError(
|
||||||
|
"percent-goal baseline semantics are gated on decision D-E — "
|
||||||
|
"TODO(D-E): construct with target_percent=None until D-E lands"
|
||||||
|
)
|
||||||
|
return self
|
||||||
|
|
||||||
|
def check(self, realized_nok: float) -> bool:
|
||||||
|
"""True when the target is reached; HARD mode raises ``GoalReached`` instead."""
|
||||||
|
reached = realized_nok >= self.target_nok
|
||||||
|
if reached and self.mode == "hard":
|
||||||
|
raise GoalReached(self.target_nok, realized_nok)
|
||||||
|
return reached
|
||||||
146
src/portfolio_optimiser_claude/ledger.py
Normal file
146
src/portfolio_optimiser_claude/ledger.py
Normal file
|
|
@ -0,0 +1,146 @@
|
||||||
|
"""The savings ledger (parity rows 2-3) — typed, fail-closed, dimension-free sum.
|
||||||
|
|
||||||
|
Realized savings enter the book ONLY through the expert gate: ``realize``
|
||||||
|
requires an explicit APPROVED expert verdict (the §4.1 binary run-path shape),
|
||||||
|
a named expert identity, and an explicit timestamp (no wall-clock default —
|
||||||
|
the same determinism rule as promotion §6). Entries are keyed DIMENSION-FREE
|
||||||
|
(project + candidate identity + amount; the dimension label is annotation
|
||||||
|
only), so the same realized saving surfaced via two dimensions lands in one
|
||||||
|
first-write-wins slot (§4.2-style idempotence) and is never double-counted.
|
||||||
|
Persistence is deterministic JSON: sort_keys, indent 2, LF, trailing newline.
|
||||||
|
|
||||||
|
Ledger semantics are a STACK-LOCAL contract mirrored from the MAF plan's
|
||||||
|
capability description — never from MAF code; detail semantics may diverge
|
||||||
|
(shareability of the format is a proposed decision point in the brief).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
from collections.abc import Iterable
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
from portfolio_optimiser_claude.contracts import FeedbackContract
|
||||||
|
|
||||||
|
|
||||||
|
class LedgerGateError(ValueError):
|
||||||
|
"""A realization was refused at the expert gate — nothing enters the book."""
|
||||||
|
|
||||||
|
|
||||||
|
class LedgerEntry(BaseModel):
|
||||||
|
"""One realized saving: dimension-free key, provenance-stamped (expert + timestamp)."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(frozen=True)
|
||||||
|
|
||||||
|
key: str = Field(min_length=1)
|
||||||
|
project: str = Field(min_length=1)
|
||||||
|
measure_type: str = Field(min_length=1)
|
||||||
|
affected_codes: tuple[str, ...]
|
||||||
|
amount_nok: float = Field(gt=0, allow_inf_nan=False)
|
||||||
|
expert: str = Field(min_length=1)
|
||||||
|
timestamp: str = Field(min_length=1)
|
||||||
|
dimension: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class _LedgerFile(BaseModel):
|
||||||
|
"""The persisted ledger shape — schema-validated on load, fail-fast (§10-style)."""
|
||||||
|
|
||||||
|
entries: list[LedgerEntry]
|
||||||
|
|
||||||
|
|
||||||
|
def _mint_entry_key(
|
||||||
|
project: str, measure_type: str, affected_codes: tuple[str, ...], amount_nok: float
|
||||||
|
) -> str:
|
||||||
|
# DIMENSION-FREE by construction: the dimension label never participates,
|
||||||
|
# so two dimensions surfacing the same realized saving mint the same key.
|
||||||
|
canonical = json.dumps(
|
||||||
|
{
|
||||||
|
"affected_codes": sorted(affected_codes),
|
||||||
|
"amount_nok": amount_nok,
|
||||||
|
"measure_type": measure_type,
|
||||||
|
"project": project,
|
||||||
|
},
|
||||||
|
sort_keys=True,
|
||||||
|
separators=(",", ":"),
|
||||||
|
)
|
||||||
|
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()[:16]
|
||||||
|
|
||||||
|
|
||||||
|
class SavingsLedger:
|
||||||
|
"""The typed book of realized savings — FIRST-write-wins per dimension-free key."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._entries: dict[str, LedgerEntry] = {}
|
||||||
|
|
||||||
|
def __len__(self) -> int:
|
||||||
|
return len(self._entries)
|
||||||
|
|
||||||
|
def realize(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
project: str,
|
||||||
|
measure_type: str,
|
||||||
|
affected_codes: Iterable[str],
|
||||||
|
amount_nok: float,
|
||||||
|
verdict: FeedbackContract,
|
||||||
|
expert: str,
|
||||||
|
timestamp: str,
|
||||||
|
dimension: str | None = None,
|
||||||
|
) -> LedgerEntry:
|
||||||
|
"""Enter one realized saving — fail-closed on anything short of expert approval.
|
||||||
|
|
||||||
|
``expert`` and ``timestamp`` are explicit REQUIRED arguments (no
|
||||||
|
wall-clock default, §6-style determinism). Re-realizing the same
|
||||||
|
dimension-free key returns the FIRST entry unchanged (idempotent).
|
||||||
|
"""
|
||||||
|
if verdict.decision != "approved":
|
||||||
|
raise LedgerGateError(
|
||||||
|
f"realization refused: verdict decision {verdict.decision!r} is not "
|
||||||
|
"'approved' — only expert-approved savings enter the book (fail-closed)"
|
||||||
|
)
|
||||||
|
if not expert.strip():
|
||||||
|
raise LedgerGateError(
|
||||||
|
"realization refused: expert identity is blank — realized savings "
|
||||||
|
"require a named expert (fail-closed)"
|
||||||
|
)
|
||||||
|
codes = tuple(sorted(affected_codes))
|
||||||
|
entry = LedgerEntry(
|
||||||
|
key=_mint_entry_key(project, measure_type, codes, amount_nok),
|
||||||
|
project=project,
|
||||||
|
measure_type=measure_type,
|
||||||
|
affected_codes=codes,
|
||||||
|
amount_nok=amount_nok,
|
||||||
|
expert=expert,
|
||||||
|
timestamp=timestamp,
|
||||||
|
dimension=dimension,
|
||||||
|
)
|
||||||
|
return self._entries.setdefault(entry.key, entry)
|
||||||
|
|
||||||
|
def entries(self) -> list[LedgerEntry]:
|
||||||
|
"""All entries, ordered by key (deterministic)."""
|
||||||
|
return sorted(self._entries.values(), key=lambda entry: entry.key)
|
||||||
|
|
||||||
|
def total_realized_nok(self) -> float:
|
||||||
|
"""The dimension-free sum: one addend per distinct key, never double-counted."""
|
||||||
|
return sum(entry.amount_nok for entry in self._entries.values())
|
||||||
|
|
||||||
|
def to_json(self) -> str:
|
||||||
|
"""Deterministic JSON: sort_keys, indent 2, trailing newline."""
|
||||||
|
payload = {"entries": [entry.model_dump() for entry in self.entries()]}
|
||||||
|
return json.dumps(payload, sort_keys=True, indent=2, ensure_ascii=False) + "\n"
|
||||||
|
|
||||||
|
def save(self, path: Path) -> None:
|
||||||
|
"""Persist deterministically (LF only) — identical books yield identical bytes."""
|
||||||
|
path.write_text(self.to_json(), encoding="utf-8", newline="\n")
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def load(cls, path: Path) -> SavingsLedger:
|
||||||
|
"""Load a persisted ledger, schema-validated fail-fast (§10-style)."""
|
||||||
|
parsed = _LedgerFile(**json.loads(path.read_text(encoding="utf-8")))
|
||||||
|
ledger = cls()
|
||||||
|
for entry in parsed.entries:
|
||||||
|
ledger._entries.setdefault(entry.key, entry)
|
||||||
|
return ledger
|
||||||
63
tests/test_goals.py
Normal file
63
tests/test_goals.py
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
"""Goal contract (parity row 2): absolute target, hard/soft, fail-fast §10.
|
||||||
|
|
||||||
|
Hard goal reached -> a STRUCTURED stop signal (typed event, never silent);
|
||||||
|
soft goal reached -> a flag without stopping. The percent-goal baseline is
|
||||||
|
D-E-gated: attempting to construct one is an explicit ``NotImplementedError``
|
||||||
|
refusal, never silent semantics.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from pydantic import ValidationError
|
||||||
|
|
||||||
|
from portfolio_optimiser_claude.goals import GoalContract, GoalReached
|
||||||
|
|
||||||
|
|
||||||
|
class TestFailFastConstruction:
|
||||||
|
"""§10: a malformed goal never constructs."""
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("target", [0.0, -100000.0, math.inf, math.nan])
|
||||||
|
def test_non_positive_or_non_finite_target_is_refused(self, target: float) -> None:
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
GoalContract(target_nok=target, mode="hard")
|
||||||
|
|
||||||
|
def test_unknown_mode_is_refused(self) -> None:
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
GoalContract(target_nok=100000.0, mode="maybe") # type: ignore[arg-type]
|
||||||
|
|
||||||
|
def test_percent_goal_is_an_explicit_gated_refusal(self) -> None:
|
||||||
|
# D-E-gated: the field is reserved, the semantics are NOT implemented —
|
||||||
|
# construction refuses loudly instead of guessing a baseline.
|
||||||
|
with pytest.raises(NotImplementedError, match="D-E"):
|
||||||
|
GoalContract(target_nok=100000.0, mode="hard", target_percent=10.0)
|
||||||
|
|
||||||
|
|
||||||
|
class TestHardGoal:
|
||||||
|
"""Hard goal reached -> typed stop event carrying target + observed."""
|
||||||
|
|
||||||
|
def test_reaching_the_target_raises_a_structured_stop(self) -> None:
|
||||||
|
contract = GoalContract(target_nok=100000.0, mode="hard")
|
||||||
|
with pytest.raises(GoalReached) as excinfo:
|
||||||
|
contract.check(125000.0)
|
||||||
|
assert excinfo.value.target_nok == 100000.0
|
||||||
|
assert excinfo.value.observed_nok == 125000.0
|
||||||
|
|
||||||
|
def test_exactly_at_the_target_counts_as_reached(self) -> None:
|
||||||
|
with pytest.raises(GoalReached):
|
||||||
|
GoalContract(target_nok=100000.0, mode="hard").check(100000.0)
|
||||||
|
|
||||||
|
def test_under_the_target_returns_false_without_raising(self) -> None:
|
||||||
|
assert GoalContract(target_nok=100000.0, mode="hard").check(99999.0) is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestSoftGoal:
|
||||||
|
"""Soft goal reached -> a flag, never a stop."""
|
||||||
|
|
||||||
|
def test_reaching_the_target_flags_without_stopping(self) -> None:
|
||||||
|
assert GoalContract(target_nok=100000.0, mode="soft").check(125000.0) is True
|
||||||
|
|
||||||
|
def test_under_the_target_returns_false(self) -> None:
|
||||||
|
assert GoalContract(target_nok=100000.0, mode="soft").check(50000.0) is False
|
||||||
171
tests/test_ledger_loadbearing.py
Normal file
171
tests/test_ledger_loadbearing.py
Normal file
|
|
@ -0,0 +1,171 @@
|
||||||
|
"""Savings ledger — LOAD-BEARING (parity rows 2-3; §6-style determinism, §11).
|
||||||
|
|
||||||
|
The seam this file keeps alive: realized savings enter the book ONLY through
|
||||||
|
the fail-closed expert gate (an explicit approved verdict + expert identity +
|
||||||
|
explicit timestamp), and the sum key is DIMENSION-FREE — the same realized
|
||||||
|
candidate surfaced via two dimensions lands in ONE slot, never double-counted.
|
||||||
|
RED when an unapproved verdict's numbers enter the book (detach point 1: the
|
||||||
|
decision gate in ``realize``), or when the mint key starts carrying the
|
||||||
|
dimension (detach point 2: dimension exclusion in the sum key).
|
||||||
|
|
||||||
|
Ledger semantics are a STACK-LOCAL contract mirrored from the MAF plan's
|
||||||
|
capability description (typed store, fail-closed realize, dimension-free sum
|
||||||
|
key) — never from MAF code.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from pydantic import ValidationError
|
||||||
|
|
||||||
|
from portfolio_optimiser_claude.contracts import FeedbackContract
|
||||||
|
from portfolio_optimiser_claude.ledger import LedgerGateError, SavingsLedger
|
||||||
|
|
||||||
|
APPROVED = FeedbackContract(decision="approved", rationale="Verified on-site by expert.")
|
||||||
|
REJECTED = FeedbackContract(decision="rejected", rationale="Numbers did not hold up.")
|
||||||
|
TIMESTAMP = "2026-07-17T03:00:00Z"
|
||||||
|
EXPERT = "persona:expert-reviewer"
|
||||||
|
|
||||||
|
|
||||||
|
def _realize(
|
||||||
|
ledger: SavingsLedger,
|
||||||
|
*,
|
||||||
|
project: str = "bygg-energi-mikro",
|
||||||
|
measure_type: str = "led-retrofit",
|
||||||
|
affected_codes: frozenset[str] = frozenset({"E01"}),
|
||||||
|
amount_nok: float = 25000.0,
|
||||||
|
verdict: FeedbackContract = APPROVED,
|
||||||
|
expert: str = EXPERT,
|
||||||
|
timestamp: str = TIMESTAMP,
|
||||||
|
dimension: str | None = None,
|
||||||
|
) -> object:
|
||||||
|
return ledger.realize(
|
||||||
|
project=project,
|
||||||
|
measure_type=measure_type,
|
||||||
|
affected_codes=affected_codes,
|
||||||
|
amount_nok=amount_nok,
|
||||||
|
verdict=verdict,
|
||||||
|
expert=expert,
|
||||||
|
timestamp=timestamp,
|
||||||
|
dimension=dimension,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestExpertGate:
|
||||||
|
"""LOAD-BEARING (§11): only expert-APPROVED savings are realized — fail-closed."""
|
||||||
|
|
||||||
|
def test_rejected_verdict_is_refused_entering_nothing(self) -> None:
|
||||||
|
ledger = SavingsLedger()
|
||||||
|
with pytest.raises(LedgerGateError):
|
||||||
|
_realize(ledger, verdict=REJECTED)
|
||||||
|
assert len(ledger) == 0
|
||||||
|
assert ledger.total_realized_nok() == 0.0
|
||||||
|
|
||||||
|
def test_blank_expert_identity_is_refused(self) -> None:
|
||||||
|
ledger = SavingsLedger()
|
||||||
|
with pytest.raises(LedgerGateError):
|
||||||
|
_realize(ledger, expert=" ")
|
||||||
|
assert len(ledger) == 0
|
||||||
|
|
||||||
|
def test_expert_is_an_explicit_required_argument(self) -> None:
|
||||||
|
# No implicit expert — realization without a named expert is a call error.
|
||||||
|
with pytest.raises(TypeError):
|
||||||
|
SavingsLedger().realize( # type: ignore[call-arg]
|
||||||
|
project="p",
|
||||||
|
measure_type="m",
|
||||||
|
affected_codes=frozenset({"E01"}),
|
||||||
|
amount_nok=1.0,
|
||||||
|
verdict=APPROVED,
|
||||||
|
timestamp=TIMESTAMP,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_timestamp_is_an_explicit_required_argument(self) -> None:
|
||||||
|
# No wall-clock default — realization is deterministic and reproducible (§6-style).
|
||||||
|
with pytest.raises(TypeError):
|
||||||
|
SavingsLedger().realize( # type: ignore[call-arg]
|
||||||
|
project="p",
|
||||||
|
measure_type="m",
|
||||||
|
affected_codes=frozenset({"E01"}),
|
||||||
|
amount_nok=1.0,
|
||||||
|
verdict=APPROVED,
|
||||||
|
expert=EXPERT,
|
||||||
|
)
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("amount", [0.0, -25000.0, math.inf, math.nan])
|
||||||
|
def test_non_positive_or_non_finite_amounts_are_refused(self, amount: float) -> None:
|
||||||
|
ledger = SavingsLedger()
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
_realize(ledger, amount_nok=amount)
|
||||||
|
assert len(ledger) == 0
|
||||||
|
|
||||||
|
|
||||||
|
class TestDimensionFreeSumKey:
|
||||||
|
"""LOAD-BEARING (§11): the sum key excludes the dimension — no double counting."""
|
||||||
|
|
||||||
|
def test_same_candidate_under_two_dimensions_is_one_slot(self) -> None:
|
||||||
|
# Key assumption (K1 plan): the same realized saving surfaced via two
|
||||||
|
# dimensions in the same project must NOT be counted twice.
|
||||||
|
ledger = SavingsLedger()
|
||||||
|
first = _realize(ledger, dimension="energi")
|
||||||
|
second = _realize(ledger, dimension="vedlikehold")
|
||||||
|
assert len(ledger) == 1
|
||||||
|
assert ledger.total_realized_nok() == 25000.0
|
||||||
|
assert second == first # first-write-wins, §4.2-style idempotence
|
||||||
|
|
||||||
|
def test_distinct_candidates_in_the_same_project_both_count(self) -> None:
|
||||||
|
ledger = SavingsLedger()
|
||||||
|
_realize(ledger, measure_type="led-retrofit", amount_nok=25000.0)
|
||||||
|
_realize(ledger, measure_type="heat-recovery", amount_nok=40000.0)
|
||||||
|
assert len(ledger) == 2
|
||||||
|
assert ledger.total_realized_nok() == 65000.0
|
||||||
|
|
||||||
|
def test_same_candidate_across_projects_both_count(self) -> None:
|
||||||
|
ledger = SavingsLedger()
|
||||||
|
_realize(ledger, project="prosjekt-a")
|
||||||
|
_realize(ledger, project="prosjekt-b")
|
||||||
|
assert len(ledger) == 2
|
||||||
|
assert ledger.total_realized_nok() == 50000.0
|
||||||
|
|
||||||
|
|
||||||
|
class TestDeterministicPersistence:
|
||||||
|
"""Deterministic JSON persistence: sort_keys, indent 2, LF, trailing newline."""
|
||||||
|
|
||||||
|
def _populated(self) -> SavingsLedger:
|
||||||
|
ledger = SavingsLedger()
|
||||||
|
_realize(ledger, project="prosjekt-b", amount_nok=40000.0)
|
||||||
|
_realize(ledger, project="prosjekt-a", amount_nok=25000.0)
|
||||||
|
return ledger
|
||||||
|
|
||||||
|
def test_identical_sequences_persist_byte_identically(self, tmp_path: Path) -> None:
|
||||||
|
path_a = tmp_path / "a.json"
|
||||||
|
path_b = tmp_path / "b.json"
|
||||||
|
self._populated().save(path_a)
|
||||||
|
self._populated().save(path_b)
|
||||||
|
assert path_a.read_bytes() == path_b.read_bytes()
|
||||||
|
|
||||||
|
def test_file_is_lf_only_with_trailing_newline(self, tmp_path: Path) -> None:
|
||||||
|
path = tmp_path / "ledger.json"
|
||||||
|
self._populated().save(path)
|
||||||
|
data = path.read_bytes()
|
||||||
|
assert b"\r" not in data
|
||||||
|
assert data.endswith(b"\n")
|
||||||
|
|
||||||
|
def test_round_trip_is_byte_identical_and_sum_preserving(self, tmp_path: Path) -> None:
|
||||||
|
original = tmp_path / "original.json"
|
||||||
|
rewritten = tmp_path / "rewritten.json"
|
||||||
|
ledger = self._populated()
|
||||||
|
ledger.save(original)
|
||||||
|
loaded = SavingsLedger.load(original)
|
||||||
|
loaded.save(rewritten)
|
||||||
|
assert rewritten.read_bytes() == original.read_bytes()
|
||||||
|
assert loaded.total_realized_nok() == ledger.total_realized_nok()
|
||||||
|
|
||||||
|
def test_load_fails_fast_on_a_malformed_entry(self, tmp_path: Path) -> None:
|
||||||
|
path = tmp_path / "ledger.json"
|
||||||
|
text = self._populated().to_json().replace("25000.0", "-25000.0")
|
||||||
|
path.write_text(text, encoding="utf-8")
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
SavingsLedger.load(path)
|
||||||
Loading…
Add table
Add a link
Reference in a new issue