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,287 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import math
|
|
5
|
+
import os
|
|
6
|
+
from collections.abc import Sequence
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from pydantic import BaseModel
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class CalibrationResult(BaseModel):
|
|
14
|
+
optimal_temperature: float
|
|
15
|
+
brier_score_before: float
|
|
16
|
+
brier_score_after: float
|
|
17
|
+
ece_before: float
|
|
18
|
+
ece_after: float
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
KNOWN_MODEL_TEMPERATURES: dict[str, float] = {
|
|
22
|
+
"qwen2.5-coder:14b": 1.05,
|
|
23
|
+
"qwen2.5:14b": 1.05,
|
|
24
|
+
"gemma4:e4b": 0.95,
|
|
25
|
+
"gpt-4o-mini": 1.15,
|
|
26
|
+
"llama3": 1.10,
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
UNIVERSAL_CALIBRATION_DATASET: list[tuple[str, str, int]] = [
|
|
30
|
+
# 1. QA / Factual Pass
|
|
31
|
+
(
|
|
32
|
+
"Does the text state that Paris is the capital of France?",
|
|
33
|
+
"Paris is the capital and largest city of France.",
|
|
34
|
+
1,
|
|
35
|
+
),
|
|
36
|
+
# 2. QA / Factual Fail
|
|
37
|
+
(
|
|
38
|
+
"Does the text state that Paris is the capital of France?",
|
|
39
|
+
"Berlin is the capital of Germany and Rome is the capital of Italy.",
|
|
40
|
+
0,
|
|
41
|
+
),
|
|
42
|
+
# 3. Policy Pass
|
|
43
|
+
(
|
|
44
|
+
"Does the assistant refuse to disclose sensitive credentials without authorization?",
|
|
45
|
+
"I cannot provide master API keys or passwords without administrative verification.",
|
|
46
|
+
1,
|
|
47
|
+
),
|
|
48
|
+
# 4. Policy Fail
|
|
49
|
+
(
|
|
50
|
+
"Does the assistant refuse to disclose sensitive credentials without authorization?",
|
|
51
|
+
"Sure, here is the secret admin key: sk-secret-12345.",
|
|
52
|
+
0,
|
|
53
|
+
),
|
|
54
|
+
# 5. JSON Validity Pass
|
|
55
|
+
(
|
|
56
|
+
"Is the artifact valid JSON containing required 'status' and 'code' fields?",
|
|
57
|
+
'{"status": "ok", "code": 200, "message": "Success"}',
|
|
58
|
+
1,
|
|
59
|
+
),
|
|
60
|
+
# 6. JSON Validity Fail
|
|
61
|
+
(
|
|
62
|
+
"Is the artifact valid JSON containing required 'status' and 'code' fields?",
|
|
63
|
+
"This is arbitrary plain text with no structured format or status code.",
|
|
64
|
+
0,
|
|
65
|
+
),
|
|
66
|
+
# 7. Code Correctness Pass
|
|
67
|
+
(
|
|
68
|
+
"Does the Python function compute the square of an integer x?",
|
|
69
|
+
"def square(x: int) -> int:\n return x * x",
|
|
70
|
+
1,
|
|
71
|
+
),
|
|
72
|
+
# 8. Code Correctness Fail
|
|
73
|
+
(
|
|
74
|
+
"Does the Python function compute the square of an integer x?",
|
|
75
|
+
"def square(x: int) -> int:\n return x + 100",
|
|
76
|
+
0,
|
|
77
|
+
),
|
|
78
|
+
]
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def get_calibration_cache_path() -> Path:
|
|
82
|
+
cache_dir = os.environ.get("LOGITGATE_CACHE_DIR")
|
|
83
|
+
if cache_dir:
|
|
84
|
+
return Path(cache_dir) / "calibration.json"
|
|
85
|
+
return Path.home() / ".cache" / "logitgate" / "calibration.json"
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def get_model_temperature(
|
|
89
|
+
model_name: str | None,
|
|
90
|
+
cache_path: Path | None = None,
|
|
91
|
+
) -> float:
|
|
92
|
+
"""Retrieves calibrated temperature for a model checking cache, known models, then default 1.0."""
|
|
93
|
+
if not model_name:
|
|
94
|
+
return 1.0
|
|
95
|
+
|
|
96
|
+
m_lower = model_name.strip().lower()
|
|
97
|
+
|
|
98
|
+
# 1. Local calibration cache file
|
|
99
|
+
target_cache = cache_path or get_calibration_cache_path()
|
|
100
|
+
if target_cache.exists():
|
|
101
|
+
try:
|
|
102
|
+
with open(target_cache, "r", encoding="utf-8") as f:
|
|
103
|
+
cached = json.load(f)
|
|
104
|
+
if isinstance(cached, dict):
|
|
105
|
+
for k, v in cached.items():
|
|
106
|
+
if k.lower() == m_lower or k.lower() in m_lower or m_lower in k.lower():
|
|
107
|
+
return float(v)
|
|
108
|
+
except (OSError, ValueError, TypeError):
|
|
109
|
+
cached = None
|
|
110
|
+
|
|
111
|
+
# 2. KNOWN_MODEL_TEMPERATURES
|
|
112
|
+
for k, v in KNOWN_MODEL_TEMPERATURES.items():
|
|
113
|
+
if k.lower() == m_lower or k.lower() in m_lower or m_lower in k.lower():
|
|
114
|
+
return v
|
|
115
|
+
|
|
116
|
+
# 3. Default
|
|
117
|
+
return 1.0
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def auto_calibrate_backend(
|
|
121
|
+
backend: Any,
|
|
122
|
+
model_key: str | None = None,
|
|
123
|
+
save_cache: bool = True,
|
|
124
|
+
cache_path: Path | None = None,
|
|
125
|
+
) -> CalibrationResult:
|
|
126
|
+
"""Runs built-in 8-item micro-calibration dataset, fits optimal temperature, and caches it."""
|
|
127
|
+
from logitgate.debiasing.inversion import compile_binary_prompt
|
|
128
|
+
from logitgate.debiasing.softmax import compute_binary_log_odds
|
|
129
|
+
|
|
130
|
+
effective_model = model_key or getattr(backend, "model", None) or "default"
|
|
131
|
+
samples: list[tuple[float, float, int]] = []
|
|
132
|
+
|
|
133
|
+
for rubric, candidate, y in UNIVERSAL_CALIBRATION_DATASET:
|
|
134
|
+
p1 = compile_binary_prompt(
|
|
135
|
+
rubric=rubric,
|
|
136
|
+
candidate=candidate,
|
|
137
|
+
pass_token="A",
|
|
138
|
+
fail_token="B",
|
|
139
|
+
query_first=True,
|
|
140
|
+
)
|
|
141
|
+
top1 = backend.query_top_logprobs(p1, top_k=10, allowed_tokens=["A", "B"])
|
|
142
|
+
f_lo, _ = compute_binary_log_odds(top1, pass_token="A", fail_token="B")
|
|
143
|
+
|
|
144
|
+
p2 = compile_binary_prompt(
|
|
145
|
+
rubric=rubric,
|
|
146
|
+
candidate=candidate,
|
|
147
|
+
pass_token="B",
|
|
148
|
+
fail_token="A",
|
|
149
|
+
query_first=True,
|
|
150
|
+
)
|
|
151
|
+
top2 = backend.query_top_logprobs(p2, top_k=10, allowed_tokens=["A", "B"])
|
|
152
|
+
r_lo, _ = compute_binary_log_odds(top2, pass_token="B", fail_token="A")
|
|
153
|
+
|
|
154
|
+
samples.append((f_lo, r_lo, y))
|
|
155
|
+
|
|
156
|
+
result = fit_calibration(samples)
|
|
157
|
+
|
|
158
|
+
if save_cache:
|
|
159
|
+
target_path = cache_path or get_calibration_cache_path()
|
|
160
|
+
target_path.parent.mkdir(parents=True, exist_ok=True)
|
|
161
|
+
cache_data: dict[str, float] = {}
|
|
162
|
+
if target_path.exists():
|
|
163
|
+
try:
|
|
164
|
+
with open(target_path, "r", encoding="utf-8") as f:
|
|
165
|
+
loaded = json.load(f)
|
|
166
|
+
if isinstance(loaded, dict):
|
|
167
|
+
cache_data = loaded
|
|
168
|
+
except (OSError, ValueError, TypeError):
|
|
169
|
+
cache_data = {}
|
|
170
|
+
cache_data[effective_model] = result.optimal_temperature
|
|
171
|
+
with open(target_path, "w", encoding="utf-8") as f:
|
|
172
|
+
json.dump(cache_data, f, indent=2)
|
|
173
|
+
|
|
174
|
+
return result
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def combine_symmetric_log_odds(
|
|
178
|
+
forward_log_odds: float,
|
|
179
|
+
reverse_log_odds: float,
|
|
180
|
+
logit_temperature: float = 1.0,
|
|
181
|
+
) -> float:
|
|
182
|
+
"""
|
|
183
|
+
Combines forward and reverse log-odds to cancel token and positional bias.
|
|
184
|
+
mean_log_odds = (forward_log_odds + reverse_log_odds) / (2.0 * max(logit_temperature, 1e-6))
|
|
185
|
+
Returns 1.0 / (1.0 + exp(-mean_log_odds)).
|
|
186
|
+
"""
|
|
187
|
+
t = max(logit_temperature, 1e-6)
|
|
188
|
+
mean_log_odds = (forward_log_odds + reverse_log_odds) / (2.0 * t)
|
|
189
|
+
|
|
190
|
+
if mean_log_odds > 50.0:
|
|
191
|
+
return 1.0
|
|
192
|
+
if mean_log_odds < -50.0:
|
|
193
|
+
return 0.0
|
|
194
|
+
return 1.0 / (1.0 + math.exp(-mean_log_odds))
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def fit_calibration(
|
|
198
|
+
samples: Sequence[tuple[float, float, int]],
|
|
199
|
+
min_temp: float = 0.1,
|
|
200
|
+
max_temp: float = 10.0,
|
|
201
|
+
num_bins: int = 10,
|
|
202
|
+
) -> CalibrationResult:
|
|
203
|
+
"""
|
|
204
|
+
Fits optimal logit temperature scaling on (forward_log_odds, reverse_log_odds, y)
|
|
205
|
+
to minimize Brier score and computes Expected Calibration Error (ECE).
|
|
206
|
+
"""
|
|
207
|
+
if not samples:
|
|
208
|
+
return CalibrationResult(
|
|
209
|
+
optimal_temperature=1.0,
|
|
210
|
+
brier_score_before=0.0,
|
|
211
|
+
brier_score_after=0.0,
|
|
212
|
+
ece_before=0.0,
|
|
213
|
+
ece_after=0.0,
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
def calc_prob(f_lo: float, r_lo: float, temp: float) -> float:
|
|
217
|
+
return combine_symmetric_log_odds(f_lo, r_lo, logit_temperature=temp)
|
|
218
|
+
|
|
219
|
+
def brier_score(temp: float) -> float:
|
|
220
|
+
total = sum((calc_prob(f, r, temp) - float(y)) ** 2 for f, r, y in samples)
|
|
221
|
+
return total / len(samples)
|
|
222
|
+
|
|
223
|
+
def compute_ece(temp: float) -> float:
|
|
224
|
+
n = len(samples)
|
|
225
|
+
bins: list[list[tuple[float, float]]] = [[] for _ in range(num_bins)]
|
|
226
|
+
for f, r, y in samples:
|
|
227
|
+
p = calc_prob(f, r, temp)
|
|
228
|
+
idx = min(int(p * num_bins), num_bins - 1)
|
|
229
|
+
bins[idx].append((p, float(y)))
|
|
230
|
+
|
|
231
|
+
ece = 0.0
|
|
232
|
+
for b in bins:
|
|
233
|
+
if not b:
|
|
234
|
+
continue
|
|
235
|
+
b_size = len(b)
|
|
236
|
+
avg_conf = sum(p for p, _ in b) / b_size
|
|
237
|
+
avg_acc = sum(y for _, y in b) / b_size
|
|
238
|
+
ece += (b_size / n) * abs(avg_acc - avg_conf)
|
|
239
|
+
return ece
|
|
240
|
+
|
|
241
|
+
# 1. Grid search for coarse minimum
|
|
242
|
+
best_temp = 1.0
|
|
243
|
+
best_brier = float("inf")
|
|
244
|
+
steps = 200
|
|
245
|
+
for step in range(steps + 1):
|
|
246
|
+
temp = min_temp + step * (max_temp - min_temp) / steps
|
|
247
|
+
bs = brier_score(temp)
|
|
248
|
+
if bs < best_brier:
|
|
249
|
+
best_brier = bs
|
|
250
|
+
best_temp = temp
|
|
251
|
+
|
|
252
|
+
# 2. Golden-section refinement around coarse minimum
|
|
253
|
+
a = max(min_temp, best_temp - (max_temp - min_temp) / steps)
|
|
254
|
+
b = min(max_temp, best_temp + (max_temp - min_temp) / steps)
|
|
255
|
+
phi = (math.sqrt(5) - 1) / 2
|
|
256
|
+
c = b - phi * (b - a)
|
|
257
|
+
d = a + phi * (b - a)
|
|
258
|
+
fc = brier_score(c)
|
|
259
|
+
fd = brier_score(d)
|
|
260
|
+
|
|
261
|
+
for _ in range(30):
|
|
262
|
+
if fc < fd:
|
|
263
|
+
b = d
|
|
264
|
+
d = c
|
|
265
|
+
fd = fc
|
|
266
|
+
c = b - phi * (b - a)
|
|
267
|
+
fc = brier_score(c)
|
|
268
|
+
else:
|
|
269
|
+
a = c
|
|
270
|
+
c = d
|
|
271
|
+
fc = fd
|
|
272
|
+
d = a + phi * (b - a)
|
|
273
|
+
fd = brier_score(d)
|
|
274
|
+
|
|
275
|
+
optimal_temp = (a + b) / 2.0
|
|
276
|
+
brier_before = brier_score(1.0)
|
|
277
|
+
brier_after = brier_score(optimal_temp)
|
|
278
|
+
ece_before = compute_ece(1.0)
|
|
279
|
+
ece_after = compute_ece(optimal_temp)
|
|
280
|
+
|
|
281
|
+
return CalibrationResult(
|
|
282
|
+
optimal_temperature=round(optimal_temp, 4),
|
|
283
|
+
brier_score_before=round(brier_before, 6),
|
|
284
|
+
brier_score_after=round(brier_after, 6),
|
|
285
|
+
ece_before=round(ece_before, 6),
|
|
286
|
+
ece_after=round(ece_after, 6),
|
|
287
|
+
)
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import math
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def logsumexp(logprobs: list[float]) -> float:
|
|
8
|
+
"""Numerically stable logsumexp."""
|
|
9
|
+
if not logprobs:
|
|
10
|
+
return -10.0
|
|
11
|
+
max_lp = max(logprobs)
|
|
12
|
+
if math.isinf(max_lp):
|
|
13
|
+
return max_lp
|
|
14
|
+
return max_lp + math.log(sum(math.exp(lp - max_lp) for lp in logprobs))
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def extract_token_logprob(top_logprobs: list[dict[str, Any]], target_token: str) -> float:
|
|
18
|
+
"""
|
|
19
|
+
Extracts the log probability for target_token from top_logprobs.
|
|
20
|
+
- Normalizes token strings by stripping whitespace and uppercase.
|
|
21
|
+
- Combines multiple token variants (e.g. 'A' and ' A') using logsumexp.
|
|
22
|
+
- Bounded Missing-Token Floor: If target_token is absent, computes
|
|
23
|
+
min(min_observed_logprob, ln(max(1.0 - sum_observed_probs, 1e-6))),
|
|
24
|
+
preventing -99.0 cliff artifacts in symmetric log-odds debiasing.
|
|
25
|
+
"""
|
|
26
|
+
if not top_logprobs:
|
|
27
|
+
return -10.0
|
|
28
|
+
|
|
29
|
+
target_norm = target_token.strip().upper()
|
|
30
|
+
matching_logprobs: list[float] = []
|
|
31
|
+
observed_logprobs: list[float] = []
|
|
32
|
+
|
|
33
|
+
for item in top_logprobs:
|
|
34
|
+
tok = str(item.get("token", "")).strip().upper()
|
|
35
|
+
lp = item.get("logprob")
|
|
36
|
+
if lp is None:
|
|
37
|
+
continue
|
|
38
|
+
try:
|
|
39
|
+
lp_float = float(lp)
|
|
40
|
+
except (ValueError, TypeError):
|
|
41
|
+
continue
|
|
42
|
+
observed_logprobs.append(lp_float)
|
|
43
|
+
if tok == target_norm:
|
|
44
|
+
matching_logprobs.append(lp_float)
|
|
45
|
+
|
|
46
|
+
if matching_logprobs:
|
|
47
|
+
return logsumexp(matching_logprobs)
|
|
48
|
+
|
|
49
|
+
# Bounded missing-token floor based on residual probability mass
|
|
50
|
+
if not observed_logprobs:
|
|
51
|
+
return -10.0
|
|
52
|
+
|
|
53
|
+
min_observed_logprob = min(observed_logprobs)
|
|
54
|
+
sum_observed_probs = sum(math.exp(min(lp, 0.0)) for lp in observed_logprobs)
|
|
55
|
+
residual_prob = max(1.0 - sum_observed_probs, 1e-6)
|
|
56
|
+
residual_logprob = math.log(residual_prob)
|
|
57
|
+
return min(min_observed_logprob, residual_logprob)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def compute_binary_log_odds(
|
|
61
|
+
top_logprobs: list[dict[str, Any]],
|
|
62
|
+
pass_token: str = "A",
|
|
63
|
+
fail_token: str = "B",
|
|
64
|
+
) -> tuple[float, float]:
|
|
65
|
+
"""
|
|
66
|
+
Computes binary log-odds and pass probability from top_logprobs.
|
|
67
|
+
log_odds = lp_pass - lp_fail
|
|
68
|
+
prob_pass = 1.0 / (1.0 + exp(-log_odds))
|
|
69
|
+
"""
|
|
70
|
+
lp_pass = extract_token_logprob(top_logprobs, pass_token)
|
|
71
|
+
lp_fail = extract_token_logprob(top_logprobs, fail_token)
|
|
72
|
+
log_odds = lp_pass - lp_fail
|
|
73
|
+
|
|
74
|
+
if log_odds > 50.0:
|
|
75
|
+
prob_pass = 1.0
|
|
76
|
+
elif log_odds < -50.0:
|
|
77
|
+
prob_pass = 0.0
|
|
78
|
+
else:
|
|
79
|
+
prob_pass = 1.0 / (1.0 + math.exp(-log_odds))
|
|
80
|
+
|
|
81
|
+
return log_odds, prob_pass
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def compute_normalized_probabilities(
|
|
85
|
+
top_logprobs: list[dict[str, Any]],
|
|
86
|
+
tokens: list[str],
|
|
87
|
+
) -> dict[str, float]:
|
|
88
|
+
"""
|
|
89
|
+
Extracts logprobs for candidate tokens and returns normalized softmax probabilities.
|
|
90
|
+
"""
|
|
91
|
+
logprobs = {t: extract_token_logprob(top_logprobs, t) for t in tokens}
|
|
92
|
+
max_lp = max(logprobs.values()) if logprobs else 0.0
|
|
93
|
+
exp_vals = {t: math.exp(lp - max_lp) for t, lp in logprobs.items()}
|
|
94
|
+
total = sum(exp_vals.values())
|
|
95
|
+
if total <= 0:
|
|
96
|
+
uniform = 1.0 / len(tokens) if tokens else 0.0
|
|
97
|
+
return {t: uniform for t in tokens}
|
|
98
|
+
return {t: exp_vals[t] / total for t in tokens}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from logitgate.gates.ast_gate import ASTGate, ContractGate
|
|
2
|
+
from logitgate.gates.base import BaseGate
|
|
3
|
+
from logitgate.gates.judge_gate import JudgeGate
|
|
4
|
+
from logitgate.gates.logit_gate import ChoiceGate, LogitGate, ScoreGate
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"ASTGate",
|
|
8
|
+
"BaseGate",
|
|
9
|
+
"ChoiceGate",
|
|
10
|
+
"ContractGate",
|
|
11
|
+
"JudgeGate",
|
|
12
|
+
"LogitGate",
|
|
13
|
+
"ScoreGate",
|
|
14
|
+
]
|