margin 0.9.18__tar.gz → 0.9.20__tar.gz

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.

Potentially problematic release.


This version of margin might be problematic. Click here for more details.

Files changed (91) hide show
  1. {margin-0.9.18/margin.egg-info → margin-0.9.20}/PKG-INFO +1 -1
  2. {margin-0.9.18 → margin-0.9.20}/margin/controller.py +67 -0
  3. margin-0.9.20/margin/fingerprint.py +583 -0
  4. {margin-0.9.18 → margin-0.9.20}/margin/provenance.py +39 -5
  5. {margin-0.9.18 → margin-0.9.20}/margin/streaming.py +128 -0
  6. {margin-0.9.18 → margin-0.9.20/margin.egg-info}/PKG-INFO +1 -1
  7. {margin-0.9.18 → margin-0.9.20}/margin.egg-info/SOURCES.txt +2 -0
  8. {margin-0.9.18 → margin-0.9.20}/pyproject.toml +1 -1
  9. margin-0.9.20/tests/test_fingerprint_ext.py +444 -0
  10. margin-0.9.20/tests/test_pipeline_features.py +471 -0
  11. margin-0.9.18/margin/fingerprint.py +0 -206
  12. {margin-0.9.18 → margin-0.9.20}/LICENSE +0 -0
  13. {margin-0.9.18 → margin-0.9.20}/NOTICE +0 -0
  14. {margin-0.9.18 → margin-0.9.20}/README.md +0 -0
  15. {margin-0.9.18 → margin-0.9.20}/margin/__init__.py +0 -0
  16. {margin-0.9.18 → margin-0.9.20}/margin/__main__.py +0 -0
  17. {margin-0.9.18 → margin-0.9.20}/margin/algebra.py +0 -0
  18. {margin-0.9.18 → margin-0.9.20}/margin/anomaly.py +0 -0
  19. {margin-0.9.18 → margin-0.9.20}/margin/bridge.py +0 -0
  20. {margin-0.9.18 → margin-0.9.20}/margin/calibrate.py +0 -0
  21. {margin-0.9.18 → margin-0.9.20}/margin/causal.py +0 -0
  22. {margin-0.9.18 → margin-0.9.20}/margin/composite.py +0 -0
  23. {margin-0.9.18 → margin-0.9.20}/margin/confidence.py +0 -0
  24. {margin-0.9.18 → margin-0.9.20}/margin/config.py +0 -0
  25. {margin-0.9.18 → margin-0.9.20}/margin/contract.py +0 -0
  26. {margin-0.9.18 → margin-0.9.20}/margin/correlate.py +0 -0
  27. {margin-0.9.18 → margin-0.9.20}/margin/diff.py +0 -0
  28. {margin-0.9.18 → margin-0.9.20}/margin/drift.py +0 -0
  29. {margin-0.9.18 → margin-0.9.20}/margin/events.py +0 -0
  30. {margin-0.9.18 → margin-0.9.20}/margin/forecast.py +0 -0
  31. {margin-0.9.18 → margin-0.9.20}/margin/health.py +0 -0
  32. {margin-0.9.18 → margin-0.9.20}/margin/intent.py +0 -0
  33. {margin-0.9.18 → margin-0.9.20}/margin/ledger.py +0 -0
  34. {margin-0.9.18 → margin-0.9.20}/margin/loop.py +0 -0
  35. {margin-0.9.18 → margin-0.9.20}/margin/observation.py +0 -0
  36. {margin-0.9.18 → margin-0.9.20}/margin/persist.py +0 -0
  37. {margin-0.9.18 → margin-0.9.20}/margin/policy/__init__.py +0 -0
  38. {margin-0.9.18 → margin-0.9.20}/margin/policy/compose.py +0 -0
  39. {margin-0.9.18 → margin-0.9.20}/margin/policy/core.py +0 -0
  40. {margin-0.9.18 → margin-0.9.20}/margin/policy/temporal.py +0 -0
  41. {margin-0.9.18 → margin-0.9.20}/margin/policy/trace.py +0 -0
  42. {margin-0.9.18 → margin-0.9.20}/margin/policy/tuning.py +0 -0
  43. {margin-0.9.18 → margin-0.9.20}/margin/policy/validate.py +0 -0
  44. {margin-0.9.18 → margin-0.9.20}/margin/predicates.py +0 -0
  45. {margin-0.9.18 → margin-0.9.20}/margin/transitions.py +0 -0
  46. {margin-0.9.18 → margin-0.9.20}/margin/uncertain.py +0 -0
  47. {margin-0.9.18 → margin-0.9.20}/margin/validity.py +0 -0
  48. {margin-0.9.18 → margin-0.9.20}/margin.egg-info/dependency_links.txt +0 -0
  49. {margin-0.9.18 → margin-0.9.20}/margin.egg-info/top_level.txt +0 -0
  50. {margin-0.9.18 → margin-0.9.20}/setup.cfg +0 -0
  51. {margin-0.9.18 → margin-0.9.20}/tests/test_algebra.py +0 -0
  52. {margin-0.9.18 → margin-0.9.20}/tests/test_anomaly.py +0 -0
  53. {margin-0.9.18 → margin-0.9.20}/tests/test_bridge.py +0 -0
  54. {margin-0.9.18 → margin-0.9.20}/tests/test_calibrate.py +0 -0
  55. {margin-0.9.18 → margin-0.9.20}/tests/test_causal.py +0 -0
  56. {margin-0.9.18 → margin-0.9.20}/tests/test_composite.py +0 -0
  57. {margin-0.9.18 → margin-0.9.20}/tests/test_confidence.py +0 -0
  58. {margin-0.9.18 → margin-0.9.20}/tests/test_config.py +0 -0
  59. {margin-0.9.18 → margin-0.9.20}/tests/test_contract.py +0 -0
  60. {margin-0.9.18 → margin-0.9.20}/tests/test_controller.py +0 -0
  61. {margin-0.9.18 → margin-0.9.20}/tests/test_correlate.py +0 -0
  62. {margin-0.9.18 → margin-0.9.20}/tests/test_diff.py +0 -0
  63. {margin-0.9.18 → margin-0.9.20}/tests/test_drift.py +0 -0
  64. {margin-0.9.18 → margin-0.9.20}/tests/test_events.py +0 -0
  65. {margin-0.9.18 → margin-0.9.20}/tests/test_forecast.py +0 -0
  66. {margin-0.9.18 → margin-0.9.20}/tests/test_health.py +0 -0
  67. {margin-0.9.18 → margin-0.9.20}/tests/test_healthcare.py +0 -0
  68. {margin-0.9.18 → margin-0.9.20}/tests/test_intent.py +0 -0
  69. {margin-0.9.18 → margin-0.9.20}/tests/test_ledger.py +0 -0
  70. {margin-0.9.18 → margin-0.9.20}/tests/test_loop.py +0 -0
  71. {margin-0.9.18 → margin-0.9.20}/tests/test_neuro.py +0 -0
  72. {margin-0.9.18 → margin-0.9.20}/tests/test_observation.py +0 -0
  73. {margin-0.9.18 → margin-0.9.20}/tests/test_persist.py +0 -0
  74. {margin-0.9.18 → margin-0.9.20}/tests/test_policy.py +0 -0
  75. {margin-0.9.18 → margin-0.9.20}/tests/test_policy_compose.py +0 -0
  76. {margin-0.9.18 → margin-0.9.20}/tests/test_policy_temporal.py +0 -0
  77. {margin-0.9.18 → margin-0.9.20}/tests/test_policy_trace.py +0 -0
  78. {margin-0.9.18 → margin-0.9.20}/tests/test_policy_tuning.py +0 -0
  79. {margin-0.9.18 → margin-0.9.20}/tests/test_policy_validate.py +0 -0
  80. {margin-0.9.18 → margin-0.9.20}/tests/test_predicates.py +0 -0
  81. {margin-0.9.18 → margin-0.9.20}/tests/test_printer3d.py +0 -0
  82. {margin-0.9.18 → margin-0.9.20}/tests/test_provenance.py +0 -0
  83. {margin-0.9.18 → margin-0.9.20}/tests/test_reverse_bridge.py +0 -0
  84. {margin-0.9.18 → margin-0.9.20}/tests/test_ros2.py +0 -0
  85. {margin-0.9.18 → margin-0.9.20}/tests/test_staleness.py +0 -0
  86. {margin-0.9.18 → margin-0.9.20}/tests/test_streaming.py +0 -0
  87. {margin-0.9.18 → margin-0.9.20}/tests/test_transformer.py +0 -0
  88. {margin-0.9.18 → margin-0.9.20}/tests/test_transitions.py +0 -0
  89. {margin-0.9.18 → margin-0.9.20}/tests/test_uncertain.py +0 -0
  90. {margin-0.9.18 → margin-0.9.20}/tests/test_validity.py +0 -0
  91. {margin-0.9.18 → margin-0.9.20}/tests/test_windowing.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: margin
3
- Version: 0.9.18
3
+ Version: 0.9.20
4
4
  Summary: Typed health classification, uncertainty algebra, and correction auditing for any system that measures things and needs to explain what happened.
5
5
  Author: Cope Labs LLC
6
6
  License: MIT
@@ -124,6 +124,7 @@ class Controller:
124
124
  self.alpha_min = alpha_min
125
125
  self.alpha_max = alpha_max
126
126
  self._from_fingerprint: bool = False
127
+ self.feature_weights: Optional[list[float]] = None
127
128
 
128
129
  # ------------------------------------------------------------------
129
130
  # Factory
@@ -260,6 +261,72 @@ class Controller:
260
261
  metric = self._metric(observations)
261
262
  return self._apply(alpha, metric, a_min, a_max)
262
263
 
264
+ def step_from_features(
265
+ self,
266
+ alpha: float,
267
+ feature_vector: list[float],
268
+ weights: Optional[list[float]] = None,
269
+ alpha_min: Optional[float] = None,
270
+ alpha_max: Optional[float] = None,
271
+ ) -> tuple[float, str]:
272
+ """
273
+ Compute the next alpha from a raw feature vector.
274
+
275
+ Converts ``feature_vector`` to a scalar metric via weighted dot product,
276
+ then runs the normal control law. This enables a soft, continuous
277
+ priority score (e.g. ``[t025_score, community_similarity]``) instead
278
+ of a hard binary threshold gate.
279
+
280
+ ``metric = dot(weights, feature_vector)``
281
+
282
+ If ``weights`` is None, falls back to ``self.feature_weights`` (set via
283
+ :meth:`with_feature_weights`), then to equal weights (mean of features).
284
+
285
+ Parameters
286
+ ----------
287
+ alpha: current alpha value
288
+ feature_vector: list of floats [f0, f1, ..., fN]
289
+ weights: per-call weight override; None = use stored weights
290
+ alpha_min: per-call lower clamp override
291
+ alpha_max: per-call upper clamp override
292
+
293
+ Example::
294
+
295
+ ctrl = Controller(strategy="proportional_setpoint", kp=0.3, target=0.5)
296
+ ctrl = ctrl.with_feature_weights([0.6, 0.4])
297
+ alpha, reason = ctrl.step_from_features(
298
+ alpha, [t025_score, community_sim]
299
+ )
300
+ """
301
+ a_min = alpha_min if alpha_min is not None else self.alpha_min
302
+ a_max = alpha_max if alpha_max is not None else self.alpha_max
303
+
304
+ _weights = weights or self.feature_weights
305
+ if _weights is not None:
306
+ n = min(len(_weights), len(feature_vector))
307
+ metric = sum(_weights[i] * feature_vector[i] for i in range(n))
308
+ else:
309
+ metric = sum(feature_vector) / len(feature_vector) if feature_vector else 0.0
310
+
311
+ reason_prefix = f"features({len(feature_vector)}): metric={metric:+.3f} "
312
+ alpha_next, inner_reason = self._apply(alpha, metric, a_min, a_max)
313
+ return alpha_next, reason_prefix + inner_reason
314
+
315
+ def with_feature_weights(self, weights: list[float]) -> "Controller":
316
+ """
317
+ Return a copy of this controller with ``feature_weights`` set.
318
+
319
+ Does not mutate the original::
320
+
321
+ ctrl = Controller(strategy="proportional_setpoint", kp=0.3, target=0.5,
322
+ alpha_min=0.0, alpha_max=1.0)
323
+ ctrl = ctrl.with_feature_weights([0.6, 0.4])
324
+ """
325
+ import copy
326
+ clone = copy.copy(self)
327
+ clone.feature_weights = list(weights)
328
+ return clone
329
+
263
330
  def step_normalized(
264
331
  self,
265
332
  alpha: float,
@@ -0,0 +1,583 @@
1
+ """
2
+ Fingerprint: session statistics with noise-resistant target queries.
3
+
4
+ Monitor.fingerprint() returns a Fingerprint instead of a plain dict.
5
+ Subclasses dict — fully JSON-serializable, isinstance(fp, dict) is True,
6
+ and fp["component"]["mean"] still works unchanged.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import math
12
+ from typing import Optional
13
+
14
+
15
+ def _sorted_vals(values: list[float]) -> list[float]:
16
+ return sorted(values)
17
+
18
+
19
+ def _percentile(values: list[float], p: float) -> float:
20
+ """p-th percentile (0–100) via linear interpolation."""
21
+ if not values:
22
+ return 0.0
23
+ s = _sorted_vals(values)
24
+ n = len(s)
25
+ if n == 1:
26
+ return s[0]
27
+ idx = (p / 100.0) * (n - 1)
28
+ lo = int(idx)
29
+ hi = min(lo + 1, n - 1)
30
+ frac = idx - lo
31
+ return s[lo] * (1.0 - frac) + s[hi] * frac
32
+
33
+
34
+ def _trimmed_mean(values: list[float], fraction: float = 0.1) -> float:
35
+ """Mean after removing the top and bottom `fraction` of values."""
36
+ if not values:
37
+ return 0.0
38
+ s = _sorted_vals(values)
39
+ n = len(s)
40
+ k = max(1, int(n * fraction))
41
+ trimmed = s[k:-k] if 2 * k < n else s
42
+ return sum(trimmed) / len(trimmed)
43
+
44
+
45
+ class Fingerprint(dict):
46
+ """
47
+ Session statistics from Monitor.fingerprint().
48
+
49
+ Subclasses ``dict`` — fully JSON-serializable without a custom encoder::
50
+
51
+ fp["cpu"]["mean"] # works
52
+ json.dumps(fp) # works — serializes as {name: {mean, std, n, trend}}
53
+ isinstance(fp, dict) # True
54
+
55
+ Noise-resistant queries::
56
+
57
+ fp.robust_target("cpu") # median (default)
58
+ fp.robust_target("cpu", "trimmed") # 10% trimmed mean
59
+ fp.percentile("cpu", 25) # 25th percentile
60
+
61
+ Cross-session normalization::
62
+
63
+ fp.sigma("cpu", value) # z-score: (value − mean) / std
64
+ fp.robust_sigma("cpu", value) # (value − median) / IQR
65
+
66
+ Neural conditioning::
67
+
68
+ fp.to_tensor(["rr", "improvement"], ["mean", "std"]) # flat list
69
+ fp.to_tensor(..., format="numpy") # np.ndarray
70
+ fp.to_tensor(..., format="torch") # torch.Tensor
71
+
72
+ Session comparison::
73
+
74
+ fp.distance(other_fp) # L2 in flattened (mean,std) space
75
+ fp.kl_divergence(other_fp) # symmetric KL of Gaussian components
76
+
77
+ Online update::
78
+
79
+ fp.update("recovery_ratio", 0.12) # Welford incremental update
80
+
81
+ Multi-session aggregation::
82
+
83
+ fp.merge(other_fp, weight=0.5) # weighted average of two fingerprints
84
+
85
+ The raw ``_values`` dict stores per-component float lists from the drift
86
+ window, enabling true median / percentile without re-scanning trackers.
87
+ Raw values are ephemeral (not included in JSON / to_dict output).
88
+ """
89
+
90
+ def __init__(
91
+ self,
92
+ stats: dict[str, dict],
93
+ values: Optional[dict[str, list[float]]] = None,
94
+ ):
95
+ super().__init__(stats)
96
+ self._values: dict[str, list[float]] = values or {}
97
+
98
+ # ------------------------------------------------------------------
99
+ # Inspection
100
+ # ------------------------------------------------------------------
101
+
102
+ @property
103
+ def metrics(self) -> list[str]:
104
+ """Sorted list of components with at least one observation (n > 0)."""
105
+ return [k for k in sorted(self.keys()) if self.get(k, {}).get("n", 0) > 0]
106
+
107
+ # ------------------------------------------------------------------
108
+ # Noise-resistant queries
109
+ # ------------------------------------------------------------------
110
+
111
+ def robust_target(self, component: str, method: str = "median") -> float:
112
+ """
113
+ Noise-resistant estimate of an achievable target for ``component``.
114
+
115
+ ``method="median"`` — median of observed values (default; robust to outliers)
116
+ ``method="trimmed"`` — 10% trimmed mean
117
+ ``method="mean"`` — plain mean (same as ``fp[component]["mean"]``)
118
+
119
+ When no raw values are stored, checks for pre-computed ``"median"`` in
120
+ the stats dict (present when fingerprint came from ``Monitor.fingerprint()``),
121
+ then falls back to ``"mean"``.
122
+ """
123
+ vals = self._values.get(component)
124
+ if not vals:
125
+ stats = self.get(component, {})
126
+ if method == "median":
127
+ return stats.get("median", stats.get("mean", 0.0))
128
+ return stats.get("mean", 0.0)
129
+ if method == "median":
130
+ return _percentile(vals, 50)
131
+ if method == "trimmed":
132
+ return _trimmed_mean(vals, fraction=0.1)
133
+ # "mean" or unknown
134
+ return sum(vals) / len(vals)
135
+
136
+ def percentile(self, component: str, p: float) -> float:
137
+ """
138
+ Return the p-th percentile (0–100) of observed values for ``component``.
139
+
140
+ When no raw values are stored, checks for pre-computed ``"q25"`` / ``"q75"`` /
141
+ ``"median"`` in the stats dict (present when fingerprint came from
142
+ ``Monitor.fingerprint()``), then falls back to ``"mean"``.
143
+ """
144
+ vals = self._values.get(component)
145
+ if not vals:
146
+ stats = self.get(component, {})
147
+ if p == 25 and "q25" in stats:
148
+ return stats["q25"]
149
+ if p == 75 and "q75" in stats:
150
+ return stats["q75"]
151
+ if p == 50 and "median" in stats:
152
+ return stats["median"]
153
+ return stats.get("mean", 0.0)
154
+ return _percentile(vals, p)
155
+
156
+ # ------------------------------------------------------------------
157
+ # Cross-session normalization
158
+ # ------------------------------------------------------------------
159
+
160
+ def sigma(self, component: str, value: float) -> float:
161
+ """
162
+ Z-score of ``value`` against the fingerprint's empirical distribution.
163
+
164
+ Returns ``(value − mean) / std`` (standard z-score).
165
+
166
+ Using ``std`` as denominator is correct for cross-session normalization.
167
+ ``|mean|`` would only be appropriate when the coefficient of variation
168
+ (std/|mean|) is small; for high-CV components (e.g. ``std=0.498``,
169
+ ``mean=0.070``, CV=7.1) it amplifies by 7×, turning a normal ±0.07
170
+ swing into ±1.0 and saturating the controller.
171
+
172
+ Falls back to ``(value − mean) / |mean|`` when ``std`` is zero (constant
173
+ window or not yet stored), and returns ``value`` unchanged when both
174
+ ``mean`` and ``std`` are zero.
175
+ """
176
+ stats = self.get(component, {})
177
+ mean = stats.get("mean", 0.0)
178
+ std = stats.get("std", 0.0)
179
+ if std != 0.0:
180
+ return (value - mean) / std
181
+ if mean == 0.0:
182
+ return value
183
+ return (value - mean) / abs(mean)
184
+
185
+ def robust_sigma(self, component: str, value: float) -> float:
186
+ """
187
+ Normalize ``value`` against the fingerprint's empirical median, scaled by IQR.
188
+
189
+ Returns ``(value − median) / IQR``.
190
+
191
+ More stable than :meth:`sigma` when the distribution has high variance
192
+ or outliers (e.g. ``recovery_ratio`` std=0.498 vs mean=0.070 — the
193
+ mean-based sigma would amplify noise).
194
+
195
+ Falls back to :meth:`sigma` when IQR is zero (constant window or no
196
+ raw values stored).
197
+ """
198
+ median = self.robust_target(component, "median")
199
+ q25 = self.percentile(component, 25)
200
+ q75 = self.percentile(component, 75)
201
+ iqr = q75 - q25
202
+ if iqr == 0.0:
203
+ return self.sigma(component, value)
204
+ return (value - median) / iqr
205
+
206
+ # ------------------------------------------------------------------
207
+ # Neural conditioning
208
+ # ------------------------------------------------------------------
209
+
210
+ def to_tensor(
211
+ self,
212
+ metrics: Optional[list[str]] = None,
213
+ stats: tuple[str, ...] = ("mean", "std"),
214
+ format: str = "list",
215
+ ):
216
+ """
217
+ Export fingerprint as a flat vector for neural conditioning.
218
+
219
+ Returns the values in row-major order:
220
+ ``[metric0_stat0, metric0_stat1, metric1_stat0, ...]``
221
+
222
+ Parameters
223
+ ----------
224
+ metrics: components to include, in order; ``None`` = ``sorted(self.keys())``
225
+ stats: which stat fields to include per component (default: mean and std)
226
+ format: ``"list"`` (default, no dependencies), ``"numpy"``, or ``"torch"``
227
+
228
+ Example::
229
+
230
+ # Flat 4-element list: [rr_mean, rr_std, imp_mean, imp_std]
231
+ vec = fp.to_tensor(["recovery_ratio", "improvement"], ["mean", "std"])
232
+
233
+ # As torch tensor for D_fp conditioning:
234
+ t = fp.to_tensor(["recovery_ratio", "improvement"], format="torch")
235
+ """
236
+ _metrics = metrics if metrics is not None else sorted(self.keys())
237
+ values = []
238
+ for m in _metrics:
239
+ s = self.get(m, {})
240
+ for stat in stats:
241
+ values.append(float(s.get(stat, 0.0)))
242
+
243
+ if format == "list":
244
+ return values
245
+ if format == "numpy":
246
+ import numpy as np
247
+ return np.array(values, dtype=np.float32)
248
+ if format == "torch":
249
+ import torch
250
+ return torch.tensor(values, dtype=torch.float32)
251
+ raise ValueError(
252
+ f"Unknown format {format!r}. Supported: 'list', 'numpy', 'torch'"
253
+ )
254
+
255
+ # ------------------------------------------------------------------
256
+ # Session comparison
257
+ # ------------------------------------------------------------------
258
+
259
+ def distance(
260
+ self,
261
+ other: "Fingerprint",
262
+ metrics: Optional[list[str]] = None,
263
+ stats: tuple[str, ...] = ("mean", "std"),
264
+ ) -> float:
265
+ """
266
+ L2 distance between two fingerprints in the flattened (mean, std) space.
267
+
268
+ Uses the same vector layout as :meth:`to_tensor` — the distance is
269
+ directly interpretable as distance in the tensor space used for neural
270
+ conditioning.
271
+
272
+ Only components present in both fingerprints are compared (``metrics``
273
+ can restrict further).
274
+
275
+ Example::
276
+
277
+ # Find the Session 1 sentence fingerprint closest to current state
278
+ best = min(session1_fps, key=lambda fp1: live_fp.distance(fp1))
279
+ """
280
+ _metrics = metrics if metrics is not None else sorted(
281
+ set(self.keys()) & set(other.keys())
282
+ )
283
+ a = self.to_tensor(metrics=_metrics, stats=stats)
284
+ b = other.to_tensor(metrics=_metrics, stats=stats)
285
+ return sum((x - y) ** 2 for x, y in zip(a, b)) ** 0.5
286
+
287
+ def kl_divergence(
288
+ self,
289
+ other: "Fingerprint",
290
+ metrics: Optional[list[str]] = None,
291
+ symmetric: bool = True,
292
+ ) -> float:
293
+ """
294
+ KL divergence treating each component as an independent Gaussian N(mean, std²).
295
+
296
+ When ``symmetric=True`` (default), returns the symmetric KL:
297
+ ``0.5 * (KL(self || other) + KL(other || self))``.
298
+
299
+ Components with std=0 are skipped (degenerate Gaussians).
300
+
301
+ Example::
302
+
303
+ drift = fp_session1.kl_divergence(fp_session2)
304
+ if drift > 2.0:
305
+ print("sessions differ significantly")
306
+ """
307
+ _metrics = metrics if metrics is not None else sorted(
308
+ set(self.keys()) & set(other.keys())
309
+ )
310
+
311
+ def _kl_gaussians(p_fp: "Fingerprint", q_fp: "Fingerprint") -> float:
312
+ total = 0.0
313
+ for m in _metrics:
314
+ mu_p = p_fp.get(m, {}).get("mean", 0.0)
315
+ sig_p = max(p_fp.get(m, {}).get("std", 0.0), 1e-8)
316
+ mu_q = q_fp.get(m, {}).get("mean", 0.0)
317
+ sig_q = max(q_fp.get(m, {}).get("std", 0.0), 1e-8)
318
+ # KL(N(μ_p,σ_p²) || N(μ_q,σ_q²))
319
+ total += (
320
+ math.log(sig_q / sig_p)
321
+ + (sig_p ** 2 + (mu_p - mu_q) ** 2) / (2.0 * sig_q ** 2)
322
+ - 0.5
323
+ )
324
+ return total
325
+
326
+ if symmetric:
327
+ return 0.5 * (_kl_gaussians(self, other) + _kl_gaussians(other, self))
328
+ return _kl_gaussians(self, other)
329
+
330
+ def similarity(
331
+ self,
332
+ other: "Fingerprint",
333
+ metrics: Optional[list[str]] = None,
334
+ stats: tuple[str, ...] = ("mean", "std"),
335
+ ) -> float:
336
+ """
337
+ Cosine similarity between two fingerprints in the flattened (mean, std) space.
338
+
339
+ Returns a value in [−1, 1]; 1.0 = identical direction, 0.0 = orthogonal.
340
+ """
341
+ _metrics = metrics if metrics is not None else sorted(
342
+ set(self.keys()) & set(other.keys())
343
+ )
344
+ a = self.to_tensor(metrics=_metrics, stats=stats)
345
+ b = other.to_tensor(metrics=_metrics, stats=stats)
346
+ dot = sum(x * y for x, y in zip(a, b))
347
+ mag_a = sum(x ** 2 for x in a) ** 0.5
348
+ mag_b = sum(x ** 2 for x in b) ** 0.5
349
+ if mag_a == 0.0 or mag_b == 0.0:
350
+ return 0.0
351
+ return dot / (mag_a * mag_b)
352
+
353
+ # ------------------------------------------------------------------
354
+ # Online update
355
+ # ------------------------------------------------------------------
356
+
357
+ def update(self, component: str, value: float) -> "Fingerprint":
358
+ """
359
+ Incrementally update statistics for ``component`` using Welford's algorithm.
360
+
361
+ O(1), numerically stable. Updates ``mean``, ``std``, and ``n`` in-place.
362
+ Also appends to the raw values list if one exists (for percentile accuracy).
363
+
364
+ Returns ``self`` for chaining.
365
+
366
+ Example::
367
+
368
+ for obs in live_observations:
369
+ fp.update("recovery_ratio", obs.value)
370
+ """
371
+ stats = dict(self.get(component) or {
372
+ "mean": 0.0, "std": 0.0, "n": 0, "trend": "UNKNOWN"
373
+ })
374
+ n_old = stats.get("n", 0)
375
+ n_new = n_old + 1
376
+ mean_old = stats.get("mean", 0.0)
377
+ mean_new = mean_old + (value - mean_old) / n_new
378
+ # Reconstruct M2 from stored std²*(n-1), update, recompute std
379
+ m2_old = stats.get("std", 0.0) ** 2 * max(n_old - 1, 0)
380
+ m2_new = m2_old + (value - mean_old) * (value - mean_new)
381
+ std_new = (m2_new / (n_new - 1)) ** 0.5 if n_new >= 2 else 0.0
382
+ stats["mean"] = mean_new
383
+ stats["std"] = std_new
384
+ stats["n"] = n_new
385
+ self[component] = stats
386
+
387
+ if component in self._values:
388
+ self._values[component].append(value)
389
+
390
+ return self
391
+
392
+ # ------------------------------------------------------------------
393
+ # Multi-session aggregation
394
+ # ------------------------------------------------------------------
395
+
396
+ def merge(self, other: "Fingerprint", weight: float = 0.5) -> "Fingerprint":
397
+ """
398
+ Weighted combination of two fingerprints.
399
+
400
+ ``weight=0.5``: equal mix; ``weight=0.0``: returns copy of self;
401
+ ``weight=1.0``: returns copy of other.
402
+
403
+ Mean and std are linearly interpolated; n is summed. Components
404
+ present in only one fingerprint are included unchanged.
405
+
406
+ Example::
407
+
408
+ # Running average of Session 1 and Session 2
409
+ combined = fp_session1.merge(fp_session2, weight=0.5)
410
+ """
411
+ w = max(0.0, min(1.0, weight))
412
+ all_keys = set(self.keys()) | set(other.keys())
413
+ merged: dict[str, dict] = {}
414
+ for k in sorted(all_keys):
415
+ s = self.get(k) or {}
416
+ o = other.get(k) or {}
417
+ if not s:
418
+ merged[k] = dict(o)
419
+ elif not o:
420
+ merged[k] = dict(s)
421
+ else:
422
+ merged[k] = {
423
+ "mean": s.get("mean", 0.0) * (1 - w) + o.get("mean", 0.0) * w,
424
+ "std": s.get("std", 0.0) * (1 - w) + o.get("std", 0.0) * w,
425
+ "n": s.get("n", 0) + o.get("n", 0),
426
+ "trend": o.get("trend", s.get("trend", "UNKNOWN")),
427
+ }
428
+ # Carry over percentiles if both have them (interpolate)
429
+ for key in ("median", "q25", "q75"):
430
+ if key in s and key in o:
431
+ merged[k][key] = s[key] * (1 - w) + o[key] * w
432
+ return Fingerprint(stats=merged)
433
+
434
+ # ------------------------------------------------------------------
435
+ # Batch construction
436
+ # ------------------------------------------------------------------
437
+
438
+ @classmethod
439
+ def from_batch(
440
+ cls,
441
+ items,
442
+ feature_fn,
443
+ ) -> "Fingerprint":
444
+ """
445
+ Build a Fingerprint from a batch of items using Welford's algorithm.
446
+
447
+ ``feature_fn`` extracts a ``{component: float}`` dict from each item.
448
+ All components seen across the batch are aggregated.
449
+
450
+ Example::
451
+
452
+ # Fingerprint a batch of communities by genus composition
453
+ fp = Fingerprint.from_batch(
454
+ communities,
455
+ feature_fn=lambda c: {"genus_diversity": c.shannon_h,
456
+ "mean_quality": c.mean_t025},
457
+ )
458
+
459
+ Parameters
460
+ ----------
461
+ items: iterable of anything
462
+ feature_fn: callable(item) → dict[str, float]
463
+ """
464
+ result = cls(stats={})
465
+ for item in items:
466
+ features = feature_fn(item)
467
+ for component, value in features.items():
468
+ result.update(component, float(value))
469
+ return result
470
+
471
+ # ------------------------------------------------------------------
472
+ # Ordering / retrieval
473
+ # ------------------------------------------------------------------
474
+
475
+ @classmethod
476
+ def optimal_ordering(
477
+ cls,
478
+ items: list,
479
+ key_fn,
480
+ metrics: Optional[list[str]] = None,
481
+ stats: tuple[str, ...] = ("mean", "std"),
482
+ ) -> list:
483
+ """
484
+ Reorder ``items`` to maximise cache reuse via greedy nearest-neighbor.
485
+
486
+ ``key_fn`` extracts a :class:`Fingerprint` from each item. The
487
+ algorithm starts from the item whose fingerprint is closest to the
488
+ batch centroid, then always picks the unvisited item closest to the
489
+ current one (greedy TSP approximation).
490
+
491
+ This is a classmethod so it can be called without an instance::
492
+
493
+ ordered = Fingerprint.optimal_ordering(batches, key_fn=lambda b: b.fp)
494
+
495
+ Parameters
496
+ ----------
497
+ items: list of objects to reorder
498
+ key_fn: callable(item) → Fingerprint
499
+ metrics: restrict distance calculation to these components;
500
+ ``None`` = all shared keys between each pair
501
+ stats: which stat fields to use for distance (default: mean and std)
502
+
503
+ Returns
504
+ -------
505
+ Reordered copy of ``items``.
506
+
507
+ Example::
508
+
509
+ ordered_batches = Fingerprint.optimal_ordering(
510
+ batches,
511
+ key_fn=lambda b: b.fingerprint,
512
+ metrics=["genus_diversity", "mean_quality"],
513
+ )
514
+ """
515
+ if not items:
516
+ return []
517
+ if len(items) == 1:
518
+ return list(items)
519
+
520
+ fps = [key_fn(item) for item in items]
521
+
522
+ # Compute centroid fingerprint from all stat means
523
+ all_keys: set[str] = set()
524
+ for fp in fps:
525
+ all_keys.update(fp.keys())
526
+ _metrics = sorted(metrics) if metrics is not None else sorted(all_keys)
527
+
528
+ centroid_vals: dict[str, dict] = {}
529
+ for m in _metrics:
530
+ vals_m = [fp.get(m, {}).get("mean", 0.0) for fp in fps]
531
+ stds_m = [fp.get(m, {}).get("std", 0.0) for fp in fps]
532
+ centroid_vals[m] = {
533
+ "mean": sum(vals_m) / len(vals_m),
534
+ "std": sum(stds_m) / len(stds_m),
535
+ "n": 1,
536
+ }
537
+ centroid = cls(stats=centroid_vals)
538
+
539
+ # Start from the item closest to the centroid
540
+ start = min(range(len(fps)), key=lambda i: centroid.distance(fps[i], metrics=_metrics, stats=stats))
541
+
542
+ visited = [False] * len(items)
543
+ order = [start]
544
+ visited[start] = True
545
+
546
+ while len(order) < len(items):
547
+ current_fp = fps[order[-1]]
548
+ best_idx, best_dist = -1, float("inf")
549
+ for j, fp_j in enumerate(fps):
550
+ if visited[j]:
551
+ continue
552
+ d = current_fp.distance(fp_j, metrics=_metrics, stats=stats)
553
+ if d < best_dist:
554
+ best_dist = d
555
+ best_idx = j
556
+ order.append(best_idx)
557
+ visited[best_idx] = True
558
+
559
+ return [items[i] for i in order]
560
+
561
+ # ------------------------------------------------------------------
562
+ # Serialization
563
+ # ------------------------------------------------------------------
564
+
565
+ def n(self, component: str) -> int:
566
+ """Number of observations for ``component`` in this fingerprint."""
567
+ return self.get(component, {}).get("n", 0)
568
+
569
+ def components(self) -> list[str]:
570
+ """Sorted list of all component names (including n=0)."""
571
+ return sorted(self.keys())
572
+
573
+ def to_dict(self) -> dict:
574
+ """Plain dict (stats only; raw values are ephemeral)."""
575
+ return dict(self)
576
+
577
+ @classmethod
578
+ def from_dict(cls, d: dict) -> "Fingerprint":
579
+ """Deserialize from a plain dict (no raw values; median falls back to mean)."""
580
+ return cls(stats=d)
581
+
582
+ def __repr__(self) -> str:
583
+ return f"Fingerprint({len(self)} components, raw_values={bool(self._values)})"