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.
- decision_model_guard/__init__.py +31 -0
- decision_model_guard/__main__.py +3 -0
- decision_model_guard/_onnx.py +134 -0
- decision_model_guard/audit.py +44 -0
- decision_model_guard/calibration.py +232 -0
- decision_model_guard/cli.py +186 -0
- decision_model_guard/config.py +151 -0
- decision_model_guard/evaluate/__init__.py +7 -0
- decision_model_guard/evaluate/audit.py +266 -0
- decision_model_guard/evaluate/data/claims.yaml +483 -0
- decision_model_guard/evaluate/data/extraction_cases.jsonl +12 -0
- decision_model_guard/evaluate/data/neutral.yaml +27 -0
- decision_model_guard/evaluate/data/sample_cases.jsonl +12 -0
- decision_model_guard/evaluate/extraction.py +112 -0
- decision_model_guard/evaluate/harness.py +184 -0
- decision_model_guard/extraction.py +193 -0
- decision_model_guard/guards/__init__.py +9 -0
- decision_model_guard/guards/checks.py +81 -0
- decision_model_guard/guards/coverage.py +135 -0
- decision_model_guard/guards/invariance.py +108 -0
- decision_model_guard/guards/steering_gate.py +146 -0
- decision_model_guard/input_scanners/__init__.py +7 -0
- decision_model_guard/input_scanners/base.py +43 -0
- decision_model_guard/input_scanners/neural.py +167 -0
- decision_model_guard/input_scanners/rules.py +176 -0
- decision_model_guard/integrations/__init__.py +1 -0
- decision_model_guard/integrations/langchain.py +32 -0
- decision_model_guard/models/__init__.py +9 -0
- decision_model_guard/models/base.py +38 -0
- decision_model_guard/models/http.py +122 -0
- decision_model_guard/models/local.py +82 -0
- decision_model_guard/models/wolf_defender_jev.py +187 -0
- decision_model_guard/models/workers_ai.py +88 -0
- decision_model_guard/pipeline.py +214 -0
- decision_model_guard/policy.py +133 -0
- decision_model_guard/py.typed +0 -0
- decision_model_guard/scene.py +206 -0
- decision_model_guard/server/__init__.py +4 -0
- decision_model_guard/server/app.py +144 -0
- decision_model_guard/types.py +115 -0
- decision_model_guard/util.py +52 -0
- decision_model_guard/version.py +1 -0
- decision_model_guard-0.1.0.dist-info/METADATA +459 -0
- decision_model_guard-0.1.0.dist-info/RECORD +47 -0
- decision_model_guard-0.1.0.dist-info/WHEEL +4 -0
- decision_model_guard-0.1.0.dist-info/entry_points.txt +2 -0
- 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,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())
|