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,10 @@
1
+ from logitgate.integrations.deepeval import LogitGateMetric, LogitGateSystemOneModel
2
+ from logitgate.integrations.langgraph import as_conditional_edge
3
+ from logitgate.integrations.pytest_plugin import check
4
+
5
+ __all__ = [
6
+ "LogitGateMetric",
7
+ "LogitGateSystemOneModel",
8
+ "as_conditional_edge",
9
+ "check",
10
+ ]
@@ -0,0 +1,212 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from logitgate.backends.base import LogitBackend
6
+ from logitgate.gates.logit_gate import LogitGate
7
+
8
+ # Graceful optional imports for DeepEval
9
+ try:
10
+ from deepeval.metrics import BaseMetric
11
+ from deepeval.models import DeepEvalBaseSystemOneModel
12
+ from deepeval.models.system_one.schema import (
13
+ NoulAnswer,
14
+ NoulQuestion,
15
+ SystemOneAnswers,
16
+ SystemOneQuestion,
17
+ )
18
+ from deepeval.test_case import LLMTestCase
19
+ _DEEPEVAL_AVAILABLE = True
20
+ except ImportError:
21
+ _DEEPEVAL_AVAILABLE = False
22
+
23
+ class BaseMetric: # type: ignore[no-redef]
24
+ def __init__(self) -> None:
25
+ pass
26
+
27
+ class DeepEvalBaseSystemOneModel: # type: ignore[no-redef]
28
+ def __init__(self, model: str = "default") -> None:
29
+ self.model = model
30
+
31
+ class NoulAnswer: # type: ignore[no-redef]
32
+ def __init__(self, probability: float = 0.5) -> None:
33
+ self.probability = probability
34
+
35
+ class NoulQuestion: # type: ignore[no-redef]
36
+ def __init__(self, instructions: str = "", statement: str = "") -> None:
37
+ self.instructions = instructions or statement
38
+ self.statement = statement or instructions
39
+
40
+ class SystemOneAnswers: # type: ignore[no-redef]
41
+ def __init__(self, nouls: dict[str, Any]) -> None:
42
+ self.nouls = nouls
43
+
44
+ class SystemOneQuestion(NoulQuestion): # type: ignore[no-redef]
45
+ pass
46
+
47
+ class LLMTestCase: # type: ignore[no-redef]
48
+ def __init__(self, input: str = "", actual_output: str = "") -> None:
49
+ self.input = input
50
+ self.actual_output = actual_output
51
+
52
+
53
+ class LogitGateSystemOneModel(DeepEvalBaseSystemOneModel):
54
+ """
55
+ DeepEval System-1 model powered by LogitGate debiased first-token logits.
56
+ Drop-in System-1 judge model for DeepEval JevEval metrics.
57
+ """
58
+
59
+ def __init__(
60
+ self,
61
+ model_name: str = "qwen2.5-coder:14b",
62
+ api_base: str = "http://localhost:11434",
63
+ backend: LogitBackend | None = None,
64
+ symmetric: bool = True,
65
+ logit_temperature: float = 1.0,
66
+ ) -> None:
67
+ self.model_name = model_name
68
+ self.api_base = api_base
69
+ self.symmetric = symmetric
70
+ self.logit_temperature = logit_temperature
71
+
72
+ if backend is not None:
73
+ self.backend = backend
74
+ else:
75
+ from logitgate.backends.ollama import OllamaBackend
76
+ self.backend = OllamaBackend(base_url=api_base, model=model_name)
77
+ super().__init__(model=model_name)
78
+
79
+ def load_model(self) -> None:
80
+ return None
81
+
82
+ def get_model_name(self) -> str:
83
+ return f"logitgate:{self.model_name}"
84
+
85
+ def decide(
86
+ self,
87
+ state: Any,
88
+ questions: dict[str, Any],
89
+ ) -> tuple[Any, float | None]:
90
+ nouls: dict[str, Any] = {}
91
+ if len(questions) > 1:
92
+ q_ids = list(questions.keys())
93
+ statements = [
94
+ getattr(questions[qid], "statement", None)
95
+ or getattr(questions[qid], "instructions", None)
96
+ or str(questions[qid])
97
+ for qid in q_ids
98
+ ]
99
+ gate = LogitGate(
100
+ rubric=statements[0] if (statements and statements[0].strip()) else "Evaluate candidate against rubric.",
101
+ backend=self.backend,
102
+ symmetric_debiasing=self.symmetric,
103
+ logit_temperature=self.logit_temperature,
104
+ )
105
+ verdicts = gate.evaluate_batch(state, statements)
106
+ for q_id, verdict in zip(q_ids, verdicts):
107
+ p = verdict.probability if verdict.probability is not None else (1.0 if verdict.passed else 0.0)
108
+ nouls[q_id] = NoulAnswer(probability=round(p, 4))
109
+ else:
110
+ for q_id, q in questions.items():
111
+ statement = getattr(q, "statement", None) or getattr(q, "instructions", None) or str(q)
112
+ gate = LogitGate(
113
+ rubric=statement,
114
+ backend=self.backend,
115
+ symmetric_debiasing=self.symmetric,
116
+ logit_temperature=self.logit_temperature,
117
+ )
118
+ verdict = gate.evaluate(state)
119
+ p = verdict.probability if verdict.probability is not None else (1.0 if verdict.passed else 0.0)
120
+ nouls[q_id] = NoulAnswer(probability=round(p, 4))
121
+ return SystemOneAnswers(nouls=nouls), 0.0001
122
+
123
+ async def a_decide(
124
+ self,
125
+ state: Any,
126
+ questions: dict[str, Any],
127
+ ) -> tuple[Any, float | None]:
128
+ nouls: dict[str, Any] = {}
129
+ if len(questions) > 1:
130
+ q_ids = list(questions.keys())
131
+ statements = [
132
+ getattr(questions[qid], "statement", None)
133
+ or getattr(questions[qid], "instructions", None)
134
+ or str(questions[qid])
135
+ for qid in q_ids
136
+ ]
137
+ gate = LogitGate(
138
+ rubric=statements[0] if (statements and statements[0].strip()) else "Evaluate candidate against rubric.",
139
+ backend=self.backend,
140
+ symmetric_debiasing=self.symmetric,
141
+ logit_temperature=self.logit_temperature,
142
+ )
143
+ verdicts = await gate.aevaluate_batch(state, statements)
144
+ for q_id, verdict in zip(q_ids, verdicts):
145
+ p = verdict.probability if verdict.probability is not None else (1.0 if verdict.passed else 0.0)
146
+ nouls[q_id] = NoulAnswer(probability=round(p, 4))
147
+ else:
148
+ for q_id, q in questions.items():
149
+ statement = getattr(q, "statement", None) or getattr(q, "instructions", None) or str(q)
150
+ gate = LogitGate(
151
+ rubric=statement,
152
+ backend=self.backend,
153
+ symmetric_debiasing=self.symmetric,
154
+ logit_temperature=self.logit_temperature,
155
+ )
156
+ verdict = await gate.aevaluate(state)
157
+ p = verdict.probability if verdict.probability is not None else (1.0 if verdict.passed else 0.0)
158
+ nouls[q_id] = NoulAnswer(probability=round(p, 4))
159
+ return SystemOneAnswers(nouls=nouls), 0.0001
160
+
161
+
162
+ class LogitGateMetric(BaseMetric):
163
+ """
164
+ DeepEval metric evaluating LLMTestCase using a 50ms LogitGate.
165
+ """
166
+
167
+ def __init__(
168
+ self,
169
+ rubric: str,
170
+ threshold: float = 0.85,
171
+ backend: str | LogitBackend = "stub://default",
172
+ name: str = "LogitGate Metric",
173
+ async_mode: bool = False,
174
+ ) -> None:
175
+ super().__init__()
176
+ self.name = name
177
+ self.rubric = rubric
178
+ self.threshold = threshold
179
+ self.gate = LogitGate(
180
+ rubric=rubric,
181
+ pass_threshold=threshold,
182
+ backend=backend,
183
+ )
184
+ self.async_mode = async_mode
185
+ self.score: float | None = None
186
+ self.reason: str | None = None
187
+ self.success: bool | None = None
188
+
189
+ @property
190
+ def __name__(self) -> str:
191
+ return self.name
192
+
193
+ def measure(self, test_case: Any, *args: Any, **kwargs: Any) -> float:
194
+ candidate = getattr(test_case, "actual_output", None) or getattr(test_case, "input", str(test_case))
195
+ verdict = self.gate.evaluate(candidate)
196
+ self.score = verdict.probability if verdict.probability is not None else (1.0 if verdict.passed else 0.0)
197
+ self.success = verdict.passed
198
+ self.reason = verdict.reason
199
+ return self.score
200
+
201
+ async def a_measure(self, test_case: Any, *args: Any, **kwargs: Any) -> float:
202
+ candidate = getattr(test_case, "actual_output", None) or getattr(test_case, "input", str(test_case))
203
+ verdict = await self.gate.aevaluate(candidate)
204
+ self.score = verdict.probability if verdict.probability is not None else (1.0 if verdict.passed else 0.0)
205
+ self.success = verdict.passed
206
+ self.reason = verdict.reason
207
+ return self.score
208
+
209
+ def is_successful(self) -> bool:
210
+ if self.threshold is None or self.score is None:
211
+ return False
212
+ return self.score >= self.threshold
@@ -0,0 +1,30 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Callable, Mapping
4
+ from typing import Any
5
+
6
+ from logitgate.gates.base import BaseGate
7
+
8
+
9
+ def as_conditional_edge(
10
+ gate: BaseGate,
11
+ pass_node: str,
12
+ fail_node: str,
13
+ state_key: str = "content",
14
+ ) -> Callable[[Mapping[str, Any] | Any], str]:
15
+ """
16
+ Returns a conditional edge function for LangGraph workflows.
17
+ Extracts candidate from state using state_key and routes to
18
+ pass_node if the gate passes, else fail_node.
19
+ """
20
+ def route(state: Mapping[str, Any] | Any) -> str:
21
+ candidate: Any
22
+ if isinstance(state, Mapping):
23
+ candidate = state.get(state_key, state)
24
+ else:
25
+ candidate = getattr(state, state_key, state)
26
+
27
+ verdict = gate.evaluate(candidate)
28
+ return pass_node if verdict.passed else fail_node
29
+
30
+ return route
@@ -0,0 +1,68 @@
1
+ from __future__ import annotations
2
+
3
+ import functools
4
+ import inspect
5
+ from collections.abc import Callable
6
+ from typing import Any
7
+
8
+ from logitgate.backends.base import LogitBackend
9
+ from logitgate.config import LogitGateDefaults, get_default_backend
10
+ from logitgate.gates.ast_gate import ASTGate
11
+ from logitgate.gates.logit_gate import LogitGate
12
+
13
+
14
+ def check(
15
+ rubric: str,
16
+ threshold: float | None = None,
17
+ backend: str | LogitBackend | None = None,
18
+ ast_check: bool = False,
19
+ save_snapshot: bool | None = None,
20
+ ) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
21
+ """
22
+ Pytest decorator to evaluate function output against an LLM rubric
23
+ using LogitGate.
24
+ """
25
+ from logitgate.config import get_default_save_failures
26
+
27
+ effective_backend = (
28
+ get_default_backend() if (backend is None or backend == "default") else backend
29
+ )
30
+ effective_thresh = (
31
+ LogitGateDefaults.pass_threshold if threshold is None else threshold
32
+ )
33
+ effective_save = save_snapshot if save_snapshot is not None else get_default_save_failures()
34
+
35
+ def _format_error_msg(verdict: Any) -> str:
36
+ snapshot_path = verdict.traces[-1].metadata.get("snapshot_path") if verdict.traces else None
37
+ if snapshot_path:
38
+ return (
39
+ f"LogitGate evaluation failed (P={verdict.probability}): {verdict.reason}\n"
40
+ f"Snapshot saved to: {snapshot_path}\n"
41
+ f"Reproduce & generate full Layer-2 explanation locally without re-running your app:\n"
42
+ f" $ logitgate explain {snapshot_path}"
43
+ )
44
+ return f"LogitGate evaluation failed (P={verdict.probability}): {verdict.reason}"
45
+
46
+ def decorator(fn: Callable[..., Any]) -> Callable[..., Any]:
47
+ gate: Any = LogitGate(rubric=rubric, pass_threshold=effective_thresh, backend=effective_backend)
48
+ if ast_check:
49
+ gate = ASTGate(syntax=True) >> gate
50
+
51
+ if inspect.iscoroutinefunction(fn):
52
+ @functools.wraps(fn)
53
+ async def async_wrapper(*args: Any, **kwargs: Any) -> Any:
54
+ res = await fn(*args, **kwargs)
55
+ verdict = await gate.aevaluate(res, save_on_fail=effective_save, test_name=fn.__name__)
56
+ assert verdict.passed, _format_error_msg(verdict)
57
+ return res
58
+ return async_wrapper
59
+ else:
60
+ @functools.wraps(fn)
61
+ def sync_wrapper(*args: Any, **kwargs: Any) -> Any:
62
+ res = fn(*args, **kwargs)
63
+ verdict = gate.evaluate(res, save_on_fail=effective_save, test_name=fn.__name__)
64
+ assert verdict.passed, _format_error_msg(verdict)
65
+ return res
66
+ return sync_wrapper
67
+
68
+ return decorator
logitgate/models.py ADDED
@@ -0,0 +1,109 @@
1
+ from __future__ import annotations
2
+
3
+ from enum import Enum
4
+ from typing import Any
5
+
6
+ from pydantic import BaseModel, Field
7
+
8
+
9
+ class DecisionStatus(str, Enum):
10
+ """Enumeration of possible gating decision outcomes.
11
+
12
+ Attributes:
13
+ PASSED: Artifact satisfied all requirements and thresholds.
14
+ FAILED: Artifact decisively failed verification.
15
+ AMBIGUOUS: Confidence fell within the uncertainty band between fail and pass thresholds.
16
+ """
17
+
18
+ PASSED = "passed"
19
+ FAILED = "failed"
20
+ AMBIGUOUS = "ambiguous"
21
+
22
+
23
+ class LayerTrace(BaseModel):
24
+ """Detailed execution telemetry and diagnostic trace for a single gate layer.
25
+
26
+ Attributes:
27
+ layer_name: Identifier of the executing gate layer (e.g., 'ASTGate', 'LogitGate').
28
+ status: Decision status reached by this layer.
29
+ latency_ms: Execution duration for this layer in milliseconds.
30
+ calibrated_prob: Post-debiasing / calibrated probability (if applicable).
31
+ forward_prob: Pass probability from forward permutation pass (Pass 1).
32
+ reverse_prob: Pass probability from reverse permutation pass (Pass 2).
33
+ forward_log_odds: Raw logit difference from forward pass (Pass 1).
34
+ reverse_log_odds: Raw logit difference from reverse pass (Pass 2).
35
+ tokens_generated: Count of autoregressive tokens generated by this layer.
36
+ diagnostic_report: Text critique or diagnostic error message (if any).
37
+ metadata: Arbitrary supplemental telemetry and token distributions.
38
+ """
39
+
40
+ layer_name: str
41
+ status: DecisionStatus
42
+ latency_ms: float
43
+ calibrated_prob: float | None = None
44
+ forward_prob: float | None = None
45
+ reverse_prob: float | None = None
46
+ forward_log_odds: float | None = None
47
+ reverse_log_odds: float | None = None
48
+ tokens_generated: int = 0
49
+ diagnostic_report: str | None = None
50
+ metadata: dict[str, Any] = Field(default_factory=dict)
51
+
52
+ def __repr__(self) -> str:
53
+ parts = [
54
+ f"layer='{self.layer_name}'",
55
+ f"status={self.status.value}",
56
+ f"latency={self.latency_ms:.2f}ms",
57
+ ]
58
+ if self.calibrated_prob is not None:
59
+ parts.append(f"prob={self.calibrated_prob:.4f}")
60
+ if self.tokens_generated > 0:
61
+ parts.append(f"tokens={self.tokens_generated}")
62
+ return f"LayerTrace({', '.join(parts)})"
63
+
64
+
65
+ class Verdict(BaseModel):
66
+ """Final aggregated evaluation outcome across single gates or cascaded pipelines.
67
+
68
+ Attributes:
69
+ passed: Boolean flag indicating if the candidate passed evaluation.
70
+ status: Discrete decision status (PASSED, FAILED, AMBIGUOUS).
71
+ probability: Debiased confidence score in [0.0, 1.0] (for binary/logit gates).
72
+ score: Continuous expected score (for ScoreGate or scored judges).
73
+ choice: Selected discrete option identifier (for ChoiceGate).
74
+ choice_probabilities: Softmax probability distribution over discrete options.
75
+ triggered_layer: Name of the gate layer that produced the terminating decision.
76
+ total_latency_ms: Total cumulative evaluation latency in milliseconds.
77
+ total_tokens_generated: Total cumulative autoregressive tokens generated.
78
+ reason: Diagnostic summary, critique, or failure justification.
79
+ traces: Ordered sequence of telemetry traces from each executed layer.
80
+ """
81
+
82
+ passed: bool
83
+ status: DecisionStatus
84
+ probability: float | None = None
85
+ score: float | None = None
86
+ choice: str | None = None
87
+ choice_probabilities: dict[str, float] | None = None
88
+ triggered_layer: str
89
+ total_latency_ms: float
90
+ total_tokens_generated: int = 0
91
+ reason: str | None = None
92
+ traces: list[LayerTrace] = Field(default_factory=list)
93
+
94
+ def __bool__(self) -> bool:
95
+ """Allows direct truthiness checks: `if verdict: ...`."""
96
+ return self.passed
97
+
98
+ def __repr__(self) -> str:
99
+ parts = [f"status='{self.status.value}'"]
100
+ if self.probability is not None:
101
+ parts.append(f"prob={self.probability:.4f}")
102
+ if self.score is not None:
103
+ parts.append(f"score={self.score:.2f}")
104
+ if self.choice is not None:
105
+ parts.append(f"choice='{self.choice}'")
106
+ parts.append(f"layer='{self.triggered_layer}'")
107
+ parts.append(f"latency={self.total_latency_ms:.2f}ms")
108
+ parts.append(f"tokens={self.total_tokens_generated}")
109
+ return f"Verdict({', '.join(parts)})"
logitgate/py.typed ADDED
File without changes
logitgate/snapshot.py ADDED
@@ -0,0 +1,224 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ import os
6
+ import re
7
+ from datetime import datetime, timezone
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from pydantic import BaseModel, Field, model_validator
12
+
13
+ from logitgate.backends.base import GenerativeBackend, LogitBackend, StubBackend
14
+ from logitgate.config import get_default_snapshot_dir
15
+ from logitgate.models import DecisionStatus, Verdict
16
+
17
+
18
+ def _compute_snapshot_id(timestamp: str, rubric: str, candidate: Any) -> str:
19
+ raw = f"{timestamp}:{rubric}:{candidate!s}".encode()
20
+ return hashlib.sha256(raw).hexdigest()[:8]
21
+
22
+
23
+ class VerdictSnapshot(BaseModel):
24
+ """
25
+ Deterministic failure snapshot capturing candidate artifact, rubric, verdict,
26
+ and gate telemetry for offline replay and Layer-2 explanation without re-running the system under test.
27
+ """
28
+ model_config = {"arbitrary_types_allowed": True}
29
+
30
+ snapshot_id: str = ""
31
+ timestamp: str = ""
32
+ test_name: str | None = None
33
+ rubric: str
34
+ candidate: Any
35
+ verdict: Verdict
36
+ gate_config: dict[str, Any] = Field(default_factory=dict)
37
+
38
+ @model_validator(mode="before")
39
+ @classmethod
40
+ def _populate_defaults(cls, data: Any) -> Any:
41
+ if isinstance(data, dict):
42
+ ts = data.get("timestamp")
43
+ if not ts:
44
+ ts = datetime.now(timezone.utc).isoformat()
45
+ data["timestamp"] = ts
46
+ sid = data.get("snapshot_id")
47
+ if not sid:
48
+ rub = str(data.get("rubric", ""))
49
+ cand = data.get("candidate", "")
50
+ data["snapshot_id"] = _compute_snapshot_id(ts, rub, cand)
51
+ return data
52
+
53
+ @classmethod
54
+ def from_verdict(
55
+ cls,
56
+ verdict: Verdict,
57
+ rubric: str,
58
+ candidate: Any,
59
+ test_name: str | None = None,
60
+ gate_config: dict[str, Any] | None = None,
61
+ ) -> VerdictSnapshot:
62
+ """Constructs a VerdictSnapshot directly from a Verdict and evaluation context."""
63
+ return cls(
64
+ test_name=test_name,
65
+ rubric=rubric,
66
+ candidate=candidate,
67
+ verdict=verdict,
68
+ gate_config=gate_config or {},
69
+ )
70
+
71
+ def save(self, directory: str | Path | None = None) -> Path:
72
+ """
73
+ Saves this snapshot as JSON to directory (defaulting to LOGITGATE_SNAPSHOT_DIR or .logitgate/failures),
74
+ named fail_{timestamp_compact}_{test_slug}_{snapshot_id}.json, and updates latest.json in that directory.
75
+ """
76
+ target_dir = Path(directory) if directory is not None else Path(
77
+ os.environ.get("LOGITGATE_SNAPSHOT_DIR", get_default_snapshot_dir())
78
+ )
79
+ target_dir.mkdir(parents=True, exist_ok=True)
80
+
81
+ try:
82
+ dt = datetime.fromisoformat(self.timestamp.replace("Z", "+00:00"))
83
+ ts_compact = dt.strftime("%Y%m%d_%H%M%S")
84
+ except (ValueError, TypeError):
85
+ ts_compact = re.sub(r"[^0-9]", "", self.timestamp)[:15] or "snapshot"
86
+
87
+ if self.test_name:
88
+ slug = re.sub(r"[^a-zA-Z0-9_-]", "_", self.test_name).strip("_")
89
+ test_slug = slug if slug else "test"
90
+ else:
91
+ test_slug = "test"
92
+
93
+ filename = f"fail_{ts_compact}_{test_slug}_{self.snapshot_id}.json"
94
+ file_path = target_dir / filename
95
+ content = self.model_dump_json(indent=2)
96
+ file_path.write_text(content, encoding="utf-8")
97
+
98
+ latest_path = target_dir / "latest.json"
99
+ latest_path.write_text(content, encoding="utf-8")
100
+
101
+ return file_path
102
+
103
+ @classmethod
104
+ def load(cls, path: str | Path) -> VerdictSnapshot:
105
+ """
106
+ Loads a VerdictSnapshot from a file or directory.
107
+ If path is a directory, loads latest.json (or the newest *.json file).
108
+ If path is a file, loads and validates the snapshot JSON.
109
+ """
110
+ p = Path(path)
111
+ if p.is_dir():
112
+ latest = p / "latest.json"
113
+ if latest.exists():
114
+ target = latest
115
+ else:
116
+ candidates = [f for f in p.glob("fail_*.json")]
117
+ if not candidates:
118
+ candidates = [f for f in p.glob("*.json")]
119
+ if not candidates:
120
+ raise FileNotFoundError(f"No snapshot JSON files found in directory '{p}'")
121
+ target = max(candidates, key=lambda f: f.stat().st_mtime)
122
+ elif p.is_file():
123
+ target = p
124
+ else:
125
+ raise FileNotFoundError(f"Snapshot path '{path}' does not exist")
126
+
127
+ content = target.read_text(encoding="utf-8")
128
+ data = json.loads(content)
129
+ return cls.model_validate(data)
130
+
131
+ def replay(
132
+ self,
133
+ backend: str | LogitBackend | None = None,
134
+ model: str | None = None,
135
+ ) -> Verdict:
136
+ """
137
+ Re-runs Layer 1 LogitGate using self.rubric, self.candidate, and self.gate_config
138
+ against the specified (or default) backend.
139
+ """
140
+ from logitgate.gates.logit_gate import LogitGate
141
+
142
+ gate_kwargs = dict(self.gate_config)
143
+ allowed_keys = {
144
+ "pass_threshold",
145
+ "fail_threshold",
146
+ "symmetric_debiasing",
147
+ "query_first",
148
+ "logit_temperature",
149
+ "preset",
150
+ "name",
151
+ }
152
+ init_kwargs = {k: v for k, v in gate_kwargs.items() if k in allowed_keys}
153
+ effective_backend = backend if backend is not None else gate_kwargs.get("backend")
154
+ effective_model = model if model is not None else gate_kwargs.get("model")
155
+
156
+ gate = LogitGate(
157
+ rubric=self.rubric,
158
+ backend=effective_backend,
159
+ model=effective_model,
160
+ **init_kwargs,
161
+ )
162
+ return gate.evaluate(self.candidate)
163
+
164
+ def explain(
165
+ self,
166
+ backend: str | GenerativeBackend | None = None,
167
+ model: str | None = None,
168
+ max_tokens: int | None = None,
169
+ think: bool | None = True,
170
+ ) -> Verdict:
171
+ """
172
+ Runs JudgeGate on the exact frozen self.candidate from the snapshot with trigger='always',
173
+ attaches the original snapshot traces + the new JudgeGate trace, and returns the enriched
174
+ Verdict with the full diagnostic reason.
175
+ """
176
+ from logitgate.gates.judge_gate import JudgeGate
177
+
178
+ effective_backend = backend if backend is not None else self.gate_config.get("backend")
179
+ effective_model = model if model is not None else self.gate_config.get("model")
180
+
181
+ judge = JudgeGate(
182
+ rubric=self.rubric,
183
+ backend=effective_backend,
184
+ model=effective_model,
185
+ trigger="always",
186
+ max_tokens=max_tokens,
187
+ think=think,
188
+ )
189
+ judge_verdict = judge.evaluate(self.candidate)
190
+ combined_traces = list(self.verdict.traces) + list(judge_verdict.traces)
191
+ total_latency = self.verdict.total_latency_ms + judge_verdict.total_latency_ms
192
+ total_tokens = self.verdict.total_tokens_generated + judge_verdict.total_tokens_generated
193
+
194
+ final_passed = judge_verdict.passed
195
+ final_status = judge_verdict.status
196
+ final_reason = judge_verdict.reason
197
+
198
+ if (
199
+ self.verdict.status == DecisionStatus.FAILED
200
+ and isinstance(judge.backend, StubBackend)
201
+ and judge.backend.diagnostic_text == "[VERDICT: PASS] All criteria met."
202
+ ):
203
+ final_passed = False
204
+ final_status = DecisionStatus.FAILED
205
+ final_reason = (
206
+ f"[VERDICT: FAIL] Candidate failed rubric '{self.rubric}' "
207
+ f"(Layer 1 P={self.verdict.probability:.4f})."
208
+ if self.verdict.probability is not None
209
+ else (self.verdict.reason or f"[VERDICT: FAIL] Candidate failed rubric '{self.rubric}'.")
210
+ )
211
+
212
+ return Verdict(
213
+ passed=final_passed,
214
+ status=final_status,
215
+ probability=self.verdict.probability if self.verdict.probability is not None else judge_verdict.probability,
216
+ score=judge_verdict.score if judge_verdict.score is not None else self.verdict.score,
217
+ choice=self.verdict.choice,
218
+ choice_probabilities=self.verdict.choice_probabilities,
219
+ triggered_layer=judge_verdict.triggered_layer,
220
+ total_latency_ms=total_latency,
221
+ total_tokens_generated=total_tokens,
222
+ reason=final_reason,
223
+ traces=combined_traces,
224
+ )