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/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
+ )
@@ -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()