target-revenue/src/target_revenue/remission.py

368 lines
13 KiB
Python
Raw Normal View History

"""Remission Credit calculation and ledger application (WP-0013).
Implements `trsl:policy:linear-longstop-v0@1.0` from
`specs/policies/linear-longstop-v0.md`:
R(t) = T0 × clamp((t t0)/(tL t0), 0, 1)
Design decisions (WP-0013-T01, recorded 2026-08-05):
1. **t0 (Phase activation)** is the Trust Service registration timestamp
(`phase_manifests.registered_at`). No new manifest field: in Stage 0 a
Phase is not active until registered. Pure callers pass `t0` explicitly
so offline packages stay free of hosting state.
2. **Cadence** is monthly UTC by convention for scheduled runs (1st of
each month 00:00 UTC, or longstop if sooner). The pure model itself is
*cumulative*, not period-keyed: each invocation remits
`max(0, R(as_of) already_recorded_policy_remission)`. Re-running at
the same `as_of` is a no-op; catching up after a missed schedule works
without double-counting. On-demand apply uses the same delta formula.
3. **Idempotency** follows from (2). Policy-produced entries are identified
by `type == remission-credit` and `extension.id` matching the policy
(corrections stay out of the "already remitted" sum so a deliberate
`remission-correction` is not silently undone by the next run).
4. **Actor** is a dedicated Licensor credential labeled
`system:policy-engine` (rights: operator). Policy-driven entries are not
attributed to a human; `submitted_by_token` is never left null. The
credential is auto-issued on first use per Licensor tenant.
"""
from __future__ import annotations
import secrets
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
from psycopg import Connection
from .registry import Licensor
#: Credential label for the non-human actor that submits policy remissions.
POLICY_ENGINE_CREDENTIAL_LABEL = "system:policy-engine"
#: Dust floor: skip ledger writes smaller than this (currency major units).
MIN_REMISSION_AMOUNT = 0.01
LINEAR_LONGSTOP_V0_POLICY_ID = "trsl:policy:linear-longstop-v0"
LINEAR_LONGSTOP_V0_VERSION = "1.0"
LINEAR_LONGSTOP_V0_URN = f"{LINEAR_LONGSTOP_V0_POLICY_ID}@{LINEAR_LONGSTOP_V0_VERSION}"
@dataclass(frozen=True)
class RemissionPlan:
"""Result of planning one policy-driven remission at a point in time."""
policy_id: str
policy_version: str
cumulative_expected: float
already_remitted: float
delta: float
as_of: datetime
t0: datetime
longstop_at: datetime
initial_target: float
@property
def should_append(self) -> bool:
return self.delta >= MIN_REMISSION_AMOUNT
def parse_policy_urn(urn: str) -> tuple[str, str]:
"""Split `trsl:policy:slug@version` into (id, version)."""
if "@" not in urn:
raise ValueError(f"policy URN missing @version: {urn!r}")
policy_id, version = urn.rsplit("@", 1)
if not policy_id.startswith("trsl:policy:"):
raise ValueError(f"not a degeneration policy URN: {urn!r}")
return policy_id, version
def _parse_ts(ts: str | datetime) -> datetime:
if isinstance(ts, datetime):
if ts.tzinfo is None:
raise ValueError("timestamps must be timezone-aware")
return ts
dt = datetime.fromisoformat(ts.replace("Z", "+00:00"))
if dt.tzinfo is None:
raise ValueError("timestamps must be timezone-aware")
return dt
def clamp01(x: float) -> float:
return 0.0 if x < 0.0 else 1.0 if x > 1.0 else x
def cumulative_remission(
initial_target: float,
t0: datetime,
longstop_at: datetime,
as_of: datetime,
) -> float:
"""R(t) for linear-longstop-v0. Pure; no I/O.
Before t0 0; at/after longstop full Initial Target; linear in between.
"""
t0 = _parse_ts(t0)
longstop_at = _parse_ts(longstop_at)
as_of = _parse_ts(as_of)
if initial_target <= 0:
raise ValueError("initial_target must be positive")
span = (longstop_at - t0).total_seconds()
if span <= 0:
# Degenerate longstop at or before activation: full remission once as_of >= t0.
return float(initial_target) if as_of >= t0 else 0.0
progress = clamp01((as_of - t0).total_seconds() / span)
return float(initial_target) * progress
def policy_remission_already_recorded(
entries: list[dict[str, Any]],
policy_id: str = LINEAR_LONGSTOP_V0_POLICY_ID,
) -> float:
"""Sum of `remission-credit` amounts produced by this policy id.
Does not include `remission-correction` or
`administrative-correction-remission` those are deliberate human
adjustments that must not be auto-undone by the next policy run.
"""
total = 0.0
for entry in entries:
if entry.get("type") != "remission-credit":
continue
ext = entry.get("extension") or {}
if ext.get("id") == policy_id:
total += float(entry["amount"])
return total
def plan_remission(
manifest: dict[str, Any],
entries: list[dict[str, Any]],
t0: datetime,
as_of: datetime,
) -> RemissionPlan | None:
"""Plan the next policy-driven remission for a Phase, or None if N/A.
Returns None when the Phase's degeneration_policy is not a supported
automated policy (today: only linear-longstop-v0). Raises ValueError
on missing longstop / bad timestamps.
"""
policy_urn = manifest["phase"]["degeneration_policy"]
try:
policy_id, policy_version = parse_policy_urn(policy_urn)
except ValueError:
return None
if policy_id != LINEAR_LONGSTOP_V0_POLICY_ID:
return None
initial = float(manifest["phase"]["initial_target"]["amount"])
longstop_at = _parse_ts(manifest["phase"]["longstop_at"])
t0_dt = _parse_ts(t0)
as_of_dt = _parse_ts(as_of)
expected = cumulative_remission(initial, t0_dt, longstop_at, as_of_dt)
already = policy_remission_already_recorded(entries, policy_id)
# Never remit more than still outstanding against Initial Target when
# other credits already reduced it — fold clamps Outstanding at 0, but
# over-remitting relative to T0 would still inflate Remission Credit
# facts. Cap cumulative expected at T0 (formula already does) and delta
# at max(0, T0 - already) is implicit. Also do not reverse over-remission
# via negative delta (corrections handle that).
delta = max(0.0, expected - already)
# Round to cents to avoid float dust ledger spam.
delta = round(delta, 2)
expected = round(expected, 2)
return RemissionPlan(
policy_id=policy_id,
policy_version=policy_version,
cumulative_expected=expected,
already_remitted=round(already, 2),
delta=delta,
as_of=as_of_dt,
t0=t0_dt,
longstop_at=longstop_at,
initial_target=initial,
)
def build_remission_entry_input(
phase_id: str,
plan: RemissionPlan,
currency: str,
entry_id: str | None = None,
) -> dict[str, Any] | None:
"""Build a ledger entry input (no previous_entry_hash/signature).
Returns None when `plan.should_append` is false (idempotent no-op).
"""
if not plan.should_append:
return None
if entry_id is None:
entry_id = f"trsl:entry:rem{secrets.token_hex(12)}"
as_of_iso = plan.as_of.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
return {
"id": entry_id,
"phase": phase_id,
"type": "remission-credit",
"amount": plan.delta,
"currency": currency,
"recognized_at": as_of_iso,
"extension": {
"id": plan.policy_id,
"version": plan.policy_version,
},
"evidence_reference": (
f"{plan.policy_id}@{plan.policy_version}"
f"#as_of={as_of_iso}"
f"#cumulative={plan.cumulative_expected}"
),
}
def next_monthly_boundary(as_of: datetime) -> datetime:
"""Next 1st-of-month 00:00 UTC strictly after `as_of`."""
as_of = _parse_ts(as_of).astimezone(timezone.utc)
if as_of.month == 12:
candidate = datetime(as_of.year + 1, 1, 1, tzinfo=timezone.utc)
else:
candidate = datetime(as_of.year, as_of.month + 1, 1, tzinfo=timezone.utc)
# If as_of is exactly on a boundary, still move to the *next* one
# (scheduled run at T has already been eligible for that instant).
if as_of >= candidate:
if candidate.month == 12:
candidate = datetime(candidate.year + 1, 1, 1, tzinfo=timezone.utc)
else:
candidate = datetime(candidate.year, candidate.month + 1, 1, tzinfo=timezone.utc)
return candidate
def next_scheduled_remission_at(as_of: datetime, longstop_at: datetime) -> datetime | None:
"""Next scheduled recognition instant: min(next monthly UTC, longstop), if still future."""
as_of = _parse_ts(as_of)
longstop_at = _parse_ts(longstop_at)
if as_of >= longstop_at:
return None
monthly = next_monthly_boundary(as_of)
return monthly if monthly < longstop_at else longstop_at
# --- Hosted application path ------------------------------------------------
def get_phase_registered_at(conn: "Connection", phase_id: str) -> datetime | None:
row = conn.execute(
"SELECT registered_at FROM phase_manifests WHERE phase_id = %s",
(phase_id,),
).fetchone()
return row[0] if row else None
def ensure_policy_engine_credential(conn: "Connection", licensor_id: str) -> "Licensor":
"""Return the `system:policy-engine` credential for a Licensor, creating it if needed."""
from . import registry
from .registry import Licensor
row = conn.execute(
"""
SELECT token, licensor_id, credential_label, rights
FROM licensors
WHERE licensor_id = %s
AND credential_label = %s
AND revoked_at IS NULL
LIMIT 1
""",
(licensor_id, POLICY_ENGINE_CREDENTIAL_LABEL),
).fetchone()
if row is not None:
token, lid, label, rights = row
return Licensor(token=token, licensor_id=lid, credential_label=label, rights=rights)
return registry.issue_sub_credential(
conn,
licensor_id=licensor_id,
credential_label=POLICY_ENGINE_CREDENTIAL_LABEL,
rights="operator",
issued_by="system:remission-automation",
)
def apply_remission_for_phase(
conn: "Connection",
phase_id: str,
signing_key: "Ed25519PrivateKey",
as_of: datetime | None = None,
) -> dict[str, Any] | None:
"""Compute and append a policy remission entry for one Phase if needed.
Returns the signed entry if one was written, else None (already current,
unsupported policy, or phase missing). Uses the Phase owner's
`system:policy-engine` credential for `submitted_by_token`.
"""
from . import ledger, registry
from .registry import RegistrationError
if as_of is None:
as_of = datetime.now(timezone.utc)
else:
as_of = _parse_ts(as_of)
manifest = registry.get_phase_manifest(conn, phase_id)
if manifest is None:
raise RegistrationError(f"phase {phase_id!r} is not registered")
registered_at = get_phase_registered_at(conn, phase_id)
if registered_at is None:
raise RegistrationError(f"phase {phase_id!r} has no registered_at")
if registered_at.tzinfo is None:
registered_at = registered_at.replace(tzinfo=timezone.utc)
entries = ledger.get_ledger(conn, phase_id)
plan = plan_remission(manifest, entries, t0=registered_at, as_of=as_of)
if plan is None:
raise RegistrationError(
f"phase {phase_id!r} degeneration_policy is not an automated policy "
f"(supported: {LINEAR_LONGSTOP_V0_URN})"
)
entry_input = build_remission_entry_input(
phase_id,
plan,
currency=manifest["phase"]["initial_target"]["currency"],
)
if entry_input is None:
return None
licensor_id = conn.execute(
"SELECT licensor_id FROM phase_manifests WHERE phase_id = %s",
(phase_id,),
).fetchone()[0]
engine = ensure_policy_engine_credential(conn, licensor_id)
return ledger.append_entry(conn, engine, phase_id, entry_input, signing_key)
def apply_remission_for_all_phases(
conn: "Connection",
signing_key: "Ed25519PrivateKey",
as_of: datetime | None = None,
) -> list[dict[str, Any]]:
"""Run policy remission for every registered Phase. Returns appended entries."""
from .registry import RegistrationError
rows = conn.execute("SELECT phase_id FROM phase_manifests ORDER BY phase_id").fetchall()
written: list[dict[str, Any]] = []
for (phase_id,) in rows:
try:
entry = apply_remission_for_phase(conn, phase_id, signing_key, as_of=as_of)
except RegistrationError:
# Skip Phases with unsupported policies rather than aborting the batch.
continue
if entry is not None:
written.append(entry)
return written