logitgate 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.
- logitgate/__init__.py +103 -0
- logitgate/backends/__init__.py +25 -0
- logitgate/backends/base.py +259 -0
- logitgate/backends/dory_pytorch.py +330 -0
- logitgate/backends/llamacpp.py +352 -0
- logitgate/backends/ollama.py +286 -0
- logitgate/backends/openai_compat.py +195 -0
- logitgate/backends/recurrent.py +245 -0
- logitgate/cascade.py +394 -0
- logitgate/cli.py +264 -0
- logitgate/config.py +172 -0
- logitgate/debiasing/__init__.py +35 -0
- logitgate/debiasing/inversion.py +180 -0
- logitgate/debiasing/permutation.py +287 -0
- logitgate/debiasing/softmax.py +98 -0
- logitgate/gates/__init__.py +14 -0
- logitgate/gates/ast_gate.py +544 -0
- logitgate/gates/base.py +68 -0
- logitgate/gates/judge_gate.py +318 -0
- logitgate/gates/logit_gate.py +951 -0
- logitgate/integrations/__init__.py +10 -0
- logitgate/integrations/deepeval.py +212 -0
- logitgate/integrations/langgraph.py +30 -0
- logitgate/integrations/pytest_plugin.py +68 -0
- logitgate/models.py +109 -0
- logitgate/py.typed +0 -0
- logitgate/snapshot.py +224 -0
- logitgate-0.2.0.dist-info/METADATA +1236 -0
- logitgate-0.2.0.dist-info/RECORD +33 -0
- logitgate-0.2.0.dist-info/WHEEL +5 -0
- logitgate-0.2.0.dist-info/entry_points.txt +5 -0
- logitgate-0.2.0.dist-info/licenses/LICENSE +201 -0
- logitgate-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
import time
|
|
6
|
+
from typing import Any, Literal
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
from logitgate.backends.base import GenerativeBackend, resolve_generative_backend
|
|
11
|
+
from logitgate.config import get_default_backend, get_default_model
|
|
12
|
+
from logitgate.gates.base import BaseGate
|
|
13
|
+
from logitgate.models import DecisionStatus, LayerTrace, Verdict
|
|
14
|
+
|
|
15
|
+
TriggerMode = Literal["on_ambiguous_or_fail", "on_ambiguous_only", "always"]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class JudgeGate(BaseGate):
|
|
19
|
+
"""Generative System-2 Reasoning Judge (Layer 2).
|
|
20
|
+
|
|
21
|
+
Generates actionable diagnostic critiques and explicit failure explanations
|
|
22
|
+
using an autoregressive LLM only when triggered by upstream gate ambiguity or failure.
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
rubric: Optional evaluation rubric. If omitted, inherits the rubric from
|
|
26
|
+
upstream LogitGate when executing inside a Cascade.
|
|
27
|
+
backend: Backend instance or specifier URI implementing `GenerativeBackend`.
|
|
28
|
+
model: Optional model identifier string for the backend.
|
|
29
|
+
trigger: Activation policy for this judge layer:
|
|
30
|
+
- 'on_ambiguous_or_fail': Triggers if upstream gate returns AMBIGUOUS or FAILED.
|
|
31
|
+
- 'on_ambiguous_only': Triggers only on AMBIGUOUS; decisive FAILED short-circuits.
|
|
32
|
+
- 'always': Always executes generative critique regardless of upstream confidence.
|
|
33
|
+
name: Identifier for this gate instance.
|
|
34
|
+
|
|
35
|
+
Raises:
|
|
36
|
+
ValueError: If trigger is not one of the allowed modes, or rubric is empty whitespace.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
def __init__(
|
|
40
|
+
self,
|
|
41
|
+
rubric: str | None = None,
|
|
42
|
+
backend: str | GenerativeBackend | None = None,
|
|
43
|
+
model: str | None = None,
|
|
44
|
+
trigger: TriggerMode = "on_ambiguous_or_fail",
|
|
45
|
+
max_tokens: int | None = None,
|
|
46
|
+
think: bool | None = None,
|
|
47
|
+
name: str = "JudgeGate",
|
|
48
|
+
) -> None:
|
|
49
|
+
valid_triggers = {"on_ambiguous_or_fail", "on_ambiguous_only", "always"}
|
|
50
|
+
if trigger not in valid_triggers:
|
|
51
|
+
raise ValueError(f"Invalid trigger '{trigger}'. Must be one of {sorted(valid_triggers)}")
|
|
52
|
+
if rubric is not None and not rubric.strip():
|
|
53
|
+
raise ValueError("rubric cannot be empty or whitespace")
|
|
54
|
+
|
|
55
|
+
effective_backend = (
|
|
56
|
+
get_default_backend() if (backend is None or backend == "default") else backend
|
|
57
|
+
)
|
|
58
|
+
effective_model = model or get_default_model()
|
|
59
|
+
|
|
60
|
+
self.rubric = rubric.strip() if rubric is not None else None
|
|
61
|
+
self.backend = resolve_generative_backend(effective_backend, model=effective_model)
|
|
62
|
+
self.model = effective_model
|
|
63
|
+
self.trigger = trigger
|
|
64
|
+
self.max_tokens = max_tokens
|
|
65
|
+
self.think = think
|
|
66
|
+
self.name = name
|
|
67
|
+
|
|
68
|
+
def __repr__(self) -> str:
|
|
69
|
+
rubric_repr = (
|
|
70
|
+
repr(self.rubric[:40] + "..." if len(self.rubric) > 40 else self.rubric)
|
|
71
|
+
if self.rubric
|
|
72
|
+
else "None (inherited)"
|
|
73
|
+
)
|
|
74
|
+
max_tok_str = f", max_tokens={self.max_tokens}" if self.max_tokens is not None else ""
|
|
75
|
+
return f"JudgeGate(rubric={rubric_repr}, trigger='{self.trigger}'{max_tok_str}, backend={self.backend!r})"
|
|
76
|
+
|
|
77
|
+
def _compile_prompt(self, candidate: Any, rubric_override: str | None = None) -> str:
|
|
78
|
+
effective_rubric = (rubric_override or self.rubric or "Evaluate if the artifact complies with standards.").strip()
|
|
79
|
+
if self.max_tokens is not None and self.max_tokens <= 80:
|
|
80
|
+
return (
|
|
81
|
+
f"[EVALUATION RUBRIC]\n{effective_rubric}\n\n"
|
|
82
|
+
f"[CANDIDATE ARTIFACT]\n{str(candidate).strip()}\n\n"
|
|
83
|
+
f"[INSTRUCTIONS]\n"
|
|
84
|
+
f"Begin your response on the very first line with your verdict tag: [VERDICT: PASS] or [VERDICT: FAIL]\n"
|
|
85
|
+
f"Then provide a concise 1-2 sentence diagnostic critique explaining why the candidate artifact passes or fails the rubric, and end by repeating [VERDICT: PASS] or [VERDICT: FAIL]."
|
|
86
|
+
)
|
|
87
|
+
return (
|
|
88
|
+
f"[EVALUATION RUBRIC]\n{effective_rubric}\n\n"
|
|
89
|
+
f"[CANDIDATE ARTIFACT]\n{str(candidate).strip()}\n\n"
|
|
90
|
+
f"[INSTRUCTIONS]\n"
|
|
91
|
+
f"First, concisely verify each requirement or calculation in the candidate artifact against the rubric step-by-step (in 2-4 concise bullet points).\n"
|
|
92
|
+
f"After completing your verification, conclude on the very last line with your verdict tag: [VERDICT: PASS] or [VERDICT: FAIL]."
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
def _parse_verdict(self, text: str) -> tuple[bool, DecisionStatus, float | None]:
|
|
96
|
+
# 1. Standard / Markdown tag check (matches [VERDICT: PASS], **[VERDICT: PASS]**, Verdict: PASS, etc.)
|
|
97
|
+
# Use the final occurrence so step-by-step reasoning concluding with [VERDICT: ...] is authoritative.
|
|
98
|
+
matches = list(re.finditer(r"\[?\*?\*?VERDICT[\s\*\]]*:[\s\*\[]*(PASS|FAIL)[\*\]]*", text, re.IGNORECASE))
|
|
99
|
+
if matches:
|
|
100
|
+
tag = matches[-1].group(1).upper()
|
|
101
|
+
if tag == "PASS":
|
|
102
|
+
return True, DecisionStatus.PASSED, 1.0
|
|
103
|
+
return False, DecisionStatus.FAILED, 0.0
|
|
104
|
+
|
|
105
|
+
# 2. JSON check
|
|
106
|
+
json_match = re.search(r"\{.*?\}", text, re.DOTALL)
|
|
107
|
+
if json_match:
|
|
108
|
+
try:
|
|
109
|
+
data = json.loads(json_match.group(0))
|
|
110
|
+
if isinstance(data, dict):
|
|
111
|
+
verdict_val = str(data.get("verdict", "")).lower()
|
|
112
|
+
is_pass = data.get("pass", data.get("passed", verdict_val == "pass"))
|
|
113
|
+
score = float(data.get("score", 1.0 if is_pass else 0.0))
|
|
114
|
+
return bool(is_pass), DecisionStatus.PASSED if is_pass else DecisionStatus.FAILED, score
|
|
115
|
+
except (json.JSONDecodeError, ValueError, TypeError, KeyError):
|
|
116
|
+
data = None
|
|
117
|
+
|
|
118
|
+
# 3. Eating Our Own Dogfood: When the critique text omits an explicit [VERDICT: ...] tag
|
|
119
|
+
# (e.g. due to max_tokens truncation mid-explanation) and the backend supports LogitBackend,
|
|
120
|
+
# probe the generated critique text with a 1-token LogitGate before falling back to loose regexes.
|
|
121
|
+
from logitgate.backends.base import LogitBackend, StubBackend
|
|
122
|
+
if isinstance(self.backend, LogitBackend) and not (isinstance(self.backend, StubBackend) and self.backend.logprob_responses is None):
|
|
123
|
+
try:
|
|
124
|
+
from logitgate.gates.logit_gate import LogitGate
|
|
125
|
+
probe_gate = LogitGate(
|
|
126
|
+
rubric="Based on this review critique, did the candidate artifact satisfy all requirements and PASS the evaluation without any defects or errors?",
|
|
127
|
+
backend=self.backend,
|
|
128
|
+
model=self.model,
|
|
129
|
+
symmetric_debiasing=False,
|
|
130
|
+
)
|
|
131
|
+
pv = probe_gate.evaluate(text)
|
|
132
|
+
if pv.probability is not None:
|
|
133
|
+
is_pass = (pv.probability >= 0.5)
|
|
134
|
+
return is_pass, DecisionStatus.PASSED if is_pass else DecisionStatus.FAILED, pv.probability
|
|
135
|
+
except (ValueError, RuntimeError, OSError, httpx.HTTPError):
|
|
136
|
+
pv = None
|
|
137
|
+
|
|
138
|
+
# 4. Explicit keyword heuristic on candidate lines (last non-empty line first, then first line)
|
|
139
|
+
lines = [line.strip() for line in text.strip().splitlines() if line.strip()]
|
|
140
|
+
candidate_lines = []
|
|
141
|
+
if lines:
|
|
142
|
+
candidate_lines.append(lines[-1])
|
|
143
|
+
if len(lines) > 1:
|
|
144
|
+
candidate_lines.append(lines[0])
|
|
145
|
+
|
|
146
|
+
def sanitize_negations(s: str) -> str:
|
|
147
|
+
neg_pattern = r"\b(not|no|without|never|doesn't|does\s+not|did\s+not|didn't|zero)\s+(?:any\s+|to\s+)?(?:fail|failed|failure|failures|reject|rejected)\b"
|
|
148
|
+
return re.sub(neg_pattern, " ", s, flags=re.IGNORECASE)
|
|
149
|
+
|
|
150
|
+
pass_re = re.compile(r"\b(PASS|PASSED|PASSES|APPROVE|APPROVED)\b", re.IGNORECASE)
|
|
151
|
+
fail_re = re.compile(r"\b(FAIL|FAILED|FAILS|FAILURE|REJECT|REJECTED)\b", re.IGNORECASE)
|
|
152
|
+
|
|
153
|
+
for line in candidate_lines:
|
|
154
|
+
sanitized = sanitize_negations(line)
|
|
155
|
+
has_fail = bool(fail_re.search(sanitized))
|
|
156
|
+
has_pass = bool(pass_re.search(sanitized))
|
|
157
|
+
if has_pass and not has_fail:
|
|
158
|
+
return True, DecisionStatus.PASSED, 1.0
|
|
159
|
+
if has_fail and not has_pass:
|
|
160
|
+
return False, DecisionStatus.FAILED, 0.0
|
|
161
|
+
if has_fail and has_pass:
|
|
162
|
+
p_match = pass_re.search(sanitized)
|
|
163
|
+
f_match = fail_re.search(sanitized)
|
|
164
|
+
if p_match and f_match:
|
|
165
|
+
if p_match.start() < f_match.start():
|
|
166
|
+
return True, DecisionStatus.PASSED, 1.0
|
|
167
|
+
return False, DecisionStatus.FAILED, 0.0
|
|
168
|
+
|
|
169
|
+
# 5. Full text fallback heuristic
|
|
170
|
+
sanitized_full = sanitize_negations(text)
|
|
171
|
+
has_fail = bool(fail_re.search(sanitized_full))
|
|
172
|
+
has_pass = bool(pass_re.search(sanitized_full))
|
|
173
|
+
if has_pass and not has_fail:
|
|
174
|
+
return True, DecisionStatus.PASSED, 1.0
|
|
175
|
+
if has_fail and not has_pass:
|
|
176
|
+
return False, DecisionStatus.FAILED, 0.0
|
|
177
|
+
|
|
178
|
+
return False, DecisionStatus.FAILED, 0.0
|
|
179
|
+
|
|
180
|
+
async def _aparse_verdict(self, text: str) -> tuple[bool, DecisionStatus, float | None]:
|
|
181
|
+
# 1. Standard / Markdown tag check
|
|
182
|
+
match = re.search(r"\[?\*?\*?VERDICT[\s\*\]]*:[\s\*\[]*(PASS|FAIL)[\*\]]*", text, re.IGNORECASE)
|
|
183
|
+
if match:
|
|
184
|
+
tag = match.group(1).upper()
|
|
185
|
+
if tag == "PASS":
|
|
186
|
+
return True, DecisionStatus.PASSED, 1.0
|
|
187
|
+
return False, DecisionStatus.FAILED, 0.0
|
|
188
|
+
|
|
189
|
+
# 2. JSON check
|
|
190
|
+
json_match = re.search(r"\{.*?\}", text, re.DOTALL)
|
|
191
|
+
if json_match:
|
|
192
|
+
try:
|
|
193
|
+
data = json.loads(json_match.group(0))
|
|
194
|
+
if isinstance(data, dict):
|
|
195
|
+
verdict_val = str(data.get("verdict", "")).lower()
|
|
196
|
+
is_pass = data.get("pass", data.get("passed", verdict_val == "pass"))
|
|
197
|
+
score = float(data.get("score", 1.0 if is_pass else 0.0))
|
|
198
|
+
return bool(is_pass), DecisionStatus.PASSED if is_pass else DecisionStatus.FAILED, score
|
|
199
|
+
except (json.JSONDecodeError, ValueError, TypeError, KeyError):
|
|
200
|
+
data = None
|
|
201
|
+
|
|
202
|
+
# 3. Candidate lines check
|
|
203
|
+
lines = [line.strip() for line in text.strip().splitlines() if line.strip()]
|
|
204
|
+
candidate_lines = []
|
|
205
|
+
if lines:
|
|
206
|
+
candidate_lines.append(lines[0])
|
|
207
|
+
if len(lines) > 1:
|
|
208
|
+
candidate_lines.append(lines[-1])
|
|
209
|
+
|
|
210
|
+
def sanitize_negations(s: str) -> str:
|
|
211
|
+
neg_pattern = r"\b(not|no|without|never|doesn't|does\s+not|did\s+not|didn't|zero)\s+(?:any\s+|to\s+)?(?:fail|failed|failure|failures|reject|rejected)\b"
|
|
212
|
+
return re.sub(neg_pattern, " ", s, flags=re.IGNORECASE)
|
|
213
|
+
|
|
214
|
+
pass_re = re.compile(r"\b(PASS|PASSED|PASSES|APPROVE|APPROVED)\b", re.IGNORECASE)
|
|
215
|
+
fail_re = re.compile(r"\b(FAIL|FAILED|FAILS|FAILURE|REJECT|REJECTED)\b", re.IGNORECASE)
|
|
216
|
+
|
|
217
|
+
for line in candidate_lines:
|
|
218
|
+
sanitized = sanitize_negations(line)
|
|
219
|
+
has_fail = bool(fail_re.search(sanitized))
|
|
220
|
+
has_pass = bool(pass_re.search(sanitized))
|
|
221
|
+
if has_pass and not has_fail:
|
|
222
|
+
return True, DecisionStatus.PASSED, 1.0
|
|
223
|
+
if has_fail and not has_pass:
|
|
224
|
+
return False, DecisionStatus.FAILED, 0.0
|
|
225
|
+
if has_fail and has_pass:
|
|
226
|
+
p_match = pass_re.search(sanitized)
|
|
227
|
+
f_match = fail_re.search(sanitized)
|
|
228
|
+
if p_match and f_match:
|
|
229
|
+
if p_match.start() < f_match.start():
|
|
230
|
+
return True, DecisionStatus.PASSED, 1.0
|
|
231
|
+
return False, DecisionStatus.FAILED, 0.0
|
|
232
|
+
|
|
233
|
+
# 4. Eating Our Own Dogfood: Asynchronous LogitGate probe
|
|
234
|
+
from logitgate.backends.base import LogitBackend, StubBackend
|
|
235
|
+
if isinstance(self.backend, LogitBackend) and not (isinstance(self.backend, StubBackend) and self.backend.logprob_responses is None):
|
|
236
|
+
try:
|
|
237
|
+
from logitgate.gates.logit_gate import LogitGate
|
|
238
|
+
probe_gate = LogitGate(
|
|
239
|
+
rubric="Does this review critique conclude that the candidate artifact PASSED the evaluation?",
|
|
240
|
+
backend=self.backend,
|
|
241
|
+
model=self.model,
|
|
242
|
+
symmetric_debiasing=False,
|
|
243
|
+
)
|
|
244
|
+
pv = await probe_gate.aevaluate(text)
|
|
245
|
+
if pv.probability is not None:
|
|
246
|
+
is_pass = (pv.probability >= 0.5)
|
|
247
|
+
return is_pass, DecisionStatus.PASSED if is_pass else DecisionStatus.FAILED, pv.probability
|
|
248
|
+
except (ValueError, RuntimeError, OSError, httpx.HTTPError):
|
|
249
|
+
pv = None
|
|
250
|
+
|
|
251
|
+
# 5. Full text fallback heuristic
|
|
252
|
+
sanitized_full = sanitize_negations(text)
|
|
253
|
+
has_fail = bool(fail_re.search(sanitized_full))
|
|
254
|
+
has_pass = bool(pass_re.search(sanitized_full))
|
|
255
|
+
if has_pass and not has_fail:
|
|
256
|
+
return True, DecisionStatus.PASSED, 1.0
|
|
257
|
+
if has_fail and not has_pass:
|
|
258
|
+
return False, DecisionStatus.FAILED, 0.0
|
|
259
|
+
|
|
260
|
+
return False, DecisionStatus.FAILED, 0.0
|
|
261
|
+
|
|
262
|
+
def evaluate(self, candidate: Any, rubric_override: str | None = None, **kwargs: Any) -> Verdict:
|
|
263
|
+
t0 = time.perf_counter()
|
|
264
|
+
prompt = self._compile_prompt(candidate, rubric_override=rubric_override)
|
|
265
|
+
gen_kwargs = dict(kwargs)
|
|
266
|
+
if self.think is not None and "think" not in gen_kwargs:
|
|
267
|
+
gen_kwargs["think"] = self.think
|
|
268
|
+
text, tokens = self.backend.generate(prompt, max_tokens=self.max_tokens, **gen_kwargs)
|
|
269
|
+
latency = (time.perf_counter() - t0) * 1000.0
|
|
270
|
+
|
|
271
|
+
passed, status, score = self._parse_verdict(text)
|
|
272
|
+
trace = LayerTrace(
|
|
273
|
+
layer_name=self.name,
|
|
274
|
+
status=status,
|
|
275
|
+
latency_ms=latency,
|
|
276
|
+
tokens_generated=tokens,
|
|
277
|
+
diagnostic_report=text,
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
return Verdict(
|
|
281
|
+
passed=passed,
|
|
282
|
+
status=status,
|
|
283
|
+
score=score,
|
|
284
|
+
triggered_layer=self.name,
|
|
285
|
+
total_latency_ms=latency,
|
|
286
|
+
total_tokens_generated=tokens,
|
|
287
|
+
reason=text,
|
|
288
|
+
traces=[trace],
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
async def aevaluate(self, candidate: Any, rubric_override: str | None = None, **kwargs: Any) -> Verdict:
|
|
292
|
+
t0 = time.perf_counter()
|
|
293
|
+
prompt = self._compile_prompt(candidate, rubric_override=rubric_override)
|
|
294
|
+
gen_kwargs = dict(kwargs)
|
|
295
|
+
if self.think is not None and "think" not in gen_kwargs:
|
|
296
|
+
gen_kwargs["think"] = self.think
|
|
297
|
+
text, tokens = await self.backend.agenerate(prompt, max_tokens=self.max_tokens, **gen_kwargs)
|
|
298
|
+
latency = (time.perf_counter() - t0) * 1000.0
|
|
299
|
+
|
|
300
|
+
passed, status, score = await self._aparse_verdict(text)
|
|
301
|
+
trace = LayerTrace(
|
|
302
|
+
layer_name=self.name,
|
|
303
|
+
status=status,
|
|
304
|
+
latency_ms=latency,
|
|
305
|
+
tokens_generated=tokens,
|
|
306
|
+
diagnostic_report=text,
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
return Verdict(
|
|
310
|
+
passed=passed,
|
|
311
|
+
status=status,
|
|
312
|
+
score=score,
|
|
313
|
+
triggered_layer=self.name,
|
|
314
|
+
total_latency_ms=latency,
|
|
315
|
+
total_tokens_generated=tokens,
|
|
316
|
+
reason=text,
|
|
317
|
+
traces=[trace],
|
|
318
|
+
)
|