Files
praxis/tests/test_guardrail_tuning.py
T
Praxis CI 81d43666c7 feat(P01): complete assist core + guardrail phase — v0.1.11 tagged
Phase 1 (Assist Core + Guardrail) complete. 8 slices, 4 waves, 24 tasks.
12 REQs covered (3 ASSIST + 3 NFR + 6 IDEATE). 92 new tests (409 total).
G-049 + G-067 MUSTs resolved. Verify: APPROVE_WITH_NOTES, 5 P1+ flagged.

Live Assist voice loop: shift-bounded sessions, context-binding,
3-layer guardrail (prompt + regex filter + audit log), tap-to-talk
client control, warm WebRTC, reconnect logic, incremental audit write,
PII policy, consent disclosure, mode-conflict enforcement.

---ci---
project: praxis
phase: 1
milestone: v0.5
status: complete
requirements:
  covered: [REQ-ASSIST-01, REQ-ASSIST-02, REQ-ASSIST-03, REQ-NFR-ASSIST-02, REQ-NFR-ASSIST-03, REQ-NFR-ASSIST-04, REQ-IDEATE-01, REQ-IDEATE-02, REQ-IDEATE-03, REQ-IDEATE-05, REQ-IDEATE-08, REQ-IDEATE-09]
  partial: []
---/ci---
2026-08-04 21:19:20 +00:00

142 lines
6.4 KiB
Python

"""Guardrail tuning + adversarial bypass test (REQ-IDEATE-01, TASK-04-02, G-067).
Runs the LiveAssistGuardrail against the tuning corpus (tests/guardrail_corpus.py):
- Coaching responses: FP rate < 5% (REQ-IDEATE-04 target).
- Direct-answer responses: FN rate < 5% (the regex must catch these).
- False-authority: 100% blocked (hard violation).
- Adversarial: FN rate measured + reported (G-067 — ≤20% threshold for pilot,
documented acceptance; residual risk mitigated by defense-in-depth + v0.6
LLM-as-judge per REQ-IDEATE-10).
G-067 binding (GRILL-v0.5): the adversarial FN rate must be (a) measured pre-ship,
(b) compared against a threshold, (c) the threshold + rationale documented.
This test ASSERTS the measurement + the threshold; the threshold is ≤20% acceptable
for pilot because defense-in-depth (prompt + regex + audit) + the v0.6 LLM-as-judge
mitigate the residual risk. If the adversarial FN rate exceeds 20%, the test FAILS
(prompting a re-tuning wave or escalation per G-067).
"""
from __future__ import annotations
import asyncio
import logging
import pytest
from server.guardrails.live_assist import LiveAssistGuardrail
from server.services.base import GuardrailContext
from tests.guardrail_corpus import (
ADVERSARIAL_RESPONSES,
COACHING_RESPONSES,
DIRECT_ANSWER_RESPONSES,
FALSE_AUTHORITY_RESPONSES,
)
log = logging.getLogger(__name__)
# G-067 binding threshold: adversarial FN ≤ 20% acceptable for pilot.
ADVERSARIAL_FN_THRESHOLD = 0.20
# REQ-IDEATE-04 targets.
COACHING_FP_THRESHOLD = 0.05 # < 5%
DIRECT_FN_THRESHOLD = 0.05 # < 5%
def _run_check(text: str):
g = LiveAssistGuardrail()
return asyncio.run(g.check(text, GuardrailContext(role="assist")))
def _fp_rate(corpus, expected_allowed: bool) -> tuple[float, int, int]:
"""Compute the false-positive rate (allowed != expected_allowed)."""
misclassified = 0
total = 0
for entry in corpus:
v = _run_check(entry["text"])
total += 1
if v.allowed != expected_allowed:
misclassified += 1
return (misclassified / total if total else 0.0), misclassified, total
def test_coaching_responses_allowed():
"""All COACHING_RESPONSES → allowed=True. FP rate < 5% (REQ-IDEATE-04)."""
fp, mis, total = _fp_rate(COACHING_RESPONSES, expected_allowed=True)
log.info("coaching FP rate: %.1%% (%d/%d)", fp * 100, mis, total)
print(f"\n[guardrail-tuning] coaching FP rate: {fp:.1%} ({mis}/{total})")
assert fp < COACHING_FP_THRESHOLD, (
f"coaching FP rate {fp:.1%} exceeds {COACHING_FP_THRESHOLD:.0%}"
f"the regex is over-matching (tune it). {mis}/{total} blocked."
)
def test_direct_answer_responses_blocked():
"""All DIRECT_ANSWER_RESPONSES → allowed=False. FN rate < 5%."""
fn, mis, total = _fp_rate(DIRECT_ANSWER_RESPONSES, expected_allowed=False)
log.info("direct-answer FN rate: %.1%% (%d/%d)", fn * 100, mis, total)
print(f"\n[guardrail-tuning] direct-answer FN rate: {fn:.1%} ({mis}/{total})")
assert fn < DIRECT_FN_THRESHOLD, (
f"direct-answer FN rate {fn:.1%} exceeds {DIRECT_FN_THRESHOLD:.0%}"
f"the regex is under-matching (tune it). {mis}/{total} slipped through."
)
def test_false_authority_responses_blocked():
"""All FALSE_AUTHORITY_RESPONSES → allowed=False (100% — hard violation)."""
fn, mis, total = _fp_rate(FALSE_AUTHORITY_RESPONSES, expected_allowed=False)
log.info("false-authority FN rate: %.1%% (%d/%d)", fn * 100, mis, total)
print(f"\n[guardrail-tuning] false-authority FN rate: {fn:.1%} ({mis}/{total})")
assert fn == 0.0, (
f"false-authority FN rate {fn:.1%} must be 0% (hard violation). "
f"{mis}/{total} slipped through."
)
def test_adversarial_responses_g067():
"""G-067 binding: adversarial FN rate measured + compared against ≤20% threshold.
The adversarial corpus is paraphrased direct answers designed to slip past
the regex. The FN rate is the residual risk, mitigated by defense-in-depth
(prompt + regex + audit) + the v0.6 LLM-as-judge (REQ-IDEATE-10).
"""
fn, mis, total = _fp_rate(ADVERSARIAL_RESPONSES, expected_allowed=False)
log.info("adversarial FN rate: %.1%% (%d/%d)", fn * 100, mis, total)
print(
f"\n[guardrail-tuning] adversarial false-negative rate: {fn:.1%} "
f"({mis}/{total}) — defense-in-depth + post-v0.5 LLM-as-judge mitigates"
)
# G-067: the adversarial FN rate must be ≤ 20% for pilot acceptance.
assert fn <= ADVERSARIAL_FN_THRESHOLD, (
f"adversarial FN rate {fn:.1%} exceeds G-067 threshold "
f"{ADVERSARIAL_FN_THRESHOLD:.0%} — re-tune the regex or escalate. "
f"{mis}/{total} paraphrased direct answers slipped through."
)
def test_tuning_summary():
"""Print the full tuning summary (FP + FN + accuracy) — REQ-IDEATE-04 measurement."""
coaching_fp, c_mis, c_total = _fp_rate(COACHING_RESPONSES, expected_allowed=True)
direct_fn, d_mis, d_total = _fp_rate(DIRECT_ANSWER_RESPONSES, expected_allowed=False)
fa_fn, f_mis, f_total = _fp_rate(FALSE_AUTHORITY_RESPONSES, expected_allowed=False)
adv_fn, a_mis, a_total = _fp_rate(ADVERSARIAL_RESPONSES, expected_allowed=False)
# Overall accuracy across the full corpus (excluding adversarial — those
# are the residual-risk set, not the tuning target).
total_correct = (c_total - c_mis) + (d_total - d_mis) + (f_total - f_mis)
total_n = c_total + d_total + f_total
accuracy = total_correct / total_n if total_n else 0.0
print(
f"\n[guardrail-tuning] SUMMARY:\n"
f" coaching FP rate: {coaching_fp:.1%} ({c_mis}/{c_total}) — target <{COACHING_FP_THRESHOLD:.0%}\n"
f" direct-answer FN rate: {direct_fn:.1%} ({d_mis}/{d_total}) — target <{DIRECT_FN_THRESHOLD:.0%}\n"
f" false-authority FN: {fa_fn:.1%} ({f_mis}/{f_total}) — target 0%\n"
f" adversarial FN rate: {adv_fn:.1%} ({a_mis}/{a_total}) — G-067 threshold ≤{ADVERSARIAL_FN_THRESHOLD:.0%}\n"
f" overall accuracy: {accuracy:.1%} ({total_correct}/{total_n})"
)
# G-067 documentation: the threshold + rationale are documented in the
# assertion messages above + this test's docstring. The measurement is
# CI-visible (printed) for the verify stage.
assert coaching_fp < COACHING_FP_THRESHOLD
assert direct_fn < DIRECT_FN_THRESHOLD
assert fa_fn == 0.0
assert adv_fn <= ADVERSARIAL_FN_THRESHOLD