utils/earned_time/earned_time/_maturity.py
Krzysztof kuhy Rudnicki a745d6bdf4
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
earned_time pre-commit / pre-commit (push) Waiting to run
earned_time tests / test (push) Waiting to run
file length / file-length (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
earned_time 0.6.0: gate maturity classifier (python -m earned_time maturity), confirmed_on, first_credits-aware penalty start
How far to trust a gate is computed from its signed ledger: new / maturing /
mature (>= 14 verified credit days, first >= 21 days old), with a read-only
CLI table. Only signed, non-manual, paying credits count; a gate's own
re-evaluation grant (detail.grant_of, book-guard bonus: rows) counts at
amount 0.

Earner.confirmed_on (hand-set) is new. penalty_start = max(penalty_from, day
after first real credit, day after confirmed_on); resolve/base_for take an
optional first_credits map and can only delay a penalty, never add one.
Omitted, every day resolves exactly as in 0.5.0.

Fail closed: a gate with confirmed_on and no credit on record (unreadable
key/ledger, deleted or emptied ledger) falls back to max(penalty_from, day
after confirmed_on), so deleting a ledger cannot lift a penalty.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WdfmsGMht9ypyvxGPmNF1V
2026-10-09 21:21:18 +02:00

195 lines
7.3 KiB
Python

# Copyright (c) 2026 Krzysztof Rudnicki
"""How far to trust a gate: computed from its signed ledger, never listed.
A gate is **new** until it has paid out for real and kuhy has confirmed a
working session (``Earner.confirmed_on``), **mature** once it has paid on
:data:`MATURE_MIN_CREDIT_DAYS` distinct days and its first payout is at least
:data:`MATURE_MIN_AGE_DAYS` old, and **maturing** in between. A new gate is
lenient (no penalty before it has worked); a mature one is suspicious of
"just grant me the points".
Only a *real* credit counts (:func:`~earned_time._evidence.real_credit`):
HMAC-verified, accepted by the earner's matcher, not a hand-granted row, and
paying something. A manual grant is exactly the evidence that the gate did
*not* work.
**The penalty never starts before the gate has paid out**
(:func:`penalty_start`): a gate that never earned anything cannot cost
anything. The adjustment can only delay a penalty. A ledger or key that
cannot be read is "could not check": no evidence of maturity.
**Fail closed once confirmed.** A gate with a hand-set ``confirmed_on`` has
been seen to work, so "no credit on record" -- unreadable key or ledger, a
deleted ledger, an emptied one -- cannot lift its penalty: it falls back to
:func:`confirmed_start`. Only a gate without ``confirmed_on`` is lenient.
"""
from __future__ import annotations
from dataclasses import dataclass
from datetime import UTC, date, datetime, timedelta
from typing import TYPE_CHECKING, Final, Literal
from earned_time._evidence import credit_history
if TYPE_CHECKING:
from pathlib import Path
from earned_time._policy import Earner
Level = Literal["new", "maturing", "mature"]
# Distinct days with a real credit, and how old the first one must be.
MATURE_MIN_CREDIT_DAYS: Final = 14
MATURE_MIN_AGE_DAYS: Final = 21
_DAY: Final = timedelta(days=1)
@dataclass(frozen=True)
class Maturity:
"""One gate's verdict, with the evidence behind it.
Attributes:
name: The earner's registry name.
level: ``"new"``, ``"maturing"`` or ``"mature"``.
reasons: Human-readable, in the order they were decided.
credit_days: Distinct local days, up to the verdict's day, with a
real credit.
credit_rows: Real credit rows behind them (a replayed ``entry_id`` once).
first_credit: The first such day, or ``None``.
last_credit: The last such day, or ``None``.
confirmed_on: The earner's ``confirmed_on``.
penalty_from: The earner's ``penalty_from``.
penalty_start: The effective first penalised day
(:func:`penalty_start`); ``None`` means not penalised.
checked: ``False`` when the ledger or the key could not be read.
"""
name: str
level: Level
reasons: tuple[str, ...]
credit_days: int
credit_rows: int
first_credit: date | None
last_credit: date | None
confirmed_on: date | None
penalty_from: date | None
penalty_start: date | None
checked: bool
def confirmed_start(earner: Earner) -> date | None:
"""``max(penalty_from, day after confirmed_on)``; ``None`` for a pure bonus.
What is left of :func:`penalty_start` when the ledger was not consulted:
a ``confirmed_on`` of ``None`` drops out of the max.
"""
if earner.penalty_from is None:
return None
if earner.confirmed_on is None:
return earner.penalty_from
return max(earner.penalty_from, earner.confirmed_on + _DAY)
def penalty_start(earner: Earner, first_credit: date | None) -> date | None:
"""The first day ``earner``'s penalty may apply, given its first real credit.
``max(penalty_from, day after first_credit, day after confirmed_on)``;
a ``confirmed_on`` of ``None`` drops out of the max. No first credit
(never paid out, or could not check): :func:`confirmed_start` when kuhy
set ``confirmed_on`` -- fail closed, deleting a ledger lifts nothing --
else ``None``, not penalised. ``None`` for a pure bonus. Never earlier
than ``penalty_from``.
"""
start = confirmed_start(earner)
if start is None:
return None
if first_credit is None:
return start if earner.confirmed_on is not None else None
return max(start, first_credit + _DAY)
def _level(days: list[date], earner: Earner, today: date, reasons: list[str]) -> Level:
if not days:
reasons.append("no verified credit yet")
return "new"
if earner.confirmed_on is None:
reasons.append("no confirmed_on: kuhy has not confirmed a real session")
return "new"
age = (today - days[0]).days
if len(days) >= MATURE_MIN_CREDIT_DAYS and age >= MATURE_MIN_AGE_DAYS:
reasons.append(
f"{len(days)} credit days >= {MATURE_MIN_CREDIT_DAYS} and first "
f"credit {age} days ago >= {MATURE_MIN_AGE_DAYS}"
)
return "mature"
reasons.append(
f"{len(days)}/{MATURE_MIN_CREDIT_DAYS} credit days, first credit "
f"{age}/{MATURE_MIN_AGE_DAYS} days ago"
)
return "maturing"
def _penalty_reason(
earner: Earner, start: date | None, first: date | None, *, checked: bool
) -> str:
if earner.penalty_from is None:
return "no penalty_from: a pure bonus"
if start is not None and first is None:
why = "could not check" if not checked else "no verified credit"
return (
f"{why} but confirmed_on {earner.confirmed_on}: "
f"fail closed, penalty from {start}"
)
if start is None:
return f"never paid out: no penalty (penalty_from {earner.penalty_from})"
if start > earner.penalty_from:
return f"penalty delayed from {earner.penalty_from} to {start}"
return f"penalty from {start}, as registered"
def maturity(
earner: Earner, ledger: Path, key_file: Path, today: date | None = None
) -> Maturity:
"""Classify ``earner`` from its signed ledger, as of ``today`` (default now).
Args:
earner: A ledger-backed earner (it must have ``match``).
ledger: The gate's ledger file, resolved by the caller.
key_file: The shared HMAC key, resolved by the caller.
today: The verdict's day; credits after it are ignored.
Returns:
The verdict. An unreadable ledger or key is ``checked=False``: level
``"new"``, with the reason recorded; no penalty unless ``confirmed_on``
is set (then :func:`confirmed_start`, fail closed).
Raises:
ValueError: ``earner`` has no ``match``; its answer is the consumer's.
"""
if earner.match is None:
msg = f"earner {earner.name!r} has no shared reader; supply its answer"
raise ValueError(msg)
target = today or datetime.now(tz=UTC).astimezone().date()
reasons: list[str] = []
found = credit_history(earner, ledger, key_file, target, reasons)
days, paid = found if found is not None else ([], 0)
level = _level(days, earner, target, reasons)
first = days[0] if days else None
start = penalty_start(earner, first)
reasons.append(_penalty_reason(earner, start, first, checked=found is not None))
return Maturity(
name=earner.name,
level=level,
reasons=tuple(reasons),
credit_days=len(days),
credit_rows=paid,
first_credit=first,
last_credit=days[-1] if days else None,
confirmed_on=earner.confirmed_on,
penalty_from=earner.penalty_from,
penalty_start=start,
checked=found is not None,
)