errlore 0.1.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.
- errlore/__init__.py +16 -0
- errlore/errmem/__init__.py +21 -0
- errlore/errmem/classifier.py +54 -0
- errlore/errmem/injector.py +156 -0
- errlore/errmem/patterns.py +193 -0
- errlore/errmem/tracker.py +157 -0
- errlore/facade.py +602 -0
- errlore/io/__init__.py +21 -0
- errlore/io/jsonl_index.py +199 -0
- errlore/io/jsonl_writer.py +342 -0
- errlore/io/repair.py +199 -0
- errlore/lessons/__init__.py +16 -0
- errlore/lessons/models.py +129 -0
- errlore/lessons/store.py +495 -0
- errlore/py.typed +0 -0
- errlore/retrieval/__init__.py +66 -0
- errlore/retrieval/backend.py +117 -0
- errlore/retrieval/index.py +288 -0
- errlore/sanitize.py +117 -0
- errlore/trust/__init__.py +11 -0
- errlore/trust/engine.py +602 -0
- errlore-0.1.0.dist-info/METADATA +214 -0
- errlore-0.1.0.dist-info/RECORD +25 -0
- errlore-0.1.0.dist-info/WHEEL +4 -0
- errlore-0.1.0.dist-info/licenses/LICENSE +21 -0
errlore/trust/engine.py
ADDED
|
@@ -0,0 +1,602 @@
|
|
|
1
|
+
"""TrustEngine -- adaptive per-model trust weights with Bayesian cold start.
|
|
2
|
+
|
|
3
|
+
Maintains dynamic trust weights for an ensemble of models (or any scored
|
|
4
|
+
entities) using logit-space updates with adaptive learning rate, volatility
|
|
5
|
+
damping, domain-specific EMA bias, Beta-prior cold start, entropy
|
|
6
|
+
enforcement, and temporal decay towards neutral.
|
|
7
|
+
|
|
8
|
+
Key formulas:
|
|
9
|
+
logit_delta = lr * ((outcome - 0.5) * 2 + disagreement) + domain_bias
|
|
10
|
+
w_new = sigmoid(logit(w_old) + logit_delta)
|
|
11
|
+
|
|
12
|
+
Outcome is centered around 0.5 so that values below 0.5 DECREASE the weight
|
|
13
|
+
and values above 0.5 INCREASE it. This prevents the monotonic-growth bug
|
|
14
|
+
where all models converge to the cap regardless of actual quality.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import contextlib
|
|
20
|
+
import json
|
|
21
|
+
import logging
|
|
22
|
+
import math
|
|
23
|
+
import os
|
|
24
|
+
import tempfile
|
|
25
|
+
import threading
|
|
26
|
+
import time
|
|
27
|
+
from collections import defaultdict
|
|
28
|
+
from dataclasses import dataclass, field
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
from typing import Any
|
|
31
|
+
|
|
32
|
+
logger = logging.getLogger("errlore.trust")
|
|
33
|
+
|
|
34
|
+
DEFAULT_WEIGHT: float = 0.5
|
|
35
|
+
"""Default weight assigned to a model with no history."""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# ---------------------------------------------------------------------------
|
|
39
|
+
# Math primitives
|
|
40
|
+
# ---------------------------------------------------------------------------
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _sigmoid(x: float) -> float:
|
|
44
|
+
"""Numerically stable sigmoid."""
|
|
45
|
+
if x >= 0:
|
|
46
|
+
return 1.0 / (1.0 + math.exp(-x))
|
|
47
|
+
z = math.exp(x)
|
|
48
|
+
return z / (1.0 + z)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _logit(p: float, eps: float = 1e-7) -> float:
|
|
52
|
+
"""Inverse sigmoid (log-odds)."""
|
|
53
|
+
p = max(eps, min(1.0 - eps, p))
|
|
54
|
+
return math.log(p / (1.0 - p))
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
# ---------------------------------------------------------------------------
|
|
58
|
+
# Public dataclass: FeedbackSignal
|
|
59
|
+
# ---------------------------------------------------------------------------
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass(frozen=True, slots=True)
|
|
63
|
+
class FeedbackSignal:
|
|
64
|
+
"""Generic quality signal for a model observation.
|
|
65
|
+
|
|
66
|
+
Attributes:
|
|
67
|
+
outcome: Quality score in [0, 1]. Values below 0.5 decrease trust,
|
|
68
|
+
above 0.5 increase it.
|
|
69
|
+
domain: Task domain (e.g. "code_generation", "research").
|
|
70
|
+
weight: Importance multiplier for this observation (default 1.0).
|
|
71
|
+
disagreement: Signed disagreement bonus/penalty in [-1, 1].
|
|
72
|
+
Positive = model was contrarian and correct (boost).
|
|
73
|
+
Negative = model deviated and was wrong (penalty).
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
outcome: float
|
|
77
|
+
domain: str = "general"
|
|
78
|
+
weight: float = 1.0
|
|
79
|
+
disagreement: float = 0.0
|
|
80
|
+
|
|
81
|
+
def __post_init__(self) -> None:
|
|
82
|
+
if not 0.0 <= self.outcome <= 1.0:
|
|
83
|
+
raise ValueError(f"outcome must be in [0, 1], got {self.outcome}")
|
|
84
|
+
if not -1.0 <= self.disagreement <= 1.0:
|
|
85
|
+
raise ValueError(f"disagreement must be in [-1, 1], got {self.disagreement}")
|
|
86
|
+
# B9: reject NaN / inf for weight.
|
|
87
|
+
if not (0.0 <= self.weight < float("inf")):
|
|
88
|
+
raise ValueError(f"weight must be >= 0 and finite, got {self.weight}")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
# ---------------------------------------------------------------------------
|
|
92
|
+
# BetaPrior for cold start
|
|
93
|
+
# ---------------------------------------------------------------------------
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@dataclass
|
|
97
|
+
class BetaPrior:
|
|
98
|
+
"""Bayesian Beta prior for cold-start blending."""
|
|
99
|
+
|
|
100
|
+
alpha: float = 1.0
|
|
101
|
+
beta_param: float = 1.0
|
|
102
|
+
|
|
103
|
+
def update(self, success: float) -> None:
|
|
104
|
+
"""Record an observation (success in [0, 1])."""
|
|
105
|
+
self.alpha += success
|
|
106
|
+
self.beta_param += 1.0 - success
|
|
107
|
+
|
|
108
|
+
@property
|
|
109
|
+
def mean(self) -> float:
|
|
110
|
+
"""Posterior mean."""
|
|
111
|
+
return self.alpha / (self.alpha + self.beta_param)
|
|
112
|
+
|
|
113
|
+
@property
|
|
114
|
+
def observations(self) -> float:
|
|
115
|
+
"""Number of effective observations (excluding the uniform prior)."""
|
|
116
|
+
return (self.alpha + self.beta_param) - 2.0
|
|
117
|
+
|
|
118
|
+
def is_cold(self, threshold: int = 15) -> bool:
|
|
119
|
+
"""True if insufficient observations to trust the logit update alone."""
|
|
120
|
+
return self.observations < threshold
|
|
121
|
+
|
|
122
|
+
def to_dict(self) -> dict[str, float]:
|
|
123
|
+
return {"alpha": self.alpha, "beta_param": self.beta_param}
|
|
124
|
+
|
|
125
|
+
@classmethod
|
|
126
|
+
def from_dict(cls, d: dict[str, float]) -> BetaPrior:
|
|
127
|
+
return cls(alpha=d["alpha"], beta_param=d["beta_param"])
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _init_prior(static_weight: float) -> BetaPrior:
|
|
131
|
+
"""Create a prior skewed towards the given static weight."""
|
|
132
|
+
pseudo_obs = 5.0
|
|
133
|
+
return BetaPrior(
|
|
134
|
+
alpha=1.0 + pseudo_obs * static_weight,
|
|
135
|
+
beta_param=1.0 + pseudo_obs * (1.0 - static_weight),
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
# ---------------------------------------------------------------------------
|
|
140
|
+
# Adaptive learning rate
|
|
141
|
+
# ---------------------------------------------------------------------------
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _compute_adaptive_lr(
|
|
145
|
+
task_count: int,
|
|
146
|
+
weight_history: list[float],
|
|
147
|
+
initial_lr: float = 0.15,
|
|
148
|
+
min_lr: float = 0.02,
|
|
149
|
+
) -> float:
|
|
150
|
+
"""LR decays with log(task_count), damped by recent volatility."""
|
|
151
|
+
k = 0.3
|
|
152
|
+
decayed_lr = initial_lr / (1.0 + k * math.log(1.0 + task_count))
|
|
153
|
+
if len(weight_history) >= 3:
|
|
154
|
+
recent = weight_history[-10:]
|
|
155
|
+
mean = sum(recent) / len(recent)
|
|
156
|
+
variance = sum((w - mean) ** 2 for w in recent) / len(recent)
|
|
157
|
+
volatility_factor = 1.0 / (1.0 + 5.0 * math.sqrt(variance))
|
|
158
|
+
else:
|
|
159
|
+
volatility_factor = 1.0
|
|
160
|
+
return max(min_lr, decayed_lr * volatility_factor)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
# ---------------------------------------------------------------------------
|
|
164
|
+
# Domain bias (EMA)
|
|
165
|
+
# ---------------------------------------------------------------------------
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _compute_domain_bias(
|
|
169
|
+
model_id: str,
|
|
170
|
+
domain: str,
|
|
171
|
+
history: list[dict[str, Any]],
|
|
172
|
+
half_life_hours: float = 24.0,
|
|
173
|
+
) -> float:
|
|
174
|
+
"""Time-weighted domain bias from historical outcome quality.
|
|
175
|
+
|
|
176
|
+
Computes a recency-weighted average outcome for the model in the given
|
|
177
|
+
domain, then converts deviation from 0.5 into a small bias term.
|
|
178
|
+
"""
|
|
179
|
+
now = time.time()
|
|
180
|
+
half_life_sec = half_life_hours * 3600.0
|
|
181
|
+
relevant = [
|
|
182
|
+
h for h in history
|
|
183
|
+
if h.get("model") == model_id and h.get("domain", "general") == domain
|
|
184
|
+
]
|
|
185
|
+
if len(relevant) < 3:
|
|
186
|
+
return 0.0
|
|
187
|
+
total_weight = 0.0
|
|
188
|
+
weighted_outcome = 0.0
|
|
189
|
+
for h in relevant:
|
|
190
|
+
ts = h.get("timestamp", now)
|
|
191
|
+
recency = math.exp(-max(0.0, now - ts) * math.log(2) / half_life_sec)
|
|
192
|
+
outcome = h.get("outcome", 0.5)
|
|
193
|
+
weighted_outcome += recency * outcome
|
|
194
|
+
total_weight += recency
|
|
195
|
+
if total_weight < 1e-9:
|
|
196
|
+
return 0.0
|
|
197
|
+
avg_outcome = weighted_outcome / total_weight
|
|
198
|
+
bias = (avg_outcome - 0.5) * 0.20
|
|
199
|
+
return max(-0.10, min(0.10, bias))
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
# ---------------------------------------------------------------------------
|
|
203
|
+
# Entropy enforcement
|
|
204
|
+
# ---------------------------------------------------------------------------
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def enforce_entropy(
|
|
208
|
+
weights: dict[str, float],
|
|
209
|
+
min_weight: float = 0.1,
|
|
210
|
+
entropy_threshold: float = 0.7,
|
|
211
|
+
) -> dict[str, float]:
|
|
212
|
+
"""Prevent collapse to a single model by enforcing minimum entropy.
|
|
213
|
+
|
|
214
|
+
When normalized entropy drops below the threshold, weights are blended
|
|
215
|
+
towards uniform distribution.
|
|
216
|
+
"""
|
|
217
|
+
n = len(weights)
|
|
218
|
+
if n < 2:
|
|
219
|
+
return dict(weights)
|
|
220
|
+
total = sum(weights.values())
|
|
221
|
+
if total < 1e-9:
|
|
222
|
+
return dict.fromkeys(weights, 1.0 / n)
|
|
223
|
+
probs = {m: w / total for m, w in weights.items()}
|
|
224
|
+
entropy = -sum(p * math.log(p) for p in probs.values() if p > 1e-12)
|
|
225
|
+
max_entropy = math.log(n)
|
|
226
|
+
norm_entropy = entropy / max_entropy if max_entropy > 0 else 1.0
|
|
227
|
+
|
|
228
|
+
result = dict(weights)
|
|
229
|
+
for mid in result:
|
|
230
|
+
if result[mid] < min_weight:
|
|
231
|
+
result[mid] = min_weight
|
|
232
|
+
|
|
233
|
+
if norm_entropy < entropy_threshold:
|
|
234
|
+
uniform = 1.0 / n
|
|
235
|
+
alpha = 0.3 * (1.0 - norm_entropy / entropy_threshold)
|
|
236
|
+
for mid in result:
|
|
237
|
+
result[mid] = (1.0 - alpha) * result[mid] + alpha * uniform
|
|
238
|
+
|
|
239
|
+
return result
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
# ---------------------------------------------------------------------------
|
|
243
|
+
# TrustEngine
|
|
244
|
+
# ---------------------------------------------------------------------------
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
@dataclass
|
|
248
|
+
class TrustEngine:
|
|
249
|
+
"""Adaptive trust-weight engine for model ensembles.
|
|
250
|
+
|
|
251
|
+
Args:
|
|
252
|
+
domains: Tuple of recognized domain names.
|
|
253
|
+
initial_lr: Starting learning rate for logit updates.
|
|
254
|
+
min_lr: Floor for the adaptive learning rate.
|
|
255
|
+
cap: Maximum allowed weight (hard ceiling).
|
|
256
|
+
floor: Minimum allowed weight (hard floor).
|
|
257
|
+
entropy_threshold: Normalized entropy below which regularization kicks in.
|
|
258
|
+
cold_start_threshold: Number of observations before prior blending fades.
|
|
259
|
+
state_path: Path to JSON persistence file. None = in-memory only.
|
|
260
|
+
lr_multipliers: Per-model LR multiplier (e.g. {"deepseek-r1": 0.6} to
|
|
261
|
+
slow down rate-limited models). Default empty = 1.0 for all.
|
|
262
|
+
decay_rate: Per-update mean-reversion strength towards 0.5.
|
|
263
|
+
"""
|
|
264
|
+
|
|
265
|
+
domains: tuple[str, ...] = ("general",)
|
|
266
|
+
initial_lr: float = 0.15
|
|
267
|
+
min_lr: float = 0.02
|
|
268
|
+
cap: float = 0.92
|
|
269
|
+
floor: float = 0.1
|
|
270
|
+
entropy_threshold: float = 0.7
|
|
271
|
+
cold_start_threshold: int = 15
|
|
272
|
+
state_path: Path | None = None
|
|
273
|
+
lr_multipliers: dict[str, float] = field(default_factory=dict)
|
|
274
|
+
decay_rate: float = 0.005
|
|
275
|
+
|
|
276
|
+
# Internal state
|
|
277
|
+
_models: dict[str, dict[str, float]] = field(default_factory=dict)
|
|
278
|
+
_priors: dict[str, dict[str, BetaPrior]] = field(default_factory=dict)
|
|
279
|
+
_weight_history: dict[str, dict[str, list[float]]] = field(
|
|
280
|
+
default_factory=lambda: defaultdict(lambda: defaultdict(list)),
|
|
281
|
+
)
|
|
282
|
+
_task_counts: dict[str, dict[str, int]] = field(
|
|
283
|
+
default_factory=lambda: defaultdict(lambda: defaultdict(int)),
|
|
284
|
+
)
|
|
285
|
+
_bias_history: list[dict[str, Any]] = field(default_factory=list)
|
|
286
|
+
_update_count: int = field(default=0, repr=False)
|
|
287
|
+
_lock: threading.Lock = field(default_factory=threading.Lock, repr=False)
|
|
288
|
+
|
|
289
|
+
# ------------------------------------------------------------------
|
|
290
|
+
# Construction helpers
|
|
291
|
+
# ------------------------------------------------------------------
|
|
292
|
+
|
|
293
|
+
def register_model(self, model_id: str, initial_weight: float = DEFAULT_WEIGHT) -> None:
|
|
294
|
+
"""Register a model with an optional initial weight hint.
|
|
295
|
+
|
|
296
|
+
Safe to call multiple times -- only initializes missing domains.
|
|
297
|
+
"""
|
|
298
|
+
with self._lock:
|
|
299
|
+
self._ensure_model(model_id, initial_weight)
|
|
300
|
+
|
|
301
|
+
def _ensure_model(self, model_id: str, static_weight: float = DEFAULT_WEIGHT) -> None:
|
|
302
|
+
"""Lazily initialize model state for all known domains."""
|
|
303
|
+
if model_id not in self._models:
|
|
304
|
+
self._models[model_id] = {}
|
|
305
|
+
logger.debug("Registered model %s (initial=%.2f)", model_id, static_weight)
|
|
306
|
+
if model_id not in self._priors:
|
|
307
|
+
self._priors[model_id] = {}
|
|
308
|
+
for domain in self.domains:
|
|
309
|
+
if domain not in self._models[model_id]:
|
|
310
|
+
self._models[model_id][domain] = static_weight
|
|
311
|
+
self._priors[model_id][domain] = _init_prior(static_weight)
|
|
312
|
+
self._weight_history[model_id][domain] = [static_weight]
|
|
313
|
+
self._task_counts[model_id][domain] = 0
|
|
314
|
+
|
|
315
|
+
# ------------------------------------------------------------------
|
|
316
|
+
# Core update
|
|
317
|
+
# ------------------------------------------------------------------
|
|
318
|
+
|
|
319
|
+
def update(self, model_id: str, signal: FeedbackSignal) -> float:
|
|
320
|
+
"""Update trust weight for a model given a feedback signal.
|
|
321
|
+
|
|
322
|
+
Returns the new weight after update.
|
|
323
|
+
"""
|
|
324
|
+
with self._lock:
|
|
325
|
+
return self._update_impl(model_id, signal)
|
|
326
|
+
|
|
327
|
+
def _update_impl(self, model_id: str, signal: FeedbackSignal) -> float:
|
|
328
|
+
"""Internal update (caller holds _lock)."""
|
|
329
|
+
self._ensure_model(model_id)
|
|
330
|
+
domain = signal.domain if signal.domain in self.domains else "general"
|
|
331
|
+
|
|
332
|
+
w_old = self._models[model_id].get(domain, DEFAULT_WEIGHT)
|
|
333
|
+
prior = self._priors[model_id].get(domain, BetaPrior())
|
|
334
|
+
task_count = self._task_counts[model_id].get(domain, 0)
|
|
335
|
+
history = self._weight_history[model_id].get(domain, [w_old])
|
|
336
|
+
|
|
337
|
+
# Adaptive LR with per-model multiplier
|
|
338
|
+
base_lr = _compute_adaptive_lr(task_count, history, self.initial_lr, self.min_lr)
|
|
339
|
+
multiplier = self.lr_multipliers.get(model_id, 1.0)
|
|
340
|
+
lr = base_lr * multiplier * signal.weight
|
|
341
|
+
|
|
342
|
+
# Domain bias
|
|
343
|
+
domain_bias = _compute_domain_bias(model_id, domain, self._bias_history)
|
|
344
|
+
|
|
345
|
+
# Core logit update: CENTERED outcome so <0.5 decreases, >0.5 increases
|
|
346
|
+
centered_outcome = (signal.outcome - 0.5) * 2.0 # in [-1, 1]
|
|
347
|
+
logit_delta = lr * (centered_outcome + signal.disagreement) + domain_bias
|
|
348
|
+
|
|
349
|
+
if prior.is_cold(self.cold_start_threshold):
|
|
350
|
+
prior.update(signal.outcome)
|
|
351
|
+
cold_ratio = max(0.0, min(
|
|
352
|
+
1.0, 1.0 - prior.observations / self.cold_start_threshold
|
|
353
|
+
))
|
|
354
|
+
logit_new = _logit(w_old) + logit_delta
|
|
355
|
+
w_new = cold_ratio * prior.mean + (1.0 - cold_ratio) * _sigmoid(logit_new)
|
|
356
|
+
else:
|
|
357
|
+
prior.update(signal.outcome)
|
|
358
|
+
w_new = _sigmoid(_logit(w_old) + logit_delta)
|
|
359
|
+
|
|
360
|
+
# Temporal decay: mean-reversion towards 0.5
|
|
361
|
+
w_new = w_new * (1.0 - self.decay_rate) + 0.5 * self.decay_rate
|
|
362
|
+
|
|
363
|
+
# Per-model rate limiting via lr_multipliers affects max change rate
|
|
364
|
+
if multiplier < 1.0 and w_old > 1e-9:
|
|
365
|
+
max_change = 1.0 + (1.0 - multiplier) * 0.5 # e.g. mult=0.6 -> max 1.2x
|
|
366
|
+
min_change = 1.0 - (1.0 - multiplier) * 0.5 # e.g. mult=0.6 -> min 0.8x
|
|
367
|
+
ratio = w_new / w_old
|
|
368
|
+
ratio = max(min_change, min(max_change, ratio))
|
|
369
|
+
w_new = w_old * ratio
|
|
370
|
+
|
|
371
|
+
# Hard cap/floor
|
|
372
|
+
w_new = max(self.floor, min(self.cap, w_new))
|
|
373
|
+
|
|
374
|
+
# Store state
|
|
375
|
+
self._models[model_id][domain] = w_new
|
|
376
|
+
self._priors[model_id][domain] = prior
|
|
377
|
+
self._weight_history[model_id][domain].append(w_new)
|
|
378
|
+
if len(self._weight_history[model_id][domain]) > 100:
|
|
379
|
+
self._weight_history[model_id][domain].pop(0)
|
|
380
|
+
self._task_counts[model_id][domain] = task_count + 1
|
|
381
|
+
|
|
382
|
+
# Bias history for domain EMA
|
|
383
|
+
self._bias_history.append({
|
|
384
|
+
"model": model_id,
|
|
385
|
+
"domain": domain,
|
|
386
|
+
"outcome": signal.outcome,
|
|
387
|
+
"timestamp": time.time(),
|
|
388
|
+
})
|
|
389
|
+
if len(self._bias_history) > 10000:
|
|
390
|
+
self._bias_history = self._bias_history[-8000:]
|
|
391
|
+
|
|
392
|
+
logger.debug(
|
|
393
|
+
"update: %s [%s] %.4f -> %.4f (outcome=%.2f, lr=%.4f, cold=%s)",
|
|
394
|
+
model_id, domain, w_old, w_new, signal.outcome, lr,
|
|
395
|
+
prior.is_cold(self.cold_start_threshold),
|
|
396
|
+
)
|
|
397
|
+
self._maybe_persist()
|
|
398
|
+
return w_new
|
|
399
|
+
|
|
400
|
+
# ------------------------------------------------------------------
|
|
401
|
+
# Convenience: thumbs up/down
|
|
402
|
+
# ------------------------------------------------------------------
|
|
403
|
+
|
|
404
|
+
def update_from_feedback(
|
|
405
|
+
self,
|
|
406
|
+
model_id: str,
|
|
407
|
+
positive: bool,
|
|
408
|
+
domain: str = "general",
|
|
409
|
+
) -> float:
|
|
410
|
+
"""Simple thumbs-up/thumbs-down update.
|
|
411
|
+
|
|
412
|
+
Translates boolean feedback to outcome (1.0 or 0.0) and delegates
|
|
413
|
+
to the core update method.
|
|
414
|
+
"""
|
|
415
|
+
signal = FeedbackSignal(
|
|
416
|
+
outcome=1.0 if positive else 0.0,
|
|
417
|
+
domain=domain,
|
|
418
|
+
)
|
|
419
|
+
return self.update(model_id, signal)
|
|
420
|
+
|
|
421
|
+
# ------------------------------------------------------------------
|
|
422
|
+
# Batch update
|
|
423
|
+
# ------------------------------------------------------------------
|
|
424
|
+
|
|
425
|
+
def update_batch(
|
|
426
|
+
self,
|
|
427
|
+
model_ids: list[str],
|
|
428
|
+
signal: FeedbackSignal,
|
|
429
|
+
per_model_disagreement: dict[str, float] | None = None,
|
|
430
|
+
) -> dict[str, float]:
|
|
431
|
+
"""Atomically update multiple models with the same base signal.
|
|
432
|
+
|
|
433
|
+
per_model_disagreement overrides signal.disagreement per model.
|
|
434
|
+
"""
|
|
435
|
+
with self._lock:
|
|
436
|
+
results: dict[str, float] = {}
|
|
437
|
+
for mid in model_ids:
|
|
438
|
+
if per_model_disagreement and mid in per_model_disagreement:
|
|
439
|
+
s = FeedbackSignal(
|
|
440
|
+
outcome=signal.outcome,
|
|
441
|
+
domain=signal.domain,
|
|
442
|
+
weight=signal.weight,
|
|
443
|
+
disagreement=per_model_disagreement[mid],
|
|
444
|
+
)
|
|
445
|
+
else:
|
|
446
|
+
s = signal
|
|
447
|
+
results[mid] = self._update_impl(mid, s)
|
|
448
|
+
return results
|
|
449
|
+
|
|
450
|
+
# ------------------------------------------------------------------
|
|
451
|
+
# Weight retrieval
|
|
452
|
+
# ------------------------------------------------------------------
|
|
453
|
+
|
|
454
|
+
def get_weights(self, domain: str = "general") -> dict[str, float]:
|
|
455
|
+
"""Return entropy-enforced weights for all registered models."""
|
|
456
|
+
with self._lock:
|
|
457
|
+
raw = {
|
|
458
|
+
mid: domains.get(domain, domains.get("general", DEFAULT_WEIGHT))
|
|
459
|
+
for mid, domains in self._models.items()
|
|
460
|
+
}
|
|
461
|
+
return enforce_entropy(raw, self.floor, self.entropy_threshold)
|
|
462
|
+
|
|
463
|
+
def get_weight(self, model_id: str, domain: str = "general") -> float:
|
|
464
|
+
"""Return raw weight for a single model (no entropy enforcement)."""
|
|
465
|
+
with self._lock:
|
|
466
|
+
if model_id not in self._models:
|
|
467
|
+
return DEFAULT_WEIGHT
|
|
468
|
+
return self._models[model_id].get(domain, DEFAULT_WEIGHT)
|
|
469
|
+
|
|
470
|
+
# ------------------------------------------------------------------
|
|
471
|
+
# Persistence
|
|
472
|
+
# ------------------------------------------------------------------
|
|
473
|
+
|
|
474
|
+
def _write_state_locked(self, state: dict[str, Any]) -> None:
|
|
475
|
+
"""Write *state* dict to disk via tmp + os.replace (caller holds lock)."""
|
|
476
|
+
if self.state_path is None:
|
|
477
|
+
return
|
|
478
|
+
self.state_path.parent.mkdir(parents=True, exist_ok=True)
|
|
479
|
+
data = json.dumps(state, indent=2, ensure_ascii=False)
|
|
480
|
+
fd, tmp = tempfile.mkstemp(
|
|
481
|
+
dir=self.state_path.parent, suffix=".tmp",
|
|
482
|
+
)
|
|
483
|
+
try:
|
|
484
|
+
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
485
|
+
f.write(data)
|
|
486
|
+
os.replace(tmp, str(self.state_path))
|
|
487
|
+
except (OSError, TypeError, ValueError):
|
|
488
|
+
with contextlib.suppress(OSError):
|
|
489
|
+
os.unlink(tmp)
|
|
490
|
+
raise
|
|
491
|
+
|
|
492
|
+
def save(self) -> None:
|
|
493
|
+
"""Persist full engine state to disk (atomic write)."""
|
|
494
|
+
if self.state_path is None:
|
|
495
|
+
return
|
|
496
|
+
try:
|
|
497
|
+
with self._lock:
|
|
498
|
+
state = self._serialize()
|
|
499
|
+
self._write_state_locked(state)
|
|
500
|
+
logger.debug("State saved to %s", self.state_path)
|
|
501
|
+
except (OSError, TypeError, ValueError) as exc:
|
|
502
|
+
logger.warning("Save failed: %s", exc, exc_info=True)
|
|
503
|
+
|
|
504
|
+
@classmethod
|
|
505
|
+
def load(cls, path: Path, **kwargs: Any) -> TrustEngine:
|
|
506
|
+
"""Load engine from a persisted state file.
|
|
507
|
+
|
|
508
|
+
Extra kwargs are forwarded to the constructor (e.g. domains, cap).
|
|
509
|
+
"""
|
|
510
|
+
engine = cls(state_path=path, **kwargs)
|
|
511
|
+
if path.exists():
|
|
512
|
+
try:
|
|
513
|
+
engine._restore(path)
|
|
514
|
+
logger.info("Restored state from %s (%d models)", path, len(engine._models))
|
|
515
|
+
except (OSError, json.JSONDecodeError, KeyError, TypeError, ValueError) as exc:
|
|
516
|
+
logger.warning("Restore failed, using defaults: %s", exc, exc_info=True)
|
|
517
|
+
return engine
|
|
518
|
+
|
|
519
|
+
def _serialize(self) -> dict[str, Any]:
|
|
520
|
+
"""Snapshot internal state for JSON serialization."""
|
|
521
|
+
return {
|
|
522
|
+
"models": {m: dict(d) for m, d in self._models.items()},
|
|
523
|
+
"task_counts": {m: dict(d) for m, d in self._task_counts.items()},
|
|
524
|
+
"priors": {
|
|
525
|
+
m: {dom: p.to_dict() for dom, p in doms.items()}
|
|
526
|
+
for m, doms in self._priors.items()
|
|
527
|
+
},
|
|
528
|
+
"bias_history": list(self._bias_history[-2000:]),
|
|
529
|
+
"weight_history": {
|
|
530
|
+
m: {d: list(h) for d, h in doms.items()}
|
|
531
|
+
for m, doms in self._weight_history.items()
|
|
532
|
+
},
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
def _restore(self, path: Path) -> None:
|
|
536
|
+
"""Restore state from a JSON file.
|
|
537
|
+
|
|
538
|
+
B10: weights read from disk are validated through ``float()`` and
|
|
539
|
+
clamped to ``[floor, cap]``. Non-numeric values cause the model
|
|
540
|
+
to be skipped with a warning.
|
|
541
|
+
"""
|
|
542
|
+
raw = json.loads(path.read_text(encoding="utf-8"))
|
|
543
|
+
|
|
544
|
+
# B10: validate and clamp restored weights.
|
|
545
|
+
restored_models: dict[str, dict[str, float]] = {}
|
|
546
|
+
for m, domains in raw.get("models", {}).items():
|
|
547
|
+
if not isinstance(domains, dict):
|
|
548
|
+
logger.warning("Skipping model %s: domains is not a dict", m)
|
|
549
|
+
continue
|
|
550
|
+
cleaned: dict[str, float] = {}
|
|
551
|
+
for domain, w in domains.items():
|
|
552
|
+
try:
|
|
553
|
+
fw = float(w)
|
|
554
|
+
except (TypeError, ValueError):
|
|
555
|
+
logger.warning(
|
|
556
|
+
"Skipping model %s domain %s: non-numeric weight %r",
|
|
557
|
+
m, domain, w,
|
|
558
|
+
)
|
|
559
|
+
continue
|
|
560
|
+
if math.isnan(fw) or math.isinf(fw):
|
|
561
|
+
logger.warning(
|
|
562
|
+
"Skipping model %s domain %s: weight is %r",
|
|
563
|
+
m, domain, fw,
|
|
564
|
+
)
|
|
565
|
+
continue
|
|
566
|
+
cleaned[domain] = max(self.floor, min(self.cap, fw))
|
|
567
|
+
if cleaned:
|
|
568
|
+
restored_models[m] = cleaned
|
|
569
|
+
self._models = restored_models
|
|
570
|
+
|
|
571
|
+
self._bias_history = raw.get("bias_history", [])
|
|
572
|
+
for m, domains in raw.get("task_counts", {}).items():
|
|
573
|
+
for domain, count in domains.items():
|
|
574
|
+
self._task_counts[m][domain] = count
|
|
575
|
+
for m, domains in raw.get("weight_history", {}).items():
|
|
576
|
+
for domain, hist in domains.items():
|
|
577
|
+
self._weight_history[m][domain] = hist
|
|
578
|
+
for m, domains in raw.get("priors", {}).items():
|
|
579
|
+
self._priors[m] = {}
|
|
580
|
+
for domain, p in domains.items():
|
|
581
|
+
self._priors[m][domain] = BetaPrior.from_dict(p)
|
|
582
|
+
|
|
583
|
+
def _maybe_persist(self) -> None:
|
|
584
|
+
"""Auto-persist every 10 updates."""
|
|
585
|
+
self._update_count += 1
|
|
586
|
+
if self._update_count % 10 == 0:
|
|
587
|
+
# Release lock before disk I/O -- save() acquires its own lock
|
|
588
|
+
# We call _persist_weights directly to avoid double-locking
|
|
589
|
+
self._persist_no_lock()
|
|
590
|
+
|
|
591
|
+
def _persist_no_lock(self) -> None:
|
|
592
|
+
"""Periodic persist of FULL state via atomic write.
|
|
593
|
+
|
|
594
|
+
Called from ``_maybe_persist`` which runs inside ``_lock``.
|
|
595
|
+
"""
|
|
596
|
+
if self.state_path is None:
|
|
597
|
+
return
|
|
598
|
+
try:
|
|
599
|
+
state = self._serialize()
|
|
600
|
+
self._write_state_locked(state)
|
|
601
|
+
except (OSError, TypeError, ValueError) as exc:
|
|
602
|
+
logger.debug("Periodic persist failed: %s", exc, exc_info=True)
|