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:
tegwick 2026-07-29 21:42:52 +02:00
parent b7be3d3512
commit dfc1d90c28
6 changed files with 287 additions and 3 deletions

View file

@ -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

96
tests/test_metrics.py Normal file
View 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))