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,951 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import math
|
|
5
|
+
import time
|
|
6
|
+
from collections.abc import Mapping, Sequence
|
|
7
|
+
from typing import Any, Literal
|
|
8
|
+
|
|
9
|
+
from logitgate.backends.base import LogitBackend, resolve_logit_backend
|
|
10
|
+
from logitgate.config import LogitGateDefaults, get_default_backend, get_default_model
|
|
11
|
+
from logitgate.debiasing.inversion import (
|
|
12
|
+
compile_batch_binary_prompt,
|
|
13
|
+
compile_binary_prompt,
|
|
14
|
+
compile_choice_prompt,
|
|
15
|
+
compile_score_prompt,
|
|
16
|
+
)
|
|
17
|
+
from logitgate.debiasing.permutation import combine_symmetric_log_odds
|
|
18
|
+
from logitgate.debiasing.softmax import (
|
|
19
|
+
compute_binary_log_odds,
|
|
20
|
+
extract_token_logprob,
|
|
21
|
+
)
|
|
22
|
+
from logitgate.gates.base import BaseGate
|
|
23
|
+
from logitgate.models import DecisionStatus, LayerTrace, Verdict
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class LogitGate(BaseGate):
|
|
27
|
+
"""First-token Logit Gate (Layer 1).
|
|
28
|
+
|
|
29
|
+
Computes calibrated binary classification confidence scores directly from the
|
|
30
|
+
language model's first-token log-probabilities (`max_tokens=1`) without generating
|
|
31
|
+
multi-token Chain-of-Thought critiques. Uses symmetric log-odds debiasing across
|
|
32
|
+
forward and reverse label mappings to cancel prior token biases (Zhao et al., 2021)
|
|
33
|
+
and positional biases.
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
rubric: Clear evaluation criterion or question to test against candidate.
|
|
37
|
+
backend: Backend instance or specifier URI (e.g. 'ollama://', 'openai://', 'stub://').
|
|
38
|
+
model: Optional model identifier string for the backend.
|
|
39
|
+
pass_threshold: Probability threshold required to award a PASSED verdict (default: 0.85).
|
|
40
|
+
fail_threshold: Probability threshold below which a decisive FAILED verdict is awarded (default: 0.20).
|
|
41
|
+
symmetric_debiasing: When True, runs forward (A=Pass, B=Fail) and reverse (B=Pass, A=Fail)
|
|
42
|
+
passes and averages them in log-odds space.
|
|
43
|
+
query_first: When True, compiles prompt with rubric and candidate first, ensuring
|
|
44
|
+
identical KV-cache prefixes across both passes.
|
|
45
|
+
logit_temperature: Post-hoc temperature scaling parameter (default: 1.0).
|
|
46
|
+
name: Identifier for this gate instance.
|
|
47
|
+
|
|
48
|
+
Raises:
|
|
49
|
+
ValueError: If rubric is empty, thresholds are outside [0, 1], pass_threshold <= fail_threshold,
|
|
50
|
+
or logit_temperature <= 0.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
def __init__(
|
|
54
|
+
self,
|
|
55
|
+
rubric: str,
|
|
56
|
+
backend: str | LogitBackend | None = None,
|
|
57
|
+
model: str | None = None,
|
|
58
|
+
pass_threshold: float | None = None,
|
|
59
|
+
fail_threshold: float | None = None,
|
|
60
|
+
symmetric_debiasing: bool | None = None,
|
|
61
|
+
query_first: bool = True,
|
|
62
|
+
logit_temperature: float | Literal["auto"] | None = None,
|
|
63
|
+
preset: Literal["strict", "balanced", "fast_triage"] | None = None,
|
|
64
|
+
save_on_fail: bool | None = None,
|
|
65
|
+
name: str = "LogitGate",
|
|
66
|
+
) -> None:
|
|
67
|
+
if preset is not None:
|
|
68
|
+
if preset == "strict":
|
|
69
|
+
default_pass = 0.90
|
|
70
|
+
default_fail = 0.25
|
|
71
|
+
default_sym = True
|
|
72
|
+
elif preset == "balanced":
|
|
73
|
+
default_pass = 0.85
|
|
74
|
+
default_fail = 0.20
|
|
75
|
+
default_sym = True
|
|
76
|
+
elif preset == "fast_triage":
|
|
77
|
+
default_pass = 0.80
|
|
78
|
+
default_fail = 0.20
|
|
79
|
+
default_sym = False
|
|
80
|
+
else:
|
|
81
|
+
raise ValueError(
|
|
82
|
+
f"Invalid preset '{preset}'. Must be one of ['strict', 'balanced', 'fast_triage']"
|
|
83
|
+
)
|
|
84
|
+
else:
|
|
85
|
+
default_pass = LogitGateDefaults.pass_threshold
|
|
86
|
+
default_fail = LogitGateDefaults.fail_threshold
|
|
87
|
+
default_sym = True
|
|
88
|
+
|
|
89
|
+
effective_pass_thresh = (
|
|
90
|
+
default_pass if pass_threshold is None else pass_threshold
|
|
91
|
+
)
|
|
92
|
+
effective_fail_thresh = (
|
|
93
|
+
default_fail if fail_threshold is None else fail_threshold
|
|
94
|
+
)
|
|
95
|
+
effective_sym = (
|
|
96
|
+
default_sym if symmetric_debiasing is None else symmetric_debiasing
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
if not rubric or not rubric.strip():
|
|
100
|
+
raise ValueError("rubric cannot be empty or whitespace")
|
|
101
|
+
if not (0.0 <= effective_pass_thresh <= 1.0):
|
|
102
|
+
raise ValueError(f"pass_threshold must be between 0.0 and 1.0, got {effective_pass_thresh}")
|
|
103
|
+
if not (0.0 <= effective_fail_thresh <= 1.0):
|
|
104
|
+
raise ValueError(f"fail_threshold must be between 0.0 and 1.0, got {effective_fail_thresh}")
|
|
105
|
+
if effective_pass_thresh <= effective_fail_thresh:
|
|
106
|
+
raise ValueError(
|
|
107
|
+
f"pass_threshold ({effective_pass_thresh}) must be strictly greater than fail_threshold ({effective_fail_thresh})"
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
effective_backend = (
|
|
111
|
+
get_default_backend() if (backend is None or backend == "default") else backend
|
|
112
|
+
)
|
|
113
|
+
effective_model = model or get_default_model()
|
|
114
|
+
self.backend = resolve_logit_backend(effective_backend, model=effective_model)
|
|
115
|
+
self.model = effective_model
|
|
116
|
+
|
|
117
|
+
if logit_temperature == "auto":
|
|
118
|
+
from logitgate.debiasing.permutation import (
|
|
119
|
+
auto_calibrate_backend,
|
|
120
|
+
get_model_temperature,
|
|
121
|
+
)
|
|
122
|
+
temp = get_model_temperature(effective_model)
|
|
123
|
+
if temp == 1.0 and effective_model not in ("default", "stub", None):
|
|
124
|
+
cal_res = auto_calibrate_backend(self.backend, model_key=effective_model)
|
|
125
|
+
temp = cal_res.optimal_temperature
|
|
126
|
+
effective_temp = temp
|
|
127
|
+
elif logit_temperature is not None:
|
|
128
|
+
effective_temp = float(logit_temperature)
|
|
129
|
+
else:
|
|
130
|
+
from logitgate.debiasing.permutation import get_model_temperature
|
|
131
|
+
if LogitGateDefaults.logit_temperature != 1.0:
|
|
132
|
+
effective_temp = LogitGateDefaults.logit_temperature
|
|
133
|
+
else:
|
|
134
|
+
model_temp = get_model_temperature(effective_model)
|
|
135
|
+
effective_temp = model_temp if model_temp != 1.0 else LogitGateDefaults.logit_temperature
|
|
136
|
+
|
|
137
|
+
if effective_temp <= 0.0:
|
|
138
|
+
raise ValueError(f"logit_temperature must be positive, got {effective_temp}")
|
|
139
|
+
|
|
140
|
+
self.rubric = rubric.strip()
|
|
141
|
+
self.preset = preset
|
|
142
|
+
self.save_on_fail = save_on_fail
|
|
143
|
+
self.pass_threshold = effective_pass_thresh
|
|
144
|
+
self.fail_threshold = effective_fail_thresh
|
|
145
|
+
self.symmetric_debiasing = effective_sym
|
|
146
|
+
self.query_first = query_first
|
|
147
|
+
self.logit_temperature = effective_temp
|
|
148
|
+
self.name = name
|
|
149
|
+
|
|
150
|
+
def _handle_failure_snapshot(self, candidate: Any, verdict: Verdict, **kwargs: Any) -> Verdict:
|
|
151
|
+
if verdict.passed:
|
|
152
|
+
return verdict
|
|
153
|
+
|
|
154
|
+
save_on_fail = kwargs.get("save_on_fail", self.save_on_fail)
|
|
155
|
+
from logitgate.config import get_default_save_failures
|
|
156
|
+
should_save = save_on_fail if save_on_fail is not None else get_default_save_failures()
|
|
157
|
+
if not should_save:
|
|
158
|
+
return verdict
|
|
159
|
+
|
|
160
|
+
if verdict.traces and "snapshot_path" in verdict.traces[-1].metadata:
|
|
161
|
+
return verdict
|
|
162
|
+
|
|
163
|
+
from logitgate.backends.base import infer_backend_spec
|
|
164
|
+
from logitgate.snapshot import VerdictSnapshot
|
|
165
|
+
gate_config: dict[str, Any] = {
|
|
166
|
+
"rubric": self.rubric,
|
|
167
|
+
"backend": infer_backend_spec(self.backend),
|
|
168
|
+
"pass_threshold": self.pass_threshold,
|
|
169
|
+
"fail_threshold": self.fail_threshold,
|
|
170
|
+
"symmetric_debiasing": self.symmetric_debiasing,
|
|
171
|
+
"query_first": self.query_first,
|
|
172
|
+
"logit_temperature": self.logit_temperature,
|
|
173
|
+
"preset": self.preset,
|
|
174
|
+
"name": self.name,
|
|
175
|
+
}
|
|
176
|
+
if hasattr(self, "model") and self.model:
|
|
177
|
+
gate_config["model"] = self.model
|
|
178
|
+
|
|
179
|
+
test_name = kwargs.get("test_name")
|
|
180
|
+
snapshot = VerdictSnapshot.from_verdict(
|
|
181
|
+
verdict=verdict,
|
|
182
|
+
rubric=self.rubric,
|
|
183
|
+
candidate=candidate,
|
|
184
|
+
test_name=test_name,
|
|
185
|
+
gate_config=gate_config,
|
|
186
|
+
)
|
|
187
|
+
saved_path = snapshot.save(directory=kwargs.get("snapshot_dir"))
|
|
188
|
+
if verdict.traces:
|
|
189
|
+
verdict.traces[-1].metadata["snapshot_path"] = str(saved_path)
|
|
190
|
+
return verdict
|
|
191
|
+
|
|
192
|
+
def __repr__(self) -> str:
|
|
193
|
+
rubric_repr = repr(self.rubric[:40] + "..." if len(self.rubric) > 40 else self.rubric)
|
|
194
|
+
return (
|
|
195
|
+
f"LogitGate(rubric={rubric_repr}, "
|
|
196
|
+
f"pass_threshold={self.pass_threshold}, "
|
|
197
|
+
f"fail_threshold={self.fail_threshold}, "
|
|
198
|
+
f"backend={self.backend!r})"
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
def evaluate(self, candidate: Any, **kwargs: Any) -> Verdict:
|
|
202
|
+
t0 = time.perf_counter()
|
|
203
|
+
|
|
204
|
+
# Pass 1: A=Pass, B=Fail
|
|
205
|
+
prompt1 = compile_binary_prompt(
|
|
206
|
+
rubric=self.rubric,
|
|
207
|
+
candidate=candidate,
|
|
208
|
+
pass_token="A",
|
|
209
|
+
fail_token="B",
|
|
210
|
+
query_first=self.query_first,
|
|
211
|
+
)
|
|
212
|
+
top1 = self.backend.query_top_logprobs(prompt1, top_k=10, allowed_tokens=["A", "B"])
|
|
213
|
+
l1, p1 = compute_binary_log_odds(top1, pass_token="A", fail_token="B")
|
|
214
|
+
|
|
215
|
+
if self.symmetric_debiasing:
|
|
216
|
+
# Pass 2: A=Fail, B=Pass (prefix identical in prompt cache)
|
|
217
|
+
prompt2 = compile_binary_prompt(
|
|
218
|
+
rubric=self.rubric,
|
|
219
|
+
candidate=candidate,
|
|
220
|
+
pass_token="B",
|
|
221
|
+
fail_token="A",
|
|
222
|
+
query_first=self.query_first,
|
|
223
|
+
)
|
|
224
|
+
top2 = self.backend.query_top_logprobs(prompt2, top_k=10, allowed_tokens=["A", "B"])
|
|
225
|
+
l2, p2 = compute_binary_log_odds(top2, pass_token="B", fail_token="A")
|
|
226
|
+
calibrated_prob = combine_symmetric_log_odds(
|
|
227
|
+
forward_log_odds=l1,
|
|
228
|
+
reverse_log_odds=l2,
|
|
229
|
+
logit_temperature=self.logit_temperature,
|
|
230
|
+
)
|
|
231
|
+
else:
|
|
232
|
+
l2, p2 = None, None
|
|
233
|
+
calibrated_prob = p1
|
|
234
|
+
|
|
235
|
+
latency = (time.perf_counter() - t0) * 1000.0
|
|
236
|
+
|
|
237
|
+
if calibrated_prob >= self.pass_threshold:
|
|
238
|
+
status = DecisionStatus.PASSED
|
|
239
|
+
passed = True
|
|
240
|
+
elif calibrated_prob <= self.fail_threshold:
|
|
241
|
+
status = DecisionStatus.FAILED
|
|
242
|
+
passed = False
|
|
243
|
+
else:
|
|
244
|
+
status = DecisionStatus.AMBIGUOUS
|
|
245
|
+
passed = False
|
|
246
|
+
|
|
247
|
+
trace = LayerTrace(
|
|
248
|
+
layer_name=self.name,
|
|
249
|
+
status=status,
|
|
250
|
+
latency_ms=latency,
|
|
251
|
+
calibrated_prob=round(calibrated_prob, 4),
|
|
252
|
+
forward_prob=round(p1, 4),
|
|
253
|
+
reverse_prob=round(p2, 4) if p2 is not None else None,
|
|
254
|
+
forward_log_odds=round(l1, 4),
|
|
255
|
+
reverse_log_odds=round(l2, 4) if l2 is not None else None,
|
|
256
|
+
tokens_generated=0,
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
verdict = Verdict(
|
|
260
|
+
passed=passed,
|
|
261
|
+
status=status,
|
|
262
|
+
probability=round(calibrated_prob, 4),
|
|
263
|
+
triggered_layer=self.name,
|
|
264
|
+
total_latency_ms=latency,
|
|
265
|
+
total_tokens_generated=0,
|
|
266
|
+
reason=f"LogitGate prob={calibrated_prob:.4f} (pass_thresh={self.pass_threshold}, fail_thresh={self.fail_threshold})",
|
|
267
|
+
traces=[trace],
|
|
268
|
+
)
|
|
269
|
+
return self._handle_failure_snapshot(candidate, verdict, **kwargs)
|
|
270
|
+
|
|
271
|
+
async def aevaluate(self, candidate: Any, **kwargs: Any) -> Verdict:
|
|
272
|
+
t0 = time.perf_counter()
|
|
273
|
+
|
|
274
|
+
prompt1 = compile_binary_prompt(
|
|
275
|
+
rubric=self.rubric,
|
|
276
|
+
candidate=candidate,
|
|
277
|
+
pass_token="A",
|
|
278
|
+
fail_token="B",
|
|
279
|
+
query_first=self.query_first,
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
if self.symmetric_debiasing:
|
|
283
|
+
prompt2 = compile_binary_prompt(
|
|
284
|
+
rubric=self.rubric,
|
|
285
|
+
candidate=candidate,
|
|
286
|
+
pass_token="B",
|
|
287
|
+
fail_token="A",
|
|
288
|
+
query_first=self.query_first,
|
|
289
|
+
)
|
|
290
|
+
top1, top2 = await asyncio.gather(
|
|
291
|
+
self.backend.aquery_top_logprobs(prompt1, top_k=10, allowed_tokens=["A", "B"]),
|
|
292
|
+
self.backend.aquery_top_logprobs(prompt2, top_k=10, allowed_tokens=["A", "B"]),
|
|
293
|
+
)
|
|
294
|
+
l1, p1 = compute_binary_log_odds(top1, pass_token="A", fail_token="B")
|
|
295
|
+
l2, p2 = compute_binary_log_odds(top2, pass_token="B", fail_token="A")
|
|
296
|
+
calibrated_prob = combine_symmetric_log_odds(
|
|
297
|
+
forward_log_odds=l1,
|
|
298
|
+
reverse_log_odds=l2,
|
|
299
|
+
logit_temperature=self.logit_temperature,
|
|
300
|
+
)
|
|
301
|
+
else:
|
|
302
|
+
top1 = await self.backend.aquery_top_logprobs(prompt1, top_k=10, allowed_tokens=["A", "B"])
|
|
303
|
+
l1, p1 = compute_binary_log_odds(top1, pass_token="A", fail_token="B")
|
|
304
|
+
l2, p2 = None, None
|
|
305
|
+
calibrated_prob = p1
|
|
306
|
+
|
|
307
|
+
latency = (time.perf_counter() - t0) * 1000.0
|
|
308
|
+
|
|
309
|
+
if calibrated_prob >= self.pass_threshold:
|
|
310
|
+
status = DecisionStatus.PASSED
|
|
311
|
+
passed = True
|
|
312
|
+
elif calibrated_prob <= self.fail_threshold:
|
|
313
|
+
status = DecisionStatus.FAILED
|
|
314
|
+
passed = False
|
|
315
|
+
else:
|
|
316
|
+
status = DecisionStatus.AMBIGUOUS
|
|
317
|
+
passed = False
|
|
318
|
+
|
|
319
|
+
trace = LayerTrace(
|
|
320
|
+
layer_name=self.name,
|
|
321
|
+
status=status,
|
|
322
|
+
latency_ms=latency,
|
|
323
|
+
calibrated_prob=round(calibrated_prob, 4),
|
|
324
|
+
forward_prob=round(p1, 4),
|
|
325
|
+
reverse_prob=round(p2, 4) if p2 is not None else None,
|
|
326
|
+
forward_log_odds=round(l1, 4),
|
|
327
|
+
reverse_log_odds=round(l2, 4) if l2 is not None else None,
|
|
328
|
+
tokens_generated=0,
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
verdict = Verdict(
|
|
332
|
+
passed=passed,
|
|
333
|
+
status=status,
|
|
334
|
+
probability=round(calibrated_prob, 4),
|
|
335
|
+
triggered_layer=self.name,
|
|
336
|
+
total_latency_ms=latency,
|
|
337
|
+
total_tokens_generated=0,
|
|
338
|
+
reason=f"LogitGate prob={calibrated_prob:.4f} (pass_thresh={self.pass_threshold}, fail_thresh={self.fail_threshold})",
|
|
339
|
+
traces=[trace],
|
|
340
|
+
)
|
|
341
|
+
return self._handle_failure_snapshot(candidate, verdict, **kwargs)
|
|
342
|
+
|
|
343
|
+
def evaluate_batch(self, candidate: Any, rubrics: Sequence[str]) -> list[Verdict]:
|
|
344
|
+
"""
|
|
345
|
+
Evaluates multiple rubrics against candidate artifact using multi-rubric
|
|
346
|
+
batch prompt compilation with 100% byte-identical KV-cache prefix sharing.
|
|
347
|
+
"""
|
|
348
|
+
if not rubrics:
|
|
349
|
+
return []
|
|
350
|
+
|
|
351
|
+
results = []
|
|
352
|
+
for idx, _ in enumerate(rubrics):
|
|
353
|
+
t0 = time.perf_counter()
|
|
354
|
+
prompt1 = compile_batch_binary_prompt(
|
|
355
|
+
rubrics=rubrics,
|
|
356
|
+
candidate=candidate,
|
|
357
|
+
rubric_index=idx,
|
|
358
|
+
pass_token="A",
|
|
359
|
+
fail_token="B",
|
|
360
|
+
query_first=False,
|
|
361
|
+
)
|
|
362
|
+
top1 = self.backend.query_top_logprobs(prompt1, top_k=10, allowed_tokens=["A", "B"])
|
|
363
|
+
l1, p1 = compute_binary_log_odds(top1, pass_token="A", fail_token="B")
|
|
364
|
+
|
|
365
|
+
if self.symmetric_debiasing:
|
|
366
|
+
prompt2 = compile_batch_binary_prompt(
|
|
367
|
+
rubrics=rubrics,
|
|
368
|
+
candidate=candidate,
|
|
369
|
+
rubric_index=idx,
|
|
370
|
+
pass_token="B",
|
|
371
|
+
fail_token="A",
|
|
372
|
+
query_first=False,
|
|
373
|
+
)
|
|
374
|
+
top2 = self.backend.query_top_logprobs(prompt2, top_k=10, allowed_tokens=["A", "B"])
|
|
375
|
+
l2, p2 = compute_binary_log_odds(top2, pass_token="B", fail_token="A")
|
|
376
|
+
calibrated_prob = combine_symmetric_log_odds(
|
|
377
|
+
forward_log_odds=l1,
|
|
378
|
+
reverse_log_odds=l2,
|
|
379
|
+
logit_temperature=self.logit_temperature,
|
|
380
|
+
)
|
|
381
|
+
else:
|
|
382
|
+
l2, p2 = None, None
|
|
383
|
+
calibrated_prob = p1
|
|
384
|
+
|
|
385
|
+
latency = (time.perf_counter() - t0) * 1000.0
|
|
386
|
+
|
|
387
|
+
if calibrated_prob >= self.pass_threshold:
|
|
388
|
+
status = DecisionStatus.PASSED
|
|
389
|
+
passed = True
|
|
390
|
+
elif calibrated_prob <= self.fail_threshold:
|
|
391
|
+
status = DecisionStatus.FAILED
|
|
392
|
+
passed = False
|
|
393
|
+
else:
|
|
394
|
+
status = DecisionStatus.AMBIGUOUS
|
|
395
|
+
passed = False
|
|
396
|
+
|
|
397
|
+
trace = LayerTrace(
|
|
398
|
+
layer_name=self.name,
|
|
399
|
+
status=status,
|
|
400
|
+
latency_ms=latency,
|
|
401
|
+
calibrated_prob=round(calibrated_prob, 4),
|
|
402
|
+
forward_prob=round(p1, 4),
|
|
403
|
+
reverse_prob=round(p2, 4) if p2 is not None else None,
|
|
404
|
+
forward_log_odds=round(l1, 4),
|
|
405
|
+
reverse_log_odds=round(l2, 4) if l2 is not None else None,
|
|
406
|
+
tokens_generated=0,
|
|
407
|
+
)
|
|
408
|
+
|
|
409
|
+
results.append(
|
|
410
|
+
Verdict(
|
|
411
|
+
passed=passed,
|
|
412
|
+
status=status,
|
|
413
|
+
probability=round(calibrated_prob, 4),
|
|
414
|
+
triggered_layer=self.name,
|
|
415
|
+
total_latency_ms=latency,
|
|
416
|
+
total_tokens_generated=0,
|
|
417
|
+
reason=f"LogitGate prob={calibrated_prob:.4f} (pass_thresh={self.pass_threshold}, fail_thresh={self.fail_threshold})",
|
|
418
|
+
traces=[trace],
|
|
419
|
+
)
|
|
420
|
+
)
|
|
421
|
+
return results
|
|
422
|
+
|
|
423
|
+
async def aevaluate_batch(self, candidate: Any, rubrics: Sequence[str]) -> list[Verdict]:
|
|
424
|
+
"""
|
|
425
|
+
Asynchronously evaluates multiple rubrics against candidate artifact using
|
|
426
|
+
multi-rubric batch prompt compilation with 100% byte-identical KV-cache prefix sharing.
|
|
427
|
+
"""
|
|
428
|
+
if not rubrics:
|
|
429
|
+
return []
|
|
430
|
+
|
|
431
|
+
async def _eval_single(idx: int) -> Verdict:
|
|
432
|
+
t0 = time.perf_counter()
|
|
433
|
+
prompt1 = compile_batch_binary_prompt(
|
|
434
|
+
rubrics=rubrics,
|
|
435
|
+
candidate=candidate,
|
|
436
|
+
rubric_index=idx,
|
|
437
|
+
pass_token="A",
|
|
438
|
+
fail_token="B",
|
|
439
|
+
query_first=False,
|
|
440
|
+
)
|
|
441
|
+
|
|
442
|
+
if self.symmetric_debiasing:
|
|
443
|
+
prompt2 = compile_batch_binary_prompt(
|
|
444
|
+
rubrics=rubrics,
|
|
445
|
+
candidate=candidate,
|
|
446
|
+
rubric_index=idx,
|
|
447
|
+
pass_token="B",
|
|
448
|
+
fail_token="A",
|
|
449
|
+
query_first=False,
|
|
450
|
+
)
|
|
451
|
+
top1, top2 = await asyncio.gather(
|
|
452
|
+
self.backend.aquery_top_logprobs(prompt1, top_k=10, allowed_tokens=["A", "B"]),
|
|
453
|
+
self.backend.aquery_top_logprobs(prompt2, top_k=10, allowed_tokens=["A", "B"]),
|
|
454
|
+
)
|
|
455
|
+
l1, p1 = compute_binary_log_odds(top1, pass_token="A", fail_token="B")
|
|
456
|
+
l2, p2 = compute_binary_log_odds(top2, pass_token="B", fail_token="A")
|
|
457
|
+
calibrated_prob = combine_symmetric_log_odds(
|
|
458
|
+
forward_log_odds=l1,
|
|
459
|
+
reverse_log_odds=l2,
|
|
460
|
+
logit_temperature=self.logit_temperature,
|
|
461
|
+
)
|
|
462
|
+
else:
|
|
463
|
+
top1 = await self.backend.aquery_top_logprobs(prompt1, top_k=10, allowed_tokens=["A", "B"])
|
|
464
|
+
l1, p1 = compute_binary_log_odds(top1, pass_token="A", fail_token="B")
|
|
465
|
+
l2, p2 = None, None
|
|
466
|
+
calibrated_prob = p1
|
|
467
|
+
|
|
468
|
+
latency = (time.perf_counter() - t0) * 1000.0
|
|
469
|
+
|
|
470
|
+
if calibrated_prob >= self.pass_threshold:
|
|
471
|
+
status = DecisionStatus.PASSED
|
|
472
|
+
passed = True
|
|
473
|
+
elif calibrated_prob <= self.fail_threshold:
|
|
474
|
+
status = DecisionStatus.FAILED
|
|
475
|
+
passed = False
|
|
476
|
+
else:
|
|
477
|
+
status = DecisionStatus.AMBIGUOUS
|
|
478
|
+
passed = False
|
|
479
|
+
|
|
480
|
+
trace = LayerTrace(
|
|
481
|
+
layer_name=self.name,
|
|
482
|
+
status=status,
|
|
483
|
+
latency_ms=latency,
|
|
484
|
+
calibrated_prob=round(calibrated_prob, 4),
|
|
485
|
+
forward_prob=round(p1, 4),
|
|
486
|
+
reverse_prob=round(p2, 4) if p2 is not None else None,
|
|
487
|
+
forward_log_odds=round(l1, 4),
|
|
488
|
+
reverse_log_odds=round(l2, 4) if l2 is not None else None,
|
|
489
|
+
tokens_generated=0,
|
|
490
|
+
)
|
|
491
|
+
|
|
492
|
+
return Verdict(
|
|
493
|
+
passed=passed,
|
|
494
|
+
status=status,
|
|
495
|
+
probability=round(calibrated_prob, 4),
|
|
496
|
+
triggered_layer=self.name,
|
|
497
|
+
total_latency_ms=latency,
|
|
498
|
+
total_tokens_generated=0,
|
|
499
|
+
reason=f"LogitGate prob={calibrated_prob:.4f} (pass_thresh={self.pass_threshold}, fail_thresh={self.fail_threshold})",
|
|
500
|
+
traces=[trace],
|
|
501
|
+
)
|
|
502
|
+
|
|
503
|
+
if len(rubrics) == 1:
|
|
504
|
+
return [await _eval_single(0)]
|
|
505
|
+
|
|
506
|
+
# Prevent KV-cache stampede: populate server cache with first item
|
|
507
|
+
first_verdict = await _eval_single(0)
|
|
508
|
+
rest_verdicts = await asyncio.gather(*[_eval_single(idx) for idx in range(1, len(rubrics))])
|
|
509
|
+
return [first_verdict, *rest_verdicts]
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
class ChoiceGate(BaseGate):
|
|
513
|
+
"""Discrete K-ary categorical decision and routing gate.
|
|
514
|
+
|
|
515
|
+
Evaluates K discrete options from first-token logits, cancelling label order
|
|
516
|
+
and position bias via symmetric permutation debiasing (Zheng et al., 2023).
|
|
517
|
+
|
|
518
|
+
Args:
|
|
519
|
+
question: Decision question or routing prompt.
|
|
520
|
+
options: Mapping of option tokens (e.g., 'A', 'B', 'C') to their descriptions.
|
|
521
|
+
backend: Backend instance or specifier URI.
|
|
522
|
+
model: Optional model identifier string for the backend.
|
|
523
|
+
symmetric_debiasing: When True, queries options in forward and reversed orderings
|
|
524
|
+
and computes the debiased logprob average.
|
|
525
|
+
query_first: When True, compiles prompt with question and candidate first.
|
|
526
|
+
logit_temperature: Temperature scaling factor for softmax normalization (default: 1.0).
|
|
527
|
+
name: Identifier for this gate instance.
|
|
528
|
+
|
|
529
|
+
Raises:
|
|
530
|
+
ValueError: If question is empty, options has fewer than 2 items, or logit_temperature <= 0.
|
|
531
|
+
"""
|
|
532
|
+
|
|
533
|
+
def __init__(
|
|
534
|
+
self,
|
|
535
|
+
question: str,
|
|
536
|
+
options: Mapping[str, str],
|
|
537
|
+
backend: str | LogitBackend | None = None,
|
|
538
|
+
model: str | None = None,
|
|
539
|
+
symmetric_debiasing: bool = True,
|
|
540
|
+
query_first: bool = True,
|
|
541
|
+
logit_temperature: float | None = None,
|
|
542
|
+
name: str = "ChoiceGate",
|
|
543
|
+
) -> None:
|
|
544
|
+
effective_temp = (
|
|
545
|
+
LogitGateDefaults.logit_temperature if logit_temperature is None else logit_temperature
|
|
546
|
+
)
|
|
547
|
+
if not question or not question.strip():
|
|
548
|
+
raise ValueError("question cannot be empty or whitespace")
|
|
549
|
+
if not options or len(options) < 2:
|
|
550
|
+
raise ValueError(f"ChoiceGate requires at least 2 options, got {len(options) if options else 0}")
|
|
551
|
+
if effective_temp <= 0.0:
|
|
552
|
+
raise ValueError(f"logit_temperature must be positive, got {effective_temp}")
|
|
553
|
+
|
|
554
|
+
effective_backend = (
|
|
555
|
+
get_default_backend() if (backend is None or backend == "default") else backend
|
|
556
|
+
)
|
|
557
|
+
effective_model = model or get_default_model()
|
|
558
|
+
|
|
559
|
+
self.question = question.strip()
|
|
560
|
+
self.options = dict(options)
|
|
561
|
+
self.backend = resolve_logit_backend(effective_backend, model=effective_model)
|
|
562
|
+
self.model = effective_model
|
|
563
|
+
self.symmetric_debiasing = symmetric_debiasing
|
|
564
|
+
self.query_first = query_first
|
|
565
|
+
self.logit_temperature = effective_temp
|
|
566
|
+
self.name = name
|
|
567
|
+
|
|
568
|
+
def __repr__(self) -> str:
|
|
569
|
+
q_repr = repr(self.question[:40] + "..." if len(self.question) > 40 else self.question)
|
|
570
|
+
return f"ChoiceGate(question={q_repr}, options={list(self.options.keys())!r}, backend={self.backend!r})"
|
|
571
|
+
|
|
572
|
+
def evaluate(self, candidate: Any, **kwargs: Any) -> Verdict:
|
|
573
|
+
t0 = time.perf_counter()
|
|
574
|
+
keys = list(self.options.keys())
|
|
575
|
+
|
|
576
|
+
# Pass 1: Original option order
|
|
577
|
+
prompt1 = compile_choice_prompt(
|
|
578
|
+
question=self.question,
|
|
579
|
+
candidate=candidate,
|
|
580
|
+
options=self.options,
|
|
581
|
+
query_first=self.query_first,
|
|
582
|
+
)
|
|
583
|
+
top1 = self.backend.query_top_logprobs(prompt1, top_k=10, allowed_tokens=keys)
|
|
584
|
+
logprobs1 = {k: extract_token_logprob(top1, k) for k in keys}
|
|
585
|
+
|
|
586
|
+
if self.symmetric_debiasing and len(keys) >= 2:
|
|
587
|
+
# Pass 2: Reversed option mapping
|
|
588
|
+
reversed_values = list(reversed(list(self.options.values())))
|
|
589
|
+
reversed_mapping = dict(zip(keys, reversed_values))
|
|
590
|
+
prompt2 = compile_choice_prompt(
|
|
591
|
+
question=self.question,
|
|
592
|
+
candidate=candidate,
|
|
593
|
+
options=reversed_mapping,
|
|
594
|
+
query_first=self.query_first,
|
|
595
|
+
)
|
|
596
|
+
top2 = self.backend.query_top_logprobs(prompt2, top_k=10, allowed_tokens=keys)
|
|
597
|
+
# Map logprobs back to original option keys
|
|
598
|
+
logprobs2: dict[str, float] = {}
|
|
599
|
+
for i, k in enumerate(keys):
|
|
600
|
+
rev_key = keys[len(keys) - 1 - i]
|
|
601
|
+
logprobs2[k] = extract_token_logprob(top2, rev_key)
|
|
602
|
+
|
|
603
|
+
# Average logprobs
|
|
604
|
+
combined_logprobs = {k: (logprobs1[k] + logprobs2[k]) / 2.0 for k in keys}
|
|
605
|
+
else:
|
|
606
|
+
combined_logprobs = logprobs1
|
|
607
|
+
|
|
608
|
+
# Temperature scaling before softmax normalization
|
|
609
|
+
temp = max(self.logit_temperature, 1e-6)
|
|
610
|
+
scaled_logprobs = {k: lp / temp for k, lp in combined_logprobs.items()}
|
|
611
|
+
|
|
612
|
+
# Normalized softmax probabilities
|
|
613
|
+
max_lp = max(scaled_logprobs.values()) if scaled_logprobs else 0.0
|
|
614
|
+
exp_vals = {k: math.exp(lp - max_lp) for k, lp in scaled_logprobs.items()}
|
|
615
|
+
total_exp = sum(exp_vals.values())
|
|
616
|
+
choice_probs = {k: exp_vals[k] / total_exp for k in keys} if total_exp > 0 else {k: 1.0 / len(keys) for k in keys}
|
|
617
|
+
|
|
618
|
+
best_choice = max(choice_probs, key=choice_probs.get) # type: ignore
|
|
619
|
+
max_prob = choice_probs[best_choice]
|
|
620
|
+
latency = (time.perf_counter() - t0) * 1000.0
|
|
621
|
+
|
|
622
|
+
trace = LayerTrace(
|
|
623
|
+
layer_name=self.name,
|
|
624
|
+
status=DecisionStatus.PASSED,
|
|
625
|
+
latency_ms=latency,
|
|
626
|
+
calibrated_prob=round(max_prob, 4),
|
|
627
|
+
metadata={"choice_probabilities": {k: round(v, 4) for k, v in choice_probs.items()}},
|
|
628
|
+
)
|
|
629
|
+
|
|
630
|
+
return Verdict(
|
|
631
|
+
passed=True,
|
|
632
|
+
status=DecisionStatus.PASSED,
|
|
633
|
+
probability=round(max_prob, 4),
|
|
634
|
+
choice=best_choice,
|
|
635
|
+
choice_probabilities={k: round(v, 4) for k, v in choice_probs.items()},
|
|
636
|
+
triggered_layer=self.name,
|
|
637
|
+
total_latency_ms=latency,
|
|
638
|
+
total_tokens_generated=0,
|
|
639
|
+
reason=f"ChoiceGate selected '{best_choice}' with P={max_prob:.4f}",
|
|
640
|
+
traces=[trace],
|
|
641
|
+
)
|
|
642
|
+
|
|
643
|
+
async def aevaluate(self, candidate: Any, **kwargs: Any) -> Verdict:
|
|
644
|
+
t0 = time.perf_counter()
|
|
645
|
+
keys = list(self.options.keys())
|
|
646
|
+
|
|
647
|
+
prompt1 = compile_choice_prompt(
|
|
648
|
+
question=self.question,
|
|
649
|
+
candidate=candidate,
|
|
650
|
+
options=self.options,
|
|
651
|
+
query_first=self.query_first,
|
|
652
|
+
)
|
|
653
|
+
|
|
654
|
+
if self.symmetric_debiasing and len(keys) >= 2:
|
|
655
|
+
reversed_values = list(reversed(list(self.options.values())))
|
|
656
|
+
reversed_mapping = dict(zip(keys, reversed_values))
|
|
657
|
+
prompt2 = compile_choice_prompt(
|
|
658
|
+
question=self.question,
|
|
659
|
+
candidate=candidate,
|
|
660
|
+
options=reversed_mapping,
|
|
661
|
+
query_first=self.query_first,
|
|
662
|
+
)
|
|
663
|
+
top1, top2 = await asyncio.gather(
|
|
664
|
+
self.backend.aquery_top_logprobs(prompt1, top_k=10, allowed_tokens=keys),
|
|
665
|
+
self.backend.aquery_top_logprobs(prompt2, top_k=10, allowed_tokens=keys),
|
|
666
|
+
)
|
|
667
|
+
logprobs1 = {k: extract_token_logprob(top1, k) for k in keys}
|
|
668
|
+
logprobs2: dict[str, float] = {}
|
|
669
|
+
for i, k in enumerate(keys):
|
|
670
|
+
rev_key = keys[len(keys) - 1 - i]
|
|
671
|
+
logprobs2[k] = extract_token_logprob(top2, rev_key)
|
|
672
|
+
|
|
673
|
+
combined_logprobs = {k: (logprobs1[k] + logprobs2[k]) / 2.0 for k in keys}
|
|
674
|
+
else:
|
|
675
|
+
top1 = await self.backend.aquery_top_logprobs(prompt1, top_k=10, allowed_tokens=keys)
|
|
676
|
+
logprobs1 = {k: extract_token_logprob(top1, k) for k in keys}
|
|
677
|
+
combined_logprobs = logprobs1
|
|
678
|
+
|
|
679
|
+
# Temperature scaling before softmax normalization
|
|
680
|
+
temp = max(self.logit_temperature, 1e-6)
|
|
681
|
+
scaled_logprobs = {k: lp / temp for k, lp in combined_logprobs.items()}
|
|
682
|
+
|
|
683
|
+
max_lp = max(scaled_logprobs.values()) if scaled_logprobs else 0.0
|
|
684
|
+
exp_vals = {k: math.exp(lp - max_lp) for k, lp in scaled_logprobs.items()}
|
|
685
|
+
total_exp = sum(exp_vals.values())
|
|
686
|
+
choice_probs = {k: exp_vals[k] / total_exp for k in keys} if total_exp > 0 else {k: 1.0 / len(keys) for k in keys}
|
|
687
|
+
|
|
688
|
+
best_choice = max(choice_probs, key=choice_probs.get) # type: ignore
|
|
689
|
+
max_prob = choice_probs[best_choice]
|
|
690
|
+
latency = (time.perf_counter() - t0) * 1000.0
|
|
691
|
+
|
|
692
|
+
trace = LayerTrace(
|
|
693
|
+
layer_name=self.name,
|
|
694
|
+
status=DecisionStatus.PASSED,
|
|
695
|
+
latency_ms=latency,
|
|
696
|
+
calibrated_prob=round(max_prob, 4),
|
|
697
|
+
metadata={"choice_probabilities": {k: round(v, 4) for k, v in choice_probs.items()}},
|
|
698
|
+
)
|
|
699
|
+
|
|
700
|
+
return Verdict(
|
|
701
|
+
passed=True,
|
|
702
|
+
status=DecisionStatus.PASSED,
|
|
703
|
+
probability=round(max_prob, 4),
|
|
704
|
+
choice=best_choice,
|
|
705
|
+
choice_probabilities={k: round(v, 4) for k, v in choice_probs.items()},
|
|
706
|
+
triggered_layer=self.name,
|
|
707
|
+
total_latency_ms=latency,
|
|
708
|
+
total_tokens_generated=0,
|
|
709
|
+
reason=f"ChoiceGate selected '{best_choice}' with P={max_prob:.4f}",
|
|
710
|
+
traces=[trace],
|
|
711
|
+
)
|
|
712
|
+
|
|
713
|
+
|
|
714
|
+
class ScoreGate(BaseGate):
|
|
715
|
+
"""Continuous probability-weighted rubric scoring gate.
|
|
716
|
+
|
|
717
|
+
Computes expected continuous quality score:
|
|
718
|
+
E[S] = sum_{s=min_score}^{max_score} s * P(s)
|
|
719
|
+
from first-token logits as formalized by Liu et al. (2023, G-Eval).
|
|
720
|
+
Supports temperature scaling and optional symmetric scale reversal debiasing
|
|
721
|
+
(evaluating both standard 1..5 and inverted 5..1 scale mappings).
|
|
722
|
+
|
|
723
|
+
Args:
|
|
724
|
+
rubric: Evaluation rubric describing scoring expectations.
|
|
725
|
+
min_score: Minimum integer score rating (default: 1).
|
|
726
|
+
max_score: Maximum integer score rating (default: 5).
|
|
727
|
+
threshold: Score threshold required for a PASSED verdict (default: 3.5).
|
|
728
|
+
backend: Backend instance or specifier URI.
|
|
729
|
+
model: Optional model identifier string for the backend.
|
|
730
|
+
symmetric_debiasing: When True, queries both normal and inverted scale prompts
|
|
731
|
+
and averages logprobs to eliminate scale-end anchoring bias.
|
|
732
|
+
query_first: When True, compiles prompt with rubric and candidate first.
|
|
733
|
+
logit_temperature: Temperature scaling factor for softmax normalization (default: 1.0).
|
|
734
|
+
name: Identifier for this gate instance.
|
|
735
|
+
|
|
736
|
+
Raises:
|
|
737
|
+
ValueError: If rubric is empty, min_score >= max_score, threshold is out of bounds,
|
|
738
|
+
or logit_temperature <= 0.
|
|
739
|
+
"""
|
|
740
|
+
|
|
741
|
+
def __init__(
|
|
742
|
+
self,
|
|
743
|
+
rubric: str,
|
|
744
|
+
min_score: int = 1,
|
|
745
|
+
max_score: int = 5,
|
|
746
|
+
threshold: float = 3.5,
|
|
747
|
+
backend: str | LogitBackend | None = None,
|
|
748
|
+
model: str | None = None,
|
|
749
|
+
symmetric_debiasing: bool = False,
|
|
750
|
+
query_first: bool = True,
|
|
751
|
+
logit_temperature: float | None = None,
|
|
752
|
+
name: str = "ScoreGate",
|
|
753
|
+
) -> None:
|
|
754
|
+
effective_temp = (
|
|
755
|
+
LogitGateDefaults.logit_temperature if logit_temperature is None else logit_temperature
|
|
756
|
+
)
|
|
757
|
+
if not rubric or not rubric.strip():
|
|
758
|
+
raise ValueError("rubric cannot be empty or whitespace")
|
|
759
|
+
if min_score >= max_score:
|
|
760
|
+
raise ValueError(f"min_score ({min_score}) must be strictly less than max_score ({max_score})")
|
|
761
|
+
if threshold < min_score or threshold > max_score:
|
|
762
|
+
raise ValueError(
|
|
763
|
+
f"threshold ({threshold}) must be between min_score ({min_score}) and max_score ({max_score})"
|
|
764
|
+
)
|
|
765
|
+
if effective_temp <= 0.0:
|
|
766
|
+
raise ValueError(f"logit_temperature must be positive, got {effective_temp}")
|
|
767
|
+
|
|
768
|
+
effective_backend = (
|
|
769
|
+
get_default_backend() if (backend is None or backend == "default") else backend
|
|
770
|
+
)
|
|
771
|
+
effective_model = model or get_default_model()
|
|
772
|
+
|
|
773
|
+
self.rubric = rubric.strip()
|
|
774
|
+
self.min_score = min_score
|
|
775
|
+
self.max_score = max_score
|
|
776
|
+
self.threshold = threshold
|
|
777
|
+
self.backend = resolve_logit_backend(effective_backend, model=effective_model)
|
|
778
|
+
self.model = effective_model
|
|
779
|
+
self.symmetric_debiasing = symmetric_debiasing
|
|
780
|
+
self.query_first = query_first
|
|
781
|
+
self.logit_temperature = effective_temp
|
|
782
|
+
self.name = name
|
|
783
|
+
|
|
784
|
+
def __repr__(self) -> str:
|
|
785
|
+
rubric_repr = repr(self.rubric[:40] + "..." if len(self.rubric) > 40 else self.rubric)
|
|
786
|
+
return (
|
|
787
|
+
f"ScoreGate(rubric={rubric_repr}, "
|
|
788
|
+
f"range=[{self.min_score}..{self.max_score}], "
|
|
789
|
+
f"threshold={self.threshold}, "
|
|
790
|
+
f"backend={self.backend!r})"
|
|
791
|
+
)
|
|
792
|
+
|
|
793
|
+
def evaluate(self, candidate: Any, **kwargs: Any) -> Verdict:
|
|
794
|
+
t0 = time.perf_counter()
|
|
795
|
+
score_tokens = [str(s) for s in range(self.min_score, self.max_score + 1)]
|
|
796
|
+
|
|
797
|
+
prompt1 = compile_score_prompt(
|
|
798
|
+
rubric=self.rubric,
|
|
799
|
+
candidate=candidate,
|
|
800
|
+
min_score=self.min_score,
|
|
801
|
+
max_score=self.max_score,
|
|
802
|
+
query_first=self.query_first,
|
|
803
|
+
inverted=False,
|
|
804
|
+
)
|
|
805
|
+
top1 = self.backend.query_top_logprobs(prompt1, top_k=10, allowed_tokens=score_tokens)
|
|
806
|
+
logprobs1 = {s: extract_token_logprob(top1, str(s)) for s in range(self.min_score, self.max_score + 1)}
|
|
807
|
+
|
|
808
|
+
if self.symmetric_debiasing:
|
|
809
|
+
prompt2 = compile_score_prompt(
|
|
810
|
+
rubric=self.rubric,
|
|
811
|
+
candidate=candidate,
|
|
812
|
+
min_score=self.min_score,
|
|
813
|
+
max_score=self.max_score,
|
|
814
|
+
query_first=self.query_first,
|
|
815
|
+
inverted=True,
|
|
816
|
+
)
|
|
817
|
+
top2 = self.backend.query_top_logprobs(prompt2, top_k=10, allowed_tokens=score_tokens)
|
|
818
|
+
# In inverted prompt: token s' represents quality score = max_score + min_score - s'
|
|
819
|
+
# So for quality score s, the token queried was s' = max_score + min_score - s
|
|
820
|
+
logprobs2 = {
|
|
821
|
+
s: extract_token_logprob(top2, str(self.max_score + self.min_score - s))
|
|
822
|
+
for s in range(self.min_score, self.max_score + 1)
|
|
823
|
+
}
|
|
824
|
+
combined_logprobs = {
|
|
825
|
+
s: (logprobs1[s] + logprobs2[s]) / 2.0
|
|
826
|
+
for s in range(self.min_score, self.max_score + 1)
|
|
827
|
+
}
|
|
828
|
+
else:
|
|
829
|
+
combined_logprobs = logprobs1
|
|
830
|
+
|
|
831
|
+
# Temperature scaling before softmax
|
|
832
|
+
temp = max(self.logit_temperature, 1e-6)
|
|
833
|
+
scaled_logprobs = {s: lp / temp for s, lp in combined_logprobs.items()}
|
|
834
|
+
|
|
835
|
+
# Normalized softmax probabilities
|
|
836
|
+
max_lp = max(scaled_logprobs.values()) if scaled_logprobs else 0.0
|
|
837
|
+
exp_vals = {s: math.exp(lp - max_lp) for s, lp in scaled_logprobs.items()}
|
|
838
|
+
total_exp = sum(exp_vals.values())
|
|
839
|
+
score_probs = (
|
|
840
|
+
{s: exp_vals[s] / total_exp for s in scaled_logprobs}
|
|
841
|
+
if total_exp > 0
|
|
842
|
+
else {s: 1.0 / len(scaled_logprobs) for s in scaled_logprobs}
|
|
843
|
+
)
|
|
844
|
+
|
|
845
|
+
# Expected score: sum(s * P(s))
|
|
846
|
+
expected_score = sum(s * prob for s, prob in score_probs.items())
|
|
847
|
+
passed = expected_score >= self.threshold
|
|
848
|
+
status = DecisionStatus.PASSED if passed else DecisionStatus.FAILED
|
|
849
|
+
latency = (time.perf_counter() - t0) * 1000.0
|
|
850
|
+
|
|
851
|
+
trace = LayerTrace(
|
|
852
|
+
layer_name=self.name,
|
|
853
|
+
status=status,
|
|
854
|
+
latency_ms=latency,
|
|
855
|
+
metadata={
|
|
856
|
+
"expected_score": round(expected_score, 4),
|
|
857
|
+
"token_probabilities": {str(k): round(v, 4) for k, v in score_probs.items()},
|
|
858
|
+
},
|
|
859
|
+
)
|
|
860
|
+
|
|
861
|
+
return Verdict(
|
|
862
|
+
passed=passed,
|
|
863
|
+
status=status,
|
|
864
|
+
score=round(expected_score, 4),
|
|
865
|
+
triggered_layer=self.name,
|
|
866
|
+
total_latency_ms=latency,
|
|
867
|
+
total_tokens_generated=0,
|
|
868
|
+
reason=f"ScoreGate E[S]={expected_score:.2f} (threshold={self.threshold})",
|
|
869
|
+
traces=[trace],
|
|
870
|
+
)
|
|
871
|
+
|
|
872
|
+
async def aevaluate(self, candidate: Any, **kwargs: Any) -> Verdict:
|
|
873
|
+
t0 = time.perf_counter()
|
|
874
|
+
score_tokens = [str(s) for s in range(self.min_score, self.max_score + 1)]
|
|
875
|
+
|
|
876
|
+
prompt1 = compile_score_prompt(
|
|
877
|
+
rubric=self.rubric,
|
|
878
|
+
candidate=candidate,
|
|
879
|
+
min_score=self.min_score,
|
|
880
|
+
max_score=self.max_score,
|
|
881
|
+
query_first=self.query_first,
|
|
882
|
+
inverted=False,
|
|
883
|
+
)
|
|
884
|
+
|
|
885
|
+
if self.symmetric_debiasing:
|
|
886
|
+
prompt2 = compile_score_prompt(
|
|
887
|
+
rubric=self.rubric,
|
|
888
|
+
candidate=candidate,
|
|
889
|
+
min_score=self.min_score,
|
|
890
|
+
max_score=self.max_score,
|
|
891
|
+
query_first=self.query_first,
|
|
892
|
+
inverted=True,
|
|
893
|
+
)
|
|
894
|
+
top1, top2 = await asyncio.gather(
|
|
895
|
+
self.backend.aquery_top_logprobs(prompt1, top_k=10, allowed_tokens=score_tokens),
|
|
896
|
+
self.backend.aquery_top_logprobs(prompt2, top_k=10, allowed_tokens=score_tokens),
|
|
897
|
+
)
|
|
898
|
+
logprobs1 = {s: extract_token_logprob(top1, str(s)) for s in range(self.min_score, self.max_score + 1)}
|
|
899
|
+
logprobs2 = {
|
|
900
|
+
s: extract_token_logprob(top2, str(self.max_score + self.min_score - s))
|
|
901
|
+
for s in range(self.min_score, self.max_score + 1)
|
|
902
|
+
}
|
|
903
|
+
combined_logprobs = {
|
|
904
|
+
s: (logprobs1[s] + logprobs2[s]) / 2.0
|
|
905
|
+
for s in range(self.min_score, self.max_score + 1)
|
|
906
|
+
}
|
|
907
|
+
else:
|
|
908
|
+
top1 = await self.backend.aquery_top_logprobs(prompt1, top_k=10, allowed_tokens=score_tokens)
|
|
909
|
+
logprobs1 = {s: extract_token_logprob(top1, str(s)) for s in range(self.min_score, self.max_score + 1)}
|
|
910
|
+
combined_logprobs = logprobs1
|
|
911
|
+
|
|
912
|
+
# Temperature scaling before softmax
|
|
913
|
+
temp = max(self.logit_temperature, 1e-6)
|
|
914
|
+
scaled_logprobs = {s: lp / temp for s, lp in combined_logprobs.items()}
|
|
915
|
+
|
|
916
|
+
# Normalized softmax probabilities
|
|
917
|
+
max_lp = max(scaled_logprobs.values()) if scaled_logprobs else 0.0
|
|
918
|
+
exp_vals = {s: math.exp(lp - max_lp) for s, lp in scaled_logprobs.items()}
|
|
919
|
+
total_exp = sum(exp_vals.values())
|
|
920
|
+
score_probs = (
|
|
921
|
+
{s: exp_vals[s] / total_exp for s in scaled_logprobs}
|
|
922
|
+
if total_exp > 0
|
|
923
|
+
else {s: 1.0 / len(scaled_logprobs) for s in scaled_logprobs}
|
|
924
|
+
)
|
|
925
|
+
|
|
926
|
+
# Expected score: sum(s * P(s))
|
|
927
|
+
expected_score = sum(s * prob for s, prob in score_probs.items())
|
|
928
|
+
passed = expected_score >= self.threshold
|
|
929
|
+
status = DecisionStatus.PASSED if passed else DecisionStatus.FAILED
|
|
930
|
+
latency = (time.perf_counter() - t0) * 1000.0
|
|
931
|
+
|
|
932
|
+
trace = LayerTrace(
|
|
933
|
+
layer_name=self.name,
|
|
934
|
+
status=status,
|
|
935
|
+
latency_ms=latency,
|
|
936
|
+
metadata={
|
|
937
|
+
"expected_score": round(expected_score, 4),
|
|
938
|
+
"token_probabilities": {str(k): round(v, 4) for k, v in score_probs.items()},
|
|
939
|
+
},
|
|
940
|
+
)
|
|
941
|
+
|
|
942
|
+
return Verdict(
|
|
943
|
+
passed=passed,
|
|
944
|
+
status=status,
|
|
945
|
+
score=round(expected_score, 4),
|
|
946
|
+
triggered_layer=self.name,
|
|
947
|
+
total_latency_ms=latency,
|
|
948
|
+
total_tokens_generated=0,
|
|
949
|
+
reason=f"ScoreGate E[S]={expected_score:.2f} (threshold={self.threshold})",
|
|
950
|
+
traces=[trace],
|
|
951
|
+
)
|