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,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
|
+
)
|