File size: 12,870 Bytes
a464cc6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
027ca33
 
 
 
 
 
 
 
 
a464cc6
027ca33
a464cc6
 
027ca33
 
 
 
 
 
 
 
 
 
a464cc6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
"""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",
]