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