margin 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.
margin/__init__.py ADDED
@@ -0,0 +1,114 @@
1
+ """
2
+ Margin: typed uncertainty algebra and health classification.
3
+
4
+ A framework for measurements that carry uncertainty, temporal validity,
5
+ provenance, and typed health states — with an auditable correction ledger.
6
+
7
+ Structure:
8
+ Foundation: confidence, validity, provenance, uncertain, algebra,
9
+ health, observation, ledger
10
+ Observability: bridge, calibrate, composite, diff, events, forecast,
11
+ predicates, transitions
12
+ Policy: policy/ (core, temporal, compose, tuning, trace, validate)
13
+ Contract: contract
14
+ Causal: causal
15
+ """
16
+
17
+ # Foundation
18
+ from .confidence import Confidence
19
+ from .validity import Validity, ValidityMode
20
+ from .provenance import new_id, are_correlated, merge
21
+ from .uncertain import UncertainValue, Source
22
+ from .algebra import add, subtract, multiply, divide, scale, compare, weighted_average
23
+ from .health import Health, Thresholds, classify, SEVERITY
24
+ from .observation import Op, Observation, Correction, Expression, Parser
25
+ from .ledger import Record, Ledger
26
+
27
+ # Observability
28
+ from .bridge import observe, observe_many, delta, to_uncertain
29
+ from .calibrate import CalibrationResult, calibrate, calibrate_many, parser_from_calibration
30
+ from .composite import CompositeObservation, AggregateStrategy
31
+ from .diff import ComponentChange, Diff, diff
32
+ from .events import EventBus
33
+ from .forecast import Forecast, forecast
34
+ from .predicates import (
35
+ any_health, all_health, count_health, component_health,
36
+ any_degraded, confidence_below, sigma_below, any_correction,
37
+ all_of, any_of, not_, Rule, evaluate_rules,
38
+ )
39
+ from .transitions import Span, Transition, ComponentHistory, track, track_all
40
+
41
+ # Policy
42
+ from .policy import (
43
+ EscalationLevel, Escalation, Action, Constraint, PolicyRule, Policy,
44
+ health_sustained, health_for_at_least,
45
+ sigma_trending_below, fire_rate_above, no_improvement,
46
+ PolicyChain, CorrectionBundle, bundle_from_policy,
47
+ PolicyComparison, diff_policies, agreement_rate,
48
+ RuleStats, TuningResult,
49
+ analyze_backtest, suggest_tuning, apply_tuning,
50
+ RuleEvaluation, DecisionTrace,
51
+ trace_evaluate, trace_backtest,
52
+ ValidationIssue, ValidationResult, validate,
53
+ )
54
+
55
+ # Contract
56
+ from .contract import (
57
+ TermStatus, TermResult, ContractTerm,
58
+ HealthTarget, ReachHealth, SustainHealth,
59
+ RecoveryThreshold, NoHarmful,
60
+ ContractResult, Contract,
61
+ )
62
+
63
+ # Causal
64
+ from .causal import (
65
+ CauseType, CausalLink, CausalGraph,
66
+ CauseExplanation, Explanation,
67
+ )
68
+
69
+ # Loop
70
+ from .loop import StepResult, step, run
71
+
72
+ __all__ = [
73
+ # Foundation
74
+ "Confidence",
75
+ "Validity", "ValidityMode",
76
+ "new_id", "are_correlated", "merge",
77
+ "UncertainValue", "Source",
78
+ "add", "subtract", "multiply", "divide", "scale", "compare", "weighted_average",
79
+ "Health", "Thresholds", "classify", "SEVERITY",
80
+ "Op", "Observation", "Correction", "Expression", "Parser",
81
+ "Record", "Ledger",
82
+ # Observability
83
+ "observe", "observe_many", "delta", "to_uncertain",
84
+ "CalibrationResult", "calibrate", "calibrate_many", "parser_from_calibration",
85
+ "CompositeObservation", "AggregateStrategy",
86
+ "ComponentChange", "Diff", "diff",
87
+ "EventBus",
88
+ "Forecast", "forecast",
89
+ "any_health", "all_health", "count_health", "component_health",
90
+ "any_degraded", "confidence_below", "sigma_below", "any_correction",
91
+ "all_of", "any_of", "not_", "Rule", "evaluate_rules",
92
+ "Span", "Transition", "ComponentHistory", "track", "track_all",
93
+ # Policy
94
+ "EscalationLevel", "Escalation", "Action", "Constraint", "PolicyRule", "Policy",
95
+ "health_sustained", "health_for_at_least",
96
+ "sigma_trending_below", "fire_rate_above", "no_improvement",
97
+ "PolicyChain", "CorrectionBundle", "bundle_from_policy",
98
+ "PolicyComparison", "diff_policies", "agreement_rate",
99
+ "RuleStats", "TuningResult",
100
+ "analyze_backtest", "suggest_tuning", "apply_tuning",
101
+ "RuleEvaluation", "DecisionTrace",
102
+ "trace_evaluate", "trace_backtest",
103
+ "ValidationIssue", "ValidationResult", "validate",
104
+ # Contract
105
+ "TermStatus", "TermResult", "ContractTerm",
106
+ "HealthTarget", "ReachHealth", "SustainHealth",
107
+ "RecoveryThreshold", "NoHarmful",
108
+ "ContractResult", "Contract",
109
+ # Causal
110
+ "CauseType", "CausalLink", "CausalGraph",
111
+ "CauseExplanation", "Explanation",
112
+ # Loop
113
+ "StepResult", "step", "run",
114
+ ]
margin/algebra.py ADDED
@@ -0,0 +1,177 @@
1
+ """
2
+ Uncertainty propagation through arithmetic operations.
3
+
4
+ Correlated values (shared provenance) combine linearly (conservative).
5
+ Independent values combine in quadrature.
6
+ """
7
+
8
+ import math
9
+ from datetime import datetime
10
+ from typing import Optional
11
+
12
+ from .uncertain import UncertainValue, Source
13
+ from .validity import Validity
14
+ from .confidence import Confidence
15
+ from .provenance import new_id, are_correlated
16
+
17
+
18
+ def _propagated_validity(inputs: list[UncertainValue]) -> Validity:
19
+ """Conservative validity: latest measurement, shortest halflife."""
20
+ if not inputs:
21
+ return Validity.static()
22
+
23
+ latest = max(inputs, key=lambda v: v.validity.measured_at)
24
+ halflives = [v.validity.halflife for v in inputs if v.validity.halflife]
25
+ shortest = min(halflives) if halflives else None
26
+
27
+ if shortest:
28
+ return Validity.decaying(shortest, latest.validity.measured_at)
29
+ return Validity.static(latest.validity.measured_at)
30
+
31
+
32
+ def add(a: UncertainValue, b: UncertainValue) -> UncertainValue:
33
+ """Add two uncertain values with correct uncertainty propagation."""
34
+ aa, bb = a.to_absolute(), b.to_absolute()
35
+ if are_correlated(a.provenance, b.provenance):
36
+ unc = aa.uncertainty + bb.uncertainty
37
+ else:
38
+ unc = math.sqrt(aa.uncertainty**2 + bb.uncertainty**2)
39
+ return UncertainValue(
40
+ point=aa.point + bb.point,
41
+ uncertainty=unc,
42
+ source=Source.PROPAGATED,
43
+ validity=_propagated_validity([a, b]),
44
+ provenance=list(set(a.provenance + b.provenance + [new_id()])),
45
+ )
46
+
47
+
48
+ def subtract(a: UncertainValue, b: UncertainValue) -> UncertainValue:
49
+ """Subtract two uncertain values."""
50
+ aa, bb = a.to_absolute(), b.to_absolute()
51
+ if are_correlated(a.provenance, b.provenance):
52
+ unc = aa.uncertainty + bb.uncertainty
53
+ else:
54
+ unc = math.sqrt(aa.uncertainty**2 + bb.uncertainty**2)
55
+ return UncertainValue(
56
+ point=aa.point - bb.point,
57
+ uncertainty=unc,
58
+ source=Source.PROPAGATED,
59
+ validity=_propagated_validity([a, b]),
60
+ provenance=list(set(a.provenance + b.provenance + [new_id()])),
61
+ )
62
+
63
+
64
+ def multiply(a: UncertainValue, b: UncertainValue) -> UncertainValue:
65
+ """Multiply two uncertain values (relative uncertainties combine).
66
+
67
+ When either operand is zero, relative uncertainty is undefined so we
68
+ fall back to absolute propagation: |b|*σ_a + |a|*σ_b (linear, safe).
69
+ """
70
+ product = a.point * b.point
71
+ prov = list(set(a.provenance + b.provenance + [new_id()]))
72
+
73
+ if a.point == 0 or b.point == 0:
74
+ aa, bb = a.to_absolute(), b.to_absolute()
75
+ unc = abs(b.point) * aa.uncertainty + abs(a.point) * bb.uncertainty
76
+ return UncertainValue(
77
+ point=product, uncertainty=unc,
78
+ source=Source.PROPAGATED,
79
+ validity=_propagated_validity([a, b]),
80
+ provenance=prov,
81
+ )
82
+
83
+ ar, br = a.to_relative(), b.to_relative()
84
+ if are_correlated(a.provenance, b.provenance):
85
+ unc = ar.uncertainty + br.uncertainty
86
+ else:
87
+ unc = math.sqrt(ar.uncertainty**2 + br.uncertainty**2)
88
+ return UncertainValue(
89
+ point=product, uncertainty=unc, relative=True,
90
+ source=Source.PROPAGATED,
91
+ validity=_propagated_validity([a, b]),
92
+ provenance=prov,
93
+ )
94
+
95
+
96
+ def divide(a: UncertainValue, b: UncertainValue) -> UncertainValue:
97
+ """Divide two uncertain values."""
98
+ if b.point == 0:
99
+ raise ValueError("Division by zero")
100
+ ar, br = a.to_relative(), b.to_relative()
101
+ if are_correlated(a.provenance, b.provenance):
102
+ unc = ar.uncertainty + br.uncertainty
103
+ else:
104
+ unc = math.sqrt(ar.uncertainty**2 + br.uncertainty**2)
105
+ return UncertainValue(
106
+ point=ar.point / br.point,
107
+ uncertainty=unc,
108
+ relative=True,
109
+ source=Source.PROPAGATED,
110
+ validity=_propagated_validity([a, b]),
111
+ provenance=list(set(a.provenance + b.provenance + [new_id()])),
112
+ )
113
+
114
+
115
+ def scale(value: UncertainValue, factor: float) -> UncertainValue:
116
+ """Scale by an exact constant. Preserves provenance without growth."""
117
+ return UncertainValue(
118
+ point=value.point * factor,
119
+ uncertainty=value.uncertainty * abs(factor),
120
+ relative=value.relative,
121
+ source=value.source,
122
+ validity=value.validity,
123
+ provenance=list(value.provenance),
124
+ )
125
+
126
+
127
+ def compare(value: UncertainValue, threshold: float, at_time: Optional[datetime] = None) -> Confidence:
128
+ """
129
+ Compare an uncertain value to a threshold. Returns a Confidence tier
130
+ based on how much the uncertainty interval overlaps the threshold.
131
+ """
132
+ at_time = at_time or datetime.now()
133
+ u = value.absolute_uncertainty(at_time)
134
+ lower = value.point - u
135
+ upper = value.point + u
136
+ width = 2 * u
137
+
138
+ if lower < threshold < upper:
139
+ return Confidence.INDETERMINATE
140
+
141
+ gap = (lower - threshold) if threshold <= lower else (threshold - upper)
142
+ if width <= 0:
143
+ return Confidence.CERTAIN
144
+
145
+ ratio = gap / width
146
+ if ratio >= 0.5:
147
+ return Confidence.CERTAIN
148
+ elif ratio >= 0.1:
149
+ return Confidence.HIGH
150
+ elif ratio >= 0.05:
151
+ return Confidence.MODERATE
152
+ else:
153
+ return Confidence.LOW
154
+
155
+
156
+ def weighted_average(values: list[UncertainValue], weights: Optional[list[float]] = None) -> UncertainValue:
157
+ """
158
+ Weighted average. Defaults to inverse-variance weighting.
159
+ """
160
+ if not values:
161
+ raise ValueError("Empty list")
162
+ if len(values) == 1:
163
+ return values[0]
164
+
165
+ if weights is None:
166
+ variances = [v.to_absolute().uncertainty**2 for v in values]
167
+ total = sum(1/v for v in variances if v > 0)
168
+ if total == 0:
169
+ weights = [1.0 / len(values)] * len(values)
170
+ else:
171
+ weights = [(1/v) / total for v in variances]
172
+
173
+ result = None
174
+ for v, w in zip(values, weights):
175
+ s = scale(v, w)
176
+ result = add(result, s) if result else s
177
+ return result
margin/bridge.py ADDED
@@ -0,0 +1,205 @@
1
+ """
2
+ Bridge between the algebra layer (UncertainValue) and the health layer
3
+ (Observation, Expression).
4
+
5
+ This connects the two halves of the library: uncertain measurements flow
6
+ into typed health classifications, and the uncertainty informs the
7
+ confidence tier automatically.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from datetime import datetime
13
+ from typing import Optional
14
+
15
+ from .uncertain import UncertainValue, Source
16
+ from .validity import Validity
17
+ from .confidence import Confidence
18
+ from .health import Health, Thresholds, classify
19
+ from .observation import Observation, Correction, Expression, Op, Parser
20
+ from .algebra import compare, subtract
21
+
22
+
23
+ def observe(
24
+ name: str,
25
+ value: UncertainValue,
26
+ baseline: UncertainValue,
27
+ thresholds: Thresholds,
28
+ correction_magnitude: float = 0.0,
29
+ at_time: Optional[datetime] = None,
30
+ ) -> Observation:
31
+ """
32
+ Create a typed Observation from an UncertainValue.
33
+
34
+ The confidence tier is derived automatically by comparing the value
35
+ against the thresholds using the algebra's `compare()` — the
36
+ uncertainty interval determines whether the call is CERTAIN, HIGH,
37
+ MODERATE, LOW, or INDETERMINATE.
38
+
39
+ Args:
40
+ name: component identifier
41
+ value: the uncertain measurement
42
+ baseline: expected healthy value (as UncertainValue)
43
+ thresholds: classification boundaries
44
+ correction_magnitude: size of active correction (for RECOVERING)
45
+ at_time: evaluation time (defaults to now)
46
+ """
47
+ at_time = at_time or datetime.now()
48
+
49
+ # Derive confidence from how the uncertainty interval relates to the
50
+ # intact threshold — the critical decision boundary
51
+ confidence = compare(value, thresholds.intact, at_time)
52
+
53
+ correcting = correction_magnitude >= thresholds.active_min
54
+ health = classify(value.point, confidence, correcting, thresholds)
55
+
56
+ return Observation(
57
+ name=name,
58
+ health=health,
59
+ value=value.point,
60
+ baseline=baseline.point,
61
+ confidence=confidence,
62
+ higher_is_better=thresholds.higher_is_better,
63
+ provenance=list(value.provenance),
64
+ measured_at=at_time,
65
+ )
66
+
67
+
68
+ def observe_many(
69
+ values: dict[str, UncertainValue],
70
+ baselines: dict[str, UncertainValue],
71
+ thresholds: Thresholds,
72
+ component_thresholds: Optional[dict[str, Thresholds]] = None,
73
+ correction_magnitude: float = 0.0,
74
+ alpha: float = 0.0,
75
+ label: str = "",
76
+ step: Optional[int] = None,
77
+ at_time: Optional[datetime] = None,
78
+ ) -> Expression:
79
+ """
80
+ Create a typed Expression from a dict of UncertainValues.
81
+
82
+ Like Parser.parse() but takes UncertainValues instead of raw floats,
83
+ so confidence is derived from the uncertainty rather than defaulting
84
+ to MODERATE.
85
+
86
+ Args:
87
+ values: {name: UncertainValue} measurements
88
+ baselines: {name: UncertainValue} expected healthy values
89
+ thresholds: default classification boundaries
90
+ component_thresholds: per-component overrides
91
+ correction_magnitude: size of active correction
92
+ alpha: correction intensity coefficient
93
+ label: optional label for the expression
94
+ step: optional sequence index
95
+ at_time: evaluation time (defaults to now)
96
+ """
97
+ at_time = at_time or datetime.now()
98
+ component_thresholds = component_thresholds or {}
99
+
100
+ observations = []
101
+ for name, uv in values.items():
102
+ bl = baselines.get(name, uv)
103
+ ct = component_thresholds.get(name, thresholds)
104
+ obs = observe(name, uv, bl, ct, correction_magnitude, at_time)
105
+ observations.append(obs)
106
+
107
+ # Build a temporary Parser to reuse _classify_op logic
108
+ corrections = []
109
+ if observations:
110
+ raw_values = {name: uv.point for name, uv in values.items()}
111
+ raw_baselines = {name: baselines.get(name, uv).point for name, uv in values.items()}
112
+ temp_parser = Parser(
113
+ baselines=raw_baselines,
114
+ thresholds=thresholds,
115
+ component_thresholds=component_thresholds,
116
+ )
117
+ target, op = temp_parser._classify_op(raw_values, correction_magnitude)
118
+
119
+ if op != Op.NOOP and target:
120
+ degraded_names = [o.name for o in observations
121
+ if o.health in (Health.DEGRADED, Health.ABLATED, Health.RECOVERING)]
122
+ prov = []
123
+ for o in observations:
124
+ if o.name == target:
125
+ prov = list(o.provenance)
126
+ break
127
+ corrections.append(Correction(
128
+ target=target, op=op,
129
+ alpha=alpha,
130
+ magnitude=correction_magnitude,
131
+ triggered_by=degraded_names,
132
+ provenance=prov,
133
+ ))
134
+
135
+ net_conf = min(
136
+ (o.confidence for o in observations),
137
+ default=Confidence.INDETERMINATE,
138
+ )
139
+
140
+ return Expression(
141
+ observations=observations,
142
+ corrections=corrections,
143
+ confidence=net_conf,
144
+ label=label,
145
+ step=step,
146
+ )
147
+
148
+
149
+ # Confidence → approximate absolute uncertainty mapping.
150
+ # These are fractions of the value used as uncertainty estimates
151
+ # when reconstructing an UncertainValue from an Observation.
152
+ _CONFIDENCE_TO_UNCERTAINTY = {
153
+ Confidence.CERTAIN: 0.01,
154
+ Confidence.HIGH: 0.05,
155
+ Confidence.MODERATE: 0.10,
156
+ Confidence.LOW: 0.25,
157
+ Confidence.INDETERMINATE: 0.50,
158
+ }
159
+
160
+
161
+ def to_uncertain(obs: Observation) -> UncertainValue:
162
+ """
163
+ Reconstruct an UncertainValue from an Observation.
164
+
165
+ Since Observations don't carry explicit uncertainty, it is inferred
166
+ from the confidence tier: CERTAIN → 1% of |value|, HIGH → 5%,
167
+ MODERATE → 10%, LOW → 25%, INDETERMINATE → 50%.
168
+
169
+ This closes the loop: observe() goes algebra→health, to_uncertain()
170
+ goes health→algebra.
171
+ """
172
+ frac = _CONFIDENCE_TO_UNCERTAINTY.get(obs.confidence, 0.10)
173
+ unc = frac * abs(obs.value) if obs.value != 0 else frac
174
+
175
+ validity = Validity.static(obs.measured_at) if obs.measured_at else Validity.static()
176
+
177
+ return UncertainValue(
178
+ point=obs.value,
179
+ uncertainty=unc,
180
+ source=Source.MODELED,
181
+ validity=validity,
182
+ provenance=list(obs.provenance) if obs.provenance else None,
183
+ )
184
+
185
+
186
+ def delta(
187
+ name: str,
188
+ before: UncertainValue,
189
+ after: UncertainValue,
190
+ baseline: UncertainValue,
191
+ thresholds: Thresholds,
192
+ at_time: Optional[datetime] = None,
193
+ ) -> tuple[Observation, Observation, UncertainValue]:
194
+ """
195
+ Compute typed before/after observations and the uncertain difference.
196
+
197
+ Returns (obs_before, obs_after, diff) where diff is an UncertainValue
198
+ representing the change with correctly propagated uncertainty.
199
+ Useful for building Records with full algebra backing.
200
+ """
201
+ at_time = at_time or datetime.now()
202
+ obs_before = observe(name, before, baseline, thresholds, at_time=at_time)
203
+ obs_after = observe(name, after, baseline, thresholds, at_time=at_time)
204
+ diff = subtract(after, before)
205
+ return obs_before, obs_after, diff
margin/calibrate.py ADDED
@@ -0,0 +1,182 @@
1
+ """
2
+ Threshold derivation from historical data.
3
+
4
+ Takes a set of "known healthy" measurements and derives baselines and
5
+ thresholds automatically.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import math
11
+ from dataclasses import dataclass
12
+ from typing import Optional
13
+
14
+ from .health import Thresholds
15
+ from .observation import Parser
16
+
17
+
18
+ @dataclass
19
+ class CalibrationResult:
20
+ """
21
+ Output of calibrate(): derived baseline and thresholds for one component.
22
+
23
+ baseline: mean of healthy measurements
24
+ std: standard deviation of healthy measurements
25
+ n_samples: number of measurements used
26
+ thresholds: derived Thresholds object
27
+ """
28
+ baseline: float
29
+ std: float
30
+ n_samples: int
31
+ thresholds: Thresholds
32
+
33
+ def to_dict(self) -> dict:
34
+ return {
35
+ "baseline": round(self.baseline, 6),
36
+ "std": round(self.std, 6),
37
+ "n_samples": self.n_samples,
38
+ "intact": self.thresholds.intact,
39
+ "ablated": self.thresholds.ablated,
40
+ "higher_is_better": self.thresholds.higher_is_better,
41
+ }
42
+
43
+
44
+ def calibrate(
45
+ values: list[float],
46
+ higher_is_better: bool = True,
47
+ intact_fraction: float = 0.70,
48
+ ablated_fraction: float = 0.30,
49
+ active_min: float = 0.05,
50
+ ) -> CalibrationResult:
51
+ """
52
+ Derive a baseline and thresholds from known-healthy measurements.
53
+
54
+ Takes a list of measurements collected when the component was operating
55
+ normally, and derives:
56
+ - baseline: mean of the measurements
57
+ - intact threshold: intact_fraction * baseline
58
+ - ablated threshold: ablated_fraction * baseline
59
+
60
+ Args:
61
+ values: list of healthy-state measurements
62
+ higher_is_better: polarity
63
+ intact_fraction: fraction of baseline for intact threshold (default 0.70)
64
+ ablated_fraction: fraction of baseline for ablated threshold (default 0.30)
65
+ active_min: minimum correction magnitude for "active"
66
+
67
+ For higher_is_better=True:
68
+ intact = baseline * intact_fraction (e.g. 70% of healthy mean)
69
+ ablated = baseline * ablated_fraction (e.g. 30% of healthy mean)
70
+
71
+ For higher_is_better=False:
72
+ intact = baseline * (1 + (1 - intact_fraction)) (e.g. 130% of healthy mean)
73
+ ablated = baseline * (1 + (1 - ablated_fraction)) (e.g. 170% of healthy mean)
74
+ This puts the "bad" direction above baseline for lower-is-better metrics.
75
+ """
76
+ if not values:
77
+ raise ValueError("Cannot calibrate from empty list")
78
+
79
+ n = len(values)
80
+ baseline = sum(values) / n
81
+
82
+ if baseline == 0:
83
+ raise ValueError("Cannot calibrate from zero baseline — thresholds would be degenerate")
84
+ if higher_is_better and baseline < 0:
85
+ raise ValueError(
86
+ f"Cannot calibrate higher_is_better=True with negative baseline ({baseline}). "
87
+ "Fraction-based thresholds invert for negative values. "
88
+ "Use higher_is_better=False or shift your measurements to be positive.")
89
+
90
+ variance = sum((v - baseline) ** 2 for v in values) / max(n - 1, 1)
91
+ std = math.sqrt(variance)
92
+
93
+ if higher_is_better:
94
+ intact = baseline * intact_fraction
95
+ ablated = baseline * ablated_fraction
96
+ else:
97
+ # For lower-is-better, thresholds are above baseline (unhealthy direction).
98
+ # Uses abs(baseline) so the math works for both positive and negative baselines.
99
+ intact = baseline + abs(baseline) * (1 - intact_fraction)
100
+ ablated = baseline + abs(baseline) * (1 - ablated_fraction)
101
+
102
+ thresholds = Thresholds(
103
+ intact=round(intact, 6),
104
+ ablated=round(ablated, 6),
105
+ higher_is_better=higher_is_better,
106
+ active_min=active_min,
107
+ )
108
+
109
+ return CalibrationResult(
110
+ baseline=baseline,
111
+ std=std,
112
+ n_samples=n,
113
+ thresholds=thresholds,
114
+ )
115
+
116
+
117
+ def calibrate_many(
118
+ component_values: dict[str, list[float]],
119
+ polarities: Optional[dict[str, bool]] = None,
120
+ intact_fraction: float = 0.70,
121
+ ablated_fraction: float = 0.30,
122
+ active_min: float = 0.05,
123
+ ) -> tuple[dict[str, float], dict[str, Thresholds]]:
124
+ """
125
+ Calibrate multiple components at once. Returns (baselines, thresholds)
126
+ ready to pass directly to Parser().
127
+
128
+ Args:
129
+ component_values: {name: [healthy_measurements]}
130
+ polarities: {name: higher_is_better} (defaults to True)
131
+ intact_fraction: fraction of baseline for intact threshold
132
+ ablated_fraction: fraction of baseline for ablated threshold
133
+ active_min: minimum correction magnitude
134
+
135
+ Returns:
136
+ (baselines_dict, thresholds_dict) suitable for:
137
+ Parser(baselines=baselines_dict,
138
+ thresholds=first_threshold,
139
+ component_thresholds=thresholds_dict)
140
+ """
141
+ polarities = polarities or {}
142
+ baselines = {}
143
+ thresholds = {}
144
+
145
+ for name, vals in component_values.items():
146
+ hib = polarities.get(name, True)
147
+ result = calibrate(vals, hib, intact_fraction, ablated_fraction, active_min)
148
+ baselines[name] = result.baseline
149
+ thresholds[name] = result.thresholds
150
+
151
+ return baselines, thresholds
152
+
153
+
154
+ def parser_from_calibration(
155
+ component_values: dict[str, list[float]],
156
+ polarities: Optional[dict[str, bool]] = None,
157
+ intact_fraction: float = 0.70,
158
+ ablated_fraction: float = 0.30,
159
+ active_min: float = 0.05,
160
+ ) -> Parser:
161
+ """
162
+ One-shot: calibrate from historical data and return a ready-to-use Parser.
163
+
164
+ Uses the first component's thresholds as the default, with all others
165
+ as component_thresholds overrides.
166
+ """
167
+ baselines, thresh_dict = calibrate_many(
168
+ component_values, polarities, intact_fraction, ablated_fraction, active_min,
169
+ )
170
+
171
+ names = list(thresh_dict.keys())
172
+ if not names:
173
+ raise ValueError("No components to calibrate")
174
+
175
+ default_thresholds = thresh_dict[names[0]]
176
+ component_thresholds = {n: t for n, t in thresh_dict.items() if n != names[0]}
177
+
178
+ return Parser(
179
+ baselines=baselines,
180
+ thresholds=default_thresholds,
181
+ component_thresholds=component_thresholds,
182
+ )