debugerai 0.2.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.
- debugai/__init__.py +51 -0
- debugai/agents/__init__.py +43 -0
- debugai/agents/base.py +192 -0
- debugai/agents/builtin.py +246 -0
- debugai/agents/registry.py +31 -0
- debugai/agents/types.py +108 -0
- debugai/analyze.py +142 -0
- debugai/calibration.py +198 -0
- debugai/cli.py +171 -0
- debugai/config.py +134 -0
- debugai/detectors.py +206 -0
- debugai/diagnosis.py +64 -0
- debugai/explainer.py +105 -0
- debugai/integrations/__init__.py +5 -0
- debugai/integrations/langchain.py +109 -0
- debugai/judge.py +171 -0
- debugai/metrics.py +139 -0
- debugai/models.py +92 -0
- debugai/providers.py +179 -0
- debugai/schema.py +66 -0
- debugai/sdk.py +1271 -0
- debugai/signals.py +399 -0
- debugai/thresholds.json +15 -0
- debugai/thresholds.py +44 -0
- debugai/tracing.py +283 -0
- debugerai-0.2.0.dist-info/METADATA +535 -0
- debugerai-0.2.0.dist-info/RECORD +31 -0
- debugerai-0.2.0.dist-info/WHEEL +5 -0
- debugerai-0.2.0.dist-info/entry_points.txt +2 -0
- debugerai-0.2.0.dist-info/licenses/LICENSE +21 -0
- debugerai-0.2.0.dist-info/top_level.txt +1 -0
debugai/signals.py
ADDED
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
"""Layer 1 — Signal Computation Engine (Architecture §4).
|
|
2
|
+
|
|
3
|
+
Computes the 8 deterministic signals that form the universal signal vector. Each
|
|
4
|
+
signal has a primary method (small ML model) and a deterministic fallback, per
|
|
5
|
+
the layered-computation design (§7.1). Signals that don't apply to a request
|
|
6
|
+
(e.g. retrieval signals on a non-RAG call) return a *healthy* sentinel so
|
|
7
|
+
downstream detectors don't misfire.
|
|
8
|
+
|
|
9
|
+
Lazy evaluation (§7.4): cheap signals compute first; expensive model-backed
|
|
10
|
+
signals (semantic overlap, NER, NLI) are skipped when the cheap signals already
|
|
11
|
+
look healthy and ``lazy=True``.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import logging
|
|
18
|
+
import math
|
|
19
|
+
import os
|
|
20
|
+
import re
|
|
21
|
+
from dataclasses import asdict, dataclass
|
|
22
|
+
|
|
23
|
+
from debugai import models
|
|
24
|
+
from debugai.schema import CaptureRecord
|
|
25
|
+
|
|
26
|
+
log = logging.getLogger("debugai.signals")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _finite(x) -> bool:
|
|
30
|
+
return isinstance(x, (int, float)) and not isinstance(x, bool) and math.isfinite(x)
|
|
31
|
+
|
|
32
|
+
_WORD_RE = re.compile(r"[A-Za-z0-9']+")
|
|
33
|
+
# Fallback "entity" heuristic: capitalised tokens, numbers, and units/currency.
|
|
34
|
+
_ENTITY_RE = re.compile(r"\b([A-Z][A-Za-z0-9]+|\$?\d[\d,.]*%?)\b")
|
|
35
|
+
# Common English function words that get capitalised at sentence starts but are
|
|
36
|
+
# not named entities. Filtered from the regex fallback to prevent false positives.
|
|
37
|
+
_GRAMMAR_WORDS: frozenset[str] = frozenset([
|
|
38
|
+
"the", "a", "an", "in", "on", "at", "of", "to", "for", "with", "by",
|
|
39
|
+
"from", "as", "is", "was", "are", "were", "be", "it", "its", "this",
|
|
40
|
+
"that", "these", "those", "he", "she", "we", "they", "i", "you",
|
|
41
|
+
"and", "or", "but", "so", "yet", "nor", "if", "then", "than",
|
|
42
|
+
"our", "my", "his", "her", "their", "your", "all", "any", "each",
|
|
43
|
+
"what", "when", "where", "which", "who", "how", "most", "more",
|
|
44
|
+
])
|
|
45
|
+
# Prompt-constraint markers that suppress output variance.
|
|
46
|
+
_CONSTRAINT_RE = re.compile(
|
|
47
|
+
r"\b(only|must|exactly|do not|don't|never|always|format|json|"
|
|
48
|
+
r"bullet|numbered|step[- ]by[- ]step|schema|template)\b",
|
|
49
|
+
re.IGNORECASE,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass
|
|
54
|
+
class SignalVector:
|
|
55
|
+
"""The universal 8-metric interface (§6). Every request produces one."""
|
|
56
|
+
|
|
57
|
+
overlap: float # 0-1 context-output overlap
|
|
58
|
+
entity_coverage: float # 0-1 fraction of output entities grounded in context
|
|
59
|
+
similarity: float # 0-1 mean retrieval cosine
|
|
60
|
+
contradiction: float # 0-1 NLI contradiction probability
|
|
61
|
+
variance: float # 0-1 output variance (proxy estimate)
|
|
62
|
+
latency_ms: float # 0-inf end-to-end latency
|
|
63
|
+
token_ratio: float # 0-1 total tokens / model max
|
|
64
|
+
context_ratio: float # 0-1 prompt tokens / context window
|
|
65
|
+
|
|
66
|
+
# Provenance flags (which path produced overlap / variance).
|
|
67
|
+
overlap_method: str = "hybrid"
|
|
68
|
+
variance_method: str = "estimated"
|
|
69
|
+
|
|
70
|
+
# Auxiliary entity accounting (drives entity-gap confidence, §5.1.3).
|
|
71
|
+
entities_total: int = 0
|
|
72
|
+
entities_missing: int = 0
|
|
73
|
+
|
|
74
|
+
def to_dict(self) -> dict:
|
|
75
|
+
return asdict(self)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
# --------------------------------------------------------------------------- #
|
|
79
|
+
# Tokenisation helpers
|
|
80
|
+
# --------------------------------------------------------------------------- #
|
|
81
|
+
def _tokens(text: str) -> set[str]:
|
|
82
|
+
return {t.lower() for t in _WORD_RE.findall(text or "")}
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _approx_token_count(text: str) -> int:
|
|
86
|
+
"""~4 chars/token rough estimate when no tokenizer/usage data is available."""
|
|
87
|
+
return max(1, math.ceil(len(text or "") / 4))
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
# --------------------------------------------------------------------------- #
|
|
91
|
+
# Signal 1 — Context-output overlap (0.35 token Jaccard + 0.65 semantic cosine)
|
|
92
|
+
# --------------------------------------------------------------------------- #
|
|
93
|
+
def compute_overlap(output: str, context: str) -> tuple[float, str]:
|
|
94
|
+
if not context.strip():
|
|
95
|
+
# No grounding context → can't assess fabrication; treat as grounded.
|
|
96
|
+
return 1.0, "no-context"
|
|
97
|
+
|
|
98
|
+
out_tok, ctx_tok = _tokens(output), _tokens(context)
|
|
99
|
+
union = out_tok | ctx_tok
|
|
100
|
+
jaccard = len(out_tok & ctx_tok) / len(union) if union else 0.0
|
|
101
|
+
|
|
102
|
+
embed = models.embedder()
|
|
103
|
+
if embed is None:
|
|
104
|
+
return round(jaccard, 4), "token-jaccard" # fallback (§4.1)
|
|
105
|
+
|
|
106
|
+
try:
|
|
107
|
+
import numpy as np
|
|
108
|
+
|
|
109
|
+
vecs = embed.encode([output, context], normalize_embeddings=True)
|
|
110
|
+
cosine = float(np.clip(np.dot(vecs[0], vecs[1]), 0.0, 1.0))
|
|
111
|
+
score = 0.35 * jaccard + 0.65 * cosine
|
|
112
|
+
return round(score, 4), "hybrid"
|
|
113
|
+
except Exception as e: # model inference failed → degrade to token overlap
|
|
114
|
+
log.warning("overlap embedding failed (%s); using token Jaccard", e)
|
|
115
|
+
return round(jaccard, 4), "token-jaccard"
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
# --------------------------------------------------------------------------- #
|
|
119
|
+
# Signal 2 — Entity coverage (spaCy NER + regex fallback)
|
|
120
|
+
# --------------------------------------------------------------------------- #
|
|
121
|
+
def _extract_entities(text: str) -> set[str]:
|
|
122
|
+
text = text if isinstance(text, str) else ("" if text is None else str(text))
|
|
123
|
+
nlp = models.ner()
|
|
124
|
+
if nlp is not None:
|
|
125
|
+
try:
|
|
126
|
+
ents = {e.text.lower() for e in nlp(text).ents}
|
|
127
|
+
if ents:
|
|
128
|
+
return ents
|
|
129
|
+
# spaCy found nothing → fall through to regex so we still get signal.
|
|
130
|
+
except Exception as e:
|
|
131
|
+
log.warning("spaCy NER failed (%s); using regex fallback", e)
|
|
132
|
+
regex_ents = {m.group(1).lower() for m in _ENTITY_RE.finditer(text)
|
|
133
|
+
if m.group(1).lower() not in _GRAMMAR_WORDS}
|
|
134
|
+
if regex_ents:
|
|
135
|
+
return regex_ents
|
|
136
|
+
return _llm_entities(text) # Tier-3 (opt-in): LLM NER when nothing else matched
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _llm_entities(text: str) -> set[str]:
|
|
140
|
+
"""Tier-3 NER (§7.1): only when spaCy + regex found nothing AND the user
|
|
141
|
+
opted in via DEBUGAI_LLM_NER (+ an OpenAI key). Costs an LLM call, so off
|
|
142
|
+
by default."""
|
|
143
|
+
if not (os.environ.get("DEBUGAI_LLM_NER") and os.environ.get("OPENAI_API_KEY") and text.strip()):
|
|
144
|
+
return set()
|
|
145
|
+
try:
|
|
146
|
+
from openai import OpenAI
|
|
147
|
+
|
|
148
|
+
client = OpenAI(timeout=20.0, max_retries=1)
|
|
149
|
+
r = client.chat.completions.create(
|
|
150
|
+
model=os.environ.get("DEBUGAI_JUDGE_MODEL", "gpt-5.5"),
|
|
151
|
+
response_format={"type": "json_object"},
|
|
152
|
+
messages=[
|
|
153
|
+
{"role": "system", "content": "Extract the named entities (people, "
|
|
154
|
+
"organisations, products, places, numbers, dates) from the text. "
|
|
155
|
+
'Respond as JSON: {"entities": ["..."]}.'},
|
|
156
|
+
{"role": "user", "content": text[:2000]},
|
|
157
|
+
],
|
|
158
|
+
)
|
|
159
|
+
data = json.loads(r.choices[0].message.content or "{}")
|
|
160
|
+
return {str(e).lower() for e in data.get("entities", []) if e}
|
|
161
|
+
except Exception as e: # pragma: no cover - network dependent
|
|
162
|
+
log.warning("LLM NER fallback failed (%s)", e)
|
|
163
|
+
return set()
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def compute_entity_coverage(output: str, context: str) -> tuple[float, int, int]:
|
|
167
|
+
"""Return (coverage_ratio, total_entities, missing_entities)."""
|
|
168
|
+
out_ents = _extract_entities(output)
|
|
169
|
+
if not out_ents:
|
|
170
|
+
return 1.0, 0, 0 # nothing claimed → nothing missing
|
|
171
|
+
ctx_blob = (context or "").lower()
|
|
172
|
+
covered = sum(1 for e in out_ents if e in ctx_blob)
|
|
173
|
+
total = len(out_ents)
|
|
174
|
+
return round(covered / total, 4), total, total - covered
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
# --------------------------------------------------------------------------- #
|
|
178
|
+
# Signal 3 — Similarity (mean retrieval cosine)
|
|
179
|
+
# --------------------------------------------------------------------------- #
|
|
180
|
+
def compute_similarity(rec: CaptureRecord) -> float:
|
|
181
|
+
# Trust only finite numeric scores (a client may pass None/strings/NaN).
|
|
182
|
+
numeric = [float(s) for s in (rec.similarity_scores or []) if _finite(s)]
|
|
183
|
+
if numeric:
|
|
184
|
+
return round(sum(numeric) / len(numeric), 4)
|
|
185
|
+
# No usable scores. If we have a query + chunks, compute the cosine ourselves.
|
|
186
|
+
embed = models.embedder()
|
|
187
|
+
if embed is not None and rec.retrieval_query and rec.retrieved_chunks:
|
|
188
|
+
try:
|
|
189
|
+
import numpy as np
|
|
190
|
+
|
|
191
|
+
q = embed.encode(rec.retrieval_query, normalize_embeddings=True)
|
|
192
|
+
cs = embed.encode(rec.retrieved_chunks, normalize_embeddings=True)
|
|
193
|
+
sims = np.clip(cs @ q, 0.0, 1.0)
|
|
194
|
+
return round(float(sims.mean()), 4)
|
|
195
|
+
except Exception as e:
|
|
196
|
+
log.warning("similarity recompute failed (%s); treating as healthy", e)
|
|
197
|
+
return 1.0 # non-RAG request → retrieval not applicable, treat as healthy
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
# --------------------------------------------------------------------------- #
|
|
201
|
+
# Signal 4 — Contradiction (cross-encoder NLI)
|
|
202
|
+
# --------------------------------------------------------------------------- #
|
|
203
|
+
def compute_contradiction(output: str, chunks: list[str]) -> float:
|
|
204
|
+
if not chunks:
|
|
205
|
+
return 0.0
|
|
206
|
+
model = models.nli_model()
|
|
207
|
+
if model is None:
|
|
208
|
+
return 0.0 # fallback: no NLI available
|
|
209
|
+
|
|
210
|
+
# HuggingFace Inference API path (DEBUGAI_NLI_API=1) — zero local RAM.
|
|
211
|
+
if getattr(model, "is_hf_api", False):
|
|
212
|
+
return _hf_api_contradiction(output, chunks)
|
|
213
|
+
|
|
214
|
+
try:
|
|
215
|
+
import numpy as np
|
|
216
|
+
|
|
217
|
+
pairs = [(c, output) for c in chunks] # (premise=chunk, hypothesis=output)
|
|
218
|
+
logits = np.atleast_2d(model.predict(pairs))
|
|
219
|
+
if logits.shape[1] < 3: # unexpected label layout → can't read contradiction
|
|
220
|
+
return 0.0
|
|
221
|
+
exp = np.exp(logits - logits.max(axis=1, keepdims=True))
|
|
222
|
+
probs = exp / exp.sum(axis=1, keepdims=True)
|
|
223
|
+
return round(float(probs[:, 0].max()), 4) # label 0 = contradiction
|
|
224
|
+
except Exception as e:
|
|
225
|
+
log.warning("NLI contradiction failed (%s); defaulting to 0.0", e)
|
|
226
|
+
return 0.0
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _hf_api_contradiction(output: str, chunks: list[str]) -> float:
|
|
230
|
+
"""Call the HuggingFace Inference API for NLI instead of a local model.
|
|
231
|
+
Model: cross-encoder/nli-deberta-v3-base on HF (full accuracy, zero RAM).
|
|
232
|
+
Set HF_TOKEN env var for higher rate limits (free account is fine).
|
|
233
|
+
"""
|
|
234
|
+
import json as _json
|
|
235
|
+
import os
|
|
236
|
+
import urllib.request
|
|
237
|
+
|
|
238
|
+
token = os.environ.get("HF_TOKEN") or os.environ.get("HUGGINGFACE_TOKEN")
|
|
239
|
+
url = f"https://api-inference.huggingface.co/models/{models.NLI_HF_MODEL_ID}"
|
|
240
|
+
headers: dict = {"Content-Type": "application/json"}
|
|
241
|
+
if token:
|
|
242
|
+
headers["Authorization"] = f"Bearer {token}"
|
|
243
|
+
|
|
244
|
+
max_contradiction = 0.0
|
|
245
|
+
for chunk in chunks[:4]: # cap at 4 chunks to stay within free-tier rate limits
|
|
246
|
+
try:
|
|
247
|
+
payload = _json.dumps({
|
|
248
|
+
"inputs": f"{chunk} [SEP] {output}",
|
|
249
|
+
"options": {"wait_for_model": True},
|
|
250
|
+
}).encode()
|
|
251
|
+
req = urllib.request.Request(url, data=payload, headers=headers)
|
|
252
|
+
resp = _json.loads(urllib.request.urlopen(req, timeout=10).read())
|
|
253
|
+
# HF returns: [{"label": "CONTRADICTION", "score": 0.9}, ...]
|
|
254
|
+
for item in resp:
|
|
255
|
+
if isinstance(item, dict) and item.get("label", "").upper() == "CONTRADICTION":
|
|
256
|
+
max_contradiction = max(max_contradiction, float(item.get("score", 0)))
|
|
257
|
+
except Exception as e:
|
|
258
|
+
log.debug("HF NLI API call failed (%s); skipping chunk", e)
|
|
259
|
+
return round(max_contradiction, 4)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
# --------------------------------------------------------------------------- #
|
|
263
|
+
# Signal 5 — Output variance (proxy estimation, §7.5)
|
|
264
|
+
# --------------------------------------------------------------------------- #
|
|
265
|
+
def estimate_variance(rec: CaptureRecord) -> tuple[float, str]:
|
|
266
|
+
temp = rec.temperature
|
|
267
|
+
if not _finite(temp):
|
|
268
|
+
return 0.0, "estimated" # no/invalid temperature → assume deterministic
|
|
269
|
+
# Base scales with sampling temperature (temp 1.5 ≈ full variance).
|
|
270
|
+
base = max(0.0, min(temp / 1.5, 1.0))
|
|
271
|
+
# Output-format / grounding constraints reduce realised variance.
|
|
272
|
+
if _CONSTRAINT_RE.search(rec.system_prompt or "") or _CONSTRAINT_RE.search(
|
|
273
|
+
rec.user_prompt or ""
|
|
274
|
+
):
|
|
275
|
+
base *= 0.5
|
|
276
|
+
return round(base, 4), "estimated"
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def measure_variance(rerun, system_prompt: str, user_prompt: str,
|
|
280
|
+
chunks: list[str], temperature, runs: int = 3) -> float:
|
|
281
|
+
"""Deep-mode variance (§7.5 Tier 2): actually run the model `runs` times and
|
|
282
|
+
measure output (in)stability as 1 − mean pairwise similarity. Costs N LLM
|
|
283
|
+
calls, so it's opt-in (async/CI). Returns 0-1; 0.0 if it can't sample."""
|
|
284
|
+
outs = []
|
|
285
|
+
for _ in range(max(2, runs)):
|
|
286
|
+
try:
|
|
287
|
+
outs.append(rerun(system_prompt, user_prompt, chunks, temperature) or "")
|
|
288
|
+
except Exception as e:
|
|
289
|
+
log.warning("variance rerun failed (%s)", e)
|
|
290
|
+
outs = [o for o in outs if o]
|
|
291
|
+
if len(outs) < 2:
|
|
292
|
+
return 0.0
|
|
293
|
+
embed = models.embedder()
|
|
294
|
+
if embed is not None:
|
|
295
|
+
try:
|
|
296
|
+
import numpy as np
|
|
297
|
+
|
|
298
|
+
v = embed.encode(outs, normalize_embeddings=True)
|
|
299
|
+
sims = np.clip(v @ v.T, 0.0, 1.0)
|
|
300
|
+
n = len(outs)
|
|
301
|
+
mean_pair = (sims.sum() - n) / (n * n - n) # off-diagonal mean cosine
|
|
302
|
+
return round(max(0.0, min(1.0 - float(mean_pair), 1.0)), 4)
|
|
303
|
+
except Exception as e:
|
|
304
|
+
log.warning("variance embedding failed (%s); using token overlap", e)
|
|
305
|
+
# token fallback: mean pairwise Jaccard dissimilarity
|
|
306
|
+
toks = [_tokens(o) for o in outs]
|
|
307
|
+
pairs, total = 0, 0.0
|
|
308
|
+
for i in range(len(toks)):
|
|
309
|
+
for j in range(i + 1, len(toks)):
|
|
310
|
+
u = toks[i] | toks[j]
|
|
311
|
+
total += (len(toks[i] & toks[j]) / len(u)) if u else 1.0
|
|
312
|
+
pairs += 1
|
|
313
|
+
return round(1.0 - (total / pairs if pairs else 1.0), 4)
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
# --------------------------------------------------------------------------- #
|
|
317
|
+
# Signals 6-8 — cheap runtime / ratio signals (pure math)
|
|
318
|
+
# --------------------------------------------------------------------------- #
|
|
319
|
+
def compute_token_ratio(rec: CaptureRecord) -> float:
|
|
320
|
+
usage = rec.token_usage or {}
|
|
321
|
+
total = usage.get("total") or (
|
|
322
|
+
usage.get("prompt", 0) + usage.get("completion", 0)
|
|
323
|
+
)
|
|
324
|
+
if not total:
|
|
325
|
+
total = _approx_token_count(rec.full_prompt) + _approx_token_count(rec.llm_output)
|
|
326
|
+
cap = rec.max_tokens or rec.context_window
|
|
327
|
+
if not cap:
|
|
328
|
+
return 0.0 # unknown cap → can't flag a limit
|
|
329
|
+
return round(min(total / cap, 1.0), 4)
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def compute_context_ratio(rec: CaptureRecord) -> float:
|
|
333
|
+
window = rec.context_window
|
|
334
|
+
if not window:
|
|
335
|
+
return 0.0 # unknown window → capacity signal not applicable
|
|
336
|
+
prompt_tokens = (
|
|
337
|
+
(rec.token_usage or {}).get("prompt")
|
|
338
|
+
or _approx_token_count(rec.full_prompt) + _approx_token_count(rec.context_text)
|
|
339
|
+
)
|
|
340
|
+
return round(min(prompt_tokens / window, 1.0), 4)
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
# --------------------------------------------------------------------------- #
|
|
344
|
+
# Orchestrator
|
|
345
|
+
# --------------------------------------------------------------------------- #
|
|
346
|
+
def compute_signals(rec: CaptureRecord, lazy: bool = False) -> SignalVector:
|
|
347
|
+
"""Compute the full 8-signal vector for a capture record.
|
|
348
|
+
|
|
349
|
+
With ``lazy=True`` the expensive model-backed signals (overlap, entity
|
|
350
|
+
coverage, contradiction) are skipped when the cheap signals already look
|
|
351
|
+
healthy — the fail-open path described in §2.3 / §7.4.
|
|
352
|
+
"""
|
|
353
|
+
similarity = compute_similarity(rec)
|
|
354
|
+
latency = float(rec.latency_ms or 0.0)
|
|
355
|
+
token_ratio = compute_token_ratio(rec)
|
|
356
|
+
context_ratio = compute_context_ratio(rec)
|
|
357
|
+
variance, var_method = estimate_variance(rec)
|
|
358
|
+
|
|
359
|
+
cheap_healthy = (
|
|
360
|
+
similarity >= 0.50
|
|
361
|
+
and context_ratio <= 0.85
|
|
362
|
+
and token_ratio <= 0.80
|
|
363
|
+
and variance <= 0.30
|
|
364
|
+
)
|
|
365
|
+
if lazy and cheap_healthy:
|
|
366
|
+
# Fail open: skip the expensive stage, assume grounded output.
|
|
367
|
+
return SignalVector(
|
|
368
|
+
overlap=1.0,
|
|
369
|
+
entity_coverage=1.0,
|
|
370
|
+
similarity=similarity,
|
|
371
|
+
contradiction=0.0,
|
|
372
|
+
variance=variance,
|
|
373
|
+
latency_ms=latency,
|
|
374
|
+
token_ratio=token_ratio,
|
|
375
|
+
context_ratio=context_ratio,
|
|
376
|
+
overlap_method="skipped-lazy",
|
|
377
|
+
variance_method=var_method,
|
|
378
|
+
)
|
|
379
|
+
|
|
380
|
+
overlap, overlap_method = compute_overlap(rec.llm_output, rec.context_text)
|
|
381
|
+
entity_coverage, ent_total, ent_missing = compute_entity_coverage(
|
|
382
|
+
rec.llm_output, rec.context_text
|
|
383
|
+
)
|
|
384
|
+
contradiction = compute_contradiction(rec.llm_output, rec.retrieved_chunks)
|
|
385
|
+
|
|
386
|
+
return SignalVector(
|
|
387
|
+
overlap=overlap,
|
|
388
|
+
entity_coverage=entity_coverage,
|
|
389
|
+
similarity=similarity,
|
|
390
|
+
contradiction=contradiction,
|
|
391
|
+
variance=variance,
|
|
392
|
+
latency_ms=latency,
|
|
393
|
+
token_ratio=token_ratio,
|
|
394
|
+
context_ratio=context_ratio,
|
|
395
|
+
overlap_method=overlap_method,
|
|
396
|
+
variance_method=var_method,
|
|
397
|
+
entities_total=ent_total,
|
|
398
|
+
entities_missing=ent_missing,
|
|
399
|
+
)
|
debugai/thresholds.json
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"_comment": "Default cold-start thresholds (Architecture §7.2). Per-user adaptive calibration replaces these after 50+ requests.",
|
|
3
|
+
"context_length_ratio_max": 0.85,
|
|
4
|
+
"token_usage_high": 0.80,
|
|
5
|
+
"latency_high_ms": 3000,
|
|
6
|
+
"overlap_low": 0.40,
|
|
7
|
+
"overlap_very_low": 0.30,
|
|
8
|
+
"similarity_min": 0.50,
|
|
9
|
+
"entity_coverage_min": 0.40,
|
|
10
|
+
"entity_coverage_hallucination": 0.50,
|
|
11
|
+
"contradiction_min": 0.20,
|
|
12
|
+
"variance_min": 0.30,
|
|
13
|
+
"temperature_high": 0.5,
|
|
14
|
+
"hallucination_fire": 0.50
|
|
15
|
+
}
|
debugai/thresholds.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Threshold configuration (Architecture §7.2).
|
|
2
|
+
|
|
3
|
+
Cold-start defaults loaded from ``thresholds.json``. In production these become
|
|
4
|
+
per-user adaptive values (cold <50 req: defaults; warm 50-500: percentile; hot
|
|
5
|
+
>500: rolling window). For the MVP we ship the deterministic defaults and expose
|
|
6
|
+
a simple override hook.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
from dataclasses import dataclass, fields
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
_DEFAULTS_PATH = Path(__file__).with_name("thresholds.json")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class Thresholds:
|
|
20
|
+
context_length_ratio_max: float = 0.85
|
|
21
|
+
token_usage_high: float = 0.80
|
|
22
|
+
latency_high_ms: float = 3000
|
|
23
|
+
overlap_low: float = 0.40
|
|
24
|
+
overlap_very_low: float = 0.30
|
|
25
|
+
similarity_min: float = 0.50
|
|
26
|
+
entity_coverage_min: float = 0.40
|
|
27
|
+
entity_coverage_hallucination: float = 0.50
|
|
28
|
+
contradiction_min: float = 0.20
|
|
29
|
+
variance_min: float = 0.30
|
|
30
|
+
temperature_high: float = 0.5
|
|
31
|
+
hallucination_fire: float = 0.50
|
|
32
|
+
|
|
33
|
+
@classmethod
|
|
34
|
+
def load(cls, path: Path | None = None) -> "Thresholds":
|
|
35
|
+
path = path or _DEFAULTS_PATH
|
|
36
|
+
try:
|
|
37
|
+
raw = json.loads(path.read_text())
|
|
38
|
+
except FileNotFoundError:
|
|
39
|
+
return cls()
|
|
40
|
+
known = {f.name for f in fields(cls)}
|
|
41
|
+
return cls(**{k: v for k, v in raw.items() if k in known})
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
DEFAULT_THRESHOLDS = Thresholds.load()
|