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.

Files changed (96) hide show
  1. {margin-0.9.28/margin.egg-info → margin-0.9.30}/PKG-INFO +1 -1
  2. {margin-0.9.28 → margin-0.9.30}/margin/__init__.py +5 -0
  3. {margin-0.9.28 → margin-0.9.30}/margin/flow.py +194 -20
  4. margin-0.9.30/margin/issue.py +325 -0
  5. {margin-0.9.28 → margin-0.9.30}/margin/observation.py +20 -6
  6. {margin-0.9.28 → margin-0.9.30}/margin/streaming.py +76 -44
  7. {margin-0.9.28 → margin-0.9.30/margin.egg-info}/PKG-INFO +1 -1
  8. {margin-0.9.28 → margin-0.9.30}/margin.egg-info/SOURCES.txt +2 -0
  9. {margin-0.9.28 → margin-0.9.30}/pyproject.toml +1 -1
  10. {margin-0.9.28 → margin-0.9.30}/tests/test_flow.py +88 -7
  11. margin-0.9.30/tests/test_issue.py +450 -0
  12. {margin-0.9.28 → margin-0.9.30}/LICENSE +0 -0
  13. {margin-0.9.28 → margin-0.9.30}/NOTICE +0 -0
  14. {margin-0.9.28 → margin-0.9.30}/README.md +0 -0
  15. {margin-0.9.28 → margin-0.9.30}/margin/__main__.py +0 -0
  16. {margin-0.9.28 → margin-0.9.30}/margin/algebra.py +0 -0
  17. {margin-0.9.28 → margin-0.9.30}/margin/anomaly.py +0 -0
  18. {margin-0.9.28 → margin-0.9.30}/margin/bridge.py +0 -0
  19. {margin-0.9.28 → margin-0.9.30}/margin/calibrate.py +0 -0
  20. {margin-0.9.28 → margin-0.9.30}/margin/causal.py +0 -0
  21. {margin-0.9.28 → margin-0.9.30}/margin/composite.py +0 -0
  22. {margin-0.9.28 → margin-0.9.30}/margin/confidence.py +0 -0
  23. {margin-0.9.28 → margin-0.9.30}/margin/config.py +0 -0
  24. {margin-0.9.28 → margin-0.9.30}/margin/contract.py +0 -0
  25. {margin-0.9.28 → margin-0.9.30}/margin/controller.py +0 -0
  26. {margin-0.9.28 → margin-0.9.30}/margin/correlate.py +0 -0
  27. {margin-0.9.28 → margin-0.9.30}/margin/diff.py +0 -0
  28. {margin-0.9.28 → margin-0.9.30}/margin/drift.py +0 -0
  29. {margin-0.9.28 → margin-0.9.30}/margin/events.py +0 -0
  30. {margin-0.9.28 → margin-0.9.30}/margin/fingerprint.py +0 -0
  31. {margin-0.9.28 → margin-0.9.30}/margin/forecast.py +0 -0
  32. {margin-0.9.28 → margin-0.9.30}/margin/health.py +0 -0
  33. {margin-0.9.28 → margin-0.9.30}/margin/intent.py +0 -0
  34. {margin-0.9.28 → margin-0.9.30}/margin/ledger.py +0 -0
  35. {margin-0.9.28 → margin-0.9.30}/margin/loop.py +0 -0
  36. {margin-0.9.28 → margin-0.9.30}/margin/persist.py +0 -0
  37. {margin-0.9.28 → margin-0.9.30}/margin/policy/__init__.py +0 -0
  38. {margin-0.9.28 → margin-0.9.30}/margin/policy/compose.py +0 -0
  39. {margin-0.9.28 → margin-0.9.30}/margin/policy/core.py +0 -0
  40. {margin-0.9.28 → margin-0.9.30}/margin/policy/temporal.py +0 -0
  41. {margin-0.9.28 → margin-0.9.30}/margin/policy/trace.py +0 -0
  42. {margin-0.9.28 → margin-0.9.30}/margin/policy/tuning.py +0 -0
  43. {margin-0.9.28 → margin-0.9.30}/margin/policy/validate.py +0 -0
  44. {margin-0.9.28 → margin-0.9.30}/margin/predicates.py +0 -0
  45. {margin-0.9.28 → margin-0.9.30}/margin/provenance.py +0 -0
  46. {margin-0.9.28 → margin-0.9.30}/margin/transitions.py +0 -0
  47. {margin-0.9.28 → margin-0.9.30}/margin/uncertain.py +0 -0
  48. {margin-0.9.28 → margin-0.9.30}/margin/validity.py +0 -0
  49. {margin-0.9.28 → margin-0.9.30}/margin.egg-info/dependency_links.txt +0 -0
  50. {margin-0.9.28 → margin-0.9.30}/margin.egg-info/top_level.txt +0 -0
  51. {margin-0.9.28 → margin-0.9.30}/setup.cfg +0 -0
  52. {margin-0.9.28 → margin-0.9.30}/tests/test_absence.py +0 -0
  53. {margin-0.9.28 → margin-0.9.30}/tests/test_algebra.py +0 -0
  54. {margin-0.9.28 → margin-0.9.30}/tests/test_anomaly.py +0 -0
  55. {margin-0.9.28 → margin-0.9.30}/tests/test_bridge.py +0 -0
  56. {margin-0.9.28 → margin-0.9.30}/tests/test_calibrate.py +0 -0
  57. {margin-0.9.28 → margin-0.9.30}/tests/test_causal.py +0 -0
  58. {margin-0.9.28 → margin-0.9.30}/tests/test_composite.py +0 -0
  59. {margin-0.9.28 → margin-0.9.30}/tests/test_confidence.py +0 -0
  60. {margin-0.9.28 → margin-0.9.30}/tests/test_config.py +0 -0
  61. {margin-0.9.28 → margin-0.9.30}/tests/test_contract.py +0 -0
  62. {margin-0.9.28 → margin-0.9.30}/tests/test_controller.py +0 -0
  63. {margin-0.9.28 → margin-0.9.30}/tests/test_correlate.py +0 -0
  64. {margin-0.9.28 → margin-0.9.30}/tests/test_diff.py +0 -0
  65. {margin-0.9.28 → margin-0.9.30}/tests/test_drift.py +0 -0
  66. {margin-0.9.28 → margin-0.9.30}/tests/test_events.py +0 -0
  67. {margin-0.9.28 → margin-0.9.30}/tests/test_fingerprint_ext.py +0 -0
  68. {margin-0.9.28 → margin-0.9.30}/tests/test_float_tolerance.py +0 -0
  69. {margin-0.9.28 → margin-0.9.30}/tests/test_forecast.py +0 -0
  70. {margin-0.9.28 → margin-0.9.30}/tests/test_health.py +0 -0
  71. {margin-0.9.28 → margin-0.9.30}/tests/test_healthcare.py +0 -0
  72. {margin-0.9.28 → margin-0.9.30}/tests/test_intent.py +0 -0
  73. {margin-0.9.28 → margin-0.9.30}/tests/test_ledger.py +0 -0
  74. {margin-0.9.28 → margin-0.9.30}/tests/test_loop.py +0 -0
  75. {margin-0.9.28 → margin-0.9.30}/tests/test_neuro.py +0 -0
  76. {margin-0.9.28 → margin-0.9.30}/tests/test_observation.py +0 -0
  77. {margin-0.9.28 → margin-0.9.30}/tests/test_persist.py +0 -0
  78. {margin-0.9.28 → margin-0.9.30}/tests/test_pipeline_features.py +0 -0
  79. {margin-0.9.28 → margin-0.9.30}/tests/test_policy.py +0 -0
  80. {margin-0.9.28 → margin-0.9.30}/tests/test_policy_compose.py +0 -0
  81. {margin-0.9.28 → margin-0.9.30}/tests/test_policy_temporal.py +0 -0
  82. {margin-0.9.28 → margin-0.9.30}/tests/test_policy_trace.py +0 -0
  83. {margin-0.9.28 → margin-0.9.30}/tests/test_policy_tuning.py +0 -0
  84. {margin-0.9.28 → margin-0.9.30}/tests/test_policy_validate.py +0 -0
  85. {margin-0.9.28 → margin-0.9.30}/tests/test_predicates.py +0 -0
  86. {margin-0.9.28 → margin-0.9.30}/tests/test_printer3d.py +0 -0
  87. {margin-0.9.28 → margin-0.9.30}/tests/test_provenance.py +0 -0
  88. {margin-0.9.28 → margin-0.9.30}/tests/test_reverse_bridge.py +0 -0
  89. {margin-0.9.28 → margin-0.9.30}/tests/test_ros2.py +0 -0
  90. {margin-0.9.28 → margin-0.9.30}/tests/test_staleness.py +0 -0
  91. {margin-0.9.28 → margin-0.9.30}/tests/test_streaming.py +0 -0
  92. {margin-0.9.28 → margin-0.9.30}/tests/test_transformer.py +0 -0
  93. {margin-0.9.28 → margin-0.9.30}/tests/test_transitions.py +0 -0
  94. {margin-0.9.28 → margin-0.9.30}/tests/test_uncertain.py +0 -0
  95. {margin-0.9.28 → margin-0.9.30}/tests/test_validity.py +0 -0
  96. {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.28
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, field
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:COMPLETE[5/5]"
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"flow:{self.stage}[{self.total_stages}/{self.total_stages}]"
195
+ return f"{prefix}[{self.total_stages}/{self.total_stages}]{suffix}"
162
196
  return (
163
- f"flow:{self.stage}[{self.stage_index + 1}/{self.total_stages}]"
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
- return {
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
- expr = monitor.update(values, **kw)
273
- guidance, reason = self._classify(stage, expr)
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 _classify(self, stage: Stage, expr: Expression) -> tuple[Guidance, str]:
285
- """Evaluate stage predicates in severity order."""
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 and stage.escalate_when(expr):
288
- return Guidance.ESCALATE, "escalate_when fired"
289
- if stage.rollback_when is not None and stage.rollback_when(expr):
290
- return Guidance.ROLLBACK, "rollback_when fired"
291
- if stage.advance_when is not None and stage.advance_when(expr):
292
- return Guidance.ADVANCE, "advance_when fired"
293
- if stage.retry_when is not None and stage.retry_when(expr):
294
- return Guidance.RETRY, "retry_when fired"
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 all monitors and transitions."""
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