"""NFR measurement tests (TASK-09-03, REQ-NFR-ASSIST-01, REQ-IDEATE-04, D-072). Tests the measurement infrastructure (NOT the actual latency — that's a Phase-1 live measurement, not a CI test): - AssistLatencyMetrics: p95/p50/p99 computed correctly from mock records. D-072: within_target = (p95 < 600), within_pilot = (p95 <= 650). - GuardrailMetrics: false_positive_rate on the tuning corpus, false_negative_rate on the direct-answer corpus, nightly_trend on mock turns. D-072 binding: the pilot tolerance is ≤ 650ms. The target is < 600ms (C-8). The test ASSERTS that the measurement infrastructure works (percentiles + flags), not that the actual latency is under budget. """ from __future__ import annotations import asyncio import datetime as _dt import json from pathlib import Path from unittest.mock import MagicMock import pytest from server.assist.guardrail_metrics import GuardrailMetrics from server.assist.latency_metrics import ( PILOT_TOLERANCE_MS, TARGET_MS, AssistLatencyMetrics, ) from server.latency import LatencyRecord # ── AssistLatencyMetrics ──────────────────────────────────────────────────── def _record(e2e_ms: float) -> LatencyRecord: """Build a LatencyRecord with a specific e2e_asr_to_tts_ms value.""" # e2e = tts_first_audio_ms - transcript_ready_ms. Use a non-zero base # because LatencyRecord.e2e_asr_to_tts_ms guards on truthiness (0.0 is falsy). base = 100.0 return LatencyRecord( transcript_ready_ms=base, tts_first_audio_ms=base + e2e_ms, ) def test_latency_empty_returns_none(): m = AssistLatencyMetrics() assert m.p50() is None assert m.p95() is None assert m.p99() is None s = m.summary() assert s["count"] == 0 assert s["p95"] is None assert s["within_target"] is False # no records → not within target assert s["within_pilot"] is False def test_latency_p95_p50_p99_computed(): """100 mock records: some <600ms, some 600-650ms, some >650ms. Verifies p50/p95/p99 are computed correctly + the within_target/within_pilot flags reflect the p95 against the D-072 thresholds. """ m = AssistLatencyMetrics() # 80 records < 600ms (within target), 15 records 600-650ms (within pilot), # 5 records > 650ms (over pilot tolerance). for i in range(80): m.record(_record(500.0 + i)) # 500..579ms for i in range(15): m.record(_record(610.0 + i)) # 610..624ms for i in range(5): m.record(_record(700.0 + i)) # 700..704ms s = m.summary() assert s["count"] == 100 assert s["p50"] is not None assert s["p95"] is not None assert s["p99"] is not None # p50 should be in the < 600ms range (median of the 80 < 600ms records). assert s["p50"] < 600.0 # p95: nearest-rank index = ceil(0.95 * 100) - 1 = 94 (0-indexed) → the 95th # sorted value. 80 records are 500..579, 15 are 610..624, 5 are 700..704. # Sorted: [500..579 (80), 610..624 (15), 700..704 (5)]. Index 94 → 610..624 # range (index 80..94 = the 610..624 set; index 94 = 624.0). assert 610.0 <= s["p95"] <= 625.0 # p99: index = ceil(0.99 * 100) - 1 = 98 → the 99th sorted value (700..704). assert s["p99"] >= 700.0 # D-072: within_target = (p95 < 600). p95 is ~624 → not within target. assert s["within_target"] is False # D-072: within_pilot = (p95 <= 650). p95 is ~624 → within pilot. assert s["within_pilot"] is True # D-072 thresholds documented in the summary. assert s["target_ms"] == TARGET_MS == 600 assert s["pilot_tolerance_ms"] == PILOT_TOLERANCE_MS == 650 def test_latency_within_target_when_p95_under_600(): """All records < 600ms → within_target=True, within_pilot=True.""" m = AssistLatencyMetrics() for i in range(20): m.record(_record(400.0 + i)) # 400..419ms s = m.summary() assert s["p95"] < 600.0 assert s["within_target"] is True assert s["within_pilot"] is True def test_latency_over_pilot_when_p95_over_650(): """All records > 650ms → within_target=False, within_pilot=False.""" m = AssistLatencyMetrics() for i in range(20): m.record(_record(700.0 + i)) # 700..719ms s = m.summary() assert s["p95"] > 650.0 assert s["within_target"] is False assert s["within_pilot"] is False def test_latency_pilot_boundary_exactly_650(): """D-072 boundary: p95 == 650 → within_pilot=True (≤ is inclusive).""" m = AssistLatencyMetrics() # 20 records all exactly 650ms → p95 = 650.0 for _ in range(20): m.record(_record(650.0)) s = m.summary() assert s["p95"] == 650.0 assert s["within_pilot"] is True # ≤ 650 (inclusive) assert s["within_target"] is False # < 600 (strict) def test_latency_d072_thresholds_documented(): """D-072: the pilot tolerance (≤650ms) + target (<600ms) are documented.""" assert TARGET_MS == 600 assert PILOT_TOLERANCE_MS == 650 assert PILOT_TOLERANCE_MS > TARGET_MS # pilot tolerance is more lenient # ── GuardrailMetrics ──────────────────────────────────────────────────────── @pytest.mark.asyncio async def test_guardrail_fp_rate_on_coaching_corpus(): """FP rate on the tuning corpus < 5% (REQ-IDEATE-04 target).""" gm = GuardrailMetrics() rate, mis, total = await gm.false_positive_rate() print(f"\n[nfr] guardrail FP rate: {rate:.1%} ({mis}/{total})") assert rate < 0.05, ( f"guardrail FP rate {rate:.1%} exceeds 5% target — the regex is " f"over-matching coaching responses. {mis}/{total} blocked." ) @pytest.mark.asyncio async def test_guardrail_fn_rate_on_direct_corpus(): """FN rate on the direct-answer corpus < 5% (REQ-IDEATE-04 target).""" gm = GuardrailMetrics() rate, mis, total = await gm.false_negative_rate() print(f"\n[nfr] guardrail FN rate: {rate:.1%} ({mis}/{total})") assert rate < 0.05, ( f"guardrail FN rate {rate:.1%} exceeds 5% target — the regex is " f"under-matching direct answers. {mis}/{total} allowed." ) @pytest.mark.asyncio async def test_guardrail_adversarial_fn_measured(): """Adversarial FN rate measured + reported (G-067 — ≤ 20% pilot threshold). This test does NOT assert the 5% target (the adversarial set is the residual-risk set, not the tuning target). It asserts the measurement infrastructure works + the rate is within the G-067 pilot threshold (≤ 20%). """ gm = GuardrailMetrics() rate, mis, total = await gm.adversarial_false_negative_rate() print(f"\n[nfr] guardrail adversarial FN rate: {rate:.1%} ({mis}/{total})") # G-067: ≤ 20% pilot threshold (the binding contract from GRILL-v0.5). assert rate <= 0.20, ( f"adversarial FN rate {rate:.1%} exceeds G-067 ≤20% threshold — " f"re-tune the regex or escalate. {mis}/{total} slipped through." ) @pytest.mark.asyncio async def test_guardrail_nightly_trend_on_mock_turns(tmp_path: Path): """nightly_trend() samples 24h of assist turns + reports fn_candidates. Seeds a temp SQLite store with assist turns (some coaching, some with direct-answer heuristic patterns) + verifies the nightly trend detects fn_candidates. """ from db.migrate import apply_migrations from db.store import PraxisStore db = tmp_path / "test_nfr_nightly.db" apply_migrations(db) store = PraxisStore(db) await store.init() # Seed an assist session + turns. session_id = await store.start_session_typed( "learner-1", "assist:refund", session_type="assist" ) # Turn 1: a coaching response (allowed, no fn_candidate). await store.log_turn_with_verdict( session_id, 0, role="assistant", asr_text="customer wants refund", tts_text="What do you think the customer needs right now?", latency_ms=580.0, guardrail_verdict_json=json.dumps({"allowed": True, "category": "coaching"}), ) # Turn 2: a direct-answer response that slipped past the guardrail # (allowed=True in the verdict, but the heuristic catches it). await store.log_turn_with_verdict( session_id, 1, role="assistant", asr_text="what should I say", tts_text="You should say: I'm sorry, here's a refund.", latency_ms=590.0, guardrail_verdict_json=json.dumps({"allowed": True, "category": "coaching"}), ) # Turn 3: a blocked response (guardrail caught it). await store.log_turn_with_verdict( session_id, 2, role="assistant", asr_text="help me", tts_text="Tell the customer: we will issue a full refund now.", latency_ms=570.0, guardrail_verdict_json=json.dumps({"allowed": False, "category": "blocked_direct_script"}), ) gm = GuardrailMetrics() trend = await gm.nightly_trend(store) assert trend["total_turns"] == 3 assert trend["blocked"] == 1 # Turn 2 should be flagged as an fn_candidate. The guardrail re-check may # catch it as a regression (it now blocks what it previously allowed) OR # the heuristic may catch it as a direct-answer pattern. Either way, it # must appear in fn_candidates. assert len(trend["fn_candidates"]) >= 1 seqs = [c.get("turn_seq") for c in trend["fn_candidates"]] assert 1 in seqs, "turn 2 (direct-answer that slipped past) must be flagged" @pytest.mark.asyncio async def test_guardrail_nightly_trend_empty_store(tmp_path: Path): """nightly_trend() on an empty store returns zeros + no fn_candidates.""" from db.migrate import apply_migrations from db.store import PraxisStore db = tmp_path / "test_nfr_nightly_empty.db" apply_migrations(db) store = PraxisStore(db) await store.init() gm = GuardrailMetrics() trend = await gm.nightly_trend(store) assert trend["total_turns"] == 0 assert trend["blocked"] == 0 assert trend["fn_candidates"] == [] @pytest.mark.asyncio async def test_guardrail_nightly_trend_excludes_practice_turns(tmp_path: Path): """nightly_trend() only samples assist turns (not practice turns).""" from db.migrate import apply_migrations from db.store import PraxisStore db = tmp_path / "test_nfr_nightly_practice.db" apply_migrations(db) store = PraxisStore(db) await store.init() # Seed a practice session (NOT assist) with a turn. practice_id = await store.start_session_typed( "learner-1", "cs_refund_ca_v01", session_type="practice" ) await store.log_turn_with_verdict( practice_id, 0, role="assistant", asr_text="hello", tts_text="You should say sorry.", latency_ms=500.0, guardrail_verdict_json=json.dumps({"allowed": True, "category": "coaching"}), ) gm = GuardrailMetrics() trend = await gm.nightly_trend(store) # Practice turns must NOT appear in the assist nightly trend. assert trend["total_turns"] == 0 assert trend["fn_candidates"] == []