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.

Files changed (96) hide show
  1. {margin-0.9.30/margin.egg-info → margin-0.9.32}/PKG-INFO +55 -2
  2. {margin-0.9.30 → margin-0.9.32}/README.md +54 -1
  3. {margin-0.9.30 → margin-0.9.32}/margin/flow.py +20 -0
  4. {margin-0.9.30 → margin-0.9.32}/margin/issue.py +80 -8
  5. {margin-0.9.30 → margin-0.9.32}/margin/streaming.py +92 -4
  6. {margin-0.9.30 → margin-0.9.32/margin.egg-info}/PKG-INFO +55 -2
  7. {margin-0.9.30 → margin-0.9.32}/pyproject.toml +1 -1
  8. {margin-0.9.30 → margin-0.9.32}/tests/test_issue.py +247 -7
  9. {margin-0.9.30 → margin-0.9.32}/LICENSE +0 -0
  10. {margin-0.9.30 → margin-0.9.32}/NOTICE +0 -0
  11. {margin-0.9.30 → margin-0.9.32}/margin/__init__.py +0 -0
  12. {margin-0.9.30 → margin-0.9.32}/margin/__main__.py +0 -0
  13. {margin-0.9.30 → margin-0.9.32}/margin/algebra.py +0 -0
  14. {margin-0.9.30 → margin-0.9.32}/margin/anomaly.py +0 -0
  15. {margin-0.9.30 → margin-0.9.32}/margin/bridge.py +0 -0
  16. {margin-0.9.30 → margin-0.9.32}/margin/calibrate.py +0 -0
  17. {margin-0.9.30 → margin-0.9.32}/margin/causal.py +0 -0
  18. {margin-0.9.30 → margin-0.9.32}/margin/composite.py +0 -0
  19. {margin-0.9.30 → margin-0.9.32}/margin/confidence.py +0 -0
  20. {margin-0.9.30 → margin-0.9.32}/margin/config.py +0 -0
  21. {margin-0.9.30 → margin-0.9.32}/margin/contract.py +0 -0
  22. {margin-0.9.30 → margin-0.9.32}/margin/controller.py +0 -0
  23. {margin-0.9.30 → margin-0.9.32}/margin/correlate.py +0 -0
  24. {margin-0.9.30 → margin-0.9.32}/margin/diff.py +0 -0
  25. {margin-0.9.30 → margin-0.9.32}/margin/drift.py +0 -0
  26. {margin-0.9.30 → margin-0.9.32}/margin/events.py +0 -0
  27. {margin-0.9.30 → margin-0.9.32}/margin/fingerprint.py +0 -0
  28. {margin-0.9.30 → margin-0.9.32}/margin/forecast.py +0 -0
  29. {margin-0.9.30 → margin-0.9.32}/margin/health.py +0 -0
  30. {margin-0.9.30 → margin-0.9.32}/margin/intent.py +0 -0
  31. {margin-0.9.30 → margin-0.9.32}/margin/ledger.py +0 -0
  32. {margin-0.9.30 → margin-0.9.32}/margin/loop.py +0 -0
  33. {margin-0.9.30 → margin-0.9.32}/margin/observation.py +0 -0
  34. {margin-0.9.30 → margin-0.9.32}/margin/persist.py +0 -0
  35. {margin-0.9.30 → margin-0.9.32}/margin/policy/__init__.py +0 -0
  36. {margin-0.9.30 → margin-0.9.32}/margin/policy/compose.py +0 -0
  37. {margin-0.9.30 → margin-0.9.32}/margin/policy/core.py +0 -0
  38. {margin-0.9.30 → margin-0.9.32}/margin/policy/temporal.py +0 -0
  39. {margin-0.9.30 → margin-0.9.32}/margin/policy/trace.py +0 -0
  40. {margin-0.9.30 → margin-0.9.32}/margin/policy/tuning.py +0 -0
  41. {margin-0.9.30 → margin-0.9.32}/margin/policy/validate.py +0 -0
  42. {margin-0.9.30 → margin-0.9.32}/margin/predicates.py +0 -0
  43. {margin-0.9.30 → margin-0.9.32}/margin/provenance.py +0 -0
  44. {margin-0.9.30 → margin-0.9.32}/margin/transitions.py +0 -0
  45. {margin-0.9.30 → margin-0.9.32}/margin/uncertain.py +0 -0
  46. {margin-0.9.30 → margin-0.9.32}/margin/validity.py +0 -0
  47. {margin-0.9.30 → margin-0.9.32}/margin.egg-info/SOURCES.txt +0 -0
  48. {margin-0.9.30 → margin-0.9.32}/margin.egg-info/dependency_links.txt +0 -0
  49. {margin-0.9.30 → margin-0.9.32}/margin.egg-info/top_level.txt +0 -0
  50. {margin-0.9.30 → margin-0.9.32}/setup.cfg +0 -0
  51. {margin-0.9.30 → margin-0.9.32}/tests/test_absence.py +0 -0
  52. {margin-0.9.30 → margin-0.9.32}/tests/test_algebra.py +0 -0
  53. {margin-0.9.30 → margin-0.9.32}/tests/test_anomaly.py +0 -0
  54. {margin-0.9.30 → margin-0.9.32}/tests/test_bridge.py +0 -0
  55. {margin-0.9.30 → margin-0.9.32}/tests/test_calibrate.py +0 -0
  56. {margin-0.9.30 → margin-0.9.32}/tests/test_causal.py +0 -0
  57. {margin-0.9.30 → margin-0.9.32}/tests/test_composite.py +0 -0
  58. {margin-0.9.30 → margin-0.9.32}/tests/test_confidence.py +0 -0
  59. {margin-0.9.30 → margin-0.9.32}/tests/test_config.py +0 -0
  60. {margin-0.9.30 → margin-0.9.32}/tests/test_contract.py +0 -0
  61. {margin-0.9.30 → margin-0.9.32}/tests/test_controller.py +0 -0
  62. {margin-0.9.30 → margin-0.9.32}/tests/test_correlate.py +0 -0
  63. {margin-0.9.30 → margin-0.9.32}/tests/test_diff.py +0 -0
  64. {margin-0.9.30 → margin-0.9.32}/tests/test_drift.py +0 -0
  65. {margin-0.9.30 → margin-0.9.32}/tests/test_events.py +0 -0
  66. {margin-0.9.30 → margin-0.9.32}/tests/test_fingerprint_ext.py +0 -0
  67. {margin-0.9.30 → margin-0.9.32}/tests/test_float_tolerance.py +0 -0
  68. {margin-0.9.30 → margin-0.9.32}/tests/test_flow.py +0 -0
  69. {margin-0.9.30 → margin-0.9.32}/tests/test_forecast.py +0 -0
  70. {margin-0.9.30 → margin-0.9.32}/tests/test_health.py +0 -0
  71. {margin-0.9.30 → margin-0.9.32}/tests/test_healthcare.py +0 -0
  72. {margin-0.9.30 → margin-0.9.32}/tests/test_intent.py +0 -0
  73. {margin-0.9.30 → margin-0.9.32}/tests/test_ledger.py +0 -0
  74. {margin-0.9.30 → margin-0.9.32}/tests/test_loop.py +0 -0
  75. {margin-0.9.30 → margin-0.9.32}/tests/test_neuro.py +0 -0
  76. {margin-0.9.30 → margin-0.9.32}/tests/test_observation.py +0 -0
  77. {margin-0.9.30 → margin-0.9.32}/tests/test_persist.py +0 -0
  78. {margin-0.9.30 → margin-0.9.32}/tests/test_pipeline_features.py +0 -0
  79. {margin-0.9.30 → margin-0.9.32}/tests/test_policy.py +0 -0
  80. {margin-0.9.30 → margin-0.9.32}/tests/test_policy_compose.py +0 -0
  81. {margin-0.9.30 → margin-0.9.32}/tests/test_policy_temporal.py +0 -0
  82. {margin-0.9.30 → margin-0.9.32}/tests/test_policy_trace.py +0 -0
  83. {margin-0.9.30 → margin-0.9.32}/tests/test_policy_tuning.py +0 -0
  84. {margin-0.9.30 → margin-0.9.32}/tests/test_policy_validate.py +0 -0
  85. {margin-0.9.30 → margin-0.9.32}/tests/test_predicates.py +0 -0
  86. {margin-0.9.30 → margin-0.9.32}/tests/test_printer3d.py +0 -0
  87. {margin-0.9.30 → margin-0.9.32}/tests/test_provenance.py +0 -0
  88. {margin-0.9.30 → margin-0.9.32}/tests/test_reverse_bridge.py +0 -0
  89. {margin-0.9.30 → margin-0.9.32}/tests/test_ros2.py +0 -0
  90. {margin-0.9.30 → margin-0.9.32}/tests/test_staleness.py +0 -0
  91. {margin-0.9.30 → margin-0.9.32}/tests/test_streaming.py +0 -0
  92. {margin-0.9.30 → margin-0.9.32}/tests/test_transformer.py +0 -0
  93. {margin-0.9.30 → margin-0.9.32}/tests/test_transitions.py +0 -0
  94. {margin-0.9.30 → margin-0.9.32}/tests/test_uncertain.py +0 -0
  95. {margin-0.9.30 → margin-0.9.32}/tests/test_validity.py +0 -0
  96. {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.30
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 all seven in one call.
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 all seven in one call.
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 / finally`` patterns in
20
- the callers. Even a FATAL internal error leaves the full recent
21
- issue history intact for post-mortem inspection.
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, ...], # last n
262
- "codes": {"BASELINE_MISSING": n, ...}, # by code
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": self.tail(n),
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
- return t.classification if t else None
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
- return t.classification if t else None
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.30
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 all seven in one call.
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.30"
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
- assert len(m.issues) == 1
241
- assert m.issues.tail(1)[0].code == IssueCode.BASELINE_MISSING
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
- assert len(m.issues) == 3
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
- assert len(m.issues) == 1
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) == 2
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