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,245 @@
1
+ """
2
+ PyTorch & HuggingFace backend with elastic recurrent layer-looping support.
3
+
4
+ Designed for architectures like NVIDIA-Nemotron-Labs Dory-4B and Universal Transformers
5
+ that support dynamic layer looping (`num_loops`) to perform latent reasoning in hidden
6
+ state space before producing first-token logits.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import math
12
+ from typing import Any
13
+
14
+ from logitgate.backends.base import GenerativeBackend, LogitBackend
15
+
16
+
17
+ class RecurrentPyTorchBackend(LogitBackend, GenerativeBackend):
18
+ """
19
+ Inference backend for PyTorch/Transformers models with elastic recurrent layer looping.
20
+
21
+ Supports architectures like NVIDIA's Dory (e.g. ``nvidia/NVIDIA-Nemotron-Labs-Dory1.0-4B-BF16``)
22
+ where hidden states are recirculated through a recurrent layer block for ``num_loops`` iterations
23
+ before computing first-token logits.
24
+
25
+ Parameters
26
+ ----------
27
+ model_name : str
28
+ HuggingFace model ID or local directory (default: ``"nvidia/NVIDIA-Nemotron-Labs-Dory1.0-4B-BF16"``).
29
+ num_loops : int
30
+ Number of internal recurrent loops to execute per token (default: 2; recommended: 1 to 4).
31
+ Higher values increase latent reasoning depth at the cost of ~30-60% prefill compute.
32
+ device : str
33
+ Target compute device (``"cuda"``, ``"cpu"``, or ``"auto"``).
34
+ model : Any, optional
35
+ Pre-instantiated model instance (useful for testing or shared memory pipelines).
36
+ tokenizer : Any, optional
37
+ Pre-instantiated tokenizer instance.
38
+ torch_dtype : Any, optional
39
+ PyTorch dtype (e.g. ``torch.bfloat16``).
40
+ """
41
+
42
+ def __init__(
43
+ self,
44
+ model_name: str = "nvidia/NVIDIA-Nemotron-Labs-Dory1.0-4B-BF16",
45
+ num_loops: int = 2,
46
+ device: str = "cuda",
47
+ model: Any = None,
48
+ tokenizer: Any = None,
49
+ torch_dtype: Any = None,
50
+ ) -> None:
51
+ if num_loops < 1:
52
+ raise ValueError(f"num_loops must be >= 1, got {num_loops}")
53
+
54
+ self.model_name = model_name
55
+ self.num_loops = num_loops
56
+ self.device = device
57
+ self._model = model
58
+ self._tokenizer = tokenizer
59
+ self.torch_dtype = torch_dtype
60
+
61
+ def __repr__(self) -> str:
62
+ return f"RecurrentPyTorchBackend(model='{self.model_name}', num_loops={self.num_loops}, device='{self.device}')"
63
+
64
+ def _ensure_loaded(self) -> tuple[Any, Any]:
65
+ """Lazy-loads model and tokenizer if not already provided."""
66
+ if self._model is not None and self._tokenizer is not None:
67
+ return self._model, self._tokenizer
68
+
69
+ try:
70
+ import torch
71
+ from transformers import AutoModelForCausalLM, AutoTokenizer
72
+ except ImportError as err:
73
+ raise ImportError(
74
+ "PyTorch and Transformers are required for RecurrentPyTorchBackend. "
75
+ "Install them via `pip install logitgate[recurrent]` or `pip install torch transformers`."
76
+ ) from err
77
+
78
+ if self._tokenizer is None:
79
+ self._tokenizer = AutoTokenizer.from_pretrained(self.model_name, trust_remote_code=True)
80
+
81
+ if self._model is None:
82
+ target_dtype = self.torch_dtype or (torch.bfloat16 if torch.cuda.is_available() else torch.float32)
83
+ if "dory" in self.model_name.lower():
84
+ from logitgate.backends.dory_pytorch import load_dory_model
85
+
86
+ self._model = load_dory_model(
87
+ model_name_or_path=self.model_name,
88
+ device=self.device if self.device != "auto" else ("cuda:0" if torch.cuda.is_available() else "cpu"),
89
+ num_loops=self.num_loops,
90
+ torch_dtype=target_dtype,
91
+ )
92
+ else:
93
+ self._model = AutoModelForCausalLM.from_pretrained(
94
+ self.model_name,
95
+ torch_dtype=target_dtype,
96
+ trust_remote_code=True,
97
+ device_map=self.device if self.device != "auto" else "auto",
98
+ )
99
+ if self.device not in ("auto", "cuda") and hasattr(self._model, "to"):
100
+ self._model = self._model.to(self.device)
101
+
102
+ return self._model, self._tokenizer
103
+
104
+ def query_top_logprobs(
105
+ self,
106
+ prompt: str,
107
+ top_k: int = 10,
108
+ allowed_tokens: list[str] | None = None,
109
+ ) -> list[dict[str, Any]]:
110
+ """
111
+ Executes a forward pass with ``num_loops`` latent recurrence and extracts first-token logprobs.
112
+ """
113
+ model, tokenizer = self._ensure_loaded()
114
+
115
+ # Tokenize prompt
116
+ inputs = tokenizer(prompt, return_tensors="pt")
117
+ if hasattr(inputs, "to") and self.device not in ("auto",):
118
+ inputs = inputs.to(model.device if hasattr(model, "device") else self.device)
119
+
120
+ # Forward pass passing num_loops if supported by model signature
121
+ import inspect
122
+
123
+ kwargs: dict[str, Any] = {}
124
+ try:
125
+ fn = model.forward if hasattr(model, "forward") else model
126
+ sig = inspect.signature(fn)
127
+ if "num_loops" in sig.parameters or any(
128
+ p.kind == inspect.Parameter.VAR_KEYWORD for p in sig.parameters.values()
129
+ ):
130
+ kwargs["num_loops"] = self.num_loops
131
+ except (ValueError, TypeError):
132
+ kwargs["num_loops"] = self.num_loops
133
+
134
+ try:
135
+ import torch
136
+ no_grad_ctx = torch.no_grad()
137
+ except ImportError:
138
+ from contextlib import nullcontext
139
+ no_grad_ctx = nullcontext()
140
+
141
+ with no_grad_ctx:
142
+ outputs = model(**inputs, **kwargs)
143
+ if hasattr(outputs, "logits"):
144
+ if hasattr(outputs.logits, "ndim"):
145
+ logits = outputs.logits[0, -1, :]
146
+ else:
147
+ logits = outputs.logits[0][-1]
148
+ elif hasattr(outputs, "ndim"):
149
+ logits = outputs[0, -1, :]
150
+ else:
151
+ logits = outputs[0][-1]
152
+
153
+ # Compute log_softmax
154
+ try:
155
+ import torch
156
+
157
+ log_probs = torch.nn.functional.log_softmax(logits, dim=-1)
158
+ except (ImportError, AttributeError, TypeError, ValueError):
159
+ # Fallback numpy log_softmax if torch is mocked or unavailable
160
+ max_l = float(max(logits))
161
+ exp_l = [math.exp(float(x) - max_l) for x in logits]
162
+ sum_exp = sum(exp_l)
163
+ log_probs = [float(x) - max_l - math.log(sum_exp) for x in logits]
164
+
165
+ results: list[dict[str, Any]] = []
166
+
167
+ if allowed_tokens:
168
+ for tok_str in allowed_tokens:
169
+ # Tokenize variant (both raw and leading-space)
170
+ token_ids = tokenizer.encode(tok_str, add_special_tokens=False)
171
+ if token_ids:
172
+ tid = token_ids[-1]
173
+ lp = float(log_probs[tid])
174
+ results.append({"token": tok_str, "logprob": lp})
175
+ if results:
176
+ return results
177
+
178
+ # General top_k
179
+ top_k_count = max(top_k, 50)
180
+ try:
181
+ import torch
182
+
183
+ if isinstance(log_probs, torch.Tensor):
184
+ top_vals, top_indices = torch.topk(log_probs, min(top_k_count, log_probs.size(-1)))
185
+ seen_indices = set(top_indices.tolist())
186
+ for lp, idx in zip(top_vals.tolist(), top_indices.tolist()):
187
+ token_str = tokenizer.decode([idx])
188
+ results.append({"token": token_str, "logprob": float(lp)})
189
+
190
+ # Always guarantee standard candidate tokens are present so extract_token_logprob never misses them
191
+ guaranteed = ["A", "B", " A", " B", "1", "2", "3", "4", "5", " 1", " 2", " 3", " 4", " 5"]
192
+ if allowed_tokens:
193
+ guaranteed.extend(allowed_tokens)
194
+ for g_tok in guaranteed:
195
+ ids = tokenizer.encode(g_tok, add_special_tokens=False)
196
+ if ids:
197
+ tid = ids[-1]
198
+ if tid not in seen_indices:
199
+ seen_indices.add(tid)
200
+ lp = float(log_probs[tid])
201
+ results.append({"token": g_tok, "logprob": lp})
202
+ return results
203
+ except (ImportError, AttributeError, TypeError, ValueError):
204
+ pass
205
+
206
+ # Fallback list sorting
207
+ indexed = sorted(enumerate(log_probs), key=lambda x: float(x[1]), reverse=True)
208
+ for idx, lp in indexed[:top_k]:
209
+ token_str = tokenizer.decode([idx]) if hasattr(tokenizer, "decode") else str(idx)
210
+ results.append({"token": token_str, "logprob": float(lp)})
211
+
212
+ return results
213
+
214
+ def generate(self, prompt: str, max_tokens: int | None = None, **kwargs: Any) -> tuple[str, int]:
215
+ """Autoregressive generation with num_loops recurrent reasoning."""
216
+ model, tokenizer = self._ensure_loaded()
217
+ inputs = tokenizer(prompt, return_tensors="pt")
218
+ if hasattr(inputs, "to") and self.device not in ("auto",):
219
+ inputs = inputs.to(model.device if hasattr(model, "device") else self.device)
220
+
221
+ gen_kwargs: dict[str, Any] = {
222
+ "max_new_tokens": max_tokens or 256,
223
+ "do_sample": False,
224
+ }
225
+ import inspect
226
+
227
+ try:
228
+ sig = inspect.signature(model.generate)
229
+ if "num_loops" in sig.parameters or any(
230
+ p.kind == inspect.Parameter.VAR_KEYWORD for p in sig.parameters.values()
231
+ ):
232
+ gen_kwargs["num_loops"] = self.num_loops
233
+ except (ValueError, TypeError):
234
+ gen_kwargs["num_loops"] = self.num_loops
235
+
236
+ if hasattr(inputs, "__getitem__") and "input_ids" in inputs:
237
+ input_tensor = inputs["input_ids"]
238
+ else:
239
+ input_tensor = inputs
240
+
241
+ outputs = model.generate(input_tensor, **gen_kwargs)
242
+ input_len = input_tensor.shape[-1]
243
+ generated_ids = outputs[0][input_len:]
244
+ text = tokenizer.decode(generated_ids, skip_special_tokens=True)
245
+ return text, len(generated_ids)
logitgate/cascade.py ADDED
@@ -0,0 +1,394 @@
1
+ from __future__ import annotations
2
+
3
+ import time
4
+ from collections.abc import Sequence
5
+ from typing import Any
6
+
7
+ from logitgate.gates.ast_gate import ASTGate, ContractGate
8
+ from logitgate.gates.base import BaseGate
9
+ from logitgate.gates.judge_gate import JudgeGate
10
+ from logitgate.gates.logit_gate import LogitGate
11
+ from logitgate.models import DecisionStatus, LayerTrace, Verdict
12
+
13
+
14
+ class Cascade(BaseGate):
15
+ """3-Layer Cascaded Router pipeline.
16
+
17
+ Orchestrates sequential execution of gates:
18
+ - Layer 0 (ASTGate): Sub-millisecond deterministic syntax, contract, and DAG checks.
19
+ - Layer 1 (LogitGate): Fast (40–120ms) debiased first-token logit gate (`max_tokens=1`).
20
+ - Layer 2 (JudgeGate): Diagnostic generative critique triggered only on ambiguous or failing artifacts.
21
+
22
+ Fast-path passes at Layer 1 short-circuit Layer 2 entirely, avoiding multi-token
23
+ Chain-of-Thought generation for non-ambiguous requests.
24
+
25
+ Args:
26
+ gates: Ordered sequence of gate instances to execute sequentially.
27
+ name: Identifier for this cascade pipeline.
28
+
29
+ Raises:
30
+ ValueError: If gates sequence is empty.
31
+ """
32
+
33
+ def __init__(self, gates: Sequence[BaseGate], name: str = "Cascade") -> None:
34
+ if not gates:
35
+ raise ValueError("Cascade requires at least one gate")
36
+ self.gates = list(gates)
37
+ self.name = name
38
+
39
+ def __repr__(self) -> str:
40
+ pipeline_repr = " >> ".join(repr(g) for g in self.gates)
41
+ return f"Cascade([{pipeline_repr}])"
42
+
43
+ def _handle_failure_snapshot(
44
+ self, candidate: Any, verdict: Verdict, current_rubric: str | None = None, **kwargs: Any
45
+ ) -> Verdict:
46
+ if verdict.passed:
47
+ return verdict
48
+
49
+ save_on_fail = kwargs.get("save_on_fail")
50
+ from logitgate.config import get_default_save_failures
51
+ should_save = save_on_fail if save_on_fail is not None else get_default_save_failures()
52
+ if not should_save:
53
+ return verdict
54
+
55
+ if verdict.traces and "snapshot_path" in verdict.traces[-1].metadata:
56
+ return verdict
57
+
58
+ from logitgate.backends.base import infer_backend_spec
59
+ from logitgate.snapshot import VerdictSnapshot
60
+ rubric = current_rubric or getattr(self.gates[0], "rubric", None) or "Cascade evaluation"
61
+ gate_config: dict[str, Any] = {"cascade_gates": [g.name for g in self.gates]}
62
+ for g in self.gates:
63
+ if isinstance(g, LogitGate):
64
+ gate_config.update({
65
+ "rubric": g.rubric,
66
+ "backend": infer_backend_spec(g.backend),
67
+ "pass_threshold": g.pass_threshold,
68
+ "fail_threshold": g.fail_threshold,
69
+ "symmetric_debiasing": g.symmetric_debiasing,
70
+ "logit_temperature": g.logit_temperature,
71
+ })
72
+ if g.model:
73
+ gate_config["model"] = g.model
74
+ break
75
+
76
+ test_name = kwargs.get("test_name")
77
+ snapshot = VerdictSnapshot.from_verdict(
78
+ verdict=verdict,
79
+ rubric=rubric,
80
+ candidate=candidate,
81
+ test_name=test_name,
82
+ gate_config=gate_config,
83
+ )
84
+ saved_path = snapshot.save(directory=kwargs.get("snapshot_dir"))
85
+ if verdict.traces:
86
+ verdict.traces[-1].metadata["snapshot_path"] = str(saved_path)
87
+ return verdict
88
+
89
+ def evaluate(self, candidate: Any, **kwargs: Any) -> Verdict:
90
+ t0 = time.perf_counter()
91
+ all_traces: list[LayerTrace] = []
92
+ total_tokens = 0
93
+ current_rubric = None
94
+ last_verdict: Verdict | None = None
95
+ gate_kwargs = dict(kwargs)
96
+ gate_kwargs["save_on_fail"] = False
97
+
98
+ for i, gate in enumerate(self.gates):
99
+ # 1. Deterministic Layer 0 Gate
100
+ if isinstance(gate, (ASTGate, ContractGate)):
101
+ verdict = gate.evaluate(candidate, **gate_kwargs)
102
+ all_traces.extend(verdict.traces)
103
+ total_tokens += verdict.total_tokens_generated
104
+ last_verdict = verdict
105
+
106
+ if not verdict.passed:
107
+ total_latency = (time.perf_counter() - t0) * 1000.0
108
+ final_v = Verdict(
109
+ passed=False,
110
+ status=DecisionStatus.FAILED,
111
+ triggered_layer=gate.name,
112
+ total_latency_ms=total_latency,
113
+ total_tokens_generated=total_tokens,
114
+ reason=verdict.reason,
115
+ traces=all_traces,
116
+ )
117
+ return self._handle_failure_snapshot(candidate, final_v, current_rubric, **kwargs)
118
+
119
+ # 2. First-Token Logit Gate Layer 1
120
+ elif isinstance(gate, LogitGate):
121
+ current_rubric = gate.rubric
122
+ verdict = gate.evaluate(candidate, **gate_kwargs)
123
+ all_traces.extend(verdict.traces)
124
+ total_tokens += verdict.total_tokens_generated
125
+ last_verdict = verdict
126
+
127
+ if verdict.status == DecisionStatus.PASSED:
128
+ # Fast-path pass: short-circuit Layer 2 entirely!
129
+ total_latency = (time.perf_counter() - t0) * 1000.0
130
+ return Verdict(
131
+ passed=True,
132
+ status=DecisionStatus.PASSED,
133
+ probability=verdict.probability,
134
+ triggered_layer=gate.name,
135
+ total_latency_ms=total_latency,
136
+ total_tokens_generated=total_tokens,
137
+ reason=f"Fast-path pass at {gate.name}: P={verdict.probability:.4f}",
138
+ traces=all_traces,
139
+ )
140
+
141
+ # Check lookahead to next JudgeGate
142
+ if i + 1 < len(self.gates):
143
+ next_judge = self.gates[i + 1]
144
+ if isinstance(next_judge, JudgeGate):
145
+ if verdict.status == DecisionStatus.FAILED and next_judge.trigger == "on_ambiguous_only":
146
+ total_latency = (time.perf_counter() - t0) * 1000.0
147
+ final_v = Verdict(
148
+ passed=False,
149
+ status=DecisionStatus.FAILED,
150
+ probability=verdict.probability,
151
+ triggered_layer=gate.name,
152
+ total_latency_ms=total_latency,
153
+ total_tokens_generated=total_tokens,
154
+ reason=f"Decisive failure at {gate.name}: P={verdict.probability:.4f}",
155
+ traces=all_traces,
156
+ )
157
+ return self._handle_failure_snapshot(candidate, final_v, current_rubric, **kwargs)
158
+ # Escalate to JudgeGate
159
+ continue
160
+
161
+ # 3. Generative Reasoning Judge Layer 2
162
+ elif isinstance(gate, JudgeGate):
163
+ prev_prob = None
164
+ prev_status = None
165
+ for tr in reversed(all_traces):
166
+ if tr.calibrated_prob is not None:
167
+ prev_prob = tr.calibrated_prob
168
+ prev_status = tr.status
169
+ break
170
+
171
+ verdict = gate.evaluate(candidate, rubric_override=current_rubric, **gate_kwargs)
172
+ all_traces.extend(verdict.traces)
173
+ total_tokens += verdict.total_tokens_generated
174
+ last_verdict = verdict
175
+
176
+ total_latency = (time.perf_counter() - t0) * 1000.0
177
+ if prev_status == DecisionStatus.FAILED and gate.trigger == "on_ambiguous_or_fail":
178
+ final_passed = False
179
+ final_status = DecisionStatus.FAILED
180
+ else:
181
+ final_passed = verdict.passed
182
+ final_status = verdict.status
183
+
184
+ final_v = Verdict(
185
+ passed=final_passed,
186
+ status=final_status,
187
+ probability=prev_prob,
188
+ score=verdict.score,
189
+ triggered_layer=gate.name,
190
+ total_latency_ms=total_latency,
191
+ total_tokens_generated=total_tokens,
192
+ reason=verdict.reason,
193
+ traces=all_traces,
194
+ )
195
+ return self._handle_failure_snapshot(candidate, final_v, current_rubric, **kwargs)
196
+
197
+ # 4. Generic Gate
198
+ else:
199
+ verdict = gate.evaluate(candidate, **gate_kwargs)
200
+ all_traces.extend(verdict.traces)
201
+ total_tokens += verdict.total_tokens_generated
202
+ last_verdict = verdict
203
+ if not verdict.passed and i == len(self.gates) - 1:
204
+ total_latency = (time.perf_counter() - t0) * 1000.0
205
+ final_v = Verdict(
206
+ passed=False,
207
+ status=verdict.status,
208
+ probability=verdict.probability,
209
+ score=verdict.score,
210
+ choice=verdict.choice,
211
+ choice_probabilities=verdict.choice_probabilities,
212
+ triggered_layer=gate.name,
213
+ total_latency_ms=total_latency,
214
+ total_tokens_generated=total_tokens,
215
+ reason=verdict.reason,
216
+ traces=all_traces,
217
+ )
218
+ return self._handle_failure_snapshot(candidate, final_v, current_rubric, **kwargs)
219
+
220
+ total_latency = (time.perf_counter() - t0) * 1000.0
221
+ if last_verdict is not None:
222
+ final_v = Verdict(
223
+ passed=last_verdict.passed,
224
+ status=last_verdict.status,
225
+ probability=last_verdict.probability,
226
+ score=last_verdict.score,
227
+ choice=last_verdict.choice,
228
+ choice_probabilities=last_verdict.choice_probabilities,
229
+ triggered_layer=last_verdict.triggered_layer,
230
+ total_latency_ms=total_latency,
231
+ total_tokens_generated=total_tokens,
232
+ reason=last_verdict.reason,
233
+ traces=all_traces,
234
+ )
235
+ return self._handle_failure_snapshot(candidate, final_v, current_rubric, **kwargs)
236
+
237
+ return Verdict(
238
+ passed=True,
239
+ status=DecisionStatus.PASSED,
240
+ triggered_layer=self.name,
241
+ total_latency_ms=total_latency,
242
+ total_tokens_generated=total_tokens,
243
+ traces=all_traces,
244
+ )
245
+
246
+ async def aevaluate(self, candidate: Any, **kwargs: Any) -> Verdict:
247
+ t0 = time.perf_counter()
248
+ all_traces: list[LayerTrace] = []
249
+ total_tokens = 0
250
+ current_rubric = None
251
+ last_verdict: Verdict | None = None
252
+ gate_kwargs = dict(kwargs)
253
+ gate_kwargs["save_on_fail"] = False
254
+
255
+ for i, gate in enumerate(self.gates):
256
+ if isinstance(gate, (ASTGate, ContractGate)):
257
+ verdict = await gate.aevaluate(candidate, **gate_kwargs)
258
+ all_traces.extend(verdict.traces)
259
+ total_tokens += verdict.total_tokens_generated
260
+ last_verdict = verdict
261
+
262
+ if not verdict.passed:
263
+ total_latency = (time.perf_counter() - t0) * 1000.0
264
+ final_v = Verdict(
265
+ passed=False,
266
+ status=DecisionStatus.FAILED,
267
+ triggered_layer=gate.name,
268
+ total_latency_ms=total_latency,
269
+ total_tokens_generated=total_tokens,
270
+ reason=verdict.reason,
271
+ traces=all_traces,
272
+ )
273
+ return self._handle_failure_snapshot(candidate, final_v, current_rubric, **kwargs)
274
+
275
+ elif isinstance(gate, LogitGate):
276
+ current_rubric = gate.rubric
277
+ verdict = await gate.aevaluate(candidate, **gate_kwargs)
278
+ all_traces.extend(verdict.traces)
279
+ total_tokens += verdict.total_tokens_generated
280
+ last_verdict = verdict
281
+
282
+ if verdict.status == DecisionStatus.PASSED:
283
+ total_latency = (time.perf_counter() - t0) * 1000.0
284
+ return Verdict(
285
+ passed=True,
286
+ status=DecisionStatus.PASSED,
287
+ probability=verdict.probability,
288
+ triggered_layer=gate.name,
289
+ total_latency_ms=total_latency,
290
+ total_tokens_generated=total_tokens,
291
+ reason=f"Fast-path pass at {gate.name}: P={verdict.probability:.4f}",
292
+ traces=all_traces,
293
+ )
294
+
295
+ if i + 1 < len(self.gates):
296
+ next_judge = self.gates[i + 1]
297
+ if isinstance(next_judge, JudgeGate):
298
+ if verdict.status == DecisionStatus.FAILED and next_judge.trigger == "on_ambiguous_only":
299
+ total_latency = (time.perf_counter() - t0) * 1000.0
300
+ final_v = Verdict(
301
+ passed=False,
302
+ status=DecisionStatus.FAILED,
303
+ probability=verdict.probability,
304
+ triggered_layer=gate.name,
305
+ total_latency_ms=total_latency,
306
+ total_tokens_generated=total_tokens,
307
+ reason=f"Decisive failure at {gate.name}: P={verdict.probability:.4f}",
308
+ traces=all_traces,
309
+ )
310
+ return self._handle_failure_snapshot(candidate, final_v, current_rubric, **kwargs)
311
+ continue
312
+
313
+ elif isinstance(gate, JudgeGate):
314
+ prev_prob = None
315
+ prev_status = None
316
+ for tr in reversed(all_traces):
317
+ if tr.calibrated_prob is not None:
318
+ prev_prob = tr.calibrated_prob
319
+ prev_status = tr.status
320
+ break
321
+
322
+ verdict = await gate.aevaluate(candidate, rubric_override=current_rubric, **gate_kwargs)
323
+ all_traces.extend(verdict.traces)
324
+ total_tokens += verdict.total_tokens_generated
325
+ last_verdict = verdict
326
+
327
+ total_latency = (time.perf_counter() - t0) * 1000.0
328
+ if prev_status == DecisionStatus.FAILED and gate.trigger == "on_ambiguous_or_fail":
329
+ final_passed = False
330
+ final_status = DecisionStatus.FAILED
331
+ else:
332
+ final_passed = verdict.passed
333
+ final_status = verdict.status
334
+
335
+ final_v = Verdict(
336
+ passed=final_passed,
337
+ status=final_status,
338
+ probability=prev_prob,
339
+ score=verdict.score,
340
+ triggered_layer=gate.name,
341
+ total_latency_ms=total_latency,
342
+ total_tokens_generated=total_tokens,
343
+ reason=verdict.reason,
344
+ traces=all_traces,
345
+ )
346
+ return self._handle_failure_snapshot(candidate, final_v, current_rubric, **kwargs)
347
+
348
+ else:
349
+ verdict = await gate.aevaluate(candidate, **gate_kwargs)
350
+ all_traces.extend(verdict.traces)
351
+ total_tokens += verdict.total_tokens_generated
352
+ last_verdict = verdict
353
+ if not verdict.passed and i == len(self.gates) - 1:
354
+ total_latency = (time.perf_counter() - t0) * 1000.0
355
+ final_v = Verdict(
356
+ passed=False,
357
+ status=verdict.status,
358
+ probability=verdict.probability,
359
+ score=verdict.score,
360
+ choice=verdict.choice,
361
+ choice_probabilities=verdict.choice_probabilities,
362
+ triggered_layer=gate.name,
363
+ total_latency_ms=total_latency,
364
+ total_tokens_generated=total_tokens,
365
+ reason=verdict.reason,
366
+ traces=all_traces,
367
+ )
368
+ return self._handle_failure_snapshot(candidate, final_v, current_rubric, **kwargs)
369
+
370
+ total_latency = (time.perf_counter() - t0) * 1000.0
371
+ if last_verdict is not None:
372
+ final_v = Verdict(
373
+ passed=last_verdict.passed,
374
+ status=last_verdict.status,
375
+ probability=last_verdict.probability,
376
+ score=last_verdict.score,
377
+ choice=last_verdict.choice,
378
+ choice_probabilities=last_verdict.choice_probabilities,
379
+ triggered_layer=last_verdict.triggered_layer,
380
+ total_latency_ms=total_latency,
381
+ total_tokens_generated=total_tokens,
382
+ reason=last_verdict.reason,
383
+ traces=all_traces,
384
+ )
385
+ return self._handle_failure_snapshot(candidate, final_v, current_rubric, **kwargs)
386
+
387
+ return Verdict(
388
+ passed=True,
389
+ status=DecisionStatus.PASSED,
390
+ triggered_layer=self.name,
391
+ total_latency_ms=total_latency,
392
+ total_tokens_generated=total_tokens,
393
+ traces=all_traces,
394
+ )