feat(fase1): fail-closed expert realize gate (F1)
This commit is contained in:
parent
e0778d2230
commit
9720acb18c
2 changed files with 167 additions and 0 deletions
|
|
@ -17,10 +17,19 @@ from __future__ import annotations
|
|||
|
||||
import hashlib
|
||||
import json
|
||||
from decimal import ROUND_HALF_UP, Decimal
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from portfolio_optimiser.verdicts import _APPROVED_DECISIONS, ProposalFeatures, Verdict
|
||||
|
||||
|
||||
class RealizationRefused(RuntimeError):
|
||||
"""Fail-closed gate (SC5, mirrors ``verdicts.PromotionRefused``): a non-approved verdict was
|
||||
offered for realization. Nothing is written — only human/persona-approved savings enter the
|
||||
ledger, never raw agent output (self-contamination)."""
|
||||
|
||||
|
||||
class LedgerEntry(BaseModel):
|
||||
"""One realized saving, linked to the approving verdict."""
|
||||
|
|
@ -151,3 +160,57 @@ class SavingsLedger(BaseModel):
|
|||
raise FileNotFoundError(f"savings ledger not found: {path!r}")
|
||||
rows = json.loads(p.read_text(encoding="utf-8"))
|
||||
return cls(entries=[LedgerEntry(**row) for row in rows])
|
||||
|
||||
|
||||
def realize(
|
||||
ledger: SavingsLedger,
|
||||
features: ProposalFeatures,
|
||||
verdict: Verdict,
|
||||
*,
|
||||
project_id: str,
|
||||
dimension: str,
|
||||
approver: str,
|
||||
experiment: str,
|
||||
timestamp: str,
|
||||
) -> LedgerEntry:
|
||||
"""Realize an APPROVED candidate into ``ledger`` and return the entry (SC5).
|
||||
|
||||
FAIL-CLOSED: a verdict whose ``decision`` is not an approval raises ``RealizationRefused`` and
|
||||
writes NOTHING — only human/persona-approved savings enter the ledger (mirrors
|
||||
``promote_verdict``). The approval set is the PROMOTION set ``{approved,
|
||||
approved_with_adjustment}``, NOT the run-path binary ``FeedbackContract`` (H6).
|
||||
|
||||
Deliberately NOT wired into ``run_project`` (role split C3): the system READS context; the
|
||||
expert/persona realizes out of band — mirroring how ``promote_verdict`` is never called in the
|
||||
run path (self-contamination guard).
|
||||
|
||||
``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).
|
||||
``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
|
||||
)
|
||||
)
|
||||
entry = LedgerEntry(
|
||||
project_id=project_id,
|
||||
dimension=dimension,
|
||||
candidate_identity=_candidate_identity(
|
||||
affected_codes=features.affected_codes,
|
||||
measure_type=features.measure_type,
|
||||
amount_ore=amount_ore,
|
||||
),
|
||||
amount_ore=amount_ore,
|
||||
verdict_id=verdict.id,
|
||||
provenance=stamp(approver=approver, experiment=experiment, timestamp=timestamp),
|
||||
)
|
||||
ledger.add_realized(entry)
|
||||
return entry
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue