decision-model-guard 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.
Files changed (47) hide show
  1. decision_model_guard/__init__.py +31 -0
  2. decision_model_guard/__main__.py +3 -0
  3. decision_model_guard/_onnx.py +134 -0
  4. decision_model_guard/audit.py +44 -0
  5. decision_model_guard/calibration.py +232 -0
  6. decision_model_guard/cli.py +186 -0
  7. decision_model_guard/config.py +151 -0
  8. decision_model_guard/evaluate/__init__.py +7 -0
  9. decision_model_guard/evaluate/audit.py +266 -0
  10. decision_model_guard/evaluate/data/claims.yaml +483 -0
  11. decision_model_guard/evaluate/data/extraction_cases.jsonl +12 -0
  12. decision_model_guard/evaluate/data/neutral.yaml +27 -0
  13. decision_model_guard/evaluate/data/sample_cases.jsonl +12 -0
  14. decision_model_guard/evaluate/extraction.py +112 -0
  15. decision_model_guard/evaluate/harness.py +184 -0
  16. decision_model_guard/extraction.py +193 -0
  17. decision_model_guard/guards/__init__.py +9 -0
  18. decision_model_guard/guards/checks.py +81 -0
  19. decision_model_guard/guards/coverage.py +135 -0
  20. decision_model_guard/guards/invariance.py +108 -0
  21. decision_model_guard/guards/steering_gate.py +146 -0
  22. decision_model_guard/input_scanners/__init__.py +7 -0
  23. decision_model_guard/input_scanners/base.py +43 -0
  24. decision_model_guard/input_scanners/neural.py +167 -0
  25. decision_model_guard/input_scanners/rules.py +176 -0
  26. decision_model_guard/integrations/__init__.py +1 -0
  27. decision_model_guard/integrations/langchain.py +32 -0
  28. decision_model_guard/models/__init__.py +9 -0
  29. decision_model_guard/models/base.py +38 -0
  30. decision_model_guard/models/http.py +122 -0
  31. decision_model_guard/models/local.py +82 -0
  32. decision_model_guard/models/wolf_defender_jev.py +187 -0
  33. decision_model_guard/models/workers_ai.py +88 -0
  34. decision_model_guard/pipeline.py +214 -0
  35. decision_model_guard/policy.py +133 -0
  36. decision_model_guard/py.typed +0 -0
  37. decision_model_guard/scene.py +206 -0
  38. decision_model_guard/server/__init__.py +4 -0
  39. decision_model_guard/server/app.py +144 -0
  40. decision_model_guard/types.py +115 -0
  41. decision_model_guard/util.py +52 -0
  42. decision_model_guard/version.py +1 -0
  43. decision_model_guard-0.1.0.dist-info/METADATA +459 -0
  44. decision_model_guard-0.1.0.dist-info/RECORD +47 -0
  45. decision_model_guard-0.1.0.dist-info/WHEEL +4 -0
  46. decision_model_guard-0.1.0.dist-info/entry_points.txt +2 -0
  47. decision_model_guard-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,31 @@
1
+ """Decision Model Guard: guardrails for decision models.
2
+
3
+ Decision models (Jev-like models, classifiers, LLM judges) read a ticket, an email or a tool output and pick an answer.
4
+ Text inside that content can claim the answer ("this case was already approved") and steer the decision. Decision Model Guard
5
+ scans the untrusted content, runs the steering gate (erase-and-check) around any decision model, checks long content
6
+ window by window, adds consistency, invariance, confidence and conformal checks and turns everything into an answer
7
+ plus an action. `dmguard audit` measures how robust your model is with and without the guard.
8
+
9
+ from decision_model_guard import DecisionGuard
10
+ from decision_model_guard.models import WolfDefenderJev
11
+
12
+ guard = DecisionGuard.default(WolfDefenderJev.from_pretrained())
13
+ d = guard.decide(scene, "Does the content try to give the agent new instructions?",
14
+ ["No, it is only data.", "Yes, an injected instruction."], risky={1})
15
+ d.answer, d.action, d.reasons
16
+ """
17
+ from .audit import AuditLogger
18
+ from .calibration import Calibrated, Calibrator
19
+ from .config import build_guard
20
+ from .extraction import ExtractionGuard, GuardedExtraction
21
+ from .guards import Confidence, Conformal, Coverage, Invariance, OptionOrder, SteeringGate
22
+ from .models import DecisionModel, FunctionModel
23
+ from .pipeline import BOUNDARY_NOTE, DecisionGuard, Request, guard_decision, scan_content
24
+ from .policy import Policy
25
+ from .scene import TRUSTED_HEADERS, Scene, parse_blocks, units
26
+ from .types import Action, Finding, GuardedDecision, GuardReport, Kind, ScanResult
27
+ from .version import __version__
28
+
29
+ __all__ = ["__version__", "DecisionGuard", "Request", "guard_decision", "scan_content", "build_guard", "Policy", "AuditLogger",
30
+ "SteeringGate", "OptionOrder", "Confidence", "Coverage", "Invariance", "Conformal", "Calibrator", "Calibrated", "ExtractionGuard", "GuardedExtraction", "Scene", "TRUSTED_HEADERS", "parse_blocks", "units", "DecisionModel",
31
+ "FunctionModel", "Action", "Kind", "Finding", "ScanResult", "GuardReport", "GuardedDecision", "BOUNDARY_NOTE"]
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
@@ -0,0 +1,134 @@
1
+ """ONNX Runtime backend. The Patronus models (Wolf Defender, the Decision Steering Gate, Wolf Defender Jev) ship four ONNX
2
+ variants in one layout, so a variant name is enough to pick a graph:
3
+
4
+ fp32 onnx/onnx_fp32/model.onnx native export
5
+ fp16 onnx/onnx_fp16/model_fp16.onnx half size
6
+ mixed onnx/onnx_mixed/model_mixed.onnx INT8 weights, FP16 embeddings
7
+ int8_int4_embeddings onnx/int8_int4_embeddings/model.onnx INT8 weights, INT4 embeddings, the smallest
8
+
9
+ A path to an .onnx file works as well. Needs the `onnx` extra (onnxruntime, tokenizers, numpy, huggingface_hub), no torch.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import os
15
+ import threading
16
+ from collections.abc import Sequence
17
+ from pathlib import Path
18
+ from typing import Any
19
+
20
+ from .util import require
21
+
22
+ VARIANTS = {"fp32": "onnx/onnx_fp32/model.onnx", "fp16": "onnx/onnx_fp16/model_fp16.onnx",
23
+ "mixed": "onnx/onnx_mixed/model_mixed.onnx", "int8_int4_embeddings": "onnx/int8_int4_embeddings/model.onnx"}
24
+ DEFAULT_VARIANT = "int8_int4_embeddings"
25
+
26
+ _SESSIONS: dict[tuple, Any] = {}
27
+ _LOCK = threading.Lock()
28
+
29
+
30
+ def resolve(repo_or_path: str, variant: str, files: Sequence[str] = ("config.json", "tokenizer.json"),
31
+ revision: str | None = None) -> tuple[Path, Path]:
32
+ """(folder with config and tokenizer, graph path). From the hub only the listed files and the chosen graph are fetched."""
33
+ rel = VARIANTS.get(variant, variant)
34
+ if not rel.endswith(".onnx"):
35
+ raise ValueError(f"onnx must be one of {sorted(VARIANTS)} or a path to an .onnx file, got {variant!r}")
36
+ if os.path.isdir(repo_or_path):
37
+ folder = Path(repo_or_path)
38
+ graph = Path(rel) if os.path.isabs(rel) else folder / rel
39
+ else:
40
+ hub = require("huggingface_hub", "onnx")
41
+ paths = [Path(hub.hf_hub_download(repo_or_path, f, revision=revision)) for f in files]
42
+ folder = paths[0].parent
43
+ graph = Path(rel) if os.path.isabs(rel) else Path(hub.hf_hub_download(repo_or_path, rel, revision=revision))
44
+ if not graph.is_file():
45
+ raise FileNotFoundError(f"ONNX graph not found: {graph}")
46
+ return folder, graph
47
+
48
+
49
+ def session(graph: Path, threads: int | None = None, providers: Sequence[str] | None = None):
50
+ """One InferenceSession per graph, thread count and providers, shared within the process."""
51
+ ort = require("onnxruntime", "onnx")
52
+ key = (str(graph), threads, tuple(providers or ()))
53
+ with _LOCK:
54
+ if key not in _SESSIONS:
55
+ opts = ort.SessionOptions()
56
+ if threads:
57
+ opts.intra_op_num_threads = threads
58
+ _SESSIONS[key] = ort.InferenceSession(str(graph), opts, providers=list(providers or ["CPUExecutionProvider"]))
59
+ return _SESSIONS[key]
60
+
61
+
62
+ def clear_sessions() -> None:
63
+ with _LOCK:
64
+ _SESSIONS.clear()
65
+
66
+
67
+ def tokenizer(folder: Path):
68
+ """tokenizers.Tokenizer without the padding and truncation settings a saved tokenizer.json may carry."""
69
+ tk = require("tokenizers", "onnx")
70
+ tok = tk.Tokenizer.from_file(str(folder / "tokenizer.json"))
71
+ tok.no_padding()
72
+ tok.no_truncation()
73
+ return tok
74
+
75
+
76
+ def read_json(path: Path) -> dict:
77
+ return json.loads(path.read_text(encoding="utf-8")) if path.is_file() else {}
78
+
79
+
80
+ def pad(seqs: Sequence[Sequence[int]], pad_id: int = 0):
81
+ np = require("numpy", "onnx")
82
+ L = max(1, max((len(s) for s in seqs), default=1))
83
+ ids = np.full((len(seqs), L), pad_id, dtype=np.int64)
84
+ mask = np.zeros((len(seqs), L), dtype=np.int64)
85
+ for i, s in enumerate(seqs):
86
+ ids[i, : len(s)] = s
87
+ mask[i, : len(s)] = 1
88
+ return ids, mask
89
+
90
+
91
+ def softmax(logits):
92
+ np = require("numpy", "onnx")
93
+ x = np.asarray(logits, dtype=np.float64)
94
+ e = np.exp(x - x.max(-1, keepdims=True))
95
+ return e / e.sum(-1, keepdims=True)
96
+
97
+
98
+ class OnnxClassifier:
99
+ """A sequence classifier exported to ONNX (inputs input_ids, attention_mask; output logits), with the tokenizer and
100
+ the label names of its repository. Same interface as the PyTorch backend of the content scanners."""
101
+
102
+ def __init__(self, repo_or_path: str, variant: str = DEFAULT_VARIANT, threads: int | None = None,
103
+ providers: Sequence[str] | None = None, revision: str | None = None):
104
+ folder, graph = resolve(repo_or_path, variant, revision=revision)
105
+ self.session = session(graph, threads, providers)
106
+ self.inputs = {i.name for i in self.session.get_inputs()}
107
+ self.tok = tokenizer(folder)
108
+ cfg = read_json(folder / "config.json")
109
+ self.id2label = {int(k): str(v) for k, v in (cfg.get("id2label") or {}).items()}
110
+ pad_token = self.tok.token_to_id("<pad>")
111
+ self.pad_id = cfg.get("pad_token_id", pad_token if pad_token is not None else 0) or 0
112
+ self._max_length: int | None = None
113
+ self._plain = tokenizer(folder) # untruncated, for head/tail and chunking
114
+
115
+ def ids(self, text: str) -> list[int]:
116
+ return self._plain.encode(text, add_special_tokens=False).ids
117
+
118
+ def decode(self, ids: Sequence[int]) -> str:
119
+ return self._plain.decode(list(ids))
120
+
121
+ def _encode(self, texts: Sequence[str], max_length: int) -> list[list[int]]:
122
+ """Token ids with the special tokens of the template, truncated like transformers' `truncation=True`."""
123
+ if self._max_length != max_length:
124
+ self.tok.enable_truncation(max_length)
125
+ self._max_length = max_length
126
+ return [e.ids for e in self.tok.encode_batch(list(texts))]
127
+
128
+ def probs(self, texts: Sequence[str], max_length: int, batch_size: int, pos: int) -> list[float]:
129
+ out: list[float] = []
130
+ for i in range(0, len(texts), batch_size):
131
+ ids, mask = pad(self._encode(texts[i: i + batch_size], max_length), self.pad_id)
132
+ feed = {k: v for k, v in (("input_ids", ids), ("attention_mask", mask)) if k in self.inputs}
133
+ out += softmax(self.session.run(None, feed)[0])[:, pos].tolist()
134
+ return out
@@ -0,0 +1,44 @@
1
+ """Audit log: one JSON line per guarded decision."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import os
6
+ import threading
7
+ import time
8
+ from typing import Any
9
+
10
+ from .types import GuardedDecision
11
+ from .util import sha256
12
+
13
+
14
+ class AuditLogger:
15
+ """Appends one JSON line per decision. By default only hashes of the scene, question and culprit are stored, so the
16
+ log holds no customer content; set `include_text=True` to keep the text for debugging. Rotates at `max_bytes`."""
17
+
18
+ def __init__(self, path: str, include_text: bool = False, max_bytes: int | None = 50_000_000):
19
+ self.path, self.include_text, self.max_bytes = path, include_text, max_bytes
20
+ self._lock = threading.Lock()
21
+ os.makedirs(os.path.dirname(os.path.abspath(path)) or ".", exist_ok=True)
22
+
23
+ def record(self, d: GuardedDecision, scene: str, question: str, meta: dict[str, Any] | None = None) -> dict[str, Any]:
24
+ rec: dict[str, Any] = {"ts": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), "id": d.id, "action": d.action.value, "kind": d.kind.value,
25
+ "answer": d.answer, "raw_answer": d.raw_answer, "changed": d.changed, "probs": [round(p, 6) for p in d.probs],
26
+ "raw_probs": [round(p, 6) for p in d.raw_probs], "reasons": d.reasons, "calls": d.calls,
27
+ "latency_ms": round(d.latency_ms, 2), "options": d.options,
28
+ "scans": {s.scanner: {"valid": s.valid, "score": round(s.score, 6)} for s in d.scans},
29
+ "guards": {g.guard: g.triggered for g in d.guards}, "meta": meta or {}}
30
+ if self.include_text:
31
+ rec.update(scene=scene, question=question, culprit=d.culprit)
32
+ else:
33
+ rec.update(scene_sha256=sha256(scene), question_sha256=sha256(question), culprit_sha256=sha256(d.culprit) if d.culprit else None)
34
+ if d.culprit: # reasons quote the culprit; keep content out of the log
35
+ rec["reasons"] = [r.replace(repr(d.culprit), "<culprit>").replace(d.culprit, "<culprit>") for r in d.reasons]
36
+ return rec
37
+
38
+ def log(self, d: GuardedDecision, scene: str, question: str, meta: dict[str, Any] | None = None) -> None:
39
+ line = json.dumps(self.record(d, scene, question, meta), ensure_ascii=False)
40
+ with self._lock:
41
+ if self.max_bytes and os.path.exists(self.path) and os.path.getsize(self.path) > self.max_bytes:
42
+ os.replace(self.path, self.path + ".1")
43
+ with open(self.path, "a", encoding="utf-8") as f:
44
+ f.write(line + "\n")
@@ -0,0 +1,232 @@
1
+ """Calibration and conformal abstention from your own labelled decisions.
2
+
3
+ A probability of 0.7 from a decision model rarely means 70 %: calibration drifts between domains, and a threshold of
4
+ 0.5 or one copied from a benchmark seldom fits your traffic. With a few hundred labelled cases:
5
+
6
+ cal = Calibrator.fit(model, cases, alpha=0.05) # temperature scaling + split-conformal threshold
7
+ cal.save("calibration.json")
8
+ guard = DecisionGuard(Calibrated(model, cal), guards=[Conformal(cal)], ...)
9
+
10
+ `Calibrated` rescales the model's probabilities (temperature scaling: one parameter, argmax unchanged). `Conformal`
11
+ turns every decision into a prediction set that contains the correct answer with probability at least 1 - alpha
12
+ (split conformal prediction; the guarantee holds when your traffic looks like the calibration cases). A set with
13
+ exactly one option is a decision; a set with two or more options, or none, means the model cannot decide at that
14
+ coverage, and the policy abstains or escalates. `fit` also reports risk thresholds for target false-positive rates.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ import math
20
+ from collections.abc import Sequence
21
+ from pathlib import Path
22
+ from typing import Any
23
+
24
+ from .models.base import DecideBatch, DecisionModel, as_model
25
+ from .types import GuardReport, Kind
26
+ from .util import argmax, normalize
27
+
28
+ EPS = 1e-12
29
+
30
+
31
+ def _scale(p: Sequence[float], t: float) -> list[float]:
32
+ logits = [math.log(max(x, EPS)) / t for x in p]
33
+ m = max(logits)
34
+ e = [math.exp(v - m) for v in logits]
35
+ s = sum(e)
36
+ return [v / s for v in e]
37
+
38
+
39
+ def _nll(P: Sequence[Sequence[float]], y: Sequence[int], t: float) -> float:
40
+ return -sum(math.log(max(_scale(p, t)[g], EPS)) for p, g in zip(P, y, strict=True)) / max(1, len(y))
41
+
42
+
43
+ def _ece(P: Sequence[Sequence[float]], y: Sequence[int], bins: int = 10) -> float:
44
+ acc = [[0, 0.0, 0] for _ in range(bins)] # count, confidence sum, correct
45
+ for p, g in zip(P, y, strict=True):
46
+ c, k = max(p), argmax(list(p))
47
+ b = min(bins - 1, int(c * bins))
48
+ acc[b][0] += 1
49
+ acc[b][1] += c
50
+ acc[b][2] += int(k == g)
51
+ n = max(1, len(y))
52
+ return sum(abs(cs / cnt - cor / cnt) * cnt / n for cnt, cs, cor in acc if cnt)
53
+
54
+
55
+ def fit_temperature(P: Sequence[Sequence[float]], y: Sequence[int]) -> float:
56
+ """Temperature minimising the negative log-likelihood (golden-section search over log T in [0.05, 20])."""
57
+ lo, hi = math.log(0.05), math.log(20.0)
58
+ g = (math.sqrt(5) - 1) / 2
59
+ a, b = hi - g * (hi - lo), lo + g * (hi - lo)
60
+ fa, fb = _nll(P, y, math.exp(a)), _nll(P, y, math.exp(b))
61
+ for _ in range(60):
62
+ if fa < fb:
63
+ hi, b, fb = b, a, fa
64
+ a = hi - g * (hi - lo)
65
+ fa = _nll(P, y, math.exp(a))
66
+ else:
67
+ lo, a, fa = a, b, fb
68
+ b = lo + g * (hi - lo)
69
+ fb = _nll(P, y, math.exp(b))
70
+ return math.exp((lo + hi) / 2)
71
+
72
+
73
+ def conformal_quantile(scores: Sequence[float], alpha: float) -> float:
74
+ """Split-conformal threshold: the ceil((n + 1)(1 - alpha))-th smallest nonconformity score."""
75
+ n = len(scores)
76
+ k = math.ceil((n + 1) * (1 - alpha))
77
+ return 1.0 if k > n else sorted(scores)[k - 1]
78
+
79
+
80
+ def collect(model: DecisionModel | DecideBatch, cases: Sequence[dict[str, Any]]) -> tuple[list[list[float]], list[int]]:
81
+ """Model probabilities and gold indices for labelled cases ({"scene", "question", "options", "gold"}), batched per task."""
82
+ m = as_model(model)
83
+ groups: dict[tuple, list[int]] = {}
84
+ for i, c in enumerate(cases):
85
+ groups.setdefault((c["question"], tuple(c["options"])), []).append(i)
86
+ P: list[list[float] | None] = [None] * len(cases)
87
+ for (q, opts), idx in groups.items():
88
+ for i, p in zip(idx, m.decide_batch([cases[i]["scene"] for i in idx], q, list(opts)), strict=True):
89
+ P[i] = normalize(p)
90
+ return [p for p in P if p is not None], [int(c["gold"]) for c in cases]
91
+
92
+
93
+ class Calibrator:
94
+ """Temperature scaling plus a split-conformal threshold, fitted on labelled decisions."""
95
+
96
+ def __init__(self, temperature: float = 1.0, qhat: float | None = None, alpha: float | None = None, n: int = 0,
97
+ metrics: dict[str, Any] | None = None, operating_points: list[dict[str, float]] | None = None,
98
+ risky: Sequence[int] | None = None, tasks: Sequence[str] | None = None):
99
+ self.temperature, self.qhat, self.alpha, self.n = temperature, qhat, alpha, n
100
+ self.metrics, self.operating_points = metrics or {}, operating_points or []
101
+ self.risky, self.tasks = list(risky) if risky else None, list(tasks or [])
102
+
103
+ # ------------------------------------------------------------------ fitting
104
+ @classmethod
105
+ def fit(cls, model: DecisionModel | DecideBatch, cases: Sequence[dict[str, Any]], alpha: float = 0.1,
106
+ target_fprs: Sequence[float] = (0.001, 0.01, 0.05, 0.1)) -> Calibrator:
107
+ """Fit on labelled cases. Use cases you did not tune anything else on; 200 or more give stable thresholds."""
108
+ if not 0 < alpha < 1:
109
+ raise ValueError("alpha must be between 0 and 1")
110
+ P, y = collect(model, cases)
111
+ return cls.fit_probs(P, y, alpha, risky=_common_risky(cases), target_fprs=target_fprs,
112
+ tasks=sorted({c["question"] for c in cases}))
113
+
114
+ @classmethod
115
+ def fit_probs(cls, P: Sequence[Sequence[float]], y: Sequence[int], alpha: float = 0.1, risky: Sequence[int] | None = None,
116
+ target_fprs: Sequence[float] = (0.001, 0.01, 0.05, 0.1), tasks: Sequence[str] | None = None) -> Calibrator:
117
+ if len(P) != len(y) or not P:
118
+ raise ValueError("need one probability list per gold label")
119
+ t = fit_temperature(P, y)
120
+ Pc = [_scale(p, t) for p in P]
121
+ qhat = conformal_quantile([1 - p[g] for p, g in zip(Pc, y, strict=True)], alpha)
122
+ sets = [[i for i, v in enumerate(p) if 1 - v <= qhat] for p in Pc]
123
+ metrics = {"cases": len(y), "accuracy": sum(argmax(list(p)) == g for p, g in zip(P, y, strict=True)) / len(y),
124
+ "nll_before": _nll(P, y, 1.0), "nll_after": _nll(P, y, t), "ece_before": _ece(P, y), "ece_after": _ece(Pc, y),
125
+ "set_coverage": sum(g in s for s, g in zip(sets, y, strict=True)) / len(y),
126
+ "singleton_rate": sum(len(s) == 1 for s in sets) / len(y),
127
+ "mean_set_size": sum(len(s) for s in sets) / len(y)}
128
+ ops = _operating_points(Pc, y, risky, target_fprs) if risky else []
129
+ return cls(t, qhat, alpha, len(y), metrics, ops, risky, tasks)
130
+
131
+ # ------------------------------------------------------------------ use
132
+ def calibrate(self, probs: Sequence[float]) -> list[float]:
133
+ return _scale(normalize(probs), self.temperature)
134
+
135
+ def prediction_set(self, probs: Sequence[float], calibrated: bool = False) -> list[int]:
136
+ """Options whose calibrated probability is at least 1 - qhat."""
137
+ if self.qhat is None:
138
+ raise ValueError("this calibrator has no conformal threshold")
139
+ p = list(probs) if calibrated else self.calibrate(probs)
140
+ return [i for i, v in enumerate(p) if 1 - v <= self.qhat]
141
+
142
+ def evaluate(self, model: DecisionModel | DecideBatch, cases: Sequence[dict[str, Any]]) -> dict[str, float]:
143
+ """Coverage and set sizes on held-out cases (check that the guarantee holds on your data)."""
144
+ P, y = collect(model, cases)
145
+ sets = [self.prediction_set(p) for p in P]
146
+ single = [(s, g) for s, g in zip(sets, y, strict=True) if len(s) == 1]
147
+ return {"cases": len(y), "coverage": sum(g in s for s, g in zip(sets, y, strict=True)) / len(y),
148
+ "singleton_rate": len(single) / len(y), "singleton_accuracy": sum(s[0] == g for s, g in single) / max(1, len(single)),
149
+ "mean_set_size": sum(len(s) for s in sets) / len(y), "ece": _ece([self.calibrate(p) for p in P], y)}
150
+
151
+ def summary(self) -> str:
152
+ m = self.metrics
153
+ lines = [f"Calibration on {self.n} cases: temperature {self.temperature:.3f}, accuracy {100 * m.get('accuracy', 0):.1f} %",
154
+ f"ECE {100 * m.get('ece_before', 0):.1f} -> {100 * m.get('ece_after', 0):.1f}, NLL {m.get('nll_before', 0):.3f} -> {m.get('nll_after', 0):.3f}"]
155
+ if self.qhat is not None:
156
+ lines.append(f"Conformal sets at {100 * (1 - self.alpha):.0f} % coverage: {100 * m.get('singleton_rate', 0):.1f} % single answers, "
157
+ f"mean set size {m.get('mean_set_size', 0):.2f}")
158
+ for op in self.operating_points:
159
+ lines.append(f"Risk threshold {op['threshold']:.4f}: FPR {100 * op['fpr']:.2f} % (target {100 * op['target_fpr']:.1f} %), "
160
+ f"TPR {100 * op['tpr']:.1f} %")
161
+ return "\n".join(lines)
162
+
163
+ # ------------------------------------------------------------------ persistence
164
+ def to_dict(self) -> dict[str, Any]:
165
+ return {"temperature": self.temperature, "qhat": self.qhat, "alpha": self.alpha, "n": self.n, "metrics": self.metrics,
166
+ "operating_points": self.operating_points, "risky": self.risky, "tasks": self.tasks}
167
+
168
+ def save(self, path: str | Path) -> None:
169
+ Path(path).write_text(json.dumps(self.to_dict(), indent=2) + "\n", encoding="utf-8")
170
+
171
+ @classmethod
172
+ def load(cls, path: str | Path) -> Calibrator:
173
+ return cls(**json.loads(Path(path).read_text(encoding="utf-8")))
174
+
175
+
176
+ def _common_risky(cases: Sequence[dict[str, Any]]) -> list[int] | None:
177
+ rs = {tuple(c["risky"]) for c in cases if c.get("risky")}
178
+ return list(rs.pop()) if len(rs) == 1 and all(c.get("risky") for c in cases) else None
179
+
180
+
181
+ def _operating_points(P, y, risky, targets) -> list[dict[str, float]]:
182
+ rs = set(risky)
183
+ risk = [sum(p[i] for i in rs) for p in P]
184
+ pos = [r for r, g in zip(risk, y, strict=True) if g in rs]
185
+ neg = sorted((r for r, g in zip(risk, y, strict=True) if g not in rs), reverse=True)
186
+ if not pos or not neg:
187
+ return []
188
+ out = []
189
+ for t in targets:
190
+ k = int(math.floor(t * len(neg))) # negatives allowed above the threshold
191
+ thr = min(1.0, neg[k] + 1e-9) if k < len(neg) else 0.0
192
+ out.append({"target_fpr": t, "threshold": thr, "fpr": sum(r >= thr for r in neg) / len(neg),
193
+ "tpr": sum(r >= thr for r in pos) / len(pos)})
194
+ return out
195
+
196
+
197
+ class Calibrated:
198
+ """A decision model with calibrated probabilities (temperature scaling; the answer itself never changes)."""
199
+
200
+ def __init__(self, model: DecisionModel | DecideBatch, calibrator: Calibrator):
201
+ self.model, self.calibrator = as_model(model), calibrator
202
+ self.name = f"calibrated:{self.model.name}"
203
+ for attr in ("context_tokens", "count_tokens"): # keep what Coverage needs
204
+ if hasattr(self.model, attr):
205
+ setattr(self, attr, getattr(self.model, attr))
206
+
207
+ def decide_batch(self, scenes: Sequence[str], question: str, options: Sequence[str]) -> list[list[float]]:
208
+ return [self.calibrator.calibrate(p) for p in self.model.decide_batch(scenes, question, options)]
209
+
210
+
211
+ class Conformal:
212
+ """Abstain unless the conformal prediction set holds exactly one option (coverage 1 - alpha of the calibrator).
213
+
214
+ The guarantee holds only for the decisions the calibrator was fitted on: questions it has not seen are skipped and
215
+ reported as such (fit one calibrator per decision task, or fit on a mix of all tasks you guard)."""
216
+
217
+ name = "Conformal"
218
+
219
+ def __init__(self, calibrator: Calibrator | str | Path):
220
+ self.calibrator = calibrator if isinstance(calibrator, Calibrator) else Calibrator.load(calibrator)
221
+ if self.calibrator.qhat is None:
222
+ raise ValueError("the calibrator has no conformal threshold; fit it with alpha")
223
+
224
+ def check(self, model: DecisionModel, scene: str, question: str, options: Sequence[str], probs: Sequence[float], kind: Kind,
225
+ **_ctx) -> GuardReport:
226
+ tasks = self.calibrator.tasks
227
+ if tasks and not any(question.startswith(t) for t in tasks): # startswith: a boundary note may follow the question
228
+ return GuardReport(self.name, False, 0.0, 0, {"skipped": "not calibrated for this question"})
229
+ already = isinstance(model, Calibrated) and model.calibrator is self.calibrator
230
+ s = self.calibrator.prediction_set(probs, calibrated=already)
231
+ return GuardReport(self.name, len(s) != 1, float(len(s) != 1), 0,
232
+ {"set": [options[i] for i in s], "coverage": round(1 - (self.calibrator.alpha or 0), 4)})
@@ -0,0 +1,186 @@
1
+ """Command line: dmguard {version, init-config, scan, decide, eval, audit, calibrate, serve}."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import json
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ from .version import __version__
10
+
11
+
12
+ def _guard(args):
13
+ from .config import build_guard
14
+ return build_guard(args.config)
15
+
16
+
17
+ def _read(args) -> str:
18
+ if getattr(args, "text", None):
19
+ return args.text
20
+ if getattr(args, "file", None):
21
+ return Path(args.file).read_text(encoding="utf-8")
22
+ if not sys.stdin.isatty():
23
+ return sys.stdin.read()
24
+ raise SystemExit("give --text, --file or pipe text on stdin")
25
+
26
+
27
+ def cmd_init(args) -> int:
28
+ from .config import EXAMPLE_CONFIG
29
+ path = Path(args.path)
30
+ if path.exists() and not args.force:
31
+ raise SystemExit(f"{path} exists (use --force to overwrite)")
32
+ path.write_text(EXAMPLE_CONFIG, encoding="utf-8")
33
+ print(f"wrote {path}")
34
+ return 0
35
+
36
+
37
+ def cmd_scan(args) -> int:
38
+ g = _guard(args)
39
+ text = _read(args)
40
+ if not text.lstrip().startswith("["):
41
+ text = f"[USER PROMPT]\n{text}"
42
+ sanitized, results = g.scan(text)
43
+ out = {"valid": all(r.valid for r in results), "results": [r.to_dict() for r in results]}
44
+ if args.sanitized:
45
+ out["sanitized"] = sanitized
46
+ print(json.dumps(out, indent=2, ensure_ascii=False))
47
+ return 0 if out["valid"] else 2
48
+
49
+
50
+ def cmd_decide(args) -> int:
51
+ g = _guard(args)
52
+ scene = _read(args)
53
+ ints = lambda s: [int(x) for x in s.split(",")] if s else None # noqa: E731
54
+ d = g.decide(scene, args.question, args.option, risky=ints(args.risky), order=ints(args.order), fallback=args.fallback)
55
+ if args.json:
56
+ print(json.dumps(d.to_dict(), indent=2, ensure_ascii=False))
57
+ else:
58
+ print(f"answer: {d.answer}")
59
+ print(f"action: {d.action.value}")
60
+ print(f"model: {d.raw_answer}")
61
+ if d.reasons:
62
+ print("reasons: " + "\n ".join(d.reasons))
63
+ print(f"calls: {d.calls} latency: {d.latency_ms:.0f} ms")
64
+ return 0
65
+
66
+
67
+ def cmd_eval(args) -> int:
68
+ from .evaluate import evaluate, load_cases
69
+ g = _guard(args)
70
+ rep = evaluate(g.model, load_cases(args.cases), guard=g, attacks_per_case=args.attacks, controls_per_case=args.controls,
71
+ langs=args.lang or ["en"], seed=args.seed)
72
+ md = rep.to_markdown()
73
+ print(md)
74
+ if args.out:
75
+ Path(args.out).write_text(json.dumps(rep.to_dict(), indent=2), encoding="utf-8")
76
+ return 0
77
+
78
+
79
+ def cmd_audit(args) -> int:
80
+ from .evaluate import FAMILIES, audit, load_cases
81
+ g = _guard(args)
82
+ rep = audit(g.model, load_cases(args.cases), guard=None if args.no_guard else g, families=args.check or list(FAMILIES),
83
+ per_case=args.per_case, padding_tokens=args.padding_tokens, seed=args.seed)
84
+ md = rep.to_markdown()
85
+ print(md)
86
+ if args.out:
87
+ Path(args.out).write_text(json.dumps(rep.to_dict(), indent=2), encoding="utf-8")
88
+ if args.markdown:
89
+ Path(args.markdown).write_text(md, encoding="utf-8")
90
+ if args.max_attack_success is not None: # for CI: fail when an attack gets through too often
91
+ key = "raw" if args.no_guard else "guarded"
92
+ worst = {k: v.rates()[key] for k, v in rep.families.items() if v.kind == "attack" and v.n}
93
+ over = {k: r for k, r in worst.items() if r is not None and r > args.max_attack_success}
94
+ if over:
95
+ print("attack success above the limit: " + ", ".join(f"{k} {100 * r:.1f} %" for k, r in over.items()), file=sys.stderr)
96
+ return 2
97
+ return 0
98
+
99
+
100
+ def cmd_calibrate(args) -> int:
101
+ import yaml
102
+
103
+ from .calibration import Calibrator
104
+ from .config import build_model
105
+ from .evaluate import load_cases
106
+ cfg = yaml.safe_load(Path(args.config).read_text())
107
+ model = build_model(cfg["model"]) # the bare model: calibration is fitted on raw probabilities
108
+ cal = Calibrator.fit(model, load_cases(args.cases), alpha=args.alpha)
109
+ print(cal.summary())
110
+ if args.test:
111
+ ev = cal.evaluate(model, load_cases(args.test))
112
+ print(f"Held-out: coverage {100 * ev['coverage']:.1f} % (target {100 * (1 - args.alpha):.0f} %), "
113
+ f"{100 * ev['singleton_rate']:.1f} % single answers, accuracy of single answers {100 * ev['singleton_accuracy']:.1f} %")
114
+ cal.save(args.out)
115
+ print(f"wrote {args.out}")
116
+ return 0
117
+
118
+
119
+ def cmd_serve(args) -> int:
120
+ from .server import create_app
121
+ from .util import require
122
+ uvicorn = require("uvicorn", "server")
123
+ uvicorn.run(create_app(config=args.config), host=args.host, port=args.port, log_level=args.log_level)
124
+ return 0
125
+
126
+
127
+ def main(argv: list[str] | None = None) -> int:
128
+ ap = argparse.ArgumentParser(prog="dmguard", description="Guardrails for decision models.")
129
+ sub = ap.add_subparsers(dest="cmd", required=True)
130
+ sub.add_parser("version")
131
+ p = sub.add_parser("init-config", help="write an example config")
132
+ p.add_argument("path", nargs="?", default="dmguard.yml")
133
+ p.add_argument("--force", action="store_true")
134
+ for name, helptext in (("scan", "scan untrusted text"), ("decide", "one guarded decision"), ("eval", "steering evaluation"),
135
+ ("audit", "robustness audit: attacks and stress checks, with and without the guard"),
136
+ ("calibrate", "fit temperature and conformal threshold on labelled cases"), ("serve", "HTTP API")):
137
+ q = sub.add_parser(name, help=helptext)
138
+ q.add_argument("--config", required=True)
139
+ if name in ("scan", "decide"):
140
+ q.add_argument("--text")
141
+ q.add_argument("--file")
142
+ if name == "scan":
143
+ q.add_argument("--sanitized", action="store_true", help="also print the sanitized text")
144
+ if name == "decide":
145
+ q.add_argument("--question", required=True)
146
+ q.add_argument("--option", action="append", required=True)
147
+ q.add_argument("--risky", help="comma-separated risky option indices")
148
+ q.add_argument("--order", help="option indices, most cautious first")
149
+ q.add_argument("--fallback", type=int)
150
+ q.add_argument("--json", action="store_true")
151
+ if name == "eval":
152
+ q.add_argument("--cases", help="JSONL cases (default: bundled samples)")
153
+ q.add_argument("--attacks", type=int, default=4)
154
+ q.add_argument("--controls", type=int, default=2)
155
+ q.add_argument("--lang", action="append")
156
+ q.add_argument("--seed", type=int, default=0)
157
+ q.add_argument("--out", help="write the report as JSON")
158
+ if name == "audit":
159
+ q.add_argument("--cases", help="JSONL cases (default: bundled samples)")
160
+ q.add_argument("--check", action="append", help="run only these checks (repeatable)")
161
+ q.add_argument("--per-case", type=int, default=2, help="variants per check and case")
162
+ q.add_argument("--padding-tokens", type=int, help="filler per side for the padding check (default: 1.2 x model context)")
163
+ q.add_argument("--no-guard", action="store_true", help="measure the model alone")
164
+ q.add_argument("--seed", type=int, default=0)
165
+ q.add_argument("--out", help="write the report as JSON")
166
+ q.add_argument("--markdown", help="write the scorecard as Markdown")
167
+ q.add_argument("--max-attack-success", type=float, help="exit 2 when an attack check exceeds this rate (CI)")
168
+ if name == "calibrate":
169
+ q.add_argument("--cases", required=True, help="labelled JSONL cases for fitting")
170
+ q.add_argument("--test", help="labelled JSONL cases to check coverage on")
171
+ q.add_argument("--alpha", type=float, default=0.1, help="allowed miss rate of the conformal sets")
172
+ q.add_argument("--out", default="calibration.json")
173
+ if name == "serve":
174
+ q.add_argument("--host", default="127.0.0.1")
175
+ q.add_argument("--port", type=int, default=8000)
176
+ q.add_argument("--log-level", default="info")
177
+ args = ap.parse_args(argv)
178
+ if args.cmd == "version":
179
+ print(__version__)
180
+ return 0
181
+ return {"init-config": cmd_init, "scan": cmd_scan, "decide": cmd_decide, "eval": cmd_eval, "audit": cmd_audit,
182
+ "calibrate": cmd_calibrate, "serve": cmd_serve}[args.cmd](args)
183
+
184
+
185
+ if __name__ == "__main__":
186
+ raise SystemExit(main())