ec397f2c65
v0.5 (Live Assist — on-the-job voice companion) milestone complete. 4 phases: P0 (pre-execution, v0.1.10) → P1 (assist core + guardrail, v0.1.11) → P2 (integration + tech-debt + NFR, v0.1.12) → P3 (final review + ship, v0.1.13 = milestone release). 16/16 REQs covered (3 ASSIST + 4 NFR + 9 IDEATE). 4 v0.6 backlog. 469 tests passed, 0 failed. 1 P0 fixed (guardrail processor safety). 8 P1+ flagged for v0.6. 8 v0.4 P1+ tech-debt addressed. G-049 + G-067 grill MUSTs resolved. ESCALATION-01 (PIPEDA) OPEN for human legal review before assist surface go-live. ---ci--- project: praxis phase: 3 milestone: v0.5 status: complete requirements: covered: [REQ-ASSIST-01, REQ-ASSIST-02, REQ-ASSIST-03, REQ-NFR-ASSIST-01, REQ-NFR-ASSIST-02, REQ-NFR-ASSIST-03, REQ-NFR-ASSIST-04, REQ-IDEATE-01, REQ-IDEATE-02, REQ-IDEATE-03, REQ-IDEATE-04, REQ-IDEATE-05, REQ-IDEATE-06, REQ-IDEATE-07, REQ-IDEATE-08, REQ-IDEATE-09] partial: [] ---/ci---
142 lines
6.4 KiB
Python
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 |