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.
@@ -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
+ )