diff --git a/README.md b/README.md index fb04a75..6df7072 100644 --- a/README.md +++ b/README.md @@ -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-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-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-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 | diff --git a/src/target_revenue/metrics.py b/src/target_revenue/metrics.py new file mode 100644 index 0000000..970eb8a --- /dev/null +++ b/src/target_revenue/metrics.py @@ -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) diff --git a/src/target_revenue/service/app.py b/src/target_revenue/service/app.py index 7461575..6382954 100644 --- a/src/target_revenue/service/app.py +++ b/src/target_revenue/service/app.py @@ -18,7 +18,7 @@ from fastapi import Depends, FastAPI, HTTPException, Request from psycopg import Connection from psycopg_pool import ConnectionPool -from .. import ledger, registry +from .. import ledger, metrics, registry from . import keys 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: raise HTTPException(status_code=404, detail="phase not found") 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()) diff --git a/tests/test_ledger_hosting.py b/tests/test_ledger_hosting.py index c487899..f8fa21e 100644 --- a/tests/test_ledger_hosting.py +++ b/tests/test_ledger_hosting.py @@ -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"])) assert second.status_code == 422 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 diff --git a/tests/test_metrics.py b/tests/test_metrics.py new file mode 100644 index 0000000..e70c0f3 --- /dev/null +++ b/tests/test_metrics.py @@ -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)) diff --git a/workplans/TREV-WP-0006-trust-service-implementation.md b/workplans/TREV-WP-0006-trust-service-implementation.md index 1d2534c..8dfe8ae 100644 --- a/workplans/TREV-WP-0006-trust-service-implementation.md +++ b/workplans/TREV-WP-0006-trust-service-implementation.md @@ -192,7 +192,7 @@ with plain system Python; no stray Docker containers left running. ```task id: TREV-WP-0006-T05 -status: todo +status: done priority: medium 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 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 ```task