Implement public metrics service (WP-0006-T05)
src/target_revenue/metrics.py: compute_metrics(manifest, entries, as_of)
is pure/deterministic like fold.py, reusing fold.py/conversion.py
unchanged. Returns facts/calculations/forecasts as three explicitly
separated blocks (TrustServicePRD TS-FR-5), covering the mandatory Q9
set plus the recommended velocity/forecast tier - forecasts are always
null rather than populated once a Phase has converted or velocity is
non-positive, so nothing disguises a projection as a fact.
Adds GET /phases/{id}/metrics (unauthenticated per FR-9/FR-10).
tests/test_metrics.py (6 tests) needs no Docker/Postgres and runs
under plain system Python. One new Docker-gated test in
test_ledger_hosting.py proves the hosted /metrics response exactly
matches compute_metrics() run offline against the same export.
This commit is contained in:
parent
b7be3d3512
commit
dfc1d90c28
6 changed files with 287 additions and 3 deletions
|
|
@ -78,7 +78,7 @@ The concept's §13 now defines a **Global Contingency Share Determination Rule**
|
||||||
| [TREV-WP-0003](workplans/TREV-WP-0003-normative-core-extraction.md) | Extract stable normative core docs — **finished**, reviewed and accepted 2026-07-29 |
|
| [TREV-WP-0003](workplans/TREV-WP-0003-normative-core-extraction.md) | Extract stable normative core docs — **finished**, reviewed and accepted 2026-07-29 |
|
||||||
| [TREV-WP-0004](workplans/TREV-WP-0004-global-jurisdiction-research.md) | Global jurisdictional research backing the License/CUA candidates — **finished**, T10 synthesis accepted 2026-07-29 with alpha/beta working defaults (full legal review deferred until out of beta — see `SCOPE.md` §1) |
|
| [TREV-WP-0004](workplans/TREV-WP-0004-global-jurisdiction-research.md) | Global jurisdictional research backing the License/CUA candidates — **finished**, T10 synthesis accepted 2026-07-29 with alpha/beta working defaults (full legal review deferred until out of beta — see `SCOPE.md` §1) |
|
||||||
| [TREV-WP-0005](workplans/TREV-WP-0005-enforcement-network-research.md) | Enforcement Network legal feasibility research — **finished**, T10 synthesis accepted 2026-07-29 on the same alpha/beta basis (Japan's Article 12 risk remains explicitly unresolved) |
|
| [TREV-WP-0005](workplans/TREV-WP-0005-enforcement-network-research.md) | Enforcement Network legal feasibility research — **finished**, T10 synthesis accepted 2026-07-29 on the same alpha/beta basis (Japan's Article 12 risk remains explicitly unresolved) |
|
||||||
| [TREV-WP-0006](workplans/TREV-WP-0006-trust-service-implementation.md) | Hosted Trust Service reference implementation (PRD Phase 4b) — active; T01 (PRD), T02 (ADR-0002, accepted), T03 (registries), T04 (Ledger append API) done; T05 (Metrics) next |
|
| [TREV-WP-0006](workplans/TREV-WP-0006-trust-service-implementation.md) | Hosted Trust Service reference implementation (PRD Phase 4b) — active; T01 (PRD), T02 (ADR-0002, accepted), T03 (registries), T04 (Ledger append API), T05 (Metrics) done; T06 (Conversion Attestation) next |
|
||||||
| [TREV-WP-0007](workplans/TREV-WP-0007-degeneration-policy-and-canonical-profiles.md) | Degeneration policy + canonical monetization profile catalog — active, not yet started |
|
| [TREV-WP-0007](workplans/TREV-WP-0007-degeneration-policy-and-canonical-profiles.md) | Degeneration policy + canonical monetization profile catalog — active, not yet started |
|
||||||
| [TREV-WP-0008](workplans/TREV-WP-0008-governance-and-pilot-rollout.md) | Governance formalization + pilot rollout across `coulomb-loop`/`net-kingdom`/`helix-forge`/`railiance-*` — active, not yet started; real Phase declarations gated behind T05 |
|
| [TREV-WP-0008](workplans/TREV-WP-0008-governance-and-pilot-rollout.md) | Governance formalization + pilot rollout across `coulomb-loop`/`net-kingdom`/`helix-forge`/`railiance-*` — active, not yet started; real Phase declarations gated behind T05 |
|
||||||
|
|
||||||
|
|
|
||||||
125
src/target_revenue/metrics.py
Normal file
125
src/target_revenue/metrics.py
Normal file
|
|
@ -0,0 +1,125 @@
|
||||||
|
"""Public metrics computation (WP-0006-T05).
|
||||||
|
|
||||||
|
Implements the mandatory public metrics set from
|
||||||
|
`specs/OpenQuestions-WorkingDefaults.md` Q9, plus its recommended
|
||||||
|
velocity/forecast tier, always labeled fact / calculation / forecast per
|
||||||
|
`specs/TargetRevenueLicenseConcept.md` §14.6 and
|
||||||
|
`specs/TrustServiceProductRequirementsDocument.md` TS-FR-5:
|
||||||
|
|
||||||
|
- **fact**: read or summed directly from the Manifest/Ledger, no modeling.
|
||||||
|
- **calculation**: a deterministic derivation over facts (e.g. a percentage).
|
||||||
|
- **forecast**: a projection that could be wrong; never positioned where a
|
||||||
|
fact is expected, and — per TSD §4.1's Metrics component's forbidden
|
||||||
|
action — never presented as if it were a ledger fact.
|
||||||
|
|
||||||
|
Pure and deterministic given `(manifest, entries, as_of)`: the only
|
||||||
|
"impure" input is the caller-supplied `as_of` timestamp, kept as an
|
||||||
|
explicit parameter (not read from the wall clock internally) so this
|
||||||
|
module stays testable and reproducible like `fold.py` (TSD §6.1).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from . import conversion as conversion_module
|
||||||
|
from . import fold as fold_module
|
||||||
|
|
||||||
|
_MATERIAL_PROGRESS_TYPES = frozenset({"development-credit", "remission-credit"})
|
||||||
|
|
||||||
|
|
||||||
|
def _parse(ts: str) -> datetime:
|
||||||
|
return datetime.fromisoformat(ts.replace("Z", "+00:00"))
|
||||||
|
|
||||||
|
|
||||||
|
def compute_metrics(
|
||||||
|
manifest: dict[str, Any], entries: list[dict[str, Any]], as_of: datetime
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Compute the full labeled metrics set for one Phase at a point in time."""
|
||||||
|
if as_of.tzinfo is None:
|
||||||
|
raise ValueError("as_of must be timezone-aware")
|
||||||
|
|
||||||
|
status = conversion_module.conversion_status(manifest, entries)
|
||||||
|
fold_result = fold_module.fold_outstanding_target(
|
||||||
|
manifest["phase"]["initial_target"]["amount"], entries
|
||||||
|
)
|
||||||
|
initial_amount = manifest["phase"]["initial_target"]["amount"]
|
||||||
|
last_entry = entries[-1] if entries else None
|
||||||
|
|
||||||
|
material_entries = [e for e in entries if e["type"] in _MATERIAL_PROGRESS_TYPES]
|
||||||
|
first_material = material_entries[0] if material_entries else None
|
||||||
|
last_material = material_entries[-1] if material_entries else None
|
||||||
|
|
||||||
|
facts = {
|
||||||
|
"initial_target_amount": initial_amount,
|
||||||
|
"initial_target_currency": manifest["phase"]["initial_target"]["currency"],
|
||||||
|
"cumulative_development_credit": fold_result.development_credit,
|
||||||
|
"cumulative_remission_credit": fold_result.remission_credit,
|
||||||
|
"outstanding_target": fold_result.outstanding_target,
|
||||||
|
"is_converted": status.is_converted,
|
||||||
|
"future_license": status.future_license,
|
||||||
|
"last_ledger_entry_id": last_entry["id"] if last_entry else None,
|
||||||
|
"longstop_at": manifest["phase"].get("longstop_at"),
|
||||||
|
}
|
||||||
|
|
||||||
|
calculations: dict[str, Any] = {
|
||||||
|
"target_satisfaction_percentage": (
|
||||||
|
None
|
||||||
|
if initial_amount <= 0
|
||||||
|
else round(
|
||||||
|
100.0
|
||||||
|
* min(1.0, (fold_result.development_credit + fold_result.remission_credit) / initial_amount),
|
||||||
|
4,
|
||||||
|
)
|
||||||
|
),
|
||||||
|
"development_credit_velocity_per_day": None,
|
||||||
|
"remission_credit_velocity_per_day": None,
|
||||||
|
"days_since_last_material_progress": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
if first_material and last_material:
|
||||||
|
span_days = (_parse(last_material["recognized_at"]) - _parse(first_material["recognized_at"])).total_seconds() / 86400.0
|
||||||
|
if span_days > 0:
|
||||||
|
dev_total = sum(
|
||||||
|
e["amount"] for e in material_entries if e["type"] == "development-credit"
|
||||||
|
)
|
||||||
|
rem_total = sum(
|
||||||
|
e["amount"] for e in material_entries if e["type"] == "remission-credit"
|
||||||
|
)
|
||||||
|
calculations["development_credit_velocity_per_day"] = round(dev_total / span_days, 6)
|
||||||
|
calculations["remission_credit_velocity_per_day"] = round(rem_total / span_days, 6)
|
||||||
|
|
||||||
|
if last_material:
|
||||||
|
calculations["days_since_last_material_progress"] = round(
|
||||||
|
(as_of - _parse(last_material["recognized_at"])).total_seconds() / 86400.0, 4
|
||||||
|
)
|
||||||
|
|
||||||
|
forecasts: dict[str, Any] = {"projected_conversion_date": None}
|
||||||
|
velocity = (
|
||||||
|
(calculations["development_credit_velocity_per_day"] or 0.0)
|
||||||
|
+ (calculations["remission_credit_velocity_per_day"] or 0.0)
|
||||||
|
)
|
||||||
|
if not status.is_converted and velocity > 0:
|
||||||
|
days_remaining = fold_result.outstanding_target / velocity
|
||||||
|
forecasts["projected_conversion_date"] = (
|
||||||
|
as_of.replace(microsecond=0) + _timedelta_days(days_remaining)
|
||||||
|
).isoformat()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"phase": manifest["phase"]["id"],
|
||||||
|
"as_of": as_of.isoformat(),
|
||||||
|
"facts": facts,
|
||||||
|
"calculations": calculations,
|
||||||
|
"forecasts": forecasts,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _timedelta_days(days: float):
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
return timedelta(days=days)
|
||||||
|
|
||||||
|
|
||||||
|
def utcnow() -> datetime:
|
||||||
|
return datetime.now(timezone.utc)
|
||||||
|
|
@ -18,7 +18,7 @@ from fastapi import Depends, FastAPI, HTTPException, Request
|
||||||
from psycopg import Connection
|
from psycopg import Connection
|
||||||
from psycopg_pool import ConnectionPool
|
from psycopg_pool import ConnectionPool
|
||||||
|
|
||||||
from .. import ledger, registry
|
from .. import ledger, metrics, registry
|
||||||
from . import keys
|
from . import keys
|
||||||
|
|
||||||
app = FastAPI(title="Target Revenue Trust Service — Registries", version="0.1.0")
|
app = FastAPI(title="Target Revenue Trust Service — Registries", version="0.1.0")
|
||||||
|
|
@ -140,3 +140,16 @@ def read_ledger(
|
||||||
if registry.get_phase_manifest(conn, phase_id) is None:
|
if registry.get_phase_manifest(conn, phase_id) is None:
|
||||||
raise HTTPException(status_code=404, detail="phase not found")
|
raise HTTPException(status_code=404, detail="phase not found")
|
||||||
return ledger.get_ledger(conn, phase_id)
|
return ledger.get_ledger(conn, phase_id)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/phases/{phase_id}/metrics")
|
||||||
|
def read_metrics(
|
||||||
|
phase_id: str, conn: Connection = Depends(get_connection)
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Public, unauthenticated per FR-9/FR-10 — see `target_revenue.metrics`
|
||||||
|
for the fact/calculation/forecast labeling this response preserves."""
|
||||||
|
manifest = registry.get_phase_manifest(conn, phase_id)
|
||||||
|
if manifest is None:
|
||||||
|
raise HTTPException(status_code=404, detail="phase not found")
|
||||||
|
entries = ledger.get_ledger(conn, phase_id)
|
||||||
|
return metrics.compute_metrics(manifest, entries, metrics.utcnow())
|
||||||
|
|
|
||||||
|
|
@ -274,3 +274,30 @@ def test_duplicate_entry_id_rejected(client, pg_container, registered_phase):
|
||||||
second = client.post(f"/phases/{phase_id}/ledger", json=e, headers=auth_headers(pg_container["token"]))
|
second = client.post(f"/phases/{phase_id}/ledger", json=e, headers=auth_headers(pg_container["token"]))
|
||||||
assert second.status_code == 422
|
assert second.status_code == 422
|
||||||
assert "already exists" in second.json()["detail"]
|
assert "already exists" in second.json()["detail"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_metrics_endpoint_unauthenticated_and_matches_offline_computation(
|
||||||
|
client, pg_container, registered_phase
|
||||||
|
):
|
||||||
|
"""WP-0006-T05: the hosted /metrics response must match what
|
||||||
|
target_revenue.metrics.compute_metrics computes offline from the same
|
||||||
|
exported Manifest + Ledger — the metrics-layer analogue of T04's
|
||||||
|
hosted/offline fold-agreement test."""
|
||||||
|
from target_revenue import metrics as metrics_module
|
||||||
|
|
||||||
|
phase_id = registered_phase["phase"]["id"]
|
||||||
|
e = _entry(phase_id, "trsl:entry:ledgertestmetrics0001", "development-credit", 4000, "2026-08-01T00:00:00Z")
|
||||||
|
resp = client.post(f"/phases/{phase_id}/ledger", json=e, headers=auth_headers(pg_container["token"]))
|
||||||
|
assert resp.status_code == 201, resp.text
|
||||||
|
|
||||||
|
# No Authorization header at all: metrics are public per FR-9/FR-10.
|
||||||
|
metrics_resp = client.get(f"/phases/{phase_id}/metrics")
|
||||||
|
assert metrics_resp.status_code == 200
|
||||||
|
hosted = metrics_resp.json()
|
||||||
|
|
||||||
|
entries = client.get(f"/phases/{phase_id}/ledger").json()
|
||||||
|
as_of = metrics_module.datetime.fromisoformat(hosted["as_of"])
|
||||||
|
offline = metrics_module.compute_metrics(registered_phase, entries, as_of)
|
||||||
|
|
||||||
|
assert hosted == offline
|
||||||
|
assert hosted["facts"]["cumulative_development_credit"] == 4000
|
||||||
|
|
|
||||||
96
tests/test_metrics.py
Normal file
96
tests/test_metrics.py
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
"""Pure, offline tests for target_revenue.metrics (WP-0006-T05).
|
||||||
|
|
||||||
|
No Docker/Postgres required — metrics.compute_metrics is a pure function
|
||||||
|
of (manifest, entries, as_of), like fold.py (TSD §6.1).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from conftest import golden_entries, golden_manifest
|
||||||
|
|
||||||
|
from target_revenue import metrics
|
||||||
|
|
||||||
|
|
||||||
|
def test_mandatory_facts_present_for_golden_phase():
|
||||||
|
manifest = golden_manifest()
|
||||||
|
entries = golden_entries()
|
||||||
|
as_of = datetime(2026, 12, 1, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
result = metrics.compute_metrics(manifest, entries, as_of)
|
||||||
|
|
||||||
|
facts = result["facts"]
|
||||||
|
assert facts["initial_target_amount"] == manifest["phase"]["initial_target"]["amount"]
|
||||||
|
assert facts["initial_target_currency"] == manifest["phase"]["initial_target"]["currency"]
|
||||||
|
assert facts["last_ledger_entry_id"] == entries[-1]["id"]
|
||||||
|
assert facts["longstop_at"] == manifest["phase"]["longstop_at"]
|
||||||
|
assert "cumulative_development_credit" in facts
|
||||||
|
assert "cumulative_remission_credit" in facts
|
||||||
|
assert "outstanding_target" in facts
|
||||||
|
assert "is_converted" in facts
|
||||||
|
|
||||||
|
|
||||||
|
def test_calculations_and_forecast_are_separately_labeled():
|
||||||
|
manifest = golden_manifest()
|
||||||
|
entries = golden_entries()
|
||||||
|
as_of = datetime(2026, 12, 1, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
result = metrics.compute_metrics(manifest, entries, as_of)
|
||||||
|
|
||||||
|
assert set(result.keys()) == {"phase", "as_of", "facts", "calculations", "forecasts"}
|
||||||
|
assert "target_satisfaction_percentage" in result["calculations"]
|
||||||
|
assert "projected_conversion_date" in result["forecasts"]
|
||||||
|
# A forecast must never appear inside the facts block.
|
||||||
|
assert "projected_conversion_date" not in result["facts"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_target_satisfaction_percentage_matches_fold():
|
||||||
|
manifest = golden_manifest()
|
||||||
|
entries = golden_entries()
|
||||||
|
as_of = datetime(2026, 12, 1, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
result = metrics.compute_metrics(manifest, entries, as_of)
|
||||||
|
facts = result["facts"]
|
||||||
|
initial = facts["initial_target_amount"]
|
||||||
|
expected_pct = round(
|
||||||
|
100.0
|
||||||
|
* min(
|
||||||
|
1.0,
|
||||||
|
(facts["cumulative_development_credit"] + facts["cumulative_remission_credit"])
|
||||||
|
/ initial,
|
||||||
|
),
|
||||||
|
4,
|
||||||
|
)
|
||||||
|
assert result["calculations"]["target_satisfaction_percentage"] == expected_pct
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_material_entries_yields_no_velocity_or_forecast():
|
||||||
|
manifest = golden_manifest()
|
||||||
|
as_of = datetime(2026, 12, 1, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
result = metrics.compute_metrics(manifest, [], as_of)
|
||||||
|
|
||||||
|
assert result["calculations"]["development_credit_velocity_per_day"] is None
|
||||||
|
assert result["calculations"]["remission_credit_velocity_per_day"] is None
|
||||||
|
assert result["calculations"]["days_since_last_material_progress"] is None
|
||||||
|
assert result["forecasts"]["projected_conversion_date"] is None
|
||||||
|
assert result["facts"]["outstanding_target"] == manifest["phase"]["initial_target"]["amount"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_converted_phase_has_no_forecast():
|
||||||
|
manifest = golden_manifest()
|
||||||
|
entries = golden_entries()
|
||||||
|
as_of = datetime(2026, 12, 1, tzinfo=timezone.utc)
|
||||||
|
result = metrics.compute_metrics(manifest, entries, as_of)
|
||||||
|
if result["facts"]["is_converted"]:
|
||||||
|
assert result["forecasts"]["projected_conversion_date"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_as_of_must_be_timezone_aware():
|
||||||
|
manifest = golden_manifest()
|
||||||
|
entries = golden_entries()
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
metrics.compute_metrics(manifest, entries, datetime(2026, 12, 1))
|
||||||
|
|
@ -192,7 +192,7 @@ with plain system Python; no stray Docker containers left running.
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: TREV-WP-0006-T05
|
id: TREV-WP-0006-T05
|
||||||
status: todo
|
status: done
|
||||||
priority: medium
|
priority: medium
|
||||||
state_hub_task_id: "6bab5e64-0091-4a25-906d-758310cbe723"
|
state_hub_task_id: "6bab5e64-0091-4a25-906d-758310cbe723"
|
||||||
```
|
```
|
||||||
|
|
@ -202,6 +202,29 @@ Q9 (Initial Target, cumulative credits, Outstanding Target, conversion
|
||||||
status, last checkpoint, Longstop timestamp as mandatory; velocity/forecast
|
status, last checkpoint, Longstop timestamp as mandatory; velocity/forecast
|
||||||
as recommended, clearly labeled as forecasts per concept §14.6).
|
as recommended, clearly labeled as forecasts per concept §14.6).
|
||||||
|
|
||||||
|
**Result:** `src/target_revenue/metrics.py`'s `compute_metrics(manifest,
|
||||||
|
entries, as_of)` — pure and deterministic like `fold.py` (the only input
|
||||||
|
that varies with wall-clock time, `as_of`, is an explicit parameter, never
|
||||||
|
read internally), reusing `fold.py`/`conversion.py` unchanged. Returns
|
||||||
|
three explicitly separated blocks per TrustServicePRD TS-FR-5: `facts`
|
||||||
|
(Q9's mandatory set — Initial Target, cumulative Development/Remission
|
||||||
|
Credit, Outstanding Target, conversion status, last ledger entry id,
|
||||||
|
Longstop timestamp), `calculations` (target satisfaction percentage,
|
||||||
|
Development/Remission Credit velocity per day, days since last material
|
||||||
|
progress), and `forecasts` (projected conversion date, `null` whenever
|
||||||
|
already converted or velocity is zero/negative — never populated as a
|
||||||
|
disguised fact). Added `GET /phases/{id}/metrics` to `service/app.py`,
|
||||||
|
unauthenticated per FR-9/FR-10. `tests/test_metrics.py` (6 tests, no
|
||||||
|
Docker/Postgres required, runs under plain system Python — no new hard
|
||||||
|
dependency) covers the mandatory-facts set, fact/calculation/forecast
|
||||||
|
separation, percentage correctness, the zero-material-entries case, and
|
||||||
|
`as_of` timezone-awareness. Added one Docker-gated test to
|
||||||
|
`tests/test_ledger_hosting.py` proving the hosted `/metrics` response
|
||||||
|
(fetched with no Authorization header) exactly equals
|
||||||
|
`compute_metrics()` run offline against the same exported Manifest +
|
||||||
|
Ledger. Full suite (offline 36 + metrics 6 + Docker-gated 22) verified
|
||||||
|
passing; no stray containers left running.
|
||||||
|
|
||||||
## Conversion Attestation publication
|
## Conversion Attestation publication
|
||||||
|
|
||||||
```task
|
```task
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue