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.
@@ -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)