margin 0.9.30__tar.gz → 0.9.32__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.
- {margin-0.9.30/margin.egg-info → margin-0.9.32}/PKG-INFO +55 -2
- {margin-0.9.30 → margin-0.9.32}/README.md +54 -1
- {margin-0.9.30 → margin-0.9.32}/margin/flow.py +20 -0
- {margin-0.9.30 → margin-0.9.32}/margin/issue.py +80 -8
- {margin-0.9.30 → margin-0.9.32}/margin/streaming.py +92 -4
- {margin-0.9.30 → margin-0.9.32/margin.egg-info}/PKG-INFO +55 -2
- {margin-0.9.30 → margin-0.9.32}/pyproject.toml +1 -1
- {margin-0.9.30 → margin-0.9.32}/tests/test_issue.py +247 -7
- {margin-0.9.30 → margin-0.9.32}/LICENSE +0 -0
- {margin-0.9.30 → margin-0.9.32}/NOTICE +0 -0
- {margin-0.9.30 → margin-0.9.32}/margin/__init__.py +0 -0
- {margin-0.9.30 → margin-0.9.32}/margin/__main__.py +0 -0
- {margin-0.9.30 → margin-0.9.32}/margin/algebra.py +0 -0
- {margin-0.9.30 → margin-0.9.32}/margin/anomaly.py +0 -0
- {margin-0.9.30 → margin-0.9.32}/margin/bridge.py +0 -0
- {margin-0.9.30 → margin-0.9.32}/margin/calibrate.py +0 -0
- {margin-0.9.30 → margin-0.9.32}/margin/causal.py +0 -0
- {margin-0.9.30 → margin-0.9.32}/margin/composite.py +0 -0
- {margin-0.9.30 → margin-0.9.32}/margin/confidence.py +0 -0
- {margin-0.9.30 → margin-0.9.32}/margin/config.py +0 -0
- {margin-0.9.30 → margin-0.9.32}/margin/contract.py +0 -0
- {margin-0.9.30 → margin-0.9.32}/margin/controller.py +0 -0
- {margin-0.9.30 → margin-0.9.32}/margin/correlate.py +0 -0
- {margin-0.9.30 → margin-0.9.32}/margin/diff.py +0 -0
- {margin-0.9.30 → margin-0.9.32}/margin/drift.py +0 -0
- {margin-0.9.30 → margin-0.9.32}/margin/events.py +0 -0
- {margin-0.9.30 → margin-0.9.32}/margin/fingerprint.py +0 -0
- {margin-0.9.30 → margin-0.9.32}/margin/forecast.py +0 -0
- {margin-0.9.30 → margin-0.9.32}/margin/health.py +0 -0
- {margin-0.9.30 → margin-0.9.32}/margin/intent.py +0 -0
- {margin-0.9.30 → margin-0.9.32}/margin/ledger.py +0 -0
- {margin-0.9.30 → margin-0.9.32}/margin/loop.py +0 -0
- {margin-0.9.30 → margin-0.9.32}/margin/observation.py +0 -0
- {margin-0.9.30 → margin-0.9.32}/margin/persist.py +0 -0
- {margin-0.9.30 → margin-0.9.32}/margin/policy/__init__.py +0 -0
- {margin-0.9.30 → margin-0.9.32}/margin/policy/compose.py +0 -0
- {margin-0.9.30 → margin-0.9.32}/margin/policy/core.py +0 -0
- {margin-0.9.30 → margin-0.9.32}/margin/policy/temporal.py +0 -0
- {margin-0.9.30 → margin-0.9.32}/margin/policy/trace.py +0 -0
- {margin-0.9.30 → margin-0.9.32}/margin/policy/tuning.py +0 -0
- {margin-0.9.30 → margin-0.9.32}/margin/policy/validate.py +0 -0
- {margin-0.9.30 → margin-0.9.32}/margin/predicates.py +0 -0
- {margin-0.9.30 → margin-0.9.32}/margin/provenance.py +0 -0
- {margin-0.9.30 → margin-0.9.32}/margin/transitions.py +0 -0
- {margin-0.9.30 → margin-0.9.32}/margin/uncertain.py +0 -0
- {margin-0.9.30 → margin-0.9.32}/margin/validity.py +0 -0
- {margin-0.9.30 → margin-0.9.32}/margin.egg-info/SOURCES.txt +0 -0
- {margin-0.9.30 → margin-0.9.32}/margin.egg-info/dependency_links.txt +0 -0
- {margin-0.9.30 → margin-0.9.32}/margin.egg-info/top_level.txt +0 -0
- {margin-0.9.30 → margin-0.9.32}/setup.cfg +0 -0
- {margin-0.9.30 → margin-0.9.32}/tests/test_absence.py +0 -0
- {margin-0.9.30 → margin-0.9.32}/tests/test_algebra.py +0 -0
- {margin-0.9.30 → margin-0.9.32}/tests/test_anomaly.py +0 -0
- {margin-0.9.30 → margin-0.9.32}/tests/test_bridge.py +0 -0
- {margin-0.9.30 → margin-0.9.32}/tests/test_calibrate.py +0 -0
- {margin-0.9.30 → margin-0.9.32}/tests/test_causal.py +0 -0
- {margin-0.9.30 → margin-0.9.32}/tests/test_composite.py +0 -0
- {margin-0.9.30 → margin-0.9.32}/tests/test_confidence.py +0 -0
- {margin-0.9.30 → margin-0.9.32}/tests/test_config.py +0 -0
- {margin-0.9.30 → margin-0.9.32}/tests/test_contract.py +0 -0
- {margin-0.9.30 → margin-0.9.32}/tests/test_controller.py +0 -0
- {margin-0.9.30 → margin-0.9.32}/tests/test_correlate.py +0 -0
- {margin-0.9.30 → margin-0.9.32}/tests/test_diff.py +0 -0
- {margin-0.9.30 → margin-0.9.32}/tests/test_drift.py +0 -0
- {margin-0.9.30 → margin-0.9.32}/tests/test_events.py +0 -0
- {margin-0.9.30 → margin-0.9.32}/tests/test_fingerprint_ext.py +0 -0
- {margin-0.9.30 → margin-0.9.32}/tests/test_float_tolerance.py +0 -0
- {margin-0.9.30 → margin-0.9.32}/tests/test_flow.py +0 -0
- {margin-0.9.30 → margin-0.9.32}/tests/test_forecast.py +0 -0
- {margin-0.9.30 → margin-0.9.32}/tests/test_health.py +0 -0
- {margin-0.9.30 → margin-0.9.32}/tests/test_healthcare.py +0 -0
- {margin-0.9.30 → margin-0.9.32}/tests/test_intent.py +0 -0
- {margin-0.9.30 → margin-0.9.32}/tests/test_ledger.py +0 -0
- {margin-0.9.30 → margin-0.9.32}/tests/test_loop.py +0 -0
- {margin-0.9.30 → margin-0.9.32}/tests/test_neuro.py +0 -0
- {margin-0.9.30 → margin-0.9.32}/tests/test_observation.py +0 -0
- {margin-0.9.30 → margin-0.9.32}/tests/test_persist.py +0 -0
- {margin-0.9.30 → margin-0.9.32}/tests/test_pipeline_features.py +0 -0
- {margin-0.9.30 → margin-0.9.32}/tests/test_policy.py +0 -0
- {margin-0.9.30 → margin-0.9.32}/tests/test_policy_compose.py +0 -0
- {margin-0.9.30 → margin-0.9.32}/tests/test_policy_temporal.py +0 -0
- {margin-0.9.30 → margin-0.9.32}/tests/test_policy_trace.py +0 -0
- {margin-0.9.30 → margin-0.9.32}/tests/test_policy_tuning.py +0 -0
- {margin-0.9.30 → margin-0.9.32}/tests/test_policy_validate.py +0 -0
- {margin-0.9.30 → margin-0.9.32}/tests/test_predicates.py +0 -0
- {margin-0.9.30 → margin-0.9.32}/tests/test_printer3d.py +0 -0
- {margin-0.9.30 → margin-0.9.32}/tests/test_provenance.py +0 -0
- {margin-0.9.30 → margin-0.9.32}/tests/test_reverse_bridge.py +0 -0
- {margin-0.9.30 → margin-0.9.32}/tests/test_ros2.py +0 -0
- {margin-0.9.30 → margin-0.9.32}/tests/test_staleness.py +0 -0
- {margin-0.9.30 → margin-0.9.32}/tests/test_streaming.py +0 -0
- {margin-0.9.30 → margin-0.9.32}/tests/test_transformer.py +0 -0
- {margin-0.9.30 → margin-0.9.32}/tests/test_transitions.py +0 -0
- {margin-0.9.30 → margin-0.9.32}/tests/test_uncertain.py +0 -0
- {margin-0.9.30 → margin-0.9.32}/tests/test_validity.py +0 -0
- {margin-0.9.30 → margin-0.9.32}/tests/test_windowing.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: margin
|
|
3
|
-
Version: 0.9.
|
|
3
|
+
Version: 0.9.32
|
|
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
|
|
@@ -86,6 +86,8 @@ Then the correction loop:
|
|
|
86
86
|
- **Causal** — dependency graphs ("api is DEGRADED because db is ABLATED")
|
|
87
87
|
- **Auto-correlation** — discover which components move together from data, with lag detection
|
|
88
88
|
- **Streaming** — incremental trackers: `Monitor.update(values)` updates health + drift + anomaly + correlation in one call
|
|
89
|
+
- **Flow** — ordered stages with typed live guidance: ADVANCE / HOLD / RETRY / ROLLBACK / ESCALATE for multi-stage processes
|
|
90
|
+
- **Issue** — typed diagnostics: library surfaces its own misconfigurations, degraded assumptions, recovered failures
|
|
89
91
|
- **Config** — define everything in YAML/JSON: `margin.load_config("margin.yaml")`
|
|
90
92
|
- **Persistence** — save/restore Monitor state across restarts, batch replay from CSV
|
|
91
93
|
- **Intent** — goal feasibility: `intent.evaluate_monitor(monitor)` → FEASIBLE / AT_RISK / INFEASIBLE with ETA
|
|
@@ -123,6 +125,55 @@ Thresholds(intact=0.02, ablated=0.10, higher_is_better=False)
|
|
|
123
125
|
|
|
124
126
|
One flag. Threads through every comparison, every sigma calculation, every correction decision, every recovery ratio. You never think about it again.
|
|
125
127
|
|
|
128
|
+
## Multi-stage process guidance
|
|
129
|
+
|
|
130
|
+
For processes where each stage has distinct entry/quality/exit criteria — deployment pipelines, fermentation phases, clinical trials, incident response:
|
|
131
|
+
|
|
132
|
+
```python
|
|
133
|
+
from margin import Flow, Stage, Parser, Thresholds
|
|
134
|
+
|
|
135
|
+
flow = Flow(stages=[
|
|
136
|
+
Stage(
|
|
137
|
+
name="lag",
|
|
138
|
+
parser=Parser(baselines={"biomass": 0.1}, thresholds=Thresholds(intact=0.08, ablated=0.02)),
|
|
139
|
+
advance_when=lambda expr: expr.observations[0].value >= 0.15,
|
|
140
|
+
),
|
|
141
|
+
Stage(name="exponential", parser=..., advance_when=...),
|
|
142
|
+
Stage(name="stationary", parser=...),
|
|
143
|
+
], label="ferment")
|
|
144
|
+
|
|
145
|
+
result = flow.observe({"biomass": 0.12})
|
|
146
|
+
print(result.to_atom())
|
|
147
|
+
# flow:ferment:lag[1/3] → HOLD
|
|
148
|
+
|
|
149
|
+
# When the advance_when predicate fires:
|
|
150
|
+
result = flow.observe({"biomass": 0.18})
|
|
151
|
+
# result.guidance == Guidance.ADVANCE
|
|
152
|
+
flow.advance()
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
Predicate exceptions are caught as ERROR issues, guidance degrades to HOLD, the flow stays alive — safety-first semantics for safety-critical contexts.
|
|
156
|
+
|
|
157
|
+
## Typed diagnostics
|
|
158
|
+
|
|
159
|
+
The library surfaces its own operational state the same way it classifies observed state:
|
|
160
|
+
|
|
161
|
+
```python
|
|
162
|
+
monitor.update({"mystery": 1.0}) # emits WARNING: BASELINE_MISSING
|
|
163
|
+
monitor.drift("unknown") # emits WARNING: UNKNOWN_COMPONENT
|
|
164
|
+
|
|
165
|
+
# Query it programmatically
|
|
166
|
+
monitor.issues.by_code("BASELINE_MISSING") # [Issue(...)]
|
|
167
|
+
monitor.issues.at_least(IssueLevel.WARNING) # all severe enough
|
|
168
|
+
monitor.diagnose() # JSON-safe dashboard summary
|
|
169
|
+
|
|
170
|
+
# Bridge to your logger
|
|
171
|
+
import logging
|
|
172
|
+
monitor.issues.attach_logger(logging.getLogger("margin"))
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
**Harvest-safe on hard fail:** if `Monitor.update()` or `Flow.observe()` raises, a `FATAL(INTERNAL_ERROR)` issue is recorded *before* the exception propagates. `monitor.issues` survives crashes intact.
|
|
176
|
+
|
|
126
177
|
## Auto-calibrate from data
|
|
127
178
|
|
|
128
179
|
Don't guess thresholds. Derive them from healthy measurements:
|
|
@@ -147,8 +198,10 @@ parser = parser_from_calibration(
|
|
|
147
198
|
| **Contract** | Are we meeting our goals? | `HealthTarget`, `SustainHealth`, `RecoveryThreshold` |
|
|
148
199
|
| **Causal** | Why did this happen? | `CausalGraph`, `CausalLink`, `Explanation` |
|
|
149
200
|
| **Intent** | Can we still get there? | `Intent`, `Feasibility`, `IntentResult` |
|
|
201
|
+
| **Flow** | What stage are we in, and what should we do? | `Flow`, `Stage`, `Guidance`, `FlowResult` |
|
|
202
|
+
| **Issue** | What did the library notice about itself? | `Issue`, `IssueLevel`, `IssueBuffer`, `IssueCode` |
|
|
150
203
|
|
|
151
|
-
`full_step()` orchestrates
|
|
204
|
+
`full_step()` orchestrates the first seven in one call. `Flow` composes them into a sequence. `Issue` is ambient — every layer emits diagnostics into the same buffer.
|
|
152
205
|
|
|
153
206
|
## Domain adapters
|
|
154
207
|
|
|
@@ -57,6 +57,8 @@ Then the correction loop:
|
|
|
57
57
|
- **Causal** — dependency graphs ("api is DEGRADED because db is ABLATED")
|
|
58
58
|
- **Auto-correlation** — discover which components move together from data, with lag detection
|
|
59
59
|
- **Streaming** — incremental trackers: `Monitor.update(values)` updates health + drift + anomaly + correlation in one call
|
|
60
|
+
- **Flow** — ordered stages with typed live guidance: ADVANCE / HOLD / RETRY / ROLLBACK / ESCALATE for multi-stage processes
|
|
61
|
+
- **Issue** — typed diagnostics: library surfaces its own misconfigurations, degraded assumptions, recovered failures
|
|
60
62
|
- **Config** — define everything in YAML/JSON: `margin.load_config("margin.yaml")`
|
|
61
63
|
- **Persistence** — save/restore Monitor state across restarts, batch replay from CSV
|
|
62
64
|
- **Intent** — goal feasibility: `intent.evaluate_monitor(monitor)` → FEASIBLE / AT_RISK / INFEASIBLE with ETA
|
|
@@ -94,6 +96,55 @@ Thresholds(intact=0.02, ablated=0.10, higher_is_better=False)
|
|
|
94
96
|
|
|
95
97
|
One flag. Threads through every comparison, every sigma calculation, every correction decision, every recovery ratio. You never think about it again.
|
|
96
98
|
|
|
99
|
+
## Multi-stage process guidance
|
|
100
|
+
|
|
101
|
+
For processes where each stage has distinct entry/quality/exit criteria — deployment pipelines, fermentation phases, clinical trials, incident response:
|
|
102
|
+
|
|
103
|
+
```python
|
|
104
|
+
from margin import Flow, Stage, Parser, Thresholds
|
|
105
|
+
|
|
106
|
+
flow = Flow(stages=[
|
|
107
|
+
Stage(
|
|
108
|
+
name="lag",
|
|
109
|
+
parser=Parser(baselines={"biomass": 0.1}, thresholds=Thresholds(intact=0.08, ablated=0.02)),
|
|
110
|
+
advance_when=lambda expr: expr.observations[0].value >= 0.15,
|
|
111
|
+
),
|
|
112
|
+
Stage(name="exponential", parser=..., advance_when=...),
|
|
113
|
+
Stage(name="stationary", parser=...),
|
|
114
|
+
], label="ferment")
|
|
115
|
+
|
|
116
|
+
result = flow.observe({"biomass": 0.12})
|
|
117
|
+
print(result.to_atom())
|
|
118
|
+
# flow:ferment:lag[1/3] → HOLD
|
|
119
|
+
|
|
120
|
+
# When the advance_when predicate fires:
|
|
121
|
+
result = flow.observe({"biomass": 0.18})
|
|
122
|
+
# result.guidance == Guidance.ADVANCE
|
|
123
|
+
flow.advance()
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
Predicate exceptions are caught as ERROR issues, guidance degrades to HOLD, the flow stays alive — safety-first semantics for safety-critical contexts.
|
|
127
|
+
|
|
128
|
+
## Typed diagnostics
|
|
129
|
+
|
|
130
|
+
The library surfaces its own operational state the same way it classifies observed state:
|
|
131
|
+
|
|
132
|
+
```python
|
|
133
|
+
monitor.update({"mystery": 1.0}) # emits WARNING: BASELINE_MISSING
|
|
134
|
+
monitor.drift("unknown") # emits WARNING: UNKNOWN_COMPONENT
|
|
135
|
+
|
|
136
|
+
# Query it programmatically
|
|
137
|
+
monitor.issues.by_code("BASELINE_MISSING") # [Issue(...)]
|
|
138
|
+
monitor.issues.at_least(IssueLevel.WARNING) # all severe enough
|
|
139
|
+
monitor.diagnose() # JSON-safe dashboard summary
|
|
140
|
+
|
|
141
|
+
# Bridge to your logger
|
|
142
|
+
import logging
|
|
143
|
+
monitor.issues.attach_logger(logging.getLogger("margin"))
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
**Harvest-safe on hard fail:** if `Monitor.update()` or `Flow.observe()` raises, a `FATAL(INTERNAL_ERROR)` issue is recorded *before* the exception propagates. `monitor.issues` survives crashes intact.
|
|
147
|
+
|
|
97
148
|
## Auto-calibrate from data
|
|
98
149
|
|
|
99
150
|
Don't guess thresholds. Derive them from healthy measurements:
|
|
@@ -118,8 +169,10 @@ parser = parser_from_calibration(
|
|
|
118
169
|
| **Contract** | Are we meeting our goals? | `HealthTarget`, `SustainHealth`, `RecoveryThreshold` |
|
|
119
170
|
| **Causal** | Why did this happen? | `CausalGraph`, `CausalLink`, `Explanation` |
|
|
120
171
|
| **Intent** | Can we still get there? | `Intent`, `Feasibility`, `IntentResult` |
|
|
172
|
+
| **Flow** | What stage are we in, and what should we do? | `Flow`, `Stage`, `Guidance`, `FlowResult` |
|
|
173
|
+
| **Issue** | What did the library notice about itself? | `Issue`, `IssueLevel`, `IssueBuffer`, `IssueCode` |
|
|
121
174
|
|
|
122
|
-
`full_step()` orchestrates
|
|
175
|
+
`full_step()` orchestrates the first seven in one call. `Flow` composes them into a sequence. `Issue` is ambient — every layer emits diagnostics into the same buffer.
|
|
123
176
|
|
|
124
177
|
## Domain adapters
|
|
125
178
|
|
|
@@ -386,6 +386,26 @@ class Flow:
|
|
|
386
386
|
# Collect issues produced this step: Parser's issues are on expr,
|
|
387
387
|
# predicate failures are emitted during _classify.
|
|
388
388
|
step_issues: List[Issue] = list(expr.issues)
|
|
389
|
+
|
|
390
|
+
# STAGE_ABLATED_ON_ENTRY: emit WARNING once if the first observation
|
|
391
|
+
# in this stage lands any component in ABLATED state. The warning
|
|
392
|
+
# helps operators notice "we entered this stage already in trouble."
|
|
393
|
+
if monitor.step == 1:
|
|
394
|
+
ablated_here = [o.name for o in expr.observations
|
|
395
|
+
if o.health == Health.ABLATED]
|
|
396
|
+
if ablated_here:
|
|
397
|
+
iss = self._issues.emit(
|
|
398
|
+
IssueLevel.WARNING,
|
|
399
|
+
IssueCode.STAGE_ABLATED_ON_ENTRY,
|
|
400
|
+
detail=(
|
|
401
|
+
f"stage {stage.name!r} entered with ABLATED "
|
|
402
|
+
f"component(s): {ablated_here}"
|
|
403
|
+
),
|
|
404
|
+
component=stage.name,
|
|
405
|
+
ablated_components=list(ablated_here),
|
|
406
|
+
)
|
|
407
|
+
step_issues.append(iss)
|
|
408
|
+
|
|
389
409
|
guidance, reason = self._classify(stage, expr, step_issues)
|
|
390
410
|
|
|
391
411
|
return FlowResult(
|
|
@@ -16,9 +16,10 @@ Design constraints
|
|
|
16
16
|
emits them at known sites.
|
|
17
17
|
|
|
18
18
|
2. **Harvestable on hard fail.** Issues are added to the buffer
|
|
19
|
-
*before* exceptions propagate, using ``try /
|
|
20
|
-
the callers
|
|
21
|
-
|
|
19
|
+
*before* exceptions propagate, using ``try / except / raise`` patterns
|
|
20
|
+
in the callers — the FATAL record is emitted in the except branch
|
|
21
|
+
before the exception is re-raised. Even a FATAL internal error leaves
|
|
22
|
+
the full recent issue history intact for post-mortem inspection.
|
|
22
23
|
|
|
23
24
|
3. **Typed, not strings.** Issue.code is a short stable identifier
|
|
24
25
|
(``BASELINE_MISSING``, ``PREDICATE_FAILURE``) that consumers can
|
|
@@ -156,16 +157,79 @@ class IssueBuffer:
|
|
|
156
157
|
raise ValueError("max_issues must be >= 1")
|
|
157
158
|
self.max_issues = max_issues
|
|
158
159
|
self._issues: Deque[Issue] = deque(maxlen=max_issues)
|
|
160
|
+
# Optional Python logging integration. When a logger is attached,
|
|
161
|
+
# every Issue added to the buffer is mirrored to the logger.
|
|
162
|
+
self._logger: Optional[Any] = None
|
|
163
|
+
self._level_map: Optional[Dict[IssueLevel, int]] = None
|
|
159
164
|
|
|
160
165
|
# ------------------------------------------------------------------
|
|
161
166
|
# Writing
|
|
162
167
|
# ------------------------------------------------------------------
|
|
163
168
|
|
|
164
169
|
def add(self, issue: Issue) -> Issue:
|
|
165
|
-
"""Append an issue. Returns the issue for chained access.
|
|
170
|
+
"""Append an issue. Returns the issue for chained access.
|
|
171
|
+
|
|
172
|
+
Mirrors to the attached logger (if any) before returning.
|
|
173
|
+
"""
|
|
166
174
|
self._issues.append(issue)
|
|
175
|
+
if self._logger is not None:
|
|
176
|
+
self._mirror_to_logger(issue)
|
|
167
177
|
return issue
|
|
168
178
|
|
|
179
|
+
def _mirror_to_logger(self, issue: "Issue") -> None:
|
|
180
|
+
"""Emit the issue to the attached Python logger."""
|
|
181
|
+
import logging as _logging
|
|
182
|
+
level_map = self._level_map or {
|
|
183
|
+
IssueLevel.INFO: _logging.INFO,
|
|
184
|
+
IssueLevel.WARNING: _logging.WARNING,
|
|
185
|
+
IssueLevel.ERROR: _logging.ERROR,
|
|
186
|
+
IssueLevel.FATAL: _logging.CRITICAL,
|
|
187
|
+
}
|
|
188
|
+
py_level = level_map.get(issue.level, _logging.WARNING)
|
|
189
|
+
if issue.component:
|
|
190
|
+
msg = f"[{issue.code}] ({issue.component}) {issue.detail}"
|
|
191
|
+
else:
|
|
192
|
+
msg = f"[{issue.code}] {issue.detail}"
|
|
193
|
+
try:
|
|
194
|
+
self._logger.log(py_level, msg, extra={
|
|
195
|
+
"margin_code": issue.code,
|
|
196
|
+
"margin_component": issue.component,
|
|
197
|
+
"margin_level": issue.level.value,
|
|
198
|
+
})
|
|
199
|
+
except Exception:
|
|
200
|
+
# Never let logging errors break the buffer.
|
|
201
|
+
pass
|
|
202
|
+
|
|
203
|
+
def attach_logger(self, logger, level_map: Optional[Dict[IssueLevel, int]] = None) -> None:
|
|
204
|
+
"""
|
|
205
|
+
Attach a Python ``logging.Logger``. Subsequent issues are mirrored
|
|
206
|
+
to the logger at the mapped level.
|
|
207
|
+
|
|
208
|
+
Default level map::
|
|
209
|
+
|
|
210
|
+
IssueLevel.INFO → logging.INFO
|
|
211
|
+
IssueLevel.WARNING → logging.WARNING
|
|
212
|
+
IssueLevel.ERROR → logging.ERROR
|
|
213
|
+
IssueLevel.FATAL → logging.CRITICAL
|
|
214
|
+
|
|
215
|
+
Example::
|
|
216
|
+
|
|
217
|
+
import logging
|
|
218
|
+
logger = logging.getLogger("margin")
|
|
219
|
+
monitor.issues.attach_logger(logger)
|
|
220
|
+
# Every issue now also appears in the Python log stream.
|
|
221
|
+
|
|
222
|
+
Pass ``level_map`` to override specific mappings.
|
|
223
|
+
Call :meth:`detach_logger` to stop mirroring.
|
|
224
|
+
"""
|
|
225
|
+
self._logger = logger
|
|
226
|
+
self._level_map = dict(level_map) if level_map else None
|
|
227
|
+
|
|
228
|
+
def detach_logger(self) -> None:
|
|
229
|
+
"""Stop mirroring issues to the previously-attached logger."""
|
|
230
|
+
self._logger = None
|
|
231
|
+
self._level_map = None
|
|
232
|
+
|
|
169
233
|
def emit(
|
|
170
234
|
self,
|
|
171
235
|
level: IssueLevel,
|
|
@@ -249,26 +313,34 @@ class IssueBuffer:
|
|
|
249
313
|
out[i.level.value] += 1
|
|
250
314
|
return out
|
|
251
315
|
|
|
252
|
-
def diagnose(self, n: int = 10) -> Dict[str, Any]:
|
|
316
|
+
def diagnose(self, n: int = 10, serialize: bool = True) -> Dict[str, Any]:
|
|
253
317
|
"""
|
|
254
318
|
Human/dashboard-friendly summary.
|
|
255
319
|
|
|
320
|
+
With ``serialize=True`` (default) the result is JSON-ready — every
|
|
321
|
+
value is a primitive or dict/list of primitives — so dashboards
|
|
322
|
+
can ``json.dumps(monitor.diagnose())`` directly.
|
|
323
|
+
|
|
324
|
+
With ``serialize=False`` the ``recent`` list contains raw
|
|
325
|
+
:class:`Issue` instances for programmatic inspection.
|
|
326
|
+
|
|
256
327
|
Returns::
|
|
257
328
|
|
|
258
329
|
{
|
|
259
330
|
"total": int,
|
|
260
331
|
"counts": {"INFO": n, "WARNING": n, "ERROR": n, "FATAL": n},
|
|
261
|
-
"recent": [Issue, ...],
|
|
262
|
-
"codes": {"BASELINE_MISSING": n, ...},
|
|
332
|
+
"recent": [dict or Issue, ...],
|
|
333
|
+
"codes": {"BASELINE_MISSING": n, ...},
|
|
263
334
|
}
|
|
264
335
|
"""
|
|
265
336
|
codes: Dict[str, int] = {}
|
|
266
337
|
for i in self._issues:
|
|
267
338
|
codes[i.code] = codes.get(i.code, 0) + 1
|
|
339
|
+
tail = self.tail(n)
|
|
268
340
|
return {
|
|
269
341
|
"total": len(self._issues),
|
|
270
342
|
"counts": self.counts(),
|
|
271
|
-
"recent":
|
|
343
|
+
"recent": [i.to_dict() for i in tail] if serialize else tail,
|
|
272
344
|
"codes": codes,
|
|
273
345
|
}
|
|
274
346
|
|
|
@@ -444,6 +444,10 @@ class Monitor:
|
|
|
444
444
|
# Parser (via Expression.issues) + direct emission from update().
|
|
445
445
|
from .issue import IssueBuffer
|
|
446
446
|
self._issues = IssueBuffer()
|
|
447
|
+
# Dedup sets: emit once per component per condition until it resolves
|
|
448
|
+
self._drift_warming: set = set() # components whose DriftTracker is still below min_samples
|
|
449
|
+
self._anomaly_warming: set = set() # AnomalyTracker below min_reference
|
|
450
|
+
self._unknown_flagged: set = set() # unknown-component queries we've already warned about
|
|
447
451
|
|
|
448
452
|
# Create per-component trackers for enabled features only
|
|
449
453
|
self._drift_trackers: dict[str, DriftTracker] = {}
|
|
@@ -539,6 +543,11 @@ class Monitor:
|
|
|
539
543
|
if self._correlation_tracker is not None:
|
|
540
544
|
self._correlation_tracker.update(values)
|
|
541
545
|
|
|
546
|
+
# Warming-up diagnostics: emit INFO once per component while a
|
|
547
|
+
# tracker is still below its min-samples threshold; clear the
|
|
548
|
+
# flag when classification graduates.
|
|
549
|
+
self._emit_warming_issues()
|
|
550
|
+
|
|
542
551
|
return self._expression
|
|
543
552
|
except Exception as _e:
|
|
544
553
|
self._issues.emit(
|
|
@@ -559,17 +568,82 @@ class Monitor:
|
|
|
559
568
|
"""IssueBuffer containing all issues emitted by this Monitor (and its Parser)."""
|
|
560
569
|
return self._issues
|
|
561
570
|
|
|
562
|
-
def diagnose(self, n: int = 10) -> dict:
|
|
571
|
+
def diagnose(self, n: int = 10, serialize: bool = True) -> dict:
|
|
563
572
|
"""Dashboard-friendly summary of recent issues. See IssueBuffer.diagnose."""
|
|
564
|
-
return self._issues.diagnose(n)
|
|
573
|
+
return self._issues.diagnose(n, serialize=serialize)
|
|
574
|
+
|
|
575
|
+
def _flag_unknown(self, component: str, where: str) -> None:
|
|
576
|
+
"""Emit UNKNOWN_COMPONENT once per unknown component queried via this method."""
|
|
577
|
+
from .issue import IssueLevel, IssueCode
|
|
578
|
+
key = (where, component)
|
|
579
|
+
if key in self._unknown_flagged:
|
|
580
|
+
return
|
|
581
|
+
self._unknown_flagged.add(key)
|
|
582
|
+
self._issues.emit(
|
|
583
|
+
IssueLevel.WARNING,
|
|
584
|
+
IssueCode.UNKNOWN_COMPONENT,
|
|
585
|
+
detail=(
|
|
586
|
+
f"{where}({component!r}): no tracker registered for this "
|
|
587
|
+
"component. Add it to Parser.baselines or enable the feature."
|
|
588
|
+
),
|
|
589
|
+
component=component,
|
|
590
|
+
)
|
|
591
|
+
|
|
592
|
+
def _emit_warming_issues(self) -> None:
|
|
593
|
+
"""Emit / clear INSUFFICIENT_SAMPLES for trackers still below min_samples."""
|
|
594
|
+
from .issue import IssueLevel, IssueCode
|
|
595
|
+
|
|
596
|
+
# Drift
|
|
597
|
+
for name, tracker in self._drift_trackers.items():
|
|
598
|
+
if tracker._classification is None and tracker.n_observations < tracker._min_samples:
|
|
599
|
+
if name not in self._drift_warming:
|
|
600
|
+
self._drift_warming.add(name)
|
|
601
|
+
self._issues.emit(
|
|
602
|
+
IssueLevel.INFO,
|
|
603
|
+
IssueCode.INSUFFICIENT_SAMPLES,
|
|
604
|
+
detail=(
|
|
605
|
+
f"drift tracker for {name!r} has "
|
|
606
|
+
f"{tracker.n_observations}/{tracker._min_samples} "
|
|
607
|
+
"required samples; classification unavailable until warmed."
|
|
608
|
+
),
|
|
609
|
+
component=name,
|
|
610
|
+
kind="drift",
|
|
611
|
+
)
|
|
612
|
+
else:
|
|
613
|
+
self._drift_warming.discard(name)
|
|
614
|
+
|
|
615
|
+
# Anomaly
|
|
616
|
+
for name, tracker in self._anomaly_trackers.items():
|
|
617
|
+
if tracker._classification is None and tracker.n_values < tracker._min_reference:
|
|
618
|
+
if name not in self._anomaly_warming:
|
|
619
|
+
self._anomaly_warming.add(name)
|
|
620
|
+
self._issues.emit(
|
|
621
|
+
IssueLevel.INFO,
|
|
622
|
+
IssueCode.INSUFFICIENT_SAMPLES,
|
|
623
|
+
detail=(
|
|
624
|
+
f"anomaly tracker for {name!r} has "
|
|
625
|
+
f"{tracker.n_values}/{tracker._min_reference} "
|
|
626
|
+
"reference values; classification unavailable until warmed."
|
|
627
|
+
),
|
|
628
|
+
component=name,
|
|
629
|
+
kind="anomaly",
|
|
630
|
+
)
|
|
631
|
+
else:
|
|
632
|
+
self._anomaly_warming.discard(name)
|
|
565
633
|
|
|
566
634
|
def drift(self, component: str) -> Optional[DriftClassification]:
|
|
567
635
|
t = self._drift_trackers.get(component)
|
|
568
|
-
|
|
636
|
+
if t is None:
|
|
637
|
+
self._flag_unknown(component, "drift")
|
|
638
|
+
return None
|
|
639
|
+
return t.classification
|
|
569
640
|
|
|
570
641
|
def anomaly(self, component: str) -> Optional[AnomalyClassification]:
|
|
571
642
|
t = self._anomaly_trackers.get(component)
|
|
572
|
-
|
|
643
|
+
if t is None:
|
|
644
|
+
self._flag_unknown(component, "anomaly")
|
|
645
|
+
return None
|
|
646
|
+
return t.classification
|
|
573
647
|
|
|
574
648
|
@property
|
|
575
649
|
def correlations(self) -> Optional[CorrelationMatrix]:
|
|
@@ -606,6 +680,7 @@ class Monitor:
|
|
|
606
680
|
d["correlations"] = self._correlation_tracker.matrix.to_dict()
|
|
607
681
|
if self.provenance_graph is not None:
|
|
608
682
|
d["provenance_nodes"] = len(self.provenance_graph.nodes)
|
|
683
|
+
d["issues"] = self._issues.diagnose(5)
|
|
609
684
|
return d
|
|
610
685
|
|
|
611
686
|
def fingerprint(self) -> Fingerprint:
|
|
@@ -818,6 +893,7 @@ class Monitor:
|
|
|
818
893
|
if score > 1.5:
|
|
819
894
|
print(f"Session diverged from baseline (score={score:.2f})")
|
|
820
895
|
"""
|
|
896
|
+
from .issue import IssueLevel, IssueCode
|
|
821
897
|
current_fp = self.fingerprint()
|
|
822
898
|
_metrics = list(metrics) if metrics is not None else list(
|
|
823
899
|
set(current_fp.keys()) & set(reference_fp.keys())
|
|
@@ -825,14 +901,26 @@ class Monitor:
|
|
|
825
901
|
if not _metrics:
|
|
826
902
|
return 0.0
|
|
827
903
|
z_scores = []
|
|
904
|
+
skipped_near_zero: list[str] = []
|
|
828
905
|
for m in _metrics:
|
|
829
906
|
ref_std = reference_fp.get(m, {}).get("std", 0.0)
|
|
830
907
|
# 1e-12 tolerance: reject floating-point noise from constant windows
|
|
831
908
|
if ref_std < 1e-12:
|
|
909
|
+
skipped_near_zero.append(m)
|
|
832
910
|
continue
|
|
833
911
|
cur_mean = current_fp.get(m, {}).get("mean", 0.0)
|
|
834
912
|
ref_mean = reference_fp.get(m, {}).get("mean", 0.0)
|
|
835
913
|
z_scores.append(abs(cur_mean - ref_mean) / ref_std)
|
|
914
|
+
if skipped_near_zero:
|
|
915
|
+
self._issues.emit(
|
|
916
|
+
IssueLevel.INFO,
|
|
917
|
+
IssueCode.NEAR_ZERO_STD,
|
|
918
|
+
detail=(
|
|
919
|
+
f"anomaly_score skipped {len(skipped_near_zero)} "
|
|
920
|
+
f"metric(s) with near-zero reference std: {skipped_near_zero}"
|
|
921
|
+
),
|
|
922
|
+
skipped_metrics=list(skipped_near_zero),
|
|
923
|
+
)
|
|
836
924
|
return sum(z_scores) / len(z_scores) if z_scores else 0.0
|
|
837
925
|
|
|
838
926
|
def reset_anomaly_reference(self, components: Optional[list[str]] = None) -> None:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: margin
|
|
3
|
-
Version: 0.9.
|
|
3
|
+
Version: 0.9.32
|
|
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
|
|
@@ -86,6 +86,8 @@ Then the correction loop:
|
|
|
86
86
|
- **Causal** — dependency graphs ("api is DEGRADED because db is ABLATED")
|
|
87
87
|
- **Auto-correlation** — discover which components move together from data, with lag detection
|
|
88
88
|
- **Streaming** — incremental trackers: `Monitor.update(values)` updates health + drift + anomaly + correlation in one call
|
|
89
|
+
- **Flow** — ordered stages with typed live guidance: ADVANCE / HOLD / RETRY / ROLLBACK / ESCALATE for multi-stage processes
|
|
90
|
+
- **Issue** — typed diagnostics: library surfaces its own misconfigurations, degraded assumptions, recovered failures
|
|
89
91
|
- **Config** — define everything in YAML/JSON: `margin.load_config("margin.yaml")`
|
|
90
92
|
- **Persistence** — save/restore Monitor state across restarts, batch replay from CSV
|
|
91
93
|
- **Intent** — goal feasibility: `intent.evaluate_monitor(monitor)` → FEASIBLE / AT_RISK / INFEASIBLE with ETA
|
|
@@ -123,6 +125,55 @@ Thresholds(intact=0.02, ablated=0.10, higher_is_better=False)
|
|
|
123
125
|
|
|
124
126
|
One flag. Threads through every comparison, every sigma calculation, every correction decision, every recovery ratio. You never think about it again.
|
|
125
127
|
|
|
128
|
+
## Multi-stage process guidance
|
|
129
|
+
|
|
130
|
+
For processes where each stage has distinct entry/quality/exit criteria — deployment pipelines, fermentation phases, clinical trials, incident response:
|
|
131
|
+
|
|
132
|
+
```python
|
|
133
|
+
from margin import Flow, Stage, Parser, Thresholds
|
|
134
|
+
|
|
135
|
+
flow = Flow(stages=[
|
|
136
|
+
Stage(
|
|
137
|
+
name="lag",
|
|
138
|
+
parser=Parser(baselines={"biomass": 0.1}, thresholds=Thresholds(intact=0.08, ablated=0.02)),
|
|
139
|
+
advance_when=lambda expr: expr.observations[0].value >= 0.15,
|
|
140
|
+
),
|
|
141
|
+
Stage(name="exponential", parser=..., advance_when=...),
|
|
142
|
+
Stage(name="stationary", parser=...),
|
|
143
|
+
], label="ferment")
|
|
144
|
+
|
|
145
|
+
result = flow.observe({"biomass": 0.12})
|
|
146
|
+
print(result.to_atom())
|
|
147
|
+
# flow:ferment:lag[1/3] → HOLD
|
|
148
|
+
|
|
149
|
+
# When the advance_when predicate fires:
|
|
150
|
+
result = flow.observe({"biomass": 0.18})
|
|
151
|
+
# result.guidance == Guidance.ADVANCE
|
|
152
|
+
flow.advance()
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
Predicate exceptions are caught as ERROR issues, guidance degrades to HOLD, the flow stays alive — safety-first semantics for safety-critical contexts.
|
|
156
|
+
|
|
157
|
+
## Typed diagnostics
|
|
158
|
+
|
|
159
|
+
The library surfaces its own operational state the same way it classifies observed state:
|
|
160
|
+
|
|
161
|
+
```python
|
|
162
|
+
monitor.update({"mystery": 1.0}) # emits WARNING: BASELINE_MISSING
|
|
163
|
+
monitor.drift("unknown") # emits WARNING: UNKNOWN_COMPONENT
|
|
164
|
+
|
|
165
|
+
# Query it programmatically
|
|
166
|
+
monitor.issues.by_code("BASELINE_MISSING") # [Issue(...)]
|
|
167
|
+
monitor.issues.at_least(IssueLevel.WARNING) # all severe enough
|
|
168
|
+
monitor.diagnose() # JSON-safe dashboard summary
|
|
169
|
+
|
|
170
|
+
# Bridge to your logger
|
|
171
|
+
import logging
|
|
172
|
+
monitor.issues.attach_logger(logging.getLogger("margin"))
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
**Harvest-safe on hard fail:** if `Monitor.update()` or `Flow.observe()` raises, a `FATAL(INTERNAL_ERROR)` issue is recorded *before* the exception propagates. `monitor.issues` survives crashes intact.
|
|
176
|
+
|
|
126
177
|
## Auto-calibrate from data
|
|
127
178
|
|
|
128
179
|
Don't guess thresholds. Derive them from healthy measurements:
|
|
@@ -147,8 +198,10 @@ parser = parser_from_calibration(
|
|
|
147
198
|
| **Contract** | Are we meeting our goals? | `HealthTarget`, `SustainHealth`, `RecoveryThreshold` |
|
|
148
199
|
| **Causal** | Why did this happen? | `CausalGraph`, `CausalLink`, `Explanation` |
|
|
149
200
|
| **Intent** | Can we still get there? | `Intent`, `Feasibility`, `IntentResult` |
|
|
201
|
+
| **Flow** | What stage are we in, and what should we do? | `Flow`, `Stage`, `Guidance`, `FlowResult` |
|
|
202
|
+
| **Issue** | What did the library notice about itself? | `Issue`, `IssueLevel`, `IssueBuffer`, `IssueCode` |
|
|
150
203
|
|
|
151
|
-
`full_step()` orchestrates
|
|
204
|
+
`full_step()` orchestrates the first seven in one call. `Flow` composes them into a sequence. `Issue` is ambient — every layer emits diagnostics into the same buffer.
|
|
152
205
|
|
|
153
206
|
## Domain adapters
|
|
154
207
|
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "margin"
|
|
7
|
-
version = "0.9.
|
|
7
|
+
version = "0.9.32"
|
|
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"}
|
|
@@ -5,6 +5,8 @@ Emphasis on harvestability on hard fail: if a Monitor or Flow method
|
|
|
5
5
|
raises an exception, the diagnostic record should be intact in the
|
|
6
6
|
buffer, not lost.
|
|
7
7
|
"""
|
|
8
|
+
import json
|
|
9
|
+
import logging
|
|
8
10
|
import pytest
|
|
9
11
|
|
|
10
12
|
from margin import (
|
|
@@ -237,8 +239,9 @@ class TestMonitorIssues:
|
|
|
237
239
|
m = self._monitor(baselines={})
|
|
238
240
|
with pytest.warns(UserWarning):
|
|
239
241
|
m.update({"x": 1.0})
|
|
240
|
-
|
|
241
|
-
|
|
242
|
+
# BASELINE_MISSING from parser, plus INSUFFICIENT_SAMPLES from
|
|
243
|
+
# freshly-created drift/anomaly trackers warming up.
|
|
244
|
+
assert len(m.issues.by_code(IssueCode.BASELINE_MISSING)) == 1
|
|
242
245
|
|
|
243
246
|
def test_monitor_issues_accumulate(self):
|
|
244
247
|
m = self._monitor(baselines={})
|
|
@@ -246,7 +249,8 @@ class TestMonitorIssues:
|
|
|
246
249
|
m.update({"a": 1.0})
|
|
247
250
|
m.update({"b": 1.0})
|
|
248
251
|
m.update({"c": 1.0})
|
|
249
|
-
|
|
252
|
+
# One BASELINE_MISSING per unique component (parser emits per unknown)
|
|
253
|
+
assert len(m.issues.by_code(IssueCode.BASELINE_MISSING)) == 3
|
|
250
254
|
|
|
251
255
|
def test_monitor_diagnose(self):
|
|
252
256
|
m = self._monitor(baselines={})
|
|
@@ -254,7 +258,6 @@ class TestMonitorIssues:
|
|
|
254
258
|
m.update({"a": 1.0})
|
|
255
259
|
m.update({"b": 1.0})
|
|
256
260
|
d = m.diagnose()
|
|
257
|
-
assert d["total"] == 2
|
|
258
261
|
assert d["codes"][IssueCode.BASELINE_MISSING] == 2
|
|
259
262
|
|
|
260
263
|
def test_hard_fail_captures_fatal_issue(self):
|
|
@@ -289,7 +292,8 @@ class TestMonitorIssues:
|
|
|
289
292
|
m = self._monitor(baselines={})
|
|
290
293
|
with pytest.warns(UserWarning):
|
|
291
294
|
m.update({"known_missing": 1.0})
|
|
292
|
-
|
|
295
|
+
# At least one BASELINE_MISSING WARNING (plus warming-up INFOs)
|
|
296
|
+
assert len(m.issues.by_level(IssueLevel.WARNING)) >= 1
|
|
293
297
|
|
|
294
298
|
class _Boom(Exception): pass
|
|
295
299
|
orig = m.parser.parse
|
|
@@ -299,8 +303,7 @@ class TestMonitorIssues:
|
|
|
299
303
|
m.parser.parse = orig
|
|
300
304
|
|
|
301
305
|
# Original WARNING is still there, plus a FATAL.
|
|
302
|
-
assert len(m.issues)
|
|
303
|
-
assert len(m.issues.by_level(IssueLevel.WARNING)) == 1
|
|
306
|
+
assert len(m.issues.by_level(IssueLevel.WARNING)) >= 1
|
|
304
307
|
assert len(m.issues.by_level(IssueLevel.FATAL)) == 1
|
|
305
308
|
|
|
306
309
|
|
|
@@ -448,3 +451,240 @@ class TestFlowResultAtomWithIssues:
|
|
|
448
451
|
# No issues this step, so no suffix parenthetical
|
|
449
452
|
assert "(" not in r.to_atom() or "step" not in r.to_atom().lower()
|
|
450
453
|
assert r.to_atom() == "flow:ferment:lag[1/1] → HOLD"
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
# ---------------------------------------------------------------------------
|
|
457
|
+
# v0.9.31 additions — full emission coverage
|
|
458
|
+
# ---------------------------------------------------------------------------
|
|
459
|
+
|
|
460
|
+
class TestDiagnoseSerialization:
|
|
461
|
+
def test_diagnose_is_json_serializable_by_default(self):
|
|
462
|
+
m = Monitor(
|
|
463
|
+
Parser(baselines={"cpu": 50.0}, thresholds=Thresholds(intact=40, ablated=10)),
|
|
464
|
+
window=20,
|
|
465
|
+
)
|
|
466
|
+
m.update({"cpu": 50.0})
|
|
467
|
+
m.drift("nonexistent") # emits UNKNOWN_COMPONENT
|
|
468
|
+
d = m.diagnose()
|
|
469
|
+
# Must round-trip through JSON cleanly.
|
|
470
|
+
s = json.dumps(d)
|
|
471
|
+
restored = json.loads(s)
|
|
472
|
+
assert restored["total"] == d["total"]
|
|
473
|
+
assert isinstance(restored["recent"], list)
|
|
474
|
+
# Recent entries are dicts, not Issue objects.
|
|
475
|
+
assert all(isinstance(r, dict) for r in restored["recent"])
|
|
476
|
+
|
|
477
|
+
def test_diagnose_serialize_false_returns_issue_objects(self):
|
|
478
|
+
m = Monitor(
|
|
479
|
+
Parser(baselines={"cpu": 50.0}, thresholds=Thresholds(intact=40, ablated=10)),
|
|
480
|
+
window=20,
|
|
481
|
+
)
|
|
482
|
+
m.drift("nonexistent")
|
|
483
|
+
d = m.diagnose(serialize=False)
|
|
484
|
+
assert all(isinstance(r, Issue) for r in d["recent"])
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
class TestAttachLogger:
|
|
488
|
+
def test_attach_logger_mirrors_emissions(self):
|
|
489
|
+
logger = logging.getLogger("margin.test.attach")
|
|
490
|
+
logger.setLevel(logging.DEBUG)
|
|
491
|
+
records = []
|
|
492
|
+
handler = logging.Handler()
|
|
493
|
+
handler.emit = lambda r: records.append(r)
|
|
494
|
+
logger.addHandler(handler)
|
|
495
|
+
|
|
496
|
+
b = IssueBuffer()
|
|
497
|
+
b.attach_logger(logger)
|
|
498
|
+
b.emit(IssueLevel.WARNING, "BASELINE_MISSING", "x", component="cpu")
|
|
499
|
+
b.emit(IssueLevel.FATAL, "INTERNAL_ERROR", "boom")
|
|
500
|
+
|
|
501
|
+
assert len(records) == 2
|
|
502
|
+
assert records[0].levelno == logging.WARNING
|
|
503
|
+
assert "BASELINE_MISSING" in records[0].getMessage()
|
|
504
|
+
assert records[0].margin_code == "BASELINE_MISSING"
|
|
505
|
+
assert records[0].margin_component == "cpu"
|
|
506
|
+
assert records[1].levelno == logging.CRITICAL
|
|
507
|
+
|
|
508
|
+
def test_detach_logger_stops_mirroring(self):
|
|
509
|
+
logger = logging.getLogger("margin.test.detach")
|
|
510
|
+
logger.setLevel(logging.DEBUG)
|
|
511
|
+
records = []
|
|
512
|
+
handler = logging.Handler()
|
|
513
|
+
handler.emit = lambda r: records.append(r)
|
|
514
|
+
logger.addHandler(handler)
|
|
515
|
+
|
|
516
|
+
b = IssueBuffer()
|
|
517
|
+
b.attach_logger(logger)
|
|
518
|
+
b.emit(IssueLevel.INFO, "X")
|
|
519
|
+
b.detach_logger()
|
|
520
|
+
b.emit(IssueLevel.INFO, "Y")
|
|
521
|
+
assert len(records) == 1
|
|
522
|
+
|
|
523
|
+
def test_logger_level_map_override(self):
|
|
524
|
+
logger = logging.getLogger("margin.test.levelmap")
|
|
525
|
+
logger.setLevel(logging.DEBUG)
|
|
526
|
+
records = []
|
|
527
|
+
handler = logging.Handler()
|
|
528
|
+
handler.emit = lambda r: records.append(r)
|
|
529
|
+
logger.addHandler(handler)
|
|
530
|
+
|
|
531
|
+
b = IssueBuffer()
|
|
532
|
+
# Map INFO to DEBUG so INFO-level issues don't spam user logs
|
|
533
|
+
b.attach_logger(logger, level_map={IssueLevel.INFO: logging.DEBUG})
|
|
534
|
+
b.emit(IssueLevel.INFO, "X")
|
|
535
|
+
# INFO → DEBUG, mirrored at DEBUG level
|
|
536
|
+
assert len(records) == 1 and records[0].levelno == logging.DEBUG
|
|
537
|
+
|
|
538
|
+
def test_logger_failure_does_not_break_buffer(self):
|
|
539
|
+
class BrokenLogger:
|
|
540
|
+
def log(self, *a, **kw):
|
|
541
|
+
raise RuntimeError("logger broken")
|
|
542
|
+
|
|
543
|
+
b = IssueBuffer()
|
|
544
|
+
b.attach_logger(BrokenLogger())
|
|
545
|
+
# Must not raise
|
|
546
|
+
b.emit(IssueLevel.INFO, "X")
|
|
547
|
+
assert len(b) == 1
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
class TestInsufficientSamplesEmission:
|
|
551
|
+
def test_warming_up_emits_info_once_per_component(self):
|
|
552
|
+
m = Monitor(
|
|
553
|
+
Parser(baselines={"cpu": 50.0}, thresholds=Thresholds(intact=40, ablated=10)),
|
|
554
|
+
window=20,
|
|
555
|
+
)
|
|
556
|
+
m.update({"cpu": 50.0})
|
|
557
|
+
m.update({"cpu": 50.0})
|
|
558
|
+
m.update({"cpu": 50.0})
|
|
559
|
+
# drift tracker below min_samples (default 3) for the first call,
|
|
560
|
+
# graduates at the third — we should see exactly one INFO per tracker
|
|
561
|
+
# (drift + anomaly) during warming, not one per call.
|
|
562
|
+
insufficient = m.issues.by_code(IssueCode.INSUFFICIENT_SAMPLES)
|
|
563
|
+
# At most one per tracker kind (drift + anomaly)
|
|
564
|
+
assert len(insufficient) <= 2
|
|
565
|
+
|
|
566
|
+
def test_insufficient_samples_cleared_after_graduation(self):
|
|
567
|
+
m = Monitor(
|
|
568
|
+
Parser(baselines={"cpu": 50.0}, thresholds=Thresholds(intact=40, ablated=10)),
|
|
569
|
+
window=20,
|
|
570
|
+
)
|
|
571
|
+
for _ in range(20):
|
|
572
|
+
m.update({"cpu": 50.0})
|
|
573
|
+
# Drift should have classified by now; warming-up flag cleared.
|
|
574
|
+
assert "cpu" not in m._drift_warming
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
class TestUnknownComponentEmission:
|
|
578
|
+
def test_unknown_component_emits_once(self):
|
|
579
|
+
m = Monitor(
|
|
580
|
+
Parser(baselines={"cpu": 50.0}, thresholds=Thresholds(intact=40, ablated=10)),
|
|
581
|
+
window=20,
|
|
582
|
+
)
|
|
583
|
+
assert m.drift("nonexistent") is None
|
|
584
|
+
assert m.drift("nonexistent") is None # repeated query
|
|
585
|
+
assert m.drift("nonexistent") is None
|
|
586
|
+
unknowns = m.issues.by_code(IssueCode.UNKNOWN_COMPONENT)
|
|
587
|
+
assert len(unknowns) == 1
|
|
588
|
+
assert unknowns[0].component == "nonexistent"
|
|
589
|
+
|
|
590
|
+
def test_unknown_component_different_methods_separate_emission(self):
|
|
591
|
+
m = Monitor(
|
|
592
|
+
Parser(baselines={"cpu": 50.0}, thresholds=Thresholds(intact=40, ablated=10)),
|
|
593
|
+
window=20,
|
|
594
|
+
)
|
|
595
|
+
m.drift("missing")
|
|
596
|
+
m.anomaly("missing") # different call site → separate emission
|
|
597
|
+
unknowns = m.issues.by_code(IssueCode.UNKNOWN_COMPONENT)
|
|
598
|
+
assert len(unknowns) == 2
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
class TestNearZeroStdEmission:
|
|
602
|
+
def test_anomaly_score_emits_near_zero_std(self):
|
|
603
|
+
from margin import Fingerprint
|
|
604
|
+
m = Monitor(
|
|
605
|
+
Parser(baselines={"x": 1.0}, thresholds=Thresholds(intact=0.8, ablated=0.2)),
|
|
606
|
+
window=20,
|
|
607
|
+
)
|
|
608
|
+
for _ in range(10):
|
|
609
|
+
m.update({"x": 1.0}) # constant — std essentially 0
|
|
610
|
+
current = m.fingerprint()
|
|
611
|
+
# Construct a reference fingerprint with near-zero std
|
|
612
|
+
ref = Fingerprint(stats={
|
|
613
|
+
"x": {"mean": 1.0, "std": 0.0, "n": 10, "trend": "STABLE"}
|
|
614
|
+
})
|
|
615
|
+
score = m.anomaly_score(ref)
|
|
616
|
+
# Skipped metric → NEAR_ZERO_STD issue
|
|
617
|
+
near_zero = m.issues.by_code(IssueCode.NEAR_ZERO_STD)
|
|
618
|
+
assert len(near_zero) == 1
|
|
619
|
+
assert "x" in near_zero[0].context.get("skipped_metrics", [])
|
|
620
|
+
|
|
621
|
+
|
|
622
|
+
class TestStageAblatedOnEntry:
|
|
623
|
+
def test_emits_warning_when_first_obs_ablated(self):
|
|
624
|
+
stage = Stage(
|
|
625
|
+
name="init",
|
|
626
|
+
parser=Parser(
|
|
627
|
+
baselines={"cpu": 50.0},
|
|
628
|
+
thresholds=Thresholds(intact=40, ablated=30),
|
|
629
|
+
),
|
|
630
|
+
)
|
|
631
|
+
f = Flow(stages=[stage], label="test")
|
|
632
|
+
# Value way below ablated threshold → ABLATED on first obs
|
|
633
|
+
result = f.observe({"cpu": 5.0})
|
|
634
|
+
entry_issues = f.issues.by_code(IssueCode.STAGE_ABLATED_ON_ENTRY)
|
|
635
|
+
assert len(entry_issues) == 1
|
|
636
|
+
assert entry_issues[0].component == "init"
|
|
637
|
+
assert "cpu" in entry_issues[0].context.get("ablated_components", [])
|
|
638
|
+
# Also attached to the step result
|
|
639
|
+
assert any(i.code == IssueCode.STAGE_ABLATED_ON_ENTRY for i in result.issues)
|
|
640
|
+
|
|
641
|
+
def test_no_emission_when_first_obs_healthy(self):
|
|
642
|
+
stage = Stage(
|
|
643
|
+
name="init",
|
|
644
|
+
parser=Parser(
|
|
645
|
+
baselines={"cpu": 50.0},
|
|
646
|
+
thresholds=Thresholds(intact=40, ablated=10),
|
|
647
|
+
),
|
|
648
|
+
)
|
|
649
|
+
f = Flow(stages=[stage])
|
|
650
|
+
f.observe({"cpu": 50.0})
|
|
651
|
+
assert len(f.issues.by_code(IssueCode.STAGE_ABLATED_ON_ENTRY)) == 0
|
|
652
|
+
|
|
653
|
+
def test_emission_only_on_first_obs_not_later(self):
|
|
654
|
+
"""If stage degrades on step 2, we don't re-emit STAGE_ABLATED_ON_ENTRY."""
|
|
655
|
+
stage = Stage(
|
|
656
|
+
name="init",
|
|
657
|
+
parser=Parser(
|
|
658
|
+
baselines={"cpu": 50.0},
|
|
659
|
+
thresholds=Thresholds(intact=40, ablated=10),
|
|
660
|
+
),
|
|
661
|
+
)
|
|
662
|
+
f = Flow(stages=[stage])
|
|
663
|
+
f.observe({"cpu": 50.0}) # healthy first obs
|
|
664
|
+
f.observe({"cpu": 5.0}) # ABLATED second obs — not entry
|
|
665
|
+
entry_issues = f.issues.by_code(IssueCode.STAGE_ABLATED_ON_ENTRY)
|
|
666
|
+
assert len(entry_issues) == 0
|
|
667
|
+
|
|
668
|
+
|
|
669
|
+
class TestStatusIncludesIssues:
|
|
670
|
+
def test_status_includes_issues_key(self):
|
|
671
|
+
m = Monitor(
|
|
672
|
+
Parser(baselines={"x": 1.0}, thresholds=Thresholds(intact=0.8, ablated=0.2)),
|
|
673
|
+
window=20,
|
|
674
|
+
)
|
|
675
|
+
m.update({"x": 1.0})
|
|
676
|
+
s = m.status()
|
|
677
|
+
assert "issues" in s
|
|
678
|
+
assert "total" in s["issues"]
|
|
679
|
+
assert "counts" in s["issues"]
|
|
680
|
+
|
|
681
|
+
def test_status_issues_are_json_serializable(self):
|
|
682
|
+
m = Monitor(
|
|
683
|
+
Parser(baselines={"cpu": 50.0}, thresholds=Thresholds(intact=40, ablated=10)),
|
|
684
|
+
window=20,
|
|
685
|
+
)
|
|
686
|
+
m.update({"cpu": 50.0})
|
|
687
|
+
m.drift("nonexistent") # emits UNKNOWN_COMPONENT
|
|
688
|
+
s = m.status()
|
|
689
|
+
# Full status() must be json-safe — that's the dashboard contract.
|
|
690
|
+
json.dumps(s)
|
|
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
|
|
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
|