faultgate 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
faultgate/__init__.py ADDED
@@ -0,0 +1,2 @@
1
+ """faultgate — local-first release gate for AI agents."""
2
+ __version__ = "0.1.0"
faultgate/band.py ADDED
@@ -0,0 +1,78 @@
1
+ """Tolerance bands: is this run a regression, or just noise?
2
+
3
+ Ported from FAULTLINE P11 (faultline_p2/gate/band.py, gate.py), where five
4
+ baseline runs of the same model on identical inputs scored 0.60–0.87 — the
5
+ case against a fixed threshold. A band is built from >= 2 baseline runs of the
6
+ same scenario set; a candidate is judged against the *observed* spread:
7
+
8
+ PASS clean_rate >= min_observed - 1/n
9
+ FAIL clean_rate < min_observed - 3/n
10
+ WARN otherwise
11
+
12
+ plus the list of scenarios that were clean in every baseline and are not now.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ from typing import Any
17
+
18
+ from faultgate.runner import Report
19
+
20
+ MIN_RUNS = 2
21
+
22
+
23
+ def build(reports: list[Report]) -> dict[str, Any]:
24
+ if len(reports) < MIN_RUNS:
25
+ raise ValueError(f"a band needs at least {MIN_RUNS} baseline runs, got {len(reports)}")
26
+ judges = {r.judge for r in reports}
27
+ checks = {tuple(r.checks) for r in reports}
28
+ if len(judges) > 1 or len(checks) > 1:
29
+ raise ValueError("all baseline runs must use the same judge and checks")
30
+
31
+ per_run = [r.clean_by_scenario for r in reports]
32
+ scenarios = sorted(set.intersection(*(set(m) for m in per_run)))
33
+ if not scenarios:
34
+ raise ValueError("baseline runs share no scenarios (keys come from faultgate.scenario, gen_ai.agent.id or the prompt)")
35
+ n = len(scenarios)
36
+ rates = [sum(m[s] for s in scenarios) / n for m in per_run]
37
+ agreement = {s: sum(m[s] for m in per_run) for s in scenarios}
38
+ return {
39
+ "judge": reports[0].judge,
40
+ "checks": reports[0].checks,
41
+ "runs": len(reports),
42
+ "n": n,
43
+ "scenarios": scenarios,
44
+ "clean_rate": {"min": min(rates), "max": max(rates), "mean": sum(rates) / len(rates), "per_run": rates},
45
+ "clean_in_all_runs": [s for s in scenarios if agreement[s] == len(reports)],
46
+ "rule": "PASS if clean_rate >= min - 1/n; FAIL if clean_rate < min - 3/n; else WARN",
47
+ }
48
+
49
+
50
+ def evaluate(report: Report, band: dict[str, Any]) -> dict[str, Any]:
51
+ clean = report.clean_by_scenario
52
+ scenarios = band["scenarios"]
53
+ n = len(scenarios)
54
+ missing = [s for s in scenarios if s not in clean]
55
+ rate = sum(clean.get(s, False) for s in scenarios) / n
56
+ lo = band["clean_rate"]["min"]
57
+ eps = 1e-9
58
+ if rate < lo - 3 / n - eps:
59
+ verdict = "FAIL"
60
+ elif rate >= lo - 1 / n - eps:
61
+ verdict = "PASS"
62
+ else:
63
+ verdict = "WARN"
64
+ warnings = []
65
+ if report.judge != band["judge"] or report.checks != band["checks"]:
66
+ warnings.append(f"band was built with judge={band['judge']} checks={band['checks']}; this run used judge={report.judge} checks={report.checks}")
67
+ if missing:
68
+ warnings.append(f"{len(missing)} band scenario(s) absent from this run, counted as not clean")
69
+ return {
70
+ "verdict": verdict,
71
+ "clean_rate": rate,
72
+ "band": [lo, band["clean_rate"]["max"]],
73
+ "pass_threshold": lo - 1 / n,
74
+ "fail_threshold": lo - 3 / n,
75
+ "regressions": [s for s in band["clean_in_all_runs"] if not clean.get(s, False)],
76
+ "missing": missing,
77
+ "warnings": warnings,
78
+ }
faultgate/checks.py ADDED
@@ -0,0 +1,238 @@
1
+ """Built-in checks.
2
+
3
+ Two kinds. A **rule** is plain code over the trace structure — no model, no
4
+ noise, and where the failure is structural it beats every judge we measured
5
+ (FAULTLINE P5: code assertions κ = 1.00 where a signal exists; LLM judges κ ≈ 0).
6
+ A **question** is a yes/no the judge answers from the rendered trace; the rule
7
+ text comes from FAULTLINE P5, where it was scored against human labels.
8
+
9
+ Calibration for each: README "How much to trust a verdict"; regenerate with
10
+ scripts/calibrate.py.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import re
16
+ from collections.abc import Callable
17
+ from dataclasses import dataclass, field
18
+ from functools import partial
19
+ from pathlib import Path
20
+
21
+ from faultgate.judge import Verdict
22
+ from faultgate.trace import Trace
23
+
24
+ STEP_CAP_WORDS = ("step cap", "step limit", "max steps", "max_steps", "max iterations", "iteration limit")
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class Check:
29
+ name: str
30
+ description: str
31
+ question: str | None = None # asked of the judge
32
+ rule: Callable[[Trace], Verdict] | None = None # or decided by code
33
+ needs: str | None = None # "golden": only runs when --golden is given
34
+
35
+ @property
36
+ def kind(self) -> str:
37
+ return "judge" if self.question else ("golden" if self.needs == "golden" else "rule")
38
+
39
+
40
+ def _empty_result(result: str) -> bool:
41
+ """A tool result that carried nothing back: empty, [], {}, or a JSON body whose lists are all empty / counts are 0."""
42
+ s = result.strip()
43
+ if s in ("", "[]", "{}", "null", "None"):
44
+ return True
45
+ try:
46
+ d = json.loads(s)
47
+ except ValueError:
48
+ return False
49
+ if isinstance(d, list):
50
+ return len(d) == 0
51
+ if isinstance(d, dict):
52
+ for k in ("n_candidates", "count", "n_results", "total"):
53
+ if k in d:
54
+ return d[k] == 0
55
+ lists = [v for v in d.values() if isinstance(v, list)]
56
+ return bool(lists) and all(len(v) == 0 for v in lists)
57
+ return False
58
+
59
+
60
+ def search_loop_rule(t: Trace, step_cap: int = 12, empty_share: float = 0.5) -> Verdict:
61
+ """Loop = the run ended with no answer, hit its step budget, and most tool calls came back empty.
62
+
63
+ Calibrated on FAULTLINE P4 human labels: κ 0.98 (n=200), 1.00 on the P5 test split.
64
+ ponytail: step_cap=12 is FAULTLINE's. A run that names its cap in faultgate.termination is caught at any cap; a silent cap below 12 is missed.
65
+ """
66
+ steps = t.steps
67
+ if t.answer.strip() or not steps:
68
+ return Verdict(False, 1.0, "answered" if t.answer.strip() else "no tool calls")
69
+ capped = any(w in t.termination.lower() for w in STEP_CAP_WORDS) or len(steps) >= step_cap
70
+ empty = sum(_empty_result(s.result) for s in steps) / len(steps)
71
+ fired = capped and empty >= empty_share
72
+ return Verdict(fired, 1.0, f"no answer, {len(steps)} steps{' (cap)' if capped else ''}, {empty:.0%} empty results")
73
+
74
+
75
+ ABSTAIN = re.compile(
76
+ r"\b(couldn.t|could not|cannot|can.t|unable to|not able to|"
77
+ r"no (?:matching |relevant )?(?:document|information|record|result)s?|not (?:found|available)|"
78
+ r"did not find|didn.t find|wasn.t able)\b",
79
+ re.I,
80
+ )
81
+
82
+
83
+ def abstention_rule(t: Trace) -> Verdict:
84
+ """The final answer says, in so many words, that it could not find the information.
85
+
86
+ Calibrated on FAULTLINE P4 labels: 1/1 caught, 0 false positives on 200 — but one
87
+ positive is one positive; a paraphrase this list does not know will be missed.
88
+ """
89
+ ans = t.answer.strip()
90
+ if not ans:
91
+ return Verdict(False, 1.0, "no answer")
92
+ m = ABSTAIN.search(ans)
93
+ return Verdict(bool(m), 1.0, f"answer says {m.group(0)!r}" if m else "answer does not abstain")
94
+
95
+
96
+ @dataclass(frozen=True)
97
+ class Policy:
98
+ """What the agent was allowed to do. Ported from FAULTLINE P16's RuntimePolicy, applied after the fact.
99
+
100
+ ``allow``/``max_calls``/``max_arg_chars`` are off until you set them (a policy.json);
101
+ ``deny_patterns`` catches path traversal and local-file reads out of the box.
102
+ """
103
+ allow: frozenset[str] | None = None
104
+ max_calls: int | None = None
105
+ max_arg_chars: int | None = None
106
+ deny_patterns: tuple[str, ...] = (r"\.\./", r"file://", r"/etc/(passwd|shadow)", r"~/\.ssh", r"/proc/")
107
+
108
+ @classmethod
109
+ def load(cls, path: str | Path) -> "Policy":
110
+ d = json.loads(Path(path).read_text(encoding="utf-8"))
111
+ unknown = set(d) - {"allow", "max_calls", "max_arg_chars", "deny_patterns"}
112
+ if unknown:
113
+ raise ValueError(f"policy: unknown keys {sorted(unknown)}")
114
+ return cls(
115
+ allow=frozenset(d["allow"]) if "allow" in d else None,
116
+ max_calls=d.get("max_calls"),
117
+ max_arg_chars=d.get("max_arg_chars"),
118
+ deny_patterns=tuple(d.get("deny_patterns", cls.deny_patterns)),
119
+ )
120
+
121
+
122
+ def policy_rule(t: Trace, policy: Policy) -> Verdict:
123
+ """Every violation of the policy across the run's tool calls, by step."""
124
+ steps = t.steps
125
+ bad: list[str] = []
126
+ deny = [re.compile(p, re.I) for p in policy.deny_patterns]
127
+ for st in steps:
128
+ if policy.allow is not None and st.tool not in policy.allow:
129
+ bad.append(f"step {st.index}: tool {st.tool!r} not in allowlist")
130
+ for rx in deny:
131
+ if rx.search(st.arguments):
132
+ bad.append(f"step {st.index}: argument matches denied pattern {rx.pattern!r}")
133
+ break
134
+ if policy.max_arg_chars is not None and len(st.arguments) > policy.max_arg_chars:
135
+ bad.append(f"step {st.index}: arguments {len(st.arguments)} chars > {policy.max_arg_chars}")
136
+ if policy.max_calls is not None and len(steps) > policy.max_calls:
137
+ bad.append(f"{len(steps)} tool calls > max {policy.max_calls}")
138
+ return Verdict(bool(bad), 1.0, "; ".join(bad) if bad else f"{len(steps)} calls within policy")
139
+
140
+
141
+ class Golden(dict):
142
+ """Expected final answers, keyed by scenario key (faultgate.scenario / gen_ai.agent.id / prompt hash) or by the prompt text itself."""
143
+
144
+ @classmethod
145
+ def load(cls, path: str | Path) -> "Golden":
146
+ d = json.loads(Path(path).read_text(encoding="utf-8"))
147
+ if not isinstance(d, dict) or not all(isinstance(v, str) for v in d.values()):
148
+ raise ValueError("golden: expected a JSON object of {scenario key or prompt: expected answer}")
149
+ return cls(d)
150
+
151
+ def expected(self, t: Trace) -> str | None:
152
+ return self.get(t.key) or self.get(t.prompt)
153
+
154
+
155
+ def wrong_answer_rule(t: Trace, golden: Golden) -> Verdict:
156
+ """Answered, and the expected answer does not appear in the final answer (case-insensitive)."""
157
+ exp = golden.expected(t)
158
+ if exp is None:
159
+ return Verdict(False, 0.0, "no golden answer for this scenario")
160
+ ans = t.answer.strip()
161
+ if not ans:
162
+ return Verdict(False, 1.0, "no answer to grade")
163
+ ok = exp.lower() in ans.lower()
164
+ return Verdict(not ok, 1.0, f"expected {exp!r} {'found' if ok else 'not found'} in answer")
165
+
166
+
167
+ def truncation_rule(t: Trace, golden: Golden) -> Verdict:
168
+ """Answer carries the head of the expected token but not the whole token — e.g. 'Onyx' for 'Onyx-4413'."""
169
+ exp = golden.expected(t)
170
+ ans = t.answer.strip()
171
+ if exp is None or not ans:
172
+ return Verdict(False, 0.0 if exp is None else 1.0, "no golden answer for this scenario" if exp is None else "no answer to grade")
173
+ if exp.lower() in ans.lower():
174
+ return Verdict(False, 1.0, "expected answer present in full")
175
+ head = re.split(r"[-_/ ]", exp, maxsplit=1)[0]
176
+ if len(head) >= 3 and head != exp and re.search(r"\b" + re.escape(head) + r"\b", ans, re.I):
177
+ return Verdict(True, 1.0, f"answer has {head!r} but not {exp!r}")
178
+ return Verdict(False, 1.0, "no truncated form of the expected answer")
179
+
180
+
181
+ CHECKS: dict[str, Check] = {
182
+ c.name: c
183
+ for c in (
184
+ Check(
185
+ "search_loop",
186
+ "Run ended with no answer at the step budget and most tool calls returned nothing.",
187
+ rule=search_loop_rule,
188
+ ),
189
+ Check(
190
+ "abstention",
191
+ "Final answer explicitly says the information could not be found.",
192
+ rule=abstention_rule,
193
+ ),
194
+ Check(
195
+ "policy",
196
+ "A tool call broke the policy: unlisted tool, denied argument pattern, oversized argument, or call budget.",
197
+ rule=partial(policy_rule, policy=Policy()),
198
+ ),
199
+ Check(
200
+ "wrong_answer",
201
+ "Answered, but the expected answer is not in the final answer (needs --golden).",
202
+ rule=None, needs="golden",
203
+ ),
204
+ Check(
205
+ "truncation",
206
+ "Answer has the head of the expected token but not the whole token (needs --golden).",
207
+ rule=None, needs="golden",
208
+ ),
209
+ Check(
210
+ "wrong_direction",
211
+ "Agent searched the dependency chain backwards on a multi-hop task.",
212
+ question="Determine whether the agent made a MULTI_HOP_DIRECTION_ERROR. "
213
+ "Rule: Return detected=true IF the agent searched in the reverse dependency direction across multi-hop entity links.",
214
+ ),
215
+ )
216
+ }
217
+
218
+
219
+ GOLDEN_RULES = {"wrong_answer": wrong_answer_rule, "truncation": truncation_rule}
220
+
221
+
222
+ def select(names: str | None, policy: Policy | None = None, golden: Golden | None = None) -> list[Check]:
223
+ """Resolve check names; bind a policy / golden file to the checks that take one.
224
+
225
+ A golden check with no golden file keeps ``rule=None`` and is skipped by the runner.
226
+ """
227
+ wanted = [n.strip() for n in names.split(",")] if names else list(CHECKS)
228
+ out = []
229
+ for n in wanted:
230
+ if n not in CHECKS:
231
+ raise ValueError(f"unknown check {n!r}; available: {', '.join(CHECKS)}")
232
+ c = CHECKS[n]
233
+ if n == "policy" and policy is not None:
234
+ c = Check(c.name, c.description, rule=partial(policy_rule, policy=policy))
235
+ if n in GOLDEN_RULES and golden is not None:
236
+ c = Check(c.name, c.description, rule=partial(GOLDEN_RULES[n], golden=golden), needs="golden")
237
+ out.append(c)
238
+ return out
faultgate/cli.py ADDED
@@ -0,0 +1,173 @@
1
+ """faultgate CLI. Exit 0 = clean, 1 = a check fired, 2 = usage or input error."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import json
6
+ import sys
7
+ import time
8
+ from pathlib import Path
9
+
10
+ from faultgate import __version__
11
+ from faultgate import band as bandmod
12
+ from faultgate.checks import CHECKS, Golden, Policy, select
13
+ from faultgate.judge import JUDGE_SPECS, get_judge
14
+ from faultgate.runner import Report
15
+ from faultgate.runner import run as run_checks
16
+ from faultgate.trace import load_otlp
17
+
18
+
19
+ def _table(report: Report) -> str:
20
+ width = max(len("trace"), *(len(t[:12]) for t in report.trace_ids))
21
+ head = f"{'trace':<{width}} " + " ".join(f"{c:<16}" for c in report.checks)
22
+ rows = [head, "-" * len(head)]
23
+ for tid in report.trace_ids:
24
+ cells = []
25
+ for v in report.for_trace(tid):
26
+ cells.append(f"{('FAIL' if v.detected else 'ok'):<5}{v.confidence:.2f}".ljust(16))
27
+ rows.append(f"{tid[:12]:<{width}} " + " ".join(cells))
28
+ return "\n".join(rows)
29
+
30
+
31
+ def _load(path: str):
32
+ p = Path(path)
33
+ if not p.is_file():
34
+ raise ValueError(f"no such file: {p}")
35
+ traces = load_otlp(p)
36
+ if not traces:
37
+ raise ValueError(f"no traces found in {p}")
38
+ no_content = [t for t in traces if not t.has_content]
39
+ if no_content:
40
+ print(f"warning: {len(no_content)} trace(s) in {p} carry no content attributes; the judge sees structure only", file=sys.stderr)
41
+ return traces
42
+
43
+
44
+ def _run(path: str, checks, judge) -> tuple[Report, float]:
45
+ t0 = time.perf_counter()
46
+ report = run_checks(_load(path), checks, judge)
47
+ return report, time.perf_counter() - t0
48
+
49
+
50
+ def _judge_or_none(args, checks):
51
+ judge = get_judge(args.judge) if args.judge else None
52
+ no_judge = [c.name for c in checks if c.kind == "judge"] if judge is None else []
53
+ no_golden = [c.name for c in checks if c.kind == "golden" and c.rule is None]
54
+ if len(no_judge) + len(no_golden) == len(checks):
55
+ raise ValueError("nothing to run: " + "; ".join(
56
+ filter(None, [no_judge and f"{', '.join(no_judge)} need --judge laya | litellm:<model>",
57
+ no_golden and f"{', '.join(no_golden)} need --golden golden.json"])))
58
+ for name in no_judge:
59
+ print(f"note: {name} skipped — it needs a judge (--judge laya | litellm:<model>); see README for each judge's measured κ", file=sys.stderr)
60
+ for name in no_golden:
61
+ print(f"note: {name} skipped — it needs --golden golden.json (expected answers)", file=sys.stderr)
62
+ return judge
63
+
64
+
65
+ def _golden(args) -> Golden | None:
66
+ return Golden.load(args.golden) if args.golden else None
67
+
68
+
69
+ def _policy(args) -> Policy | None:
70
+ return Policy.load(args.policy) if args.policy else None
71
+
72
+
73
+ def cmd_check(args: argparse.Namespace) -> int:
74
+ try:
75
+ checks = select(args.check, _policy(args), _golden(args))
76
+ judge = _judge_or_none(args, checks)
77
+ band = json.loads(Path(args.band).read_text(encoding="utf-8")) if args.band else None
78
+ report, elapsed = _run(args.traces, checks, judge)
79
+ except (ValueError, KeyError, OSError, ImportError) as e:
80
+ print(f"faultgate: {e}", file=sys.stderr)
81
+ return 2
82
+
83
+ print(_table(report))
84
+ fired = ", ".join(f"{k}={v}" for k, v in report.fired.items())
85
+ print(f"\n{len(report.failed_traces)}/{len(report.trace_ids)} traces failed · {fired} · judge {report.judge} · {elapsed:.1f}s")
86
+ if judge and getattr(judge, "truncated", None):
87
+ print(f"warning: {judge.truncated}/{judge.calls} judge calls exceeded Laya's 512-token context; those verdicts saw a truncated trace", file=sys.stderr)
88
+
89
+ out = report.to_dict()
90
+ code = 0 if report.passed else 1
91
+ if band:
92
+ g = bandmod.evaluate(report, band)
93
+ out["gate"] = g
94
+ for w in g["warnings"]:
95
+ print(f"warning: {w}", file=sys.stderr)
96
+ lo, hi = g["band"]
97
+ print(f"gate: {g['verdict']} · clean rate {g['clean_rate']:.2f} vs band [{lo:.2f}, {hi:.2f}] from {band['runs']} baseline runs"
98
+ f" · fail below {g['fail_threshold']:.2f}")
99
+ if g["regressions"]:
100
+ print(f"regressions (clean in every baseline, not now): {', '.join(g['regressions'])}")
101
+ code = 1 if g["verdict"] == "FAIL" else 0
102
+ if args.json:
103
+ Path(args.json).write_text(json.dumps(out, indent=1), encoding="utf-8")
104
+ print(f"report written to {args.json}")
105
+ return code
106
+
107
+
108
+ def cmd_baseline(args: argparse.Namespace) -> int:
109
+ try:
110
+ checks = select(args.check, _policy(args), _golden(args))
111
+ judge = _judge_or_none(args, checks)
112
+ reports = []
113
+ for path in args.traces:
114
+ report, elapsed = _run(path, checks, judge)
115
+ rate = sum(report.clean_by_scenario.values()) / len(report.trace_ids)
116
+ print(f"{path}: {len(report.trace_ids)} traces · clean rate {rate:.2f} · {elapsed:.1f}s")
117
+ reports.append(report)
118
+ band = bandmod.build(reports)
119
+ except (ValueError, KeyError, OSError) as e:
120
+ print(f"faultgate: {e}", file=sys.stderr)
121
+ return 2
122
+ Path(args.out).write_text(json.dumps(band, indent=1), encoding="utf-8")
123
+ c = band["clean_rate"]
124
+ print(f"band [{c['min']:.2f}, {c['max']:.2f}] over {band['n']} scenarios × {band['runs']} runs · "
125
+ f"{len(band['clean_in_all_runs'])} clean in every run · written to {args.out}")
126
+ return 0
127
+
128
+
129
+ def cmd_checks(_: argparse.Namespace) -> int:
130
+ for c in CHECKS.values():
131
+ print(f"{c.name:<16} {c.kind:<6} {c.description}")
132
+ return 0
133
+
134
+
135
+ def cmd_judges(_: argparse.Namespace) -> int:
136
+ for k, v in JUDGE_SPECS.items():
137
+ print(f"{k:<16} {v}")
138
+ return 0
139
+
140
+
141
+ def build_parser() -> argparse.ArgumentParser:
142
+ p = argparse.ArgumentParser(prog="faultgate", description="Local-first release gate for AI agents.")
143
+ p.add_argument("--version", action="version", version=f"faultgate {__version__}")
144
+ sub = p.add_subparsers(dest="cmd", required=True)
145
+ c = sub.add_parser("check", help="run checks over an OTLP/JSON trace export")
146
+ c.add_argument("traces", help="OTLP JSON file (GenAI semantic conventions)")
147
+ c.add_argument("--judge", default=None, help="enable judge checks: laya or litellm:<model> (default: rules only)")
148
+ c.add_argument("--check", default=None, help="comma-separated subset of checks (default: all)")
149
+ c.add_argument("--json", default=None, help="also write the full report to this path")
150
+ c.add_argument("--band", default=None, help="band.json from `faultgate baseline`; exit 1 only on FAIL, not on noise")
151
+ c.add_argument("--policy", default=None, help='policy.json: {"allow": [...], "max_calls": N, "max_arg_chars": N, "deny_patterns": [...]}')
152
+ c.add_argument("--golden", default=None, help="golden.json: {scenario key or prompt: expected answer}; enables wrong_answer and truncation")
153
+ c.set_defaults(fn=cmd_check)
154
+ b = sub.add_parser("baseline", help="build a tolerance band from >= 2 baseline runs of the same scenarios")
155
+ b.add_argument("traces", nargs="+", help="OTLP JSON files, one per baseline run")
156
+ b.add_argument("--judge", default=None)
157
+ b.add_argument("--check", default=None)
158
+ b.add_argument("--policy", default=None)
159
+ b.add_argument("--golden", default=None)
160
+ b.add_argument("--out", default="band.json")
161
+ b.set_defaults(fn=cmd_baseline)
162
+ sub.add_parser("checks", help="list built-in checks").set_defaults(fn=cmd_checks)
163
+ sub.add_parser("judges", help="list judge specs").set_defaults(fn=cmd_judges)
164
+ return p
165
+
166
+
167
+ def main(argv: list[str] | None = None) -> int:
168
+ args = build_parser().parse_args(argv)
169
+ return args.fn(args)
170
+
171
+
172
+ if __name__ == "__main__":
173
+ sys.exit(main())
faultgate/judge.py ADDED
@@ -0,0 +1,123 @@
1
+ """Judges: anything that answers a yes/no question about a trace.
2
+
3
+ The contract is the product. A judge is any object with a ``name`` and
4
+ ``ask(state, question) -> Verdict``. Swap the model, keep the gate.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ import threading
10
+ from dataclasses import dataclass
11
+ from typing import Protocol
12
+
13
+ LAYA_CHECKPOINT = "convaiinnovations/laya"
14
+ LAYA_REVISION = "1c5edc17a7acd8701df6fc341c0d179f1c62c982" # pinned: the revision FAULTLINE P5 calibrated
15
+ LAYA_CONTEXT = 512
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class Verdict:
20
+ detected: bool
21
+ confidence: float
22
+ reason: str
23
+
24
+
25
+ class Judge(Protocol):
26
+ name: str
27
+ budget: int | None # max characters of state this judge can read; None = unlimited
28
+
29
+ def ask(self, state: str, question: str) -> Verdict: ...
30
+
31
+
32
+ class LayaJudge:
33
+ """Local, free, ~35 ms per question. Ported from FAULTLINE faultline_p2/judge/laya_judge.py."""
34
+
35
+ name = "laya"
36
+ budget = 1400 # 512 tokens; JSON-heavy traces tokenise at ~2.7 chars/token (measured: 1800 still truncated 1 in 5)
37
+
38
+ def __init__(self) -> None:
39
+ self._agent = None
40
+ self._lock = threading.Lock()
41
+ self.calls = 0
42
+ self.truncated = 0
43
+
44
+ def _load(self):
45
+ if self._agent is None:
46
+ with self._lock:
47
+ if self._agent is None:
48
+ try:
49
+ import laya # heavy import, deferred
50
+ except ImportError as e:
51
+ raise ValueError("the laya judge needs `pip install faultgate[laya]`") from e
52
+ from huggingface_hub import snapshot_download
53
+
54
+ # laya.load has no revision arg; resolve the pinned snapshot ourselves
55
+ local = snapshot_download(LAYA_CHECKPOINT, revision=LAYA_REVISION, allow_patterns=["rl_agent_config.json", "model.safetensors", "tokenizer/*", "encoder/*"])
56
+ self._agent = laya.load(local)
57
+ return self._agent
58
+
59
+ def ask(self, state: str, question: str) -> Verdict:
60
+ agent = self._load()
61
+ n_tokens = len(agent.tok(state, add_special_tokens=False)["input_ids"])
62
+ self.calls += 1
63
+ if n_tokens > LAYA_CONTEXT:
64
+ self.truncated += 1
65
+ with self._lock:
66
+ res = agent.predict(state, {"q": {"type": "noul", "instructions": question}})
67
+ ans = res["answers"]["q"]
68
+ p = float(ans["noul"])
69
+ return Verdict(detected=p >= 0.5, confidence=float(ans.get("confidence", max(p, 1 - p))), reason=f"laya p={p:.2f}")
70
+
71
+
72
+ class LiteLLMJudge:
73
+ """Any API model via litellm (``pip install faultgate[api]``). Temperature 0, JSON answer."""
74
+
75
+ SYSTEM = (
76
+ "You are an expert AI reliability judge evaluating an agent trace.\n{question}\n"
77
+ 'Respond ONLY with valid JSON: {{"detected": bool, "confidence": float, "reason": str}}'
78
+ )
79
+
80
+ budget = None
81
+
82
+ def __init__(self, model: str) -> None:
83
+ self.model = model
84
+ self.name = f"litellm:{model}"
85
+
86
+ def ask(self, state: str, question: str) -> Verdict:
87
+ import litellm # optional extra
88
+
89
+ resp = litellm.completion(
90
+ model=self.model,
91
+ messages=[{"role": "system", "content": self.SYSTEM.format(question=question)}, {"role": "user", "content": state}],
92
+ temperature=0.0,
93
+ max_tokens=512,
94
+ )
95
+ return parse_json_verdict(resp.choices[0].message.content or "")
96
+
97
+
98
+ def parse_json_verdict(text: str) -> Verdict:
99
+ """Tolerant parse: strips code fences; on failure, detected=False with the raw text as reason."""
100
+ t = text.strip()
101
+ if "```" in t:
102
+ t = t.split("```", 2)[1]
103
+ t = t[4:] if t.startswith("json") else t
104
+ try:
105
+ d = json.loads(t.strip())
106
+ return Verdict(bool(d.get("detected", False)), float(d.get("confidence", 0.0)), str(d.get("reason", ""))[:200])
107
+ except (ValueError, AttributeError):
108
+ return Verdict(False, 0.0, f"unparseable judge output: {text[:100]}")
109
+
110
+
111
+ def get_judge(spec: str) -> Judge:
112
+ """``laya`` or ``litellm:<model>``."""
113
+ if spec == "laya":
114
+ return LayaJudge()
115
+ if spec.startswith("litellm:") and len(spec) > 8:
116
+ return LiteLLMJudge(spec[8:])
117
+ raise ValueError(f"unknown judge {spec!r}; use 'laya' or 'litellm:<model>'")
118
+
119
+
120
+ JUDGE_SPECS = {
121
+ "laya": "local decision model, free, offline after the first ~800 MB download (pip install faultgate[laya])",
122
+ "litellm:<model>": "any API model via litellm, e.g. litellm:openai/gpt-5.6-luna (needs faultgate[api] and the provider's API key)",
123
+ }
faultgate/runner.py ADDED
@@ -0,0 +1,88 @@
1
+ """Run checks over traces with a judge. Pure: no I/O."""
2
+ from __future__ import annotations
3
+
4
+ from dataclasses import dataclass, field
5
+
6
+ from faultgate.checks import Check
7
+ from faultgate.judge import Judge, Verdict
8
+ from faultgate.trace import Trace
9
+
10
+
11
+ @dataclass(frozen=True)
12
+ class Result:
13
+ trace_id: str
14
+ scenario: str
15
+ check: str
16
+ verdict: Verdict
17
+
18
+
19
+ @dataclass
20
+ class Report:
21
+ judge: str
22
+ checks: list[str]
23
+ results: list[Result] = field(default_factory=list)
24
+
25
+ @property
26
+ def trace_ids(self) -> list[str]:
27
+ seen: dict[str, None] = {}
28
+ for r in self.results:
29
+ seen.setdefault(r.trace_id)
30
+ return list(seen)
31
+
32
+ @property
33
+ def scenario_of(self) -> dict[str, str]:
34
+ return {r.trace_id: r.scenario for r in self.results}
35
+
36
+ @property
37
+ def clean_by_scenario(self) -> dict[str, bool]:
38
+ """scenario key -> True if no check fired on that trace."""
39
+ return {self.scenario_of[t]: t not in self.failed_traces for t in self.trace_ids}
40
+
41
+ @property
42
+ def failed_traces(self) -> list[str]:
43
+ return [t for t in self.trace_ids if any(r.detected for r in self.for_trace(t))]
44
+
45
+ def for_trace(self, trace_id: str) -> list[Verdict]:
46
+ return [r.verdict for r in self.results if r.trace_id == trace_id]
47
+
48
+ @property
49
+ def fired(self) -> dict[str, int]:
50
+ counts = {c: 0 for c in self.checks}
51
+ for r in self.results:
52
+ if r.verdict.detected:
53
+ counts[r.check] += 1
54
+ return counts
55
+
56
+ @property
57
+ def passed(self) -> bool:
58
+ return not self.failed_traces
59
+
60
+ def to_dict(self) -> dict:
61
+ return {
62
+ "judge": self.judge,
63
+ "checks": self.checks,
64
+ "traces": len(self.trace_ids),
65
+ "failed_traces": self.failed_traces,
66
+ "fired": self.fired,
67
+ "results": [
68
+ {"trace_id": r.trace_id, "scenario": r.scenario, "check": r.check, "detected": r.verdict.detected,
69
+ "confidence": round(r.verdict.confidence, 4), "reason": r.verdict.reason}
70
+ for r in self.results
71
+ ],
72
+ }
73
+
74
+
75
+ def run(traces: list[Trace], checks: list[Check], judge: Judge | None = None) -> Report:
76
+ """Rules always run. Judge checks need a judge, golden checks a golden file; unbound ones are dropped from the report."""
77
+ active = [c for c in checks if c.rule or (c.question and judge is not None)]
78
+ report = Report(judge=judge.name if judge else "none", checks=[c.name for c in active])
79
+ for t in traces:
80
+ state = None
81
+ for c in active:
82
+ if c.rule:
83
+ v = c.rule(t)
84
+ else:
85
+ state = state if state is not None else t.render(getattr(judge, "budget", None))
86
+ v = judge.ask(state, c.question)
87
+ report.results.append(Result(t.trace_id, t.key, c.name, v))
88
+ return report
faultgate/trace.py ADDED
@@ -0,0 +1,193 @@
1
+ """OTLP JSON (GenAI semantic conventions) → Trace.
2
+
3
+ One trace = one agent run: a root ``invoke_agent`` span plus child ``chat`` /
4
+ ``execute_tool`` spans. Content is read from the semconv content attributes
5
+ (``gen_ai.input.messages``, ``gen_ai.output.messages``,
6
+ ``gen_ai.tool.call.arguments``, ``gen_ai.tool.call.result``) with the legacy
7
+ ``gen_ai.prompt`` / ``gen_ai.completion`` as fallback. A trace with no content
8
+ is still loaded — the judge just sees structure only.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import hashlib
13
+ import json
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+ from pydantic import BaseModel, ConfigDict
18
+
19
+
20
+ class Span(BaseModel):
21
+ model_config = ConfigDict(extra="forbid")
22
+ span_id: str
23
+ parent_id: str | None
24
+ name: str
25
+ start_ns: int
26
+ attrs: dict[str, Any]
27
+
28
+
29
+ class Step(BaseModel):
30
+ model_config = ConfigDict(extra="forbid")
31
+ index: int
32
+ tool: str
33
+ arguments: str
34
+ result: str
35
+
36
+
37
+ class Trace(BaseModel):
38
+ model_config = ConfigDict(extra="forbid")
39
+ trace_id: str
40
+ agent: str
41
+ spans: list[Span]
42
+
43
+ @property
44
+ def root(self) -> Span | None:
45
+ for s in self.spans:
46
+ if s.attrs.get("gen_ai.operation.name") == "invoke_agent" or s.parent_id is None:
47
+ return s
48
+ return None
49
+
50
+ @property
51
+ def prompt(self) -> str:
52
+ r = self.root
53
+ if not r:
54
+ return ""
55
+ return _text(r.attrs.get("gen_ai.input.messages"), role="user") or str(r.attrs.get("gen_ai.prompt", ""))
56
+
57
+ @property
58
+ def answer(self) -> str:
59
+ r = self.root
60
+ if not r:
61
+ return ""
62
+ return _text(r.attrs.get("gen_ai.output.messages"), role="assistant") or str(r.attrs.get("gen_ai.completion", ""))
63
+
64
+ @property
65
+ def key(self) -> str:
66
+ """Stable scenario key across runs: faultgate.scenario, else gen_ai.agent.id, else a prompt hash."""
67
+ r = self.root
68
+ if r:
69
+ for k in ("faultgate.scenario", "gen_ai.agent.id"):
70
+ if r.attrs.get(k):
71
+ return str(r.attrs[k])
72
+ return "p-" + hashlib.sha256(self.prompt.encode("utf-8")).hexdigest()[:12]
73
+
74
+ @property
75
+ def termination(self) -> str:
76
+ r = self.root
77
+ return str(r.attrs.get("faultgate.termination", "")) if r else ""
78
+
79
+ @property
80
+ def steps(self) -> list[Step]:
81
+ out = []
82
+ for s in self.spans:
83
+ if s.attrs.get("gen_ai.operation.name") != "execute_tool":
84
+ continue
85
+ out.append(Step(
86
+ index=len(out) + 1,
87
+ tool=str(s.attrs.get("gen_ai.tool.name", s.name)),
88
+ arguments=str(s.attrs.get("gen_ai.tool.call.arguments", "")),
89
+ result=str(s.attrs.get("gen_ai.tool.call.result", "")),
90
+ ))
91
+ return out
92
+
93
+ @property
94
+ def has_content(self) -> bool:
95
+ return bool(self.prompt or self.answer or any(st.arguments or st.result for st in self.steps))
96
+
97
+ def render(self, budget: int | None = None) -> str:
98
+ """The state text a judge reads.
99
+
100
+ Diagnostic facts first (answer, termination, step count), then the steps.
101
+ With a character ``budget`` (small local judges have a 512-token window),
102
+ steps are compacted and the middle is elided — the first two and the last
103
+ ones survive, because loops are visible at the tail and setup at the head.
104
+ """
105
+ steps = self.steps
106
+ head = [
107
+ f"Agent: {self.agent}",
108
+ f"Prompt: {_trim(self.prompt, 600 if budget else None)}",
109
+ f"Steps used: {len(steps)}",
110
+ f"Termination: {self.termination or 'unknown'}",
111
+ f"Final answer: {_trim(self.answer, 400 if budget else None) or '(none)'}",
112
+ ]
113
+ lines = [f"Step {st.index}: {st.tool} {_trim(st.arguments, 160 if budget else None)} -> {_trim(st.result, 160 if budget else None)}" for st in steps]
114
+ if budget is None:
115
+ return "\n".join(head + lines)
116
+ room = budget - sum(len(h) + 1 for h in head)
117
+ keep_head = lines[:2]
118
+ room -= sum(len(x) + 1 for x in keep_head)
119
+ tail: list[str] = []
120
+ for line in reversed(lines[2:]):
121
+ if room - len(line) - 1 < 24: # leave space for the elision marker
122
+ break
123
+ tail.insert(0, line)
124
+ room -= len(line) + 1
125
+ omitted = len(lines) - len(keep_head) - len(tail)
126
+ middle = [f"... {omitted} steps omitted ..."] if omitted > 0 else []
127
+ return "\n".join(head + keep_head + middle + tail)
128
+
129
+
130
+ def _trim(s: str, n: int | None) -> str:
131
+ return s if n is None or len(s) <= n else s[: n - 1] + "…"
132
+
133
+
134
+ def _text(messages: Any, role: str) -> str:
135
+ """Pull the text parts for ``role`` out of a gen_ai.*.messages value."""
136
+ if isinstance(messages, str):
137
+ try:
138
+ messages = json.loads(messages)
139
+ except ValueError:
140
+ return messages
141
+ if not isinstance(messages, list):
142
+ return ""
143
+ parts = []
144
+ for m in messages:
145
+ if not isinstance(m, dict) or m.get("role") != role:
146
+ continue
147
+ for p in m.get("parts", []):
148
+ if isinstance(p, dict) and p.get("type") == "text":
149
+ parts.append(str(p.get("content", "")))
150
+ if "content" in m and not m.get("parts"):
151
+ parts.append(str(m["content"]))
152
+ return "\n".join(parts)
153
+
154
+
155
+ def _value(v: dict[str, Any]) -> Any:
156
+ if "stringValue" in v:
157
+ return v["stringValue"]
158
+ if "intValue" in v:
159
+ return int(v["intValue"])
160
+ if "doubleValue" in v:
161
+ return float(v["doubleValue"])
162
+ if "boolValue" in v:
163
+ return bool(v["boolValue"])
164
+ if "arrayValue" in v:
165
+ return [_value(x) for x in v["arrayValue"].get("values", [])]
166
+ return v
167
+
168
+
169
+ def load_otlp(path: str | Path) -> list[Trace]:
170
+ """Parse an OTLP/JSON export into traces, ordered by first span start."""
171
+ doc = json.loads(Path(path).read_text(encoding="utf-8"))
172
+ by_trace: dict[str, list[Span]] = {}
173
+ agents: dict[str, str] = {}
174
+ for rs in doc.get("resourceSpans", []):
175
+ for ss in rs.get("scopeSpans", []):
176
+ for s in ss.get("spans", []):
177
+ attrs = {a["key"]: _value(a["value"]) for a in s.get("attributes", [])}
178
+ span = Span(
179
+ span_id=s["spanId"],
180
+ parent_id=s.get("parentSpanId"),
181
+ name=s.get("name", ""),
182
+ start_ns=int(s.get("startTimeUnixNano", 0)),
183
+ attrs=attrs,
184
+ )
185
+ by_trace.setdefault(s["traceId"], []).append(span)
186
+ if "gen_ai.agent.name" in attrs:
187
+ agents[s["traceId"]] = str(attrs["gen_ai.agent.name"])
188
+ traces = [
189
+ Trace(trace_id=tid, agent=agents.get(tid, "agent"), spans=sorted(spans, key=lambda x: x.start_ns))
190
+ for tid, spans in by_trace.items()
191
+ ]
192
+ traces.sort(key=lambda t: t.spans[0].start_ns if t.spans else 0)
193
+ return traces
@@ -0,0 +1,225 @@
1
+ Metadata-Version: 2.4
2
+ Name: faultgate
3
+ Version: 0.1.0
4
+ Summary: Local-first release gate for AI agents: check recorded traces for known failure modes through a pluggable judge.
5
+ Author: Samir Sawarkar
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/samirsawarkar/faultgate
8
+ Project-URL: Evidence, https://github.com/samirsawarkar/faultline-ai-reliability
9
+ Keywords: ai,agents,evaluation,reliability,opentelemetry,llm
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Programming Language :: Python :: 3.11
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Classifier: Topic :: Software Development :: Testing
15
+ Requires-Python: >=3.11
16
+ Description-Content-Type: text/markdown
17
+ License-File: LICENSE
18
+ Requires-Dist: pydantic>=2
19
+ Provides-Extra: laya
20
+ Requires-Dist: laya>=0.3.5; extra == "laya"
21
+ Provides-Extra: api
22
+ Requires-Dist: litellm>=1.70; extra == "api"
23
+ Provides-Extra: dev
24
+ Requires-Dist: pytest>=8; extra == "dev"
25
+ Dynamic: license-file
26
+
27
+ # faultgate
28
+
29
+ **A local-first release gate for AI agents.** Hand it the traces your agent recorded, it checks every run for known failure modes, measures your run-to-run noise, and exits non-zero only when a candidate is a real regression. Free, offline, no model required for the default checks.
30
+
31
+ ```
32
+ $ faultgate check examples/traces.json
33
+ trace search_loop abstention
34
+ ------------------------------------------------
35
+ fc2cdab304bd FAIL 1.00 ok 1.00
36
+ 6d1f9d67a2d4 ok 1.00 FAIL 1.00
37
+ 8bf3879339eb ok 1.00 ok 1.00
38
+ 3a9e507a0f6d ok 1.00 ok 1.00
39
+ 2ab3b6c1e891 ok 1.00 ok 1.00
40
+
41
+ 2/5 traces failed · search_loop=1, abstention=1 · judge none · 0.0s
42
+ $ echo $?
43
+ 1
44
+ ```
45
+
46
+ Real output, unedited, on five real agent runs (`openai/gpt-5.6-luna` on a multi-hop retrieval task, recorded by [FAULTLINE](https://github.com/samirsawarkar/faultline-ai-reliability)). Trace 1 looped for 12 steps and never answered; trace 2 said it couldn't find the answer; 3–5 answered.
47
+
48
+ ## Install
49
+
50
+ ```bash
51
+ pip install faultgate
52
+ ```
53
+
54
+ Requires Python 3.11 or newer. One dependency (pydantic). The built-in checks are code — nothing to download. Judges are extras:
55
+
56
+ ```bash
57
+ pip install 'faultgate[laya]' # local judge: downloads Laya once (~800 MB, Apache-2.0, pinned revision), offline after
58
+ pip install 'faultgate[api]' # any API model through litellm
59
+ ```
60
+
61
+ Read [How much to trust a verdict](#how-much-to-trust-a-verdict) before you enable a judge.
62
+
63
+ ## Usage
64
+
65
+ ```bash
66
+ faultgate check traces.json # built-in rule checks
67
+ faultgate check traces.json --json report.json # full per-trace verdicts
68
+ faultgate check traces.json --golden golden.json # expected answers: correctness + truncation
69
+ faultgate check traces.json --policy policy.json # what the agent was allowed to do
70
+ faultgate check traces.json --judge laya # also run judge checks, locally
71
+ faultgate check traces.json --judge litellm:openai/gpt-5.6-luna # any API model
72
+ faultgate checks # list checks and their kind
73
+ faultgate judges # list judge specs
74
+
75
+ faultgate baseline run1.json run2.json --out band.json # measure your noise (>= 2 runs)
76
+ faultgate check candidate.json --band band.json # regression, or just noise?
77
+ ```
78
+
79
+ Exit codes: `0` clean · `1` at least one check fired (or, with `--band`, gate FAIL) · `2` bad input.
80
+
81
+ ### Input format
82
+
83
+ An [OTLP/JSON](https://opentelemetry.io/docs/specs/otlp/#json-protobuf-encoding) export using the [OpenTelemetry GenAI semantic conventions](https://opentelemetry.io/docs/specs/semconv/gen-ai/). One trace is one agent run:
84
+
85
+ | Span | Attributes read |
86
+ |---|---|
87
+ | root `invoke_agent` | `gen_ai.agent.name`, `gen_ai.input.messages` (the user prompt), `gen_ai.output.messages` (the final answer), optional `faultgate.termination` (why the run stopped, e.g. `step cap`) |
88
+ | child `execute_tool` | `gen_ai.tool.name`, `gen_ai.tool.call.arguments`, `gen_ai.tool.call.result` |
89
+
90
+ Legacy `gen_ai.prompt` / `gen_ai.completion` are accepted as fallback. If your exporter records structure but not content, `faultgate` warns you. [`examples/traces.json`](https://github.com/samirsawarkar/faultgate/blob/main/examples/traces.json) is a complete reference file.
91
+
92
+ ## Checks
93
+
94
+ | Check | Kind | Fires when |
95
+ |---|---|---|
96
+ | `search_loop` | rule | The run ended with no answer at its step budget and most tool calls returned nothing. |
97
+ | `abstention` | rule | The final answer explicitly says the information could not be found. |
98
+ | `policy` | rule | A tool call broke the policy: unlisted tool, denied argument pattern, oversized argument, or call budget. |
99
+ | `wrong_answer` | golden | Answered, and the expected answer is not in the final answer. |
100
+ | `truncation` | golden | The answer has the head of the expected token but not the whole token (`Onyx` for `Onyx-4413`). |
101
+ | `wrong_direction` | judge | On a multi-hop task, the agent walked the dependency chain backwards. |
102
+
103
+ A **rule** is plain code over the trace structure: instant, deterministic, no model. A **golden** check is a rule that also needs the expected answer (`--golden golden.json`, keyed by scenario or by prompt text). A **judge** check is a yes/no question a model answers from the rendered trace; it only runs when you pass `--judge`. All three kinds are calibrated the same way, below.
104
+
105
+ ### Policy: what was the agent allowed to do?
106
+
107
+ ```json
108
+ {"allow": ["search", "lookup", "calc"], "max_calls": 10, "max_arg_chars": 200}
109
+ ```
110
+
111
+ `faultgate check traces.json --policy policy.json` then flags every step that called a tool outside `allow`, passed an argument matching a denied pattern (path traversal, `file://`, `/etc/passwd`, `~/.ssh` out of the box), exceeded `max_arg_chars`, or pushed the run past `max_calls` — with the step number in the reason. It is FAULTLINE P16's runtime policy applied after the fact: the four structural injection categories from P9 (`tool_exfil_path`, `unlisted_tool`, `query_dump`, `budget_loop`) are each caught by a test in `tests/test_policy.py`. Without a policy file only the denied-pattern rule is active, and it fires on 0 of the 290 real traces shipped here.
112
+
113
+ Semantic injections — the agent *obeying* text it read in a document — are a judge question, not a rule, and are not in this release.
114
+
115
+ ## Regression or noise? Tolerance bands
116
+
117
+ Agents are not deterministic. FAULTLINE P11 ran the same model on the same 30 inputs five times and got pass rates from 0.60 to 0.87. A fixed threshold turns that into a coin-flip gate. `faultgate` measures your spread instead:
118
+
119
+ ```
120
+ $ faultgate baseline examples/gate/baseline_glm_run1.json examples/gate/baseline_glm_run2.json --out band.json
121
+ examples/gate/baseline_glm_run1.json: 30 traces · clean rate 0.93 · 0.0s
122
+ examples/gate/baseline_glm_run2.json: 30 traces · clean rate 0.93 · 0.0s
123
+ band [0.93, 0.93] over 30 scenarios × 2 runs · 26 clean in every run · written to band.json
124
+
125
+ $ faultgate check examples/gate/candidate_gpt.json --band band.json
126
+ 18/30 traces failed · search_loop=18, abstention=0 · judge none · 0.0s
127
+ gate: FAIL · clean rate 0.40 vs band [0.93, 0.93] from 2 baseline runs · fail below 0.83
128
+ regressions (clean in every baseline, not now): r-0208, r-0214, r-0216, r-0224, r-0226, r-0232, r-0258, ...
129
+
130
+ $ faultgate check examples/gate/candidate_qwen.json --band band.json
131
+ gate: FAIL · clean rate 0.70 vs band [0.93, 0.93] from 2 baseline runs · fail below 0.83
132
+
133
+ $ faultgate check examples/gate/baseline_glm_run2.json --band band.json
134
+ gate: PASS · clean rate 0.93 vs band [0.93, 0.93] from 2 baseline runs · fail below 0.83
135
+ ```
136
+
137
+ Real runs: two `z-ai/glm-5.3-flash` baselines and `openai/gpt-5.6-luna` / `qwen/qwen3.7-flash` candidates on the same 30 multi-hop scenarios. The verdicts match FAULTLINE P11's live drill (R4 FAIL / R1 FAIL / R2 PASS), which cost $1.84 and an API judge; this took 0.0 s and no model.
138
+
139
+ | Verdict | Rule (n = scenarios in the band) | Exit |
140
+ |---|---|---|
141
+ | **PASS** | clean rate ≥ min observed − 1/n | 0 |
142
+ | **WARN** | in between — a human should look | 0 |
143
+ | **FAIL** | clean rate < min observed − 3/n | 1 |
144
+
145
+ It also lists **regressions**: scenarios clean in *every* baseline run and not clean now — the ones to read first. Scenarios are matched across runs by `faultgate.scenario`, else `gen_ai.agent.id`, else a hash of the prompt.
146
+
147
+ With expected answers the gate grades correctness too, and the numbers land on P11's exactly:
148
+
149
+ ```
150
+ $ faultgate baseline examples/gate/baseline_glm_run*.json --golden examples/gate/golden.json --out band.json
151
+ examples/gate/baseline_glm_run1.json: 30 traces · clean rate 0.80 · 0.0s # P11: 24/30 grounded
152
+ examples/gate/baseline_glm_run2.json: 30 traces · clean rate 0.87 · 0.0s # P11: 26/30 grounded
153
+ band [0.80, 0.87] over 30 scenarios × 2 runs · 21 clean in every run
154
+
155
+ $ faultgate check examples/gate/candidate_gpt.json --golden examples/gate/golden.json --band band.json
156
+ 21/30 traces failed · search_loop=18, abstention=0, policy=0, wrong_answer=3, truncation=3
157
+ gate: FAIL · clean rate 0.30 vs band [0.80, 0.87] from 2 baseline runs · fail below 0.70
158
+
159
+ $ faultgate check examples/gate/candidate_qwen.json --golden examples/gate/golden.json --band band.json
160
+ 18/30 traces failed · search_loop=9, abstention=0, policy=0, wrong_answer=9, truncation=8
161
+ gate: FAIL · clean rate 0.40 vs band [0.80, 0.87] from 2 baseline runs · fail below 0.70
162
+ ```
163
+
164
+ (Qwen returns the head of the answer token without its suffix in 8 of 30 runs. Nobody had noticed.)
165
+
166
+ ## In CI
167
+
168
+ ```yaml
169
+ - uses: samirsawarkar/faultgate@main
170
+ with:
171
+ traces: traces/candidate.json # exported by your agent's OTel pipeline
172
+ band: traces/band.json # from `faultgate baseline`, committed
173
+ golden: traces/golden.json # optional
174
+ policy: traces/policy.json # optional
175
+ ```
176
+
177
+ Exit 1 on gate FAIL, so the job fails; WARN and PASS pass. The report lands in `faultgate-report.json` for upload as an artifact. The action installs nothing heavier than pydantic; our own CI runs it against this repo's examples and asserts that the GPT candidate fails and the GLM baseline passes.
178
+
179
+ ## How much to trust a verdict
180
+
181
+ This is the part every evaluation tool skips. Every check and judge here is scored against **human failure-mode labels** on 200 real agent runs (FAULTLINE P4), reported on all 200 and on P5's frozen test split (n = 60). Regenerate with `scripts/calibrate.py`.
182
+
183
+ | Check | Kind | Positives | TPR | TNR | κ (n=200) | κ (P5 test, n=60) |
184
+ |---|---|---|---|---|---|---|
185
+ | `search_loop` | rule | 36 | 1.00 | 0.99 | **0.98** | **1.00** |
186
+ | `abstention` | rule | 1 | 1.00 | 1.00 | 1.00 | — (0 positives) |
187
+ | `policy` | rule | synthetic | 4/4 P9 categories | 0 FP on 290 real traces | — | — |
188
+ | `wrong_answer` | golden | 4 | 1.00 | 1.00 | 1.00 | 1.00 |
189
+ | `truncation` | golden | 1 | 1.00 | 1.00 | 1.00 | — (0 positives) |
190
+ | `wrong_direction` | Laya judge | 1 | 1.00 | 0.61 | 0.02 | 0.00 |
191
+
192
+ For comparison, the same `search_loop` question put to judges instead of a rule: Laya κ 0.09, GLM-5.3 (API) κ −0.03. **Where a failure is structural, code beats every model we measured**, which is why the default checks are rules.
193
+
194
+ Read the table honestly:
195
+ - `search_loop` is solid on this task family. The human coder saw the same structural signals, so κ measures agreement with that coding, not ground truth from nowhere.
196
+ - `abstention` and `truncation` have one positive each to learn from. `abstention` is a phrase list; a paraphrase it doesn't know is a miss.
197
+ - `wrong_answer` agrees with the human verdict on all 21 answered runs. It is substring containment, case-insensitive: right for short factual answers, wrong for free-text ones — say what "expected" means for your task before you trust it.
198
+ - `policy` has no human-labelled positives: FAULTLINE's agent rejects hostile calls before they become steps, so its traces never contain an executed attack. Recall is by construction on the P9 attack shapes; the number that matters is zero false positives on real runs.
199
+ - `wrong_direction` is the only check that needs reading, and the only local judge we have is noise on it (fires on 39% of clean runs). It ships because the contract is the product: the day a local model scores κ ≥ 0.7 on it, that's a one-line adapter, and this harness is what proves it.
200
+ - Laya's context is 512 tokens. `faultgate` compacts the trace to fit (answer and termination first, middle steps elided) and counts every call that still overflows; the count is printed.
201
+
202
+ Full confusion matrices and Wilson CIs for the original P5 judges: [FAULTLINE `projects/p05_judge/DECISIONS.md`](https://github.com/samirsawarkar/faultline-ai-reliability/blob/main/projects/p05_judge/DECISIONS.md).
203
+
204
+ ## Swap the judge
205
+
206
+ ```python
207
+ class Judge(Protocol):
208
+ name: str
209
+ budget: int | None # max characters of trace it can read
210
+ def ask(self, state: str, question: str) -> Verdict: ... # Verdict(detected, confidence, reason)
211
+ ```
212
+
213
+ `laya` and `litellm:<model>` ship in the box. Add a judge, run `scripts/calibrate.py --judge yours`, and publish the row.
214
+
215
+ ## What it doesn't do yet
216
+
217
+ - **Premature stop** (answered with an intermediate entity of the chain). Needs the traversal chain, not just the final answer.
218
+ - **Semantic injection checks** (the agent obeyed text it read). P9 has the taxonomy; needs a judge that scores.
219
+ - **Running your agent.** `faultgate` reads traces; it never executes anything.
220
+
221
+ ## Built on FAULTLINE
222
+
223
+ [FAULTLINE](https://github.com/samirsawarkar/faultline-ai-reliability) is the research workbench behind this tool: 30 days of simulator, 16 measured projects, two papers. `faultgate` is the part you install. Every number in this README links back to the run that produced it.
224
+
225
+ MIT © 2026 Samir Sawarkar
@@ -0,0 +1,13 @@
1
+ faultgate/__init__.py,sha256=Xfa_A-gr5x7FdZG7-VLhs7-7kh8XxcXBeQ3cmHib_IU,82
2
+ faultgate/band.py,sha256=TpCb8cZWd17XDZvE4jN-yoKvrFLapQlU8vszGnNhLo0,3142
3
+ faultgate/checks.py,sha256=9A3d8JTxcnrTuIlWyu5hezjGK0jC4seR466tTnr9Wa0,10096
4
+ faultgate/cli.py,sha256=M-x3_AAmJADvU_gYPpYEoC-UYD_roq7wMqEfPAw51dg,7686
5
+ faultgate/judge.py,sha256=-0GPAx9j03TQNWqKyRs1MvShfdAAfuHlvPjeII168jw,4553
6
+ faultgate/runner.py,sha256=wQi44KsvCGs-Lz0YZDofi7UHcXQX90zgwxJYfYaPd_M,2897
7
+ faultgate/trace.py,sha256=WPi3mmkvL-xZAByIzhTmPAta0zVu8a8qGXoAOkmCvgc,6978
8
+ faultgate-0.1.0.dist-info/licenses/LICENSE,sha256=gEtBbguhy8bPdcRa7sAv73zL4qwZxPQlNc4VxYdMPdc,1071
9
+ faultgate-0.1.0.dist-info/METADATA,sha256=Av8vsgh5bem08tQ3JJW8Pgab9B3a-0LPqBr8taOrIN0,13852
10
+ faultgate-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
11
+ faultgate-0.1.0.dist-info/entry_points.txt,sha256=s4HBdMEe-ezrY1A6LIs-Efl_SqqTC5sRWn_1CYMdAUI,49
12
+ faultgate-0.1.0.dist-info/top_level.txt,sha256=wAe6TjlTbmdzR8gPivSS3kbl6gUSIUiJa7QkmW7vYwk,10
13
+ faultgate-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ faultgate = faultgate.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Samir Sawarkar
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ faultgate