margin 0.9.16__tar.gz → 0.9.18__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 (88) hide show
  1. {margin-0.9.16/margin.egg-info → margin-0.9.18}/PKG-INFO +1 -1
  2. {margin-0.9.16 → margin-0.9.18}/margin/fingerprint.py +34 -12
  3. {margin-0.9.16 → margin-0.9.18}/margin/streaming.py +4 -1
  4. {margin-0.9.16 → margin-0.9.18/margin.egg-info}/PKG-INFO +1 -1
  5. {margin-0.9.16 → margin-0.9.18}/pyproject.toml +1 -1
  6. {margin-0.9.16 → margin-0.9.18}/tests/test_controller.py +88 -2
  7. {margin-0.9.16 → margin-0.9.18}/LICENSE +0 -0
  8. {margin-0.9.16 → margin-0.9.18}/NOTICE +0 -0
  9. {margin-0.9.16 → margin-0.9.18}/README.md +0 -0
  10. {margin-0.9.16 → margin-0.9.18}/margin/__init__.py +0 -0
  11. {margin-0.9.16 → margin-0.9.18}/margin/__main__.py +0 -0
  12. {margin-0.9.16 → margin-0.9.18}/margin/algebra.py +0 -0
  13. {margin-0.9.16 → margin-0.9.18}/margin/anomaly.py +0 -0
  14. {margin-0.9.16 → margin-0.9.18}/margin/bridge.py +0 -0
  15. {margin-0.9.16 → margin-0.9.18}/margin/calibrate.py +0 -0
  16. {margin-0.9.16 → margin-0.9.18}/margin/causal.py +0 -0
  17. {margin-0.9.16 → margin-0.9.18}/margin/composite.py +0 -0
  18. {margin-0.9.16 → margin-0.9.18}/margin/confidence.py +0 -0
  19. {margin-0.9.16 → margin-0.9.18}/margin/config.py +0 -0
  20. {margin-0.9.16 → margin-0.9.18}/margin/contract.py +0 -0
  21. {margin-0.9.16 → margin-0.9.18}/margin/controller.py +0 -0
  22. {margin-0.9.16 → margin-0.9.18}/margin/correlate.py +0 -0
  23. {margin-0.9.16 → margin-0.9.18}/margin/diff.py +0 -0
  24. {margin-0.9.16 → margin-0.9.18}/margin/drift.py +0 -0
  25. {margin-0.9.16 → margin-0.9.18}/margin/events.py +0 -0
  26. {margin-0.9.16 → margin-0.9.18}/margin/forecast.py +0 -0
  27. {margin-0.9.16 → margin-0.9.18}/margin/health.py +0 -0
  28. {margin-0.9.16 → margin-0.9.18}/margin/intent.py +0 -0
  29. {margin-0.9.16 → margin-0.9.18}/margin/ledger.py +0 -0
  30. {margin-0.9.16 → margin-0.9.18}/margin/loop.py +0 -0
  31. {margin-0.9.16 → margin-0.9.18}/margin/observation.py +0 -0
  32. {margin-0.9.16 → margin-0.9.18}/margin/persist.py +0 -0
  33. {margin-0.9.16 → margin-0.9.18}/margin/policy/__init__.py +0 -0
  34. {margin-0.9.16 → margin-0.9.18}/margin/policy/compose.py +0 -0
  35. {margin-0.9.16 → margin-0.9.18}/margin/policy/core.py +0 -0
  36. {margin-0.9.16 → margin-0.9.18}/margin/policy/temporal.py +0 -0
  37. {margin-0.9.16 → margin-0.9.18}/margin/policy/trace.py +0 -0
  38. {margin-0.9.16 → margin-0.9.18}/margin/policy/tuning.py +0 -0
  39. {margin-0.9.16 → margin-0.9.18}/margin/policy/validate.py +0 -0
  40. {margin-0.9.16 → margin-0.9.18}/margin/predicates.py +0 -0
  41. {margin-0.9.16 → margin-0.9.18}/margin/provenance.py +0 -0
  42. {margin-0.9.16 → margin-0.9.18}/margin/transitions.py +0 -0
  43. {margin-0.9.16 → margin-0.9.18}/margin/uncertain.py +0 -0
  44. {margin-0.9.16 → margin-0.9.18}/margin/validity.py +0 -0
  45. {margin-0.9.16 → margin-0.9.18}/margin.egg-info/SOURCES.txt +0 -0
  46. {margin-0.9.16 → margin-0.9.18}/margin.egg-info/dependency_links.txt +0 -0
  47. {margin-0.9.16 → margin-0.9.18}/margin.egg-info/top_level.txt +0 -0
  48. {margin-0.9.16 → margin-0.9.18}/setup.cfg +0 -0
  49. {margin-0.9.16 → margin-0.9.18}/tests/test_algebra.py +0 -0
  50. {margin-0.9.16 → margin-0.9.18}/tests/test_anomaly.py +0 -0
  51. {margin-0.9.16 → margin-0.9.18}/tests/test_bridge.py +0 -0
  52. {margin-0.9.16 → margin-0.9.18}/tests/test_calibrate.py +0 -0
  53. {margin-0.9.16 → margin-0.9.18}/tests/test_causal.py +0 -0
  54. {margin-0.9.16 → margin-0.9.18}/tests/test_composite.py +0 -0
  55. {margin-0.9.16 → margin-0.9.18}/tests/test_confidence.py +0 -0
  56. {margin-0.9.16 → margin-0.9.18}/tests/test_config.py +0 -0
  57. {margin-0.9.16 → margin-0.9.18}/tests/test_contract.py +0 -0
  58. {margin-0.9.16 → margin-0.9.18}/tests/test_correlate.py +0 -0
  59. {margin-0.9.16 → margin-0.9.18}/tests/test_diff.py +0 -0
  60. {margin-0.9.16 → margin-0.9.18}/tests/test_drift.py +0 -0
  61. {margin-0.9.16 → margin-0.9.18}/tests/test_events.py +0 -0
  62. {margin-0.9.16 → margin-0.9.18}/tests/test_forecast.py +0 -0
  63. {margin-0.9.16 → margin-0.9.18}/tests/test_health.py +0 -0
  64. {margin-0.9.16 → margin-0.9.18}/tests/test_healthcare.py +0 -0
  65. {margin-0.9.16 → margin-0.9.18}/tests/test_intent.py +0 -0
  66. {margin-0.9.16 → margin-0.9.18}/tests/test_ledger.py +0 -0
  67. {margin-0.9.16 → margin-0.9.18}/tests/test_loop.py +0 -0
  68. {margin-0.9.16 → margin-0.9.18}/tests/test_neuro.py +0 -0
  69. {margin-0.9.16 → margin-0.9.18}/tests/test_observation.py +0 -0
  70. {margin-0.9.16 → margin-0.9.18}/tests/test_persist.py +0 -0
  71. {margin-0.9.16 → margin-0.9.18}/tests/test_policy.py +0 -0
  72. {margin-0.9.16 → margin-0.9.18}/tests/test_policy_compose.py +0 -0
  73. {margin-0.9.16 → margin-0.9.18}/tests/test_policy_temporal.py +0 -0
  74. {margin-0.9.16 → margin-0.9.18}/tests/test_policy_trace.py +0 -0
  75. {margin-0.9.16 → margin-0.9.18}/tests/test_policy_tuning.py +0 -0
  76. {margin-0.9.16 → margin-0.9.18}/tests/test_policy_validate.py +0 -0
  77. {margin-0.9.16 → margin-0.9.18}/tests/test_predicates.py +0 -0
  78. {margin-0.9.16 → margin-0.9.18}/tests/test_printer3d.py +0 -0
  79. {margin-0.9.16 → margin-0.9.18}/tests/test_provenance.py +0 -0
  80. {margin-0.9.16 → margin-0.9.18}/tests/test_reverse_bridge.py +0 -0
  81. {margin-0.9.16 → margin-0.9.18}/tests/test_ros2.py +0 -0
  82. {margin-0.9.16 → margin-0.9.18}/tests/test_staleness.py +0 -0
  83. {margin-0.9.16 → margin-0.9.18}/tests/test_streaming.py +0 -0
  84. {margin-0.9.16 → margin-0.9.18}/tests/test_transformer.py +0 -0
  85. {margin-0.9.16 → margin-0.9.18}/tests/test_transitions.py +0 -0
  86. {margin-0.9.16 → margin-0.9.18}/tests/test_uncertain.py +0 -0
  87. {margin-0.9.16 → margin-0.9.18}/tests/test_validity.py +0 -0
  88. {margin-0.9.16 → margin-0.9.18}/tests/test_windowing.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: margin
3
- Version: 0.9.16
3
+ Version: 0.9.18
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
@@ -87,11 +87,16 @@ class Fingerprint(dict):
87
87
  ``method="trimmed"`` — 10% trimmed mean
88
88
  ``method="mean"`` — plain mean (same as ``fp[component]["mean"]``)
89
89
 
90
- Falls back to ``fp[component]["mean"]`` when no raw values are stored.
90
+ When no raw values are stored, checks for pre-computed ``"median"`` in
91
+ the stats dict (present when fingerprint came from ``Monitor.fingerprint()``),
92
+ then falls back to ``"mean"``.
91
93
  """
92
94
  vals = self._values.get(component)
93
95
  if not vals:
94
- return self.get(component, {}).get("mean", 0.0)
96
+ stats = self.get(component, {})
97
+ if method == "median":
98
+ return stats.get("median", stats.get("mean", 0.0))
99
+ return stats.get("mean", 0.0)
95
100
  if method == "median":
96
101
  return _percentile(vals, 50)
97
102
  if method == "trimmed":
@@ -103,32 +108,49 @@ class Fingerprint(dict):
103
108
  """
104
109
  Return the p-th percentile (0–100) of observed values for ``component``.
105
110
 
106
- Falls back to the stored mean when no raw values are available.
111
+ When no raw values are stored, checks for pre-computed ``"q25"`` / ``"q75"`` /
112
+ ``"median"`` in the stats dict (present when fingerprint came from
113
+ ``Monitor.fingerprint()``), then falls back to ``"mean"``.
107
114
  """
108
115
  vals = self._values.get(component)
109
116
  if not vals:
110
- return self.get(component, {}).get("mean", 0.0)
117
+ stats = self.get(component, {})
118
+ if p == 25 and "q25" in stats:
119
+ return stats["q25"]
120
+ if p == 75 and "q75" in stats:
121
+ return stats["q75"]
122
+ if p == 50 and "median" in stats:
123
+ return stats["median"]
124
+ return stats.get("mean", 0.0)
111
125
  return _percentile(vals, p)
112
126
 
113
127
  def sigma(self, component: str, value: float) -> float:
114
128
  """
115
- Normalize ``value`` against the fingerprint's empirical mean.
129
+ Z-score of ``value`` against the fingerprint's empirical distribution.
116
130
 
117
- Returns ``(value − mean) / |mean|``.
131
+ Returns ``(value − mean) / std`` (standard z-score).
118
132
 
119
- Positive = above session baseline; negative = below. Used to
120
- convert a raw domain measurement into a cross-session comparable
121
- signal before passing it to ``Controller.step()``.
133
+ Using ``std`` as denominator is correct for cross-session normalization.
134
+ ``|mean|`` would only be appropriate when the coefficient of variation
135
+ (std/|mean|) is small; for high-CV components (e.g. ``std=0.498``,
136
+ ``mean=0.070``, CV=7.1) it amplifies by 7×, turning a normal ±0.07
137
+ swing into ±1.0 and saturating the controller.
122
138
 
123
- Returns ``value`` unchanged when the stored mean is zero
124
- (no finite normalization is possible).
139
+ Falls back to ``(value − mean) / |mean|`` when ``std`` is zero (constant
140
+ window or not yet stored), and returns ``value`` unchanged when both
141
+ ``mean`` and ``std`` are zero.
125
142
 
126
143
  Example::
127
144
 
128
145
  metric = fp.sigma("recovery_ratio", cq.recovery_ratio)
129
146
  alpha, reason = ctrl.step(alpha, metric)
130
147
  """
131
- mean = self.get(component, {}).get("mean", 0.0)
148
+ stats = self.get(component, {})
149
+ mean = stats.get("mean", 0.0)
150
+ std = stats.get("std", 0.0)
151
+ if std != 0.0:
152
+ return (value - mean) / std
153
+ # std=0: constant distribution — fall back to relative deviation
132
154
  if mean == 0.0:
133
155
  return value
134
156
  return (value - mean) / abs(mean)
@@ -64,7 +64,7 @@ from .confidence import Confidence
64
64
  from .health import Health, Thresholds, classify
65
65
  from .observation import Observation, Expression
66
66
  from .provenance import ProvenanceGraph
67
- from .fingerprint import Fingerprint
67
+ from .fingerprint import Fingerprint, _percentile as _fp_percentile
68
68
  from .drift import (
69
69
  DriftState, DriftDirection, DriftClassification,
70
70
  classify_drift,
@@ -606,6 +606,9 @@ class Monitor:
606
606
  "std": std,
607
607
  "n": n,
608
608
  "trend": dc.state.value if dc is not None else "UNKNOWN",
609
+ "median": _fp_percentile(values, 50),
610
+ "q25": _fp_percentile(values, 25),
611
+ "q75": _fp_percentile(values, 75),
609
612
  }
610
613
  raw_values[name] = values
611
614
  return Fingerprint(stats=stats, values=raw_values)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: margin
3
- Version: 0.9.16
3
+ Version: 0.9.18
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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "margin"
7
- version = "0.9.16"
7
+ version = "0.9.18"
8
8
  description = "Typed health classification, uncertainty algebra, and correction auditing for any system that measures things and needs to explain what happened."
9
9
  readme = "README.md"
10
10
  license = {text = "MIT"}
@@ -552,14 +552,100 @@ class TestFingerprintSigma:
552
552
 
553
553
  def test_negative_mean(self):
554
554
  fp = Fingerprint(stats={"rr": {"mean": -2.0, "std": 0.5, "n": 10, "trend": "STABLE"}})
555
- # (value - mean) / |mean| = (-1.0 - (-2.0)) / 2.0 = 0.5
556
- assert fp.sigma("rr", -1.0) == pytest.approx(0.5)
555
+ # z-score: (value - mean) / std = (-1.0 - (-2.0)) / 0.5 = 2.0
556
+ assert fp.sigma("rr", -1.0) == pytest.approx(2.0)
557
557
 
558
558
  def test_missing_component_returns_value(self):
559
559
  fp = _make_fp([1.0] * 10)
560
560
  # missing → mean=0.0 → returns value unchanged
561
561
  assert fp.sigma("missing", 0.7) == pytest.approx(0.7)
562
562
 
563
+ def test_uses_std_not_mean_as_denominator(self):
564
+ # Regression: Build #29 failure — high-CV component (std=0.498, mean=0.070)
565
+ # Old formula: (value - mean) / |mean| = 0.07 / 0.07 = 1.0 (7× amplification)
566
+ # New formula: (value - mean) / std = 0.07 / 0.498 ≈ 0.141 (sensible z-score)
567
+ fp = Fingerprint(stats={"rr": {"mean": 0.070, "std": 0.498, "n": 98}})
568
+ result = fp.sigma("rr", 0.14)
569
+ assert result == pytest.approx((0.14 - 0.070) / 0.498, rel=1e-4) # z-score
570
+ assert abs(result) < 0.2 # not ±1.0 (the bang-bang range)
571
+
572
+ def test_std_zero_falls_back_to_mean_relative(self):
573
+ # Constant distribution: std=0 → fall back to (value-mean)/|mean|
574
+ fp = Fingerprint(stats={"rr": {"mean": 1.0, "std": 0.0, "n": 5}})
575
+ assert fp.sigma("rr", 1.5) == pytest.approx(0.5) # (1.5-1.0)/1.0
576
+
577
+
578
+ class TestFingerprintPrecomputedPercentiles:
579
+ """Fingerprints from Monitor.fingerprint() now include median/q25/q75.
580
+ percentile() and robust_target() must use them when no raw values stored."""
581
+
582
+ def test_monitor_fingerprint_includes_median(self):
583
+ monitor = _make_monitor(20)
584
+ fp = monitor.fingerprint()
585
+ assert "median" in fp["alpha"]
586
+ assert "q25" in fp["alpha"]
587
+ assert "q75" in fp["alpha"]
588
+
589
+ def test_robust_sigma_live_after_serialization(self):
590
+ # Simulate the Build #29 scenario:
591
+ # Session 1 fingerprint saved as JSON, loaded in Session 2 — no raw values.
592
+ monitor = _make_monitor(30)
593
+ fp_live = monitor.fingerprint()
594
+ # Round-trip through JSON (loses raw values, keeps precomputed percentiles)
595
+ import json
596
+ fp_reloaded = Fingerprint.from_dict(json.loads(json.dumps(fp_live)))
597
+ # robust_sigma should differ from sigma (IQR != 0, median != mean generally)
598
+ # At minimum, it must not crash and must not return 0.0 for a nonzero input
599
+ result = fp_reloaded.robust_sigma("alpha", 0.55)
600
+ assert isinstance(result, float)
601
+
602
+ def test_robust_sigma_uses_precomputed_iqr(self):
603
+ # Construct fingerprint with known median/q25/q75
604
+ fp = Fingerprint(stats={"rr": {
605
+ "mean": 0.070, "std": 0.498, "n": 98, "trend": "DRIFTING",
606
+ "median": 0.050, "q25": 0.020, "q75": 0.120,
607
+ }})
608
+ # IQR = 0.120 - 0.020 = 0.100
609
+ # robust_sigma(0.14) = (0.14 - 0.050) / 0.100 = 0.9
610
+ assert fp.robust_sigma("rr", 0.14) == pytest.approx(0.9)
611
+
612
+ def test_robust_sigma_differs_from_mean_based(self):
613
+ # When median != mean (skewed distribution), the two should differ
614
+ fp = Fingerprint(stats={"rr": {
615
+ "mean": 0.070, "std": 0.498, "n": 98, "trend": "DRIFTING",
616
+ "median": 0.050, "q25": 0.020, "q75": 0.120,
617
+ }})
618
+ assert fp.robust_sigma("rr", 0.14) != pytest.approx(fp.sigma("rr", 0.14))
619
+
620
+ def test_regression_build29_scenario(self):
621
+ # Exact values from the user's Session 1 meta.json
622
+ fp = Fingerprint(stats={"recovery_ratio": {
623
+ "mean": 0.070, "std": 0.498, "n": 98, "trend": "DRIFTING",
624
+ "median": 0.050, "q25": 0.020, "q75": 0.120,
625
+ }})
626
+ r = fp.robust_sigma("recovery_ratio", 0.14)
627
+ # Previously returned 1.0 (mean-based fallback); now uses IQR
628
+ assert r == pytest.approx(0.9)
629
+ mean_based = fp.sigma("recovery_ratio", 0.14)
630
+ assert r != pytest.approx(mean_based)
631
+
632
+ def test_percentile_uses_precomputed_q25(self):
633
+ fp = Fingerprint(stats={"rr": {
634
+ "mean": 1.0, "std": 0.1, "n": 10, "trend": "STABLE",
635
+ "median": 1.0, "q25": 0.8, "q75": 1.2,
636
+ }})
637
+ assert fp.percentile("rr", 25) == pytest.approx(0.8)
638
+ assert fp.percentile("rr", 75) == pytest.approx(1.2)
639
+ assert fp.percentile("rr", 50) == pytest.approx(1.0)
640
+
641
+ def test_percentile_fallback_to_mean_for_other_p(self):
642
+ # Only 25/50/75 are pre-computed; other p falls back to mean
643
+ fp = Fingerprint(stats={"rr": {
644
+ "mean": 1.0, "std": 0.1, "n": 10, "trend": "STABLE",
645
+ "median": 1.0, "q25": 0.8, "q75": 1.2,
646
+ }})
647
+ assert fp.percentile("rr", 10) == pytest.approx(1.0) # falls back to mean
648
+
563
649
 
564
650
  class TestFingerprintRobustSigma:
565
651
  def test_median_centered(self):
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes