margin 0.9.28__tar.gz → 0.9.30__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.28/margin.egg-info → margin-0.9.30}/PKG-INFO +1 -1
- {margin-0.9.28 → margin-0.9.30}/margin/__init__.py +5 -0
- {margin-0.9.28 → margin-0.9.30}/margin/flow.py +194 -20
- margin-0.9.30/margin/issue.py +325 -0
- {margin-0.9.28 → margin-0.9.30}/margin/observation.py +20 -6
- {margin-0.9.28 → margin-0.9.30}/margin/streaming.py +76 -44
- {margin-0.9.28 → margin-0.9.30/margin.egg-info}/PKG-INFO +1 -1
- {margin-0.9.28 → margin-0.9.30}/margin.egg-info/SOURCES.txt +2 -0
- {margin-0.9.28 → margin-0.9.30}/pyproject.toml +1 -1
- {margin-0.9.28 → margin-0.9.30}/tests/test_flow.py +88 -7
- margin-0.9.30/tests/test_issue.py +450 -0
- {margin-0.9.28 → margin-0.9.30}/LICENSE +0 -0
- {margin-0.9.28 → margin-0.9.30}/NOTICE +0 -0
- {margin-0.9.28 → margin-0.9.30}/README.md +0 -0
- {margin-0.9.28 → margin-0.9.30}/margin/__main__.py +0 -0
- {margin-0.9.28 → margin-0.9.30}/margin/algebra.py +0 -0
- {margin-0.9.28 → margin-0.9.30}/margin/anomaly.py +0 -0
- {margin-0.9.28 → margin-0.9.30}/margin/bridge.py +0 -0
- {margin-0.9.28 → margin-0.9.30}/margin/calibrate.py +0 -0
- {margin-0.9.28 → margin-0.9.30}/margin/causal.py +0 -0
- {margin-0.9.28 → margin-0.9.30}/margin/composite.py +0 -0
- {margin-0.9.28 → margin-0.9.30}/margin/confidence.py +0 -0
- {margin-0.9.28 → margin-0.9.30}/margin/config.py +0 -0
- {margin-0.9.28 → margin-0.9.30}/margin/contract.py +0 -0
- {margin-0.9.28 → margin-0.9.30}/margin/controller.py +0 -0
- {margin-0.9.28 → margin-0.9.30}/margin/correlate.py +0 -0
- {margin-0.9.28 → margin-0.9.30}/margin/diff.py +0 -0
- {margin-0.9.28 → margin-0.9.30}/margin/drift.py +0 -0
- {margin-0.9.28 → margin-0.9.30}/margin/events.py +0 -0
- {margin-0.9.28 → margin-0.9.30}/margin/fingerprint.py +0 -0
- {margin-0.9.28 → margin-0.9.30}/margin/forecast.py +0 -0
- {margin-0.9.28 → margin-0.9.30}/margin/health.py +0 -0
- {margin-0.9.28 → margin-0.9.30}/margin/intent.py +0 -0
- {margin-0.9.28 → margin-0.9.30}/margin/ledger.py +0 -0
- {margin-0.9.28 → margin-0.9.30}/margin/loop.py +0 -0
- {margin-0.9.28 → margin-0.9.30}/margin/persist.py +0 -0
- {margin-0.9.28 → margin-0.9.30}/margin/policy/__init__.py +0 -0
- {margin-0.9.28 → margin-0.9.30}/margin/policy/compose.py +0 -0
- {margin-0.9.28 → margin-0.9.30}/margin/policy/core.py +0 -0
- {margin-0.9.28 → margin-0.9.30}/margin/policy/temporal.py +0 -0
- {margin-0.9.28 → margin-0.9.30}/margin/policy/trace.py +0 -0
- {margin-0.9.28 → margin-0.9.30}/margin/policy/tuning.py +0 -0
- {margin-0.9.28 → margin-0.9.30}/margin/policy/validate.py +0 -0
- {margin-0.9.28 → margin-0.9.30}/margin/predicates.py +0 -0
- {margin-0.9.28 → margin-0.9.30}/margin/provenance.py +0 -0
- {margin-0.9.28 → margin-0.9.30}/margin/transitions.py +0 -0
- {margin-0.9.28 → margin-0.9.30}/margin/uncertain.py +0 -0
- {margin-0.9.28 → margin-0.9.30}/margin/validity.py +0 -0
- {margin-0.9.28 → margin-0.9.30}/margin.egg-info/dependency_links.txt +0 -0
- {margin-0.9.28 → margin-0.9.30}/margin.egg-info/top_level.txt +0 -0
- {margin-0.9.28 → margin-0.9.30}/setup.cfg +0 -0
- {margin-0.9.28 → margin-0.9.30}/tests/test_absence.py +0 -0
- {margin-0.9.28 → margin-0.9.30}/tests/test_algebra.py +0 -0
- {margin-0.9.28 → margin-0.9.30}/tests/test_anomaly.py +0 -0
- {margin-0.9.28 → margin-0.9.30}/tests/test_bridge.py +0 -0
- {margin-0.9.28 → margin-0.9.30}/tests/test_calibrate.py +0 -0
- {margin-0.9.28 → margin-0.9.30}/tests/test_causal.py +0 -0
- {margin-0.9.28 → margin-0.9.30}/tests/test_composite.py +0 -0
- {margin-0.9.28 → margin-0.9.30}/tests/test_confidence.py +0 -0
- {margin-0.9.28 → margin-0.9.30}/tests/test_config.py +0 -0
- {margin-0.9.28 → margin-0.9.30}/tests/test_contract.py +0 -0
- {margin-0.9.28 → margin-0.9.30}/tests/test_controller.py +0 -0
- {margin-0.9.28 → margin-0.9.30}/tests/test_correlate.py +0 -0
- {margin-0.9.28 → margin-0.9.30}/tests/test_diff.py +0 -0
- {margin-0.9.28 → margin-0.9.30}/tests/test_drift.py +0 -0
- {margin-0.9.28 → margin-0.9.30}/tests/test_events.py +0 -0
- {margin-0.9.28 → margin-0.9.30}/tests/test_fingerprint_ext.py +0 -0
- {margin-0.9.28 → margin-0.9.30}/tests/test_float_tolerance.py +0 -0
- {margin-0.9.28 → margin-0.9.30}/tests/test_forecast.py +0 -0
- {margin-0.9.28 → margin-0.9.30}/tests/test_health.py +0 -0
- {margin-0.9.28 → margin-0.9.30}/tests/test_healthcare.py +0 -0
- {margin-0.9.28 → margin-0.9.30}/tests/test_intent.py +0 -0
- {margin-0.9.28 → margin-0.9.30}/tests/test_ledger.py +0 -0
- {margin-0.9.28 → margin-0.9.30}/tests/test_loop.py +0 -0
- {margin-0.9.28 → margin-0.9.30}/tests/test_neuro.py +0 -0
- {margin-0.9.28 → margin-0.9.30}/tests/test_observation.py +0 -0
- {margin-0.9.28 → margin-0.9.30}/tests/test_persist.py +0 -0
- {margin-0.9.28 → margin-0.9.30}/tests/test_pipeline_features.py +0 -0
- {margin-0.9.28 → margin-0.9.30}/tests/test_policy.py +0 -0
- {margin-0.9.28 → margin-0.9.30}/tests/test_policy_compose.py +0 -0
- {margin-0.9.28 → margin-0.9.30}/tests/test_policy_temporal.py +0 -0
- {margin-0.9.28 → margin-0.9.30}/tests/test_policy_trace.py +0 -0
- {margin-0.9.28 → margin-0.9.30}/tests/test_policy_tuning.py +0 -0
- {margin-0.9.28 → margin-0.9.30}/tests/test_policy_validate.py +0 -0
- {margin-0.9.28 → margin-0.9.30}/tests/test_predicates.py +0 -0
- {margin-0.9.28 → margin-0.9.30}/tests/test_printer3d.py +0 -0
- {margin-0.9.28 → margin-0.9.30}/tests/test_provenance.py +0 -0
- {margin-0.9.28 → margin-0.9.30}/tests/test_reverse_bridge.py +0 -0
- {margin-0.9.28 → margin-0.9.30}/tests/test_ros2.py +0 -0
- {margin-0.9.28 → margin-0.9.30}/tests/test_staleness.py +0 -0
- {margin-0.9.28 → margin-0.9.30}/tests/test_streaming.py +0 -0
- {margin-0.9.28 → margin-0.9.30}/tests/test_transformer.py +0 -0
- {margin-0.9.28 → margin-0.9.30}/tests/test_transitions.py +0 -0
- {margin-0.9.28 → margin-0.9.30}/tests/test_uncertain.py +0 -0
- {margin-0.9.28 → margin-0.9.30}/tests/test_validity.py +0 -0
- {margin-0.9.28 → margin-0.9.30}/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.30
|
|
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
|
|
@@ -120,6 +120,9 @@ from .intent import (
|
|
|
120
120
|
# Loop
|
|
121
121
|
from .loop import StepResult, step, run, FullStepResult, full_step
|
|
122
122
|
|
|
123
|
+
# Issue (diagnostics)
|
|
124
|
+
from .issue import Issue, IssueLevel, IssueCode, IssueBuffer, ISSUE_SEVERITY
|
|
125
|
+
|
|
123
126
|
# Flow
|
|
124
127
|
from .flow import Flow, Stage, Guidance, FlowResult, GUIDANCE_SEVERITY
|
|
125
128
|
|
|
@@ -193,6 +196,8 @@ __all__ = [
|
|
|
193
196
|
"save_monitor", "load_monitor", "replay", "replay_csv",
|
|
194
197
|
# Loop
|
|
195
198
|
"StepResult", "step", "run", "FullStepResult", "full_step",
|
|
199
|
+
# Issue (diagnostics)
|
|
200
|
+
"Issue", "IssueLevel", "IssueCode", "IssueBuffer", "ISSUE_SEVERITY",
|
|
196
201
|
# Flow
|
|
197
202
|
"Flow", "Stage", "Guidance", "FlowResult", "GUIDANCE_SEVERITY",
|
|
198
203
|
# margin-poc compat aliases
|
|
@@ -55,12 +55,13 @@ users plug in the full Contract machinery when they need it.
|
|
|
55
55
|
|
|
56
56
|
from __future__ import annotations
|
|
57
57
|
|
|
58
|
-
from dataclasses import dataclass
|
|
58
|
+
from dataclasses import dataclass
|
|
59
59
|
from datetime import datetime
|
|
60
60
|
from enum import Enum
|
|
61
61
|
from typing import Callable, Dict, List, Optional
|
|
62
62
|
|
|
63
63
|
from .health import Health
|
|
64
|
+
from .issue import Issue, IssueBuffer, IssueCode, IssueLevel
|
|
64
65
|
from .observation import Expression, Parser
|
|
65
66
|
from .streaming import Monitor
|
|
66
67
|
|
|
@@ -143,29 +144,63 @@ class Stage:
|
|
|
143
144
|
@dataclass
|
|
144
145
|
class FlowResult:
|
|
145
146
|
"""Returned from Flow.observe() and flow advancement helpers."""
|
|
147
|
+
flow_label: str
|
|
146
148
|
stage: str
|
|
147
149
|
stage_index: int
|
|
148
150
|
total_stages: int
|
|
149
151
|
expression: Optional[Expression]
|
|
150
152
|
guidance: Guidance
|
|
151
153
|
reason: str
|
|
154
|
+
issues: List[Issue] = None # type: ignore[assignment]
|
|
155
|
+
|
|
156
|
+
def __post_init__(self):
|
|
157
|
+
if self.issues is None:
|
|
158
|
+
self.issues = []
|
|
152
159
|
|
|
153
160
|
def to_atom(self) -> str:
|
|
154
161
|
"""
|
|
155
|
-
Compact flow notation.
|
|
162
|
+
Compact flow notation — consistent format across running and complete states.
|
|
163
|
+
|
|
164
|
+
With a flow label::
|
|
156
165
|
|
|
157
166
|
"flow:fermentation:lag_phase[2/5] → ADVANCE"
|
|
158
|
-
"flow:fermentation
|
|
167
|
+
"flow:fermentation[5/5]" # complete
|
|
168
|
+
|
|
169
|
+
Without a flow label (empty)::
|
|
170
|
+
|
|
171
|
+
"flow:lag_phase[2/5] → ADVANCE"
|
|
172
|
+
"flow[5/5]" # complete
|
|
159
173
|
"""
|
|
174
|
+
prefix = f"flow:{self.flow_label}" if self.flow_label else "flow"
|
|
175
|
+
suffix = ""
|
|
176
|
+
if self.issues:
|
|
177
|
+
# Annotate with issue counts by level, in severity order.
|
|
178
|
+
marks = []
|
|
179
|
+
from .issue import IssueLevel as _IL
|
|
180
|
+
counts = {l: 0 for l in _IL}
|
|
181
|
+
for iss in self.issues:
|
|
182
|
+
counts[iss.level] += 1
|
|
183
|
+
level_marks = [
|
|
184
|
+
(_IL.FATAL, "‼"),
|
|
185
|
+
(_IL.ERROR, "!"),
|
|
186
|
+
(_IL.WARNING, "⚠"),
|
|
187
|
+
(_IL.INFO, "·"),
|
|
188
|
+
]
|
|
189
|
+
for lvl, m in level_marks:
|
|
190
|
+
if counts[lvl] > 0:
|
|
191
|
+
marks.append(f"{m}{counts[lvl]}")
|
|
192
|
+
if marks:
|
|
193
|
+
suffix = " (" + " ".join(marks) + ")"
|
|
160
194
|
if self.guidance == Guidance.COMPLETE:
|
|
161
|
-
return f"
|
|
195
|
+
return f"{prefix}[{self.total_stages}/{self.total_stages}]{suffix}"
|
|
162
196
|
return (
|
|
163
|
-
f"
|
|
164
|
-
f" → {self.guidance.value}"
|
|
197
|
+
f"{prefix}:{self.stage}[{self.stage_index + 1}/{self.total_stages}]"
|
|
198
|
+
f" → {self.guidance.value}{suffix}"
|
|
165
199
|
)
|
|
166
200
|
|
|
167
201
|
def to_dict(self) -> dict:
|
|
168
|
-
|
|
202
|
+
d = {
|
|
203
|
+
"flow_label": self.flow_label,
|
|
169
204
|
"stage": self.stage,
|
|
170
205
|
"stage_index": self.stage_index,
|
|
171
206
|
"total_stages": self.total_stages,
|
|
@@ -173,6 +208,9 @@ class FlowResult:
|
|
|
173
208
|
"reason": self.reason,
|
|
174
209
|
"expression": self.expression.to_dict() if self.expression else None,
|
|
175
210
|
}
|
|
211
|
+
if self.issues:
|
|
212
|
+
d["issues"] = [i.to_dict() for i in self.issues]
|
|
213
|
+
return d
|
|
176
214
|
|
|
177
215
|
def __repr__(self) -> str:
|
|
178
216
|
return f"FlowResult({self.to_atom()} — {self.reason})"
|
|
@@ -194,6 +232,36 @@ class Flow:
|
|
|
194
232
|
(or a policy layer above) decides whether to call ``advance()``. This
|
|
195
233
|
keeps the flow deterministic and side-effect free, which matters for
|
|
196
234
|
replay, backtesting, and safety-critical use cases.
|
|
235
|
+
|
|
236
|
+
Thread safety
|
|
237
|
+
-------------
|
|
238
|
+
Flow is **not thread-safe**. ``observe()`` mutates the current stage's
|
|
239
|
+
Monitor window; ``advance()`` and ``rollback()`` mutate the stage index.
|
|
240
|
+
If you need to observe a flow from multiple threads, wrap it in a lock
|
|
241
|
+
at the call site. Per-entity flows in a request/response context (one
|
|
242
|
+
Flow instance per tracked entity) are the intended pattern.
|
|
243
|
+
|
|
244
|
+
Predicate ordering
|
|
245
|
+
------------------
|
|
246
|
+
``_classify`` evaluates predicates in strict severity order:
|
|
247
|
+
|
|
248
|
+
1. ``escalate_when`` (highest — safety gate)
|
|
249
|
+
2. ``rollback_when``
|
|
250
|
+
3. ``advance_when``
|
|
251
|
+
4. ``retry_when``
|
|
252
|
+
|
|
253
|
+
A true ``escalate_when`` dominates a true ``advance_when`` — this is
|
|
254
|
+
intentional. When a stage is in an escalation condition, we do not
|
|
255
|
+
advance even if exit criteria are also met. Safety-first semantics.
|
|
256
|
+
|
|
257
|
+
Memory note
|
|
258
|
+
-----------
|
|
259
|
+
Each ``advance()`` creates a fresh Monitor for the new stage without
|
|
260
|
+
pruning the prior stage's Monitor. For a 50-stage pipeline, 50 Monitor
|
|
261
|
+
instances accumulate in ``self._monitors``. This is intentional for
|
|
262
|
+
debuggability but may matter for very long flows. A pruning option
|
|
263
|
+
may be added in a future release; for now, construct a new Flow if
|
|
264
|
+
memory footprint matters.
|
|
197
265
|
"""
|
|
198
266
|
|
|
199
267
|
def __init__(
|
|
@@ -214,6 +282,21 @@ class Flow:
|
|
|
214
282
|
}
|
|
215
283
|
self._transitions: List[dict] = []
|
|
216
284
|
self._entered_at: datetime = datetime.now()
|
|
285
|
+
self._issues: IssueBuffer = IssueBuffer()
|
|
286
|
+
|
|
287
|
+
# Emit INFO if a stage has no predicates at all (will HOLD forever)
|
|
288
|
+
for s in self.stages:
|
|
289
|
+
if (s.advance_when is None and s.retry_when is None
|
|
290
|
+
and s.escalate_when is None and s.rollback_when is None):
|
|
291
|
+
self._issues.emit(
|
|
292
|
+
IssueLevel.INFO,
|
|
293
|
+
IssueCode.NO_PREDICATES,
|
|
294
|
+
detail=(
|
|
295
|
+
f"stage {s.name!r} has no predicates; will only leave "
|
|
296
|
+
"via explicit advance()/rollback()."
|
|
297
|
+
),
|
|
298
|
+
component=s.name,
|
|
299
|
+
)
|
|
217
300
|
|
|
218
301
|
# ------------------------------------------------------------------
|
|
219
302
|
# Inspection
|
|
@@ -246,6 +329,15 @@ class Flow:
|
|
|
246
329
|
"""History of stage transitions."""
|
|
247
330
|
return list(self._transitions)
|
|
248
331
|
|
|
332
|
+
@property
|
|
333
|
+
def issues(self) -> IssueBuffer:
|
|
334
|
+
"""IssueBuffer of diagnostics this Flow has emitted. Harvestable."""
|
|
335
|
+
return self._issues
|
|
336
|
+
|
|
337
|
+
def diagnose(self, n: int = 10) -> dict:
|
|
338
|
+
"""Dashboard-friendly summary of recent issues. See IssueBuffer.diagnose."""
|
|
339
|
+
return self._issues.diagnose(n)
|
|
340
|
+
|
|
249
341
|
# ------------------------------------------------------------------
|
|
250
342
|
# Observation
|
|
251
343
|
# ------------------------------------------------------------------
|
|
@@ -256,42 +348,121 @@ class Flow:
|
|
|
256
348
|
|
|
257
349
|
``values`` and any kwargs are forwarded to ``Monitor.update()``, so
|
|
258
350
|
confidences, absences, and provenance all pass through.
|
|
351
|
+
|
|
352
|
+
Hard-fail semantics: if the underlying Monitor raises, the issue
|
|
353
|
+
is captured as FATAL on the Flow buffer and re-raised. Predicate
|
|
354
|
+
exceptions are caught (not re-raised), recorded as ERROR issues,
|
|
355
|
+
and guidance degrades to HOLD with a ``predicate failure`` reason.
|
|
259
356
|
"""
|
|
357
|
+
import traceback as _tb
|
|
358
|
+
|
|
260
359
|
if self.is_complete:
|
|
261
360
|
return FlowResult(
|
|
361
|
+
flow_label=self.label,
|
|
262
362
|
stage=self.label or "flow",
|
|
263
363
|
stage_index=len(self.stages),
|
|
264
364
|
total_stages=len(self.stages),
|
|
265
365
|
expression=None,
|
|
266
366
|
guidance=Guidance.COMPLETE,
|
|
267
367
|
reason="all stages complete",
|
|
368
|
+
issues=[],
|
|
268
369
|
)
|
|
269
370
|
|
|
270
371
|
stage = self.current
|
|
271
372
|
monitor = self._monitors[stage.name]
|
|
272
|
-
|
|
273
|
-
|
|
373
|
+
try:
|
|
374
|
+
expr = monitor.update(values, **kw)
|
|
375
|
+
except Exception as _e:
|
|
376
|
+
self._issues.emit(
|
|
377
|
+
IssueLevel.FATAL,
|
|
378
|
+
IssueCode.INTERNAL_ERROR,
|
|
379
|
+
detail=f"Monitor.update raised in stage {stage.name!r}: {_e}",
|
|
380
|
+
component=stage.name,
|
|
381
|
+
exc_type=type(_e).__name__,
|
|
382
|
+
traceback=_tb.format_exc(),
|
|
383
|
+
)
|
|
384
|
+
raise
|
|
385
|
+
|
|
386
|
+
# Collect issues produced this step: Parser's issues are on expr,
|
|
387
|
+
# predicate failures are emitted during _classify.
|
|
388
|
+
step_issues: List[Issue] = list(expr.issues)
|
|
389
|
+
guidance, reason = self._classify(stage, expr, step_issues)
|
|
274
390
|
|
|
275
391
|
return FlowResult(
|
|
392
|
+
flow_label=self.label,
|
|
276
393
|
stage=stage.name,
|
|
277
394
|
stage_index=self._index,
|
|
278
395
|
total_stages=len(self.stages),
|
|
279
396
|
expression=expr,
|
|
280
397
|
guidance=guidance,
|
|
281
398
|
reason=reason,
|
|
399
|
+
issues=step_issues,
|
|
282
400
|
)
|
|
283
401
|
|
|
284
|
-
def
|
|
285
|
-
|
|
402
|
+
def _safe_predicate(
|
|
403
|
+
self,
|
|
404
|
+
predicate,
|
|
405
|
+
expr: Expression,
|
|
406
|
+
name: str,
|
|
407
|
+
stage_name: str,
|
|
408
|
+
collected: List[Issue],
|
|
409
|
+
) -> Optional[bool]:
|
|
410
|
+
"""
|
|
411
|
+
Call a stage predicate, capturing any exception as an ERROR issue.
|
|
412
|
+
Returns None on failure (distinguishable from False), the bool result
|
|
413
|
+
on success. Issues go both on the Flow buffer (for post-mortem) and
|
|
414
|
+
in ``collected`` (for the FlowResult this step).
|
|
415
|
+
"""
|
|
416
|
+
try:
|
|
417
|
+
return bool(predicate(expr))
|
|
418
|
+
except Exception as _e:
|
|
419
|
+
import traceback as _tb
|
|
420
|
+
iss = self._issues.emit(
|
|
421
|
+
IssueLevel.ERROR,
|
|
422
|
+
IssueCode.PREDICATE_FAILURE,
|
|
423
|
+
detail=f"{name} raised in stage {stage_name!r}: {_e}",
|
|
424
|
+
component=stage_name,
|
|
425
|
+
predicate=name,
|
|
426
|
+
exc_type=type(_e).__name__,
|
|
427
|
+
traceback=_tb.format_exc(),
|
|
428
|
+
)
|
|
429
|
+
collected.append(iss)
|
|
430
|
+
return None
|
|
431
|
+
|
|
432
|
+
def _classify(
|
|
433
|
+
self, stage: Stage, expr: Expression, collected: List[Issue],
|
|
434
|
+
) -> tuple[Guidance, str]:
|
|
435
|
+
"""Evaluate stage predicates in severity order, safely."""
|
|
286
436
|
# Highest severity first — a true escalate dominates an advance
|
|
287
|
-
if stage.escalate_when is not None
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
if stage.
|
|
294
|
-
|
|
437
|
+
if stage.escalate_when is not None:
|
|
438
|
+
fired = self._safe_predicate(
|
|
439
|
+
stage.escalate_when, expr, "escalate_when", stage.name, collected,
|
|
440
|
+
)
|
|
441
|
+
if fired:
|
|
442
|
+
return Guidance.ESCALATE, "escalate_when fired"
|
|
443
|
+
if stage.rollback_when is not None:
|
|
444
|
+
fired = self._safe_predicate(
|
|
445
|
+
stage.rollback_when, expr, "rollback_when", stage.name, collected,
|
|
446
|
+
)
|
|
447
|
+
if fired:
|
|
448
|
+
return Guidance.ROLLBACK, "rollback_when fired"
|
|
449
|
+
if stage.advance_when is not None:
|
|
450
|
+
fired = self._safe_predicate(
|
|
451
|
+
stage.advance_when, expr, "advance_when", stage.name, collected,
|
|
452
|
+
)
|
|
453
|
+
if fired:
|
|
454
|
+
return Guidance.ADVANCE, "advance_when fired"
|
|
455
|
+
if stage.retry_when is not None:
|
|
456
|
+
fired = self._safe_predicate(
|
|
457
|
+
stage.retry_when, expr, "retry_when", stage.name, collected,
|
|
458
|
+
)
|
|
459
|
+
if fired:
|
|
460
|
+
return Guidance.RETRY, "retry_when fired"
|
|
461
|
+
|
|
462
|
+
# If any predicate raised, surface that in the reason so callers
|
|
463
|
+
# notice degraded guidance without having to inspect the buffer.
|
|
464
|
+
if any(i.code == IssueCode.PREDICATE_FAILURE for i in collected):
|
|
465
|
+
return Guidance.HOLD, "predicate failure — see issues"
|
|
295
466
|
|
|
296
467
|
# Fallback classification using observation health alone
|
|
297
468
|
if any(o.health == Health.ABLATED for o in expr.observations):
|
|
@@ -343,15 +514,17 @@ class Flow:
|
|
|
343
514
|
return self._status_result("rolled back")
|
|
344
515
|
|
|
345
516
|
def reset(self) -> None:
|
|
346
|
-
"""Reset to the first stage. Clears
|
|
517
|
+
"""Reset to the first stage. Clears monitors, transitions, and issues."""
|
|
347
518
|
self._index = 0
|
|
348
519
|
self._monitors = {self.stages[0].name: self.stages[0].create_monitor()}
|
|
349
520
|
self._transitions = []
|
|
350
521
|
self._entered_at = datetime.now()
|
|
522
|
+
self._issues.clear()
|
|
351
523
|
|
|
352
524
|
def _status_result(self, reason: str) -> FlowResult:
|
|
353
525
|
if self.is_complete:
|
|
354
526
|
return FlowResult(
|
|
527
|
+
flow_label=self.label,
|
|
355
528
|
stage=self.label or "flow",
|
|
356
529
|
stage_index=len(self.stages),
|
|
357
530
|
total_stages=len(self.stages),
|
|
@@ -360,6 +533,7 @@ class Flow:
|
|
|
360
533
|
reason=reason,
|
|
361
534
|
)
|
|
362
535
|
return FlowResult(
|
|
536
|
+
flow_label=self.label,
|
|
363
537
|
stage=self.current.name,
|
|
364
538
|
stage_index=self._index,
|
|
365
539
|
total_stages=len(self.stages),
|
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Issue: typed diagnostics for the margin machinery itself.
|
|
3
|
+
|
|
4
|
+
Where Health describes the state of what we're observing, and Absence
|
|
5
|
+
describes why a value is missing, Issue describes something the library
|
|
6
|
+
noticed about its own operation: a misconfiguration, a degraded
|
|
7
|
+
assumption, a recovered failure, a near-crash.
|
|
8
|
+
|
|
9
|
+
Design constraints
|
|
10
|
+
------------------
|
|
11
|
+
|
|
12
|
+
1. **Usable, not just aesthetic.** Issues accumulate on long-lived
|
|
13
|
+
objects (``Monitor._issues``, ``Flow._issues``) and are attached to
|
|
14
|
+
per-call results (``Expression.issues``, ``FlowResult.issues``).
|
|
15
|
+
Consumers never have to construct their own Issues — the library
|
|
16
|
+
emits them at known sites.
|
|
17
|
+
|
|
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.
|
|
22
|
+
|
|
23
|
+
3. **Typed, not strings.** Issue.code is a short stable identifier
|
|
24
|
+
(``BASELINE_MISSING``, ``PREDICATE_FAILURE``) that consumers can
|
|
25
|
+
filter and aggregate programmatically — the same way DriftState
|
|
26
|
+
or Health are typed.
|
|
27
|
+
|
|
28
|
+
Exceptions vs. Issues
|
|
29
|
+
---------------------
|
|
30
|
+
|
|
31
|
+
- **Exceptions** mean "your code is broken." Invalid arguments, wrong
|
|
32
|
+
types, structural errors. These raise loudly.
|
|
33
|
+
- **Issues** mean "something worth noting happened in the system we
|
|
34
|
+
observe or in how we're observing it." Missing baselines,
|
|
35
|
+
insufficient samples, near-zero std, recovered predicate failures.
|
|
36
|
+
These accumulate.
|
|
37
|
+
|
|
38
|
+
Convenience: ``diagnose()`` on Monitor/Flow returns a grouped summary
|
|
39
|
+
suitable for dashboards or log aggregation.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
from __future__ import annotations
|
|
43
|
+
|
|
44
|
+
from collections import deque
|
|
45
|
+
from dataclasses import dataclass, field
|
|
46
|
+
from datetime import datetime
|
|
47
|
+
from enum import Enum
|
|
48
|
+
from typing import Any, Deque, Dict, List, Optional
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# -----------------------------------------------------------------------
|
|
52
|
+
# Level
|
|
53
|
+
# -----------------------------------------------------------------------
|
|
54
|
+
|
|
55
|
+
class IssueLevel(Enum):
|
|
56
|
+
"""
|
|
57
|
+
Severity of an Issue.
|
|
58
|
+
|
|
59
|
+
INFO: something worth noting but expected (insufficient samples yet)
|
|
60
|
+
WARNING: degraded assumption (baseline missing, near-zero std)
|
|
61
|
+
ERROR: recovered failure (predicate raised, continued on fallback)
|
|
62
|
+
FATAL: unrecovered failure (exception re-raised after capture)
|
|
63
|
+
"""
|
|
64
|
+
INFO = "INFO"
|
|
65
|
+
WARNING = "WARNING"
|
|
66
|
+
ERROR = "ERROR"
|
|
67
|
+
FATAL = "FATAL"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
ISSUE_SEVERITY: Dict[IssueLevel, int] = {
|
|
71
|
+
IssueLevel.INFO: 0,
|
|
72
|
+
IssueLevel.WARNING: 1,
|
|
73
|
+
IssueLevel.ERROR: 2,
|
|
74
|
+
IssueLevel.FATAL: 3,
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
# -----------------------------------------------------------------------
|
|
79
|
+
# Issue
|
|
80
|
+
# -----------------------------------------------------------------------
|
|
81
|
+
|
|
82
|
+
@dataclass
|
|
83
|
+
class Issue:
|
|
84
|
+
"""
|
|
85
|
+
One diagnostic event from the margin machinery.
|
|
86
|
+
|
|
87
|
+
level: severity
|
|
88
|
+
code: short stable identifier (``BASELINE_MISSING``, etc.)
|
|
89
|
+
component: optional component name this issue is scoped to
|
|
90
|
+
detail: free-text explanation for a human reader
|
|
91
|
+
at: timestamp (defaults to now)
|
|
92
|
+
context: optional extra fields — traceback, exc type, source, etc.
|
|
93
|
+
"""
|
|
94
|
+
level: IssueLevel
|
|
95
|
+
code: str
|
|
96
|
+
component: Optional[str] = None
|
|
97
|
+
detail: str = ""
|
|
98
|
+
at: datetime = field(default_factory=datetime.now)
|
|
99
|
+
context: Dict[str, Any] = field(default_factory=dict)
|
|
100
|
+
|
|
101
|
+
def to_dict(self) -> dict:
|
|
102
|
+
d: dict = {
|
|
103
|
+
"level": self.level.value,
|
|
104
|
+
"code": self.code,
|
|
105
|
+
"at": self.at.isoformat(),
|
|
106
|
+
}
|
|
107
|
+
if self.component is not None:
|
|
108
|
+
d["component"] = self.component
|
|
109
|
+
if self.detail:
|
|
110
|
+
d["detail"] = self.detail
|
|
111
|
+
if self.context:
|
|
112
|
+
d["context"] = dict(self.context)
|
|
113
|
+
return d
|
|
114
|
+
|
|
115
|
+
@classmethod
|
|
116
|
+
def from_dict(cls, d: dict) -> "Issue":
|
|
117
|
+
return cls(
|
|
118
|
+
level=IssueLevel(d["level"]),
|
|
119
|
+
code=d["code"],
|
|
120
|
+
component=d.get("component"),
|
|
121
|
+
detail=d.get("detail", ""),
|
|
122
|
+
at=datetime.fromisoformat(d["at"]) if "at" in d else datetime.now(),
|
|
123
|
+
context=dict(d.get("context", {})),
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
def to_atom(self) -> str:
|
|
127
|
+
"""Compact string: ``!level[code](component)`` — fits alongside observation atoms."""
|
|
128
|
+
mark = {"INFO": "·", "WARNING": "⚠", "ERROR": "!", "FATAL": "‼"}[self.level.value]
|
|
129
|
+
if self.component is not None:
|
|
130
|
+
return f"{mark}{self.level.value}[{self.code}]({self.component})"
|
|
131
|
+
return f"{mark}{self.level.value}[{self.code}]"
|
|
132
|
+
|
|
133
|
+
def __repr__(self) -> str:
|
|
134
|
+
comp = f", component={self.component!r}" if self.component is not None else ""
|
|
135
|
+
return f"Issue({self.level.value}, {self.code}{comp}, {self.detail!r})"
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
# -----------------------------------------------------------------------
|
|
139
|
+
# Buffer
|
|
140
|
+
# -----------------------------------------------------------------------
|
|
141
|
+
|
|
142
|
+
class IssueBuffer:
|
|
143
|
+
"""
|
|
144
|
+
Bounded ring buffer of Issues with simple query helpers.
|
|
145
|
+
|
|
146
|
+
Lives on long-lived objects (Monitor, Flow) so post-mortem inspection
|
|
147
|
+
survives exceptions and crashes inside library methods. Bounded so
|
|
148
|
+
long-running sessions don't accumulate unbounded memory.
|
|
149
|
+
|
|
150
|
+
Default capacity is 1000 issues. For high-rate production, consider
|
|
151
|
+
periodically exporting with ``drain()`` and clearing.
|
|
152
|
+
"""
|
|
153
|
+
|
|
154
|
+
def __init__(self, max_issues: int = 1000):
|
|
155
|
+
if max_issues < 1:
|
|
156
|
+
raise ValueError("max_issues must be >= 1")
|
|
157
|
+
self.max_issues = max_issues
|
|
158
|
+
self._issues: Deque[Issue] = deque(maxlen=max_issues)
|
|
159
|
+
|
|
160
|
+
# ------------------------------------------------------------------
|
|
161
|
+
# Writing
|
|
162
|
+
# ------------------------------------------------------------------
|
|
163
|
+
|
|
164
|
+
def add(self, issue: Issue) -> Issue:
|
|
165
|
+
"""Append an issue. Returns the issue for chained access."""
|
|
166
|
+
self._issues.append(issue)
|
|
167
|
+
return issue
|
|
168
|
+
|
|
169
|
+
def emit(
|
|
170
|
+
self,
|
|
171
|
+
level: IssueLevel,
|
|
172
|
+
code: str,
|
|
173
|
+
detail: str = "",
|
|
174
|
+
component: Optional[str] = None,
|
|
175
|
+
**context: Any,
|
|
176
|
+
) -> Issue:
|
|
177
|
+
"""
|
|
178
|
+
Construct and append an Issue inline. Returns the issue.
|
|
179
|
+
|
|
180
|
+
Convenient at call sites::
|
|
181
|
+
|
|
182
|
+
self._issues.emit(IssueLevel.WARNING, "BASELINE_MISSING",
|
|
183
|
+
f"component {name!r} has no baseline",
|
|
184
|
+
component=name)
|
|
185
|
+
"""
|
|
186
|
+
return self.add(Issue(
|
|
187
|
+
level=level,
|
|
188
|
+
code=code,
|
|
189
|
+
component=component,
|
|
190
|
+
detail=detail,
|
|
191
|
+
context=dict(context) if context else {},
|
|
192
|
+
))
|
|
193
|
+
|
|
194
|
+
def clear(self) -> None:
|
|
195
|
+
self._issues.clear()
|
|
196
|
+
|
|
197
|
+
def drain(self) -> List[Issue]:
|
|
198
|
+
"""Remove and return all current issues. Buffer becomes empty."""
|
|
199
|
+
out = list(self._issues)
|
|
200
|
+
self._issues.clear()
|
|
201
|
+
return out
|
|
202
|
+
|
|
203
|
+
# ------------------------------------------------------------------
|
|
204
|
+
# Reading
|
|
205
|
+
# ------------------------------------------------------------------
|
|
206
|
+
|
|
207
|
+
def __len__(self) -> int:
|
|
208
|
+
return len(self._issues)
|
|
209
|
+
|
|
210
|
+
def __iter__(self):
|
|
211
|
+
return iter(self._issues)
|
|
212
|
+
|
|
213
|
+
def __bool__(self) -> bool:
|
|
214
|
+
return bool(self._issues)
|
|
215
|
+
|
|
216
|
+
def to_list(self) -> List[Issue]:
|
|
217
|
+
"""Snapshot copy of all current issues."""
|
|
218
|
+
return list(self._issues)
|
|
219
|
+
|
|
220
|
+
def tail(self, n: int = 10) -> List[Issue]:
|
|
221
|
+
"""Last ``n`` issues (most recent)."""
|
|
222
|
+
if n <= 0:
|
|
223
|
+
return []
|
|
224
|
+
items = list(self._issues)
|
|
225
|
+
return items[-n:]
|
|
226
|
+
|
|
227
|
+
def by_code(self, code: str) -> List[Issue]:
|
|
228
|
+
return [i for i in self._issues if i.code == code]
|
|
229
|
+
|
|
230
|
+
def by_level(self, level: IssueLevel) -> List[Issue]:
|
|
231
|
+
return [i for i in self._issues if i.level == level]
|
|
232
|
+
|
|
233
|
+
def by_component(self, component: str) -> List[Issue]:
|
|
234
|
+
return [i for i in self._issues if i.component == component]
|
|
235
|
+
|
|
236
|
+
def at_least(self, level: IssueLevel) -> List[Issue]:
|
|
237
|
+
"""All issues with severity >= ``level``."""
|
|
238
|
+
threshold = ISSUE_SEVERITY[level]
|
|
239
|
+
return [i for i in self._issues if ISSUE_SEVERITY[i.level] >= threshold]
|
|
240
|
+
|
|
241
|
+
# ------------------------------------------------------------------
|
|
242
|
+
# Summarization
|
|
243
|
+
# ------------------------------------------------------------------
|
|
244
|
+
|
|
245
|
+
def counts(self) -> Dict[str, int]:
|
|
246
|
+
"""Count by level. Result keys are level string values."""
|
|
247
|
+
out: Dict[str, int] = {l.value: 0 for l in IssueLevel}
|
|
248
|
+
for i in self._issues:
|
|
249
|
+
out[i.level.value] += 1
|
|
250
|
+
return out
|
|
251
|
+
|
|
252
|
+
def diagnose(self, n: int = 10) -> Dict[str, Any]:
|
|
253
|
+
"""
|
|
254
|
+
Human/dashboard-friendly summary.
|
|
255
|
+
|
|
256
|
+
Returns::
|
|
257
|
+
|
|
258
|
+
{
|
|
259
|
+
"total": int,
|
|
260
|
+
"counts": {"INFO": n, "WARNING": n, "ERROR": n, "FATAL": n},
|
|
261
|
+
"recent": [Issue, ...], # last n
|
|
262
|
+
"codes": {"BASELINE_MISSING": n, ...}, # by code
|
|
263
|
+
}
|
|
264
|
+
"""
|
|
265
|
+
codes: Dict[str, int] = {}
|
|
266
|
+
for i in self._issues:
|
|
267
|
+
codes[i.code] = codes.get(i.code, 0) + 1
|
|
268
|
+
return {
|
|
269
|
+
"total": len(self._issues),
|
|
270
|
+
"counts": self.counts(),
|
|
271
|
+
"recent": self.tail(n),
|
|
272
|
+
"codes": codes,
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
# ------------------------------------------------------------------
|
|
276
|
+
# Serialization
|
|
277
|
+
# ------------------------------------------------------------------
|
|
278
|
+
|
|
279
|
+
def to_dict(self) -> dict:
|
|
280
|
+
return {
|
|
281
|
+
"max_issues": self.max_issues,
|
|
282
|
+
"issues": [i.to_dict() for i in self._issues],
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
@classmethod
|
|
286
|
+
def from_dict(cls, d: dict) -> "IssueBuffer":
|
|
287
|
+
buf = cls(max_issues=d.get("max_issues", 1000))
|
|
288
|
+
for item in d.get("issues", []):
|
|
289
|
+
buf.add(Issue.from_dict(item))
|
|
290
|
+
return buf
|
|
291
|
+
|
|
292
|
+
def __repr__(self) -> str:
|
|
293
|
+
if not self._issues:
|
|
294
|
+
return "IssueBuffer(empty)"
|
|
295
|
+
c = self.counts()
|
|
296
|
+
parts = [f"{k}={v}" for k, v in c.items() if v > 0]
|
|
297
|
+
return f"IssueBuffer({len(self._issues)}: {', '.join(parts)})"
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
# -----------------------------------------------------------------------
|
|
301
|
+
# Well-known issue codes
|
|
302
|
+
# -----------------------------------------------------------------------
|
|
303
|
+
|
|
304
|
+
# These are the codes the margin library itself emits. Users can define
|
|
305
|
+
# their own codes too — they're just strings. Centralizing the canonical
|
|
306
|
+
# ones here makes them greppable and documentable.
|
|
307
|
+
|
|
308
|
+
class IssueCode:
|
|
309
|
+
# Parser
|
|
310
|
+
BASELINE_MISSING = "BASELINE_MISSING" # WARNING
|
|
311
|
+
|
|
312
|
+
# Monitor
|
|
313
|
+
INSUFFICIENT_SAMPLES = "INSUFFICIENT_SAMPLES" # INFO
|
|
314
|
+
NEAR_ZERO_STD = "NEAR_ZERO_STD" # INFO
|
|
315
|
+
|
|
316
|
+
# Flow
|
|
317
|
+
PREDICATE_FAILURE = "PREDICATE_FAILURE" # ERROR
|
|
318
|
+
STAGE_ABLATED_ON_ENTRY = "STAGE_ABLATED_ON_ENTRY" # WARNING
|
|
319
|
+
NO_PREDICATES = "NO_PREDICATES" # INFO
|
|
320
|
+
|
|
321
|
+
# Catch-all for exceptions that were captured as issues before re-raise
|
|
322
|
+
INTERNAL_ERROR = "INTERNAL_ERROR" # FATAL
|
|
323
|
+
|
|
324
|
+
# User-supplied input problems
|
|
325
|
+
UNKNOWN_COMPONENT = "UNKNOWN_COMPONENT" # WARNING
|