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