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 +114 -0
- margin/algebra.py +177 -0
- margin/bridge.py +205 -0
- margin/calibrate.py +182 -0
- margin/causal.py +303 -0
- margin/composite.py +144 -0
- margin/confidence.py +52 -0
- margin/contract.py +293 -0
- margin/diff.py +178 -0
- margin/events.py +96 -0
- margin/forecast.py +168 -0
- margin/health.py +105 -0
- margin/ledger.py +225 -0
- margin/loop.py +192 -0
- margin/observation.py +406 -0
- margin/policy/__init__.py +52 -0
- margin/policy/compose.py +190 -0
- margin/policy/core.py +330 -0
- margin/policy/temporal.py +136 -0
- margin/policy/trace.py +200 -0
- margin/policy/tuning.py +195 -0
- margin/policy/validate.py +213 -0
- margin/predicates.py +131 -0
- margin/provenance.py +21 -0
- margin/transitions.py +187 -0
- margin/uncertain.py +108 -0
- margin/validity.py +78 -0
- margin-0.1.0.dist-info/METADATA +138 -0
- margin-0.1.0.dist-info/RECORD +32 -0
- margin-0.1.0.dist-info/WHEEL +5 -0
- margin-0.1.0.dist-info/licenses/LICENSE +21 -0
- margin-0.1.0.dist-info/top_level.txt +1 -0
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
|
+
)
|