mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-11 21:34:04 +01:00
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
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
195 lines
7.3 KiB
Python
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,
|
|
)
|