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