"""Granite Guardian 4.1 BYOC custom-rules audit (Phase 2 Day 5 task 2.14). Reads a PhysicsViolationLog (engine-agnostic; V1 NumPy or V2 cvxpylayers both work) + a CoaParseResult, applies a BYOC rule registry, emits a GuardianAudit discriminated union (approve | flag | reject) whose shape mirrors the canonical frontend contract at `app/shared/types.ts` L323-345. BYOC rule schema follows docs/architecture-spec.md L187-204. Each rule: - matches ViolationRecords by violation_type - maps to a verdict (approve | flag | reject) - carries optional templated strings for the audit's reasoning_trace + flagged_concerns + blocked_recommendations fields Verdict precedence: reject > flag > approve. If any rule fires with a reject branch, the top-level verdict is reject (D-022 lexicographic Tier-0 inviolable contract: COA-derived violations are inviolable). The actual Granite Guardian 4.1 model integration (BYOC custom prompt + think-mode trace) lands at task 2.15 + Phase 3 + Phase 4 orchestration. This module ships the deterministic rule-engine floor that the Guardian model wraps; the engine-agnostic boundary means Gate G5 can pass on the rule-engine floor even before the Granite model is wired. """ from __future__ import annotations from dataclasses import dataclass from typing import Final from apex.instruct.coa_parser import CoaParseResult from apex.shared.contracts import ( GuardianAudit, GuardianVerdict, PhysicsViolationLog, ViolationRecord, new_audit_id, ) # ---- BYOC rule registry ------------------------------------------------- @dataclass(frozen=True) class BYOCRule: """A Bring-Your-Own-Classifier rule for the Granite Guardian audit. Concrete shape per docs/architecture-spec.md L187-204. Each rule matches a single violation_type + emits a single verdict; multi- verdict rules from the architecture-spec's verdict_map are represented as separate BYOCRule instances (one per severity-trigger). Templated fields use str.format() placeholders: {step}, {long_g}, {lat_g}, {throttle_pct}, {brake_pa}, {speed_mps}, {severity}, {tier}. Templates that reference a field absent from the violation's channel_values fall back to the literal placeholder string (no crash on missing fields). """ rule_id: str violation_type: str verdict: GuardianVerdict concern_template: str | None = None # used on verdict="flag" block_template: str | None = None # used on verdict="reject" reasoning_template: str | None = None # appended to reasoning_trace on any match def _format_template(template: str, record: ViolationRecord) -> str: """Format a BYOC template against a ViolationRecord. Substitutes {step}, {type}, {severity}, {tier} from record fields and every key in record.channel_values. Missing placeholders fall back to the literal `{placeholder}` string. """ fields: dict[str, object] = { "step": record.step, "type": record.type, "severity": f"{record.severity:.4f}", "tier": record.tier, } fields.update({k: f"{v:.4f}" for k, v in record.channel_values.items()}) try: return template.format(**fields) except (KeyError, IndexError): return template DEFAULT_RULE_REGISTRY: Final[tuple[BYOCRule, ...]] = ( BYOCRule( rule_id="friction_ellipse_breach", violation_type="friction_ellipse_exceeded", verdict="flag", concern_template=( "Friction-ellipse breach at step {step}: long_g={long_g}, " "lat_g={lat_g} exceeds the constant-mu envelope by " "{severity}g. Constraint tier {tier}." ), reasoning_template=( "Rule friction_ellipse_breach fired on step {step} " "(severity {severity})." ), ), BYOCRule( rule_id="forward_euler_inconsistency", violation_type="forward_euler_inconsistent", verdict="flag", concern_template=( "Forward-Euler kinematic break at step {step}: Delta-v vs " "long_g residual exceeds the 1 Hz tolerance band by {severity} m/s." ), reasoning_template=( "Rule forward_euler_inconsistency fired on step {step}." ), ), BYOCRule( rule_id="bicycle_kinematic_break", violation_type="bicycle_kinematic_break", verdict="flag", concern_template=( "Bicycle-model kinematic break at step {step}: lat_g={lat_g} " "vs steering_rad={steering_rad} at speed_mps={speed_mps} " "disagrees by {severity}g." ), reasoning_template=( "Rule bicycle_kinematic_break fired on step {step}. V1 " "small-angle bicycle model is known to false-positive at " "race-corner speeds; V2 cvxpylayers + 8-tier Pacejka " "supersedes this check at production fidelity." ), ), BYOCRule( rule_id="coa_simultaneity_breach", violation_type="coa_simultaneity_violation", verdict="reject", block_template=( "Cannot approve coaching recommendation: COA does not " "permit simultaneous throttle + brake input at step {step} " "(throttle_pct={throttle_pct}, brake_pa={brake_pa}). " "This is a Tier-0 inviolable constraint per D-022 " "lexicographic COA hierarchy." ), reasoning_template=( "Rule coa_simultaneity_breach fired on step {step}: " "Tier-0 COA-derived simultaneity gate (inviolable)." ), ), ) """Default BYOC rule registry covering the V1 NumPy validator's four violation types + the Tier-0 COA gate. Convergence-14 expansion to the remaining 10 violation types lands at Phase 4 task 4.2.""" # ---- Verdict precedence ------------------------------------------------- _VERDICT_PRECEDENCE: Final[dict[GuardianVerdict, int]] = { "approve": 0, "flag": 1, "reject": 2, } def _max_verdict(a: GuardianVerdict, b: GuardianVerdict) -> GuardianVerdict: return a if _VERDICT_PRECEDENCE[a] >= _VERDICT_PRECEDENCE[b] else b # ---- Guardian ----------------------------------------------------------- class Guardian: """Granite Guardian 4.1 BYOC custom-rules audit driver. Stateless aside from the rule registry; .audit() can be called many times on the same instance. Each call generates a fresh audit_id via shared.contracts.violations.new_audit_id(). The Granite model itself is NOT loaded here; this class ships the deterministic rule-engine floor that the Guardian model wraps. Task 2.15 + Phase 4 wire the model in. Gate G5 (task 2.16) passes on the rule-engine floor because the floor catches all 5 impossibilities + emits the right verdict. """ def __init__(self, rules: tuple[BYOCRule, ...] | None = None): self._rules = rules if rules is not None else DEFAULT_RULE_REGISTRY def audit( self, *, violation_log: PhysicsViolationLog, coa: CoaParseResult, ) -> GuardianAudit: """Apply the BYOC rule registry to `violation_log` + `coa`. Returns a GuardianAudit whose verdict is the maximum-precedence verdict across every rule that fired (approve if none fired). """ audit_id = new_audit_id() reasoning_trace: list[str] = [] flagged_concerns: list[str] = [] blocked_recommendations: list[str] = [] top_verdict: GuardianVerdict = "approve" # Empty log + safe CoA: approve with a single reasoning line. if violation_log.is_empty(): reasoning_trace.append( f"Empty violation log on {violation_log.engine}; " f"COA driver_id={coa.driver_id} simultaneity_permitted=" f"{coa.simultaneity_permitted}. No rules fired." ) return GuardianAudit( verdict="approve", reasoning_trace=tuple(reasoning_trace), audit_id=audit_id, ) rules_by_type: dict[str, list[BYOCRule]] = {} for rule in self._rules: rules_by_type.setdefault(rule.violation_type, []).append(rule) for record in violation_log.records: for rule in rules_by_type.get(record.type, []): if rule.reasoning_template: reasoning_trace.append( _format_template(rule.reasoning_template, record) ) if rule.verdict == "flag" and rule.concern_template: flagged_concerns.append( _format_template(rule.concern_template, record) ) elif rule.verdict == "reject" and rule.block_template: blocked_recommendations.append( _format_template(rule.block_template, record) ) top_verdict = _max_verdict(top_verdict, rule.verdict) # Codex BLOCKER wave-50 close: if violation records exist but # no BYOC rule matched (V12 8-tier Pacejka + V13 SCP iterate + # Convergence-14 expansion types not yet in DEFAULT_RULE_REGISTRY), # default-approve is the wrong floor. Unmatched violation types # represent records the rule engine doesn't know how to interpret # safely. Promote unmatched-with-records to "flag" so the # downstream coaching report surfaces them as concerns rather # than approving by silence. Empty-log + safe-COA path above # remains "approve" + early-returns. if not reasoning_trace: unmatched_types = sorted({r.type for r in violation_log.records}) reasoning_trace.append( f"Violation log on {violation_log.engine} carried " f"{len(violation_log.records)} record(s) of " f"unmatched-by-registry type(s) {unmatched_types}. " f"Default verdict: flag (rule-engine floor does not " f"silently approve unmatched violation records)." ) top_verdict = _max_verdict(top_verdict, "flag") flagged_concerns.append( f"Unmatched violation type(s) {unmatched_types} " f"surfaced on {violation_log.engine}; review for BYOC " f"rule expansion + Convergence-14 ladder coverage." ) return GuardianAudit( verdict=top_verdict, reasoning_trace=tuple(reasoning_trace), audit_id=audit_id, flagged_concerns=tuple(flagged_concerns), blocked_recommendations=tuple(blocked_recommendations), ) # ---- UI text-render helper (task 2.15) --------------------------------- _RENDER_MODES: Final[tuple[str, ...]] = ("think", "no-think") def render_audit(audit: GuardianAudit, mode: str = "think") -> str: """Render a GuardianAudit as UI-consumable text. Two modes per the Granite Guardian 4.1 hybrid-thinking surface documented at docs/architecture-spec.md L440: - 'think' (default): includes the full reasoning_trace chain so the UI can show the audit's thinking. Used in the /analyze Guardian panel + the provenance footer. - 'no-think': verdict header + concerns/blocks + audit_id only. Used in low-latency surfaces (coaching-report header banner) where the reasoning chain would be visually noisy. Output is plain text; the frontend renderer (GuardianAudit component, wave-42) handles its own markdown / structure parsing from the discriminated-union audit object. This helper is for backend log surfaces (provenance footer, BeMyApp submission artifacts, paper ยง4 reproducibility appendix). """ if mode not in _RENDER_MODES: raise ValueError( f"render_audit mode must be one of {_RENDER_MODES}; got {mode!r}." ) lines: list[str] = [] lines.append(f"GUARDIAN AUDIT verdict={audit.verdict} audit_id={audit.audit_id}") if audit.verdict == "flag" and audit.flagged_concerns: lines.append("") lines.append("Flagged concerns:") for concern in audit.flagged_concerns: lines.append(f" - {concern}") if audit.verdict == "reject" and audit.blocked_recommendations: lines.append("") lines.append("Blocked recommendations:") for block in audit.blocked_recommendations: lines.append(f" - {block}") if mode == "think" and audit.reasoning_trace: lines.append("") lines.append("Reasoning trace:") for step in audit.reasoning_trace: lines.append(f" - {step}") return "\n".join(lines) + "\n" __all__ = [ "BYOCRule", "DEFAULT_RULE_REGISTRY", "Guardian", "render_audit", ]