margin 0.9.29__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.29/margin.egg-info → margin-0.9.30}/PKG-INFO +1 -1
  2. {margin-0.9.29 → margin-0.9.30}/margin/__init__.py +5 -0
  3. {margin-0.9.29 → margin-0.9.30}/margin/flow.py +146 -16
  4. margin-0.9.30/margin/issue.py +325 -0
  5. {margin-0.9.29 → margin-0.9.30}/margin/observation.py +20 -6
  6. {margin-0.9.29 → margin-0.9.30}/margin/streaming.py +76 -44
  7. {margin-0.9.29 → margin-0.9.30/margin.egg-info}/PKG-INFO +1 -1
  8. {margin-0.9.29 → margin-0.9.30}/margin.egg-info/SOURCES.txt +2 -0
  9. {margin-0.9.29 → margin-0.9.30}/pyproject.toml +1 -1
  10. margin-0.9.30/tests/test_issue.py +450 -0
  11. {margin-0.9.29 → margin-0.9.30}/LICENSE +0 -0
  12. {margin-0.9.29 → margin-0.9.30}/NOTICE +0 -0
  13. {margin-0.9.29 → margin-0.9.30}/README.md +0 -0
  14. {margin-0.9.29 → margin-0.9.30}/margin/__main__.py +0 -0
  15. {margin-0.9.29 → margin-0.9.30}/margin/algebra.py +0 -0
  16. {margin-0.9.29 → margin-0.9.30}/margin/anomaly.py +0 -0
  17. {margin-0.9.29 → margin-0.9.30}/margin/bridge.py +0 -0
  18. {margin-0.9.29 → margin-0.9.30}/margin/calibrate.py +0 -0
  19. {margin-0.9.29 → margin-0.9.30}/margin/causal.py +0 -0
  20. {margin-0.9.29 → margin-0.9.30}/margin/composite.py +0 -0
  21. {margin-0.9.29 → margin-0.9.30}/margin/confidence.py +0 -0
  22. {margin-0.9.29 → margin-0.9.30}/margin/config.py +0 -0
  23. {margin-0.9.29 → margin-0.9.30}/margin/contract.py +0 -0
  24. {margin-0.9.29 → margin-0.9.30}/margin/controller.py +0 -0
  25. {margin-0.9.29 → margin-0.9.30}/margin/correlate.py +0 -0
  26. {margin-0.9.29 → margin-0.9.30}/margin/diff.py +0 -0
  27. {margin-0.9.29 → margin-0.9.30}/margin/drift.py +0 -0
  28. {margin-0.9.29 → margin-0.9.30}/margin/events.py +0 -0
  29. {margin-0.9.29 → margin-0.9.30}/margin/fingerprint.py +0 -0
  30. {margin-0.9.29 → margin-0.9.30}/margin/forecast.py +0 -0
  31. {margin-0.9.29 → margin-0.9.30}/margin/health.py +0 -0
  32. {margin-0.9.29 → margin-0.9.30}/margin/intent.py +0 -0
  33. {margin-0.9.29 → margin-0.9.30}/margin/ledger.py +0 -0
  34. {margin-0.9.29 → margin-0.9.30}/margin/loop.py +0 -0
  35. {margin-0.9.29 → margin-0.9.30}/margin/persist.py +0 -0
  36. {margin-0.9.29 → margin-0.9.30}/margin/policy/__init__.py +0 -0
  37. {margin-0.9.29 → margin-0.9.30}/margin/policy/compose.py +0 -0
  38. {margin-0.9.29 → margin-0.9.30}/margin/policy/core.py +0 -0
  39. {margin-0.9.29 → margin-0.9.30}/margin/policy/temporal.py +0 -0
  40. {margin-0.9.29 → margin-0.9.30}/margin/policy/trace.py +0 -0
  41. {margin-0.9.29 → margin-0.9.30}/margin/policy/tuning.py +0 -0
  42. {margin-0.9.29 → margin-0.9.30}/margin/policy/validate.py +0 -0
  43. {margin-0.9.29 → margin-0.9.30}/margin/predicates.py +0 -0
  44. {margin-0.9.29 → margin-0.9.30}/margin/provenance.py +0 -0
  45. {margin-0.9.29 → margin-0.9.30}/margin/transitions.py +0 -0
  46. {margin-0.9.29 → margin-0.9.30}/margin/uncertain.py +0 -0
  47. {margin-0.9.29 → margin-0.9.30}/margin/validity.py +0 -0
  48. {margin-0.9.29 → margin-0.9.30}/margin.egg-info/dependency_links.txt +0 -0
  49. {margin-0.9.29 → margin-0.9.30}/margin.egg-info/top_level.txt +0 -0
  50. {margin-0.9.29 → margin-0.9.30}/setup.cfg +0 -0
  51. {margin-0.9.29 → margin-0.9.30}/tests/test_absence.py +0 -0
  52. {margin-0.9.29 → margin-0.9.30}/tests/test_algebra.py +0 -0
  53. {margin-0.9.29 → margin-0.9.30}/tests/test_anomaly.py +0 -0
  54. {margin-0.9.29 → margin-0.9.30}/tests/test_bridge.py +0 -0
  55. {margin-0.9.29 → margin-0.9.30}/tests/test_calibrate.py +0 -0
  56. {margin-0.9.29 → margin-0.9.30}/tests/test_causal.py +0 -0
  57. {margin-0.9.29 → margin-0.9.30}/tests/test_composite.py +0 -0
  58. {margin-0.9.29 → margin-0.9.30}/tests/test_confidence.py +0 -0
  59. {margin-0.9.29 → margin-0.9.30}/tests/test_config.py +0 -0
  60. {margin-0.9.29 → margin-0.9.30}/tests/test_contract.py +0 -0
  61. {margin-0.9.29 → margin-0.9.30}/tests/test_controller.py +0 -0
  62. {margin-0.9.29 → margin-0.9.30}/tests/test_correlate.py +0 -0
  63. {margin-0.9.29 → margin-0.9.30}/tests/test_diff.py +0 -0
  64. {margin-0.9.29 → margin-0.9.30}/tests/test_drift.py +0 -0
  65. {margin-0.9.29 → margin-0.9.30}/tests/test_events.py +0 -0
  66. {margin-0.9.29 → margin-0.9.30}/tests/test_fingerprint_ext.py +0 -0
  67. {margin-0.9.29 → margin-0.9.30}/tests/test_float_tolerance.py +0 -0
  68. {margin-0.9.29 → margin-0.9.30}/tests/test_flow.py +0 -0
  69. {margin-0.9.29 → margin-0.9.30}/tests/test_forecast.py +0 -0
  70. {margin-0.9.29 → margin-0.9.30}/tests/test_health.py +0 -0
  71. {margin-0.9.29 → margin-0.9.30}/tests/test_healthcare.py +0 -0
  72. {margin-0.9.29 → margin-0.9.30}/tests/test_intent.py +0 -0
  73. {margin-0.9.29 → margin-0.9.30}/tests/test_ledger.py +0 -0
  74. {margin-0.9.29 → margin-0.9.30}/tests/test_loop.py +0 -0
  75. {margin-0.9.29 → margin-0.9.30}/tests/test_neuro.py +0 -0
  76. {margin-0.9.29 → margin-0.9.30}/tests/test_observation.py +0 -0
  77. {margin-0.9.29 → margin-0.9.30}/tests/test_persist.py +0 -0
  78. {margin-0.9.29 → margin-0.9.30}/tests/test_pipeline_features.py +0 -0
  79. {margin-0.9.29 → margin-0.9.30}/tests/test_policy.py +0 -0
  80. {margin-0.9.29 → margin-0.9.30}/tests/test_policy_compose.py +0 -0
  81. {margin-0.9.29 → margin-0.9.30}/tests/test_policy_temporal.py +0 -0
  82. {margin-0.9.29 → margin-0.9.30}/tests/test_policy_trace.py +0 -0
  83. {margin-0.9.29 → margin-0.9.30}/tests/test_policy_tuning.py +0 -0
  84. {margin-0.9.29 → margin-0.9.30}/tests/test_policy_validate.py +0 -0
  85. {margin-0.9.29 → margin-0.9.30}/tests/test_predicates.py +0 -0
  86. {margin-0.9.29 → margin-0.9.30}/tests/test_printer3d.py +0 -0
  87. {margin-0.9.29 → margin-0.9.30}/tests/test_provenance.py +0 -0
  88. {margin-0.9.29 → margin-0.9.30}/tests/test_reverse_bridge.py +0 -0
  89. {margin-0.9.29 → margin-0.9.30}/tests/test_ros2.py +0 -0
  90. {margin-0.9.29 → margin-0.9.30}/tests/test_staleness.py +0 -0
  91. {margin-0.9.29 → margin-0.9.30}/tests/test_streaming.py +0 -0
  92. {margin-0.9.29 → margin-0.9.30}/tests/test_transformer.py +0 -0
  93. {margin-0.9.29 → margin-0.9.30}/tests/test_transitions.py +0 -0
  94. {margin-0.9.29 → margin-0.9.30}/tests/test_uncertain.py +0 -0
  95. {margin-0.9.29 → margin-0.9.30}/tests/test_validity.py +0 -0
  96. {margin-0.9.29 → 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.29
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
@@ -61,6 +61,7 @@ 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
 
@@ -150,6 +151,11 @@ class FlowResult:
150
151
  expression: Optional[Expression]
151
152
  guidance: Guidance
152
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 = []
153
159
 
154
160
  def to_atom(self) -> str:
155
161
  """
@@ -166,15 +172,34 @@ class FlowResult:
166
172
  "flow[5/5]" # complete
167
173
  """
168
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) + ")"
169
194
  if self.guidance == Guidance.COMPLETE:
170
- return f"{prefix}[{self.total_stages}/{self.total_stages}]"
195
+ return f"{prefix}[{self.total_stages}/{self.total_stages}]{suffix}"
171
196
  return (
172
197
  f"{prefix}:{self.stage}[{self.stage_index + 1}/{self.total_stages}]"
173
- f" → {self.guidance.value}"
198
+ f" → {self.guidance.value}{suffix}"
174
199
  )
175
200
 
176
201
  def to_dict(self) -> dict:
177
- return {
202
+ d = {
178
203
  "flow_label": self.flow_label,
179
204
  "stage": self.stage,
180
205
  "stage_index": self.stage_index,
@@ -183,6 +208,9 @@ class FlowResult:
183
208
  "reason": self.reason,
184
209
  "expression": self.expression.to_dict() if self.expression else None,
185
210
  }
211
+ if self.issues:
212
+ d["issues"] = [i.to_dict() for i in self.issues]
213
+ return d
186
214
 
187
215
  def __repr__(self) -> str:
188
216
  return f"FlowResult({self.to_atom()} — {self.reason})"
@@ -254,6 +282,21 @@ class Flow:
254
282
  }
255
283
  self._transitions: List[dict] = []
256
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
+ )
257
300
 
258
301
  # ------------------------------------------------------------------
259
302
  # Inspection
@@ -286,6 +329,15 @@ class Flow:
286
329
  """History of stage transitions."""
287
330
  return list(self._transitions)
288
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
+
289
341
  # ------------------------------------------------------------------
290
342
  # Observation
291
343
  # ------------------------------------------------------------------
@@ -296,7 +348,14 @@ class Flow:
296
348
 
297
349
  ``values`` and any kwargs are forwarded to ``Monitor.update()``, so
298
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.
299
356
  """
357
+ import traceback as _tb
358
+
300
359
  if self.is_complete:
301
360
  return FlowResult(
302
361
  flow_label=self.label,
@@ -306,12 +365,28 @@ class Flow:
306
365
  expression=None,
307
366
  guidance=Guidance.COMPLETE,
308
367
  reason="all stages complete",
368
+ issues=[],
309
369
  )
310
370
 
311
371
  stage = self.current
312
372
  monitor = self._monitors[stage.name]
313
- expr = monitor.update(values, **kw)
314
- 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)
315
390
 
316
391
  return FlowResult(
317
392
  flow_label=self.label,
@@ -321,19 +396,73 @@ class Flow:
321
396
  expression=expr,
322
397
  guidance=guidance,
323
398
  reason=reason,
399
+ issues=step_issues,
324
400
  )
325
401
 
326
- def _classify(self, stage: Stage, expr: Expression) -> tuple[Guidance, str]:
327
- """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."""
328
436
  # Highest severity first — a true escalate dominates an advance
329
- if stage.escalate_when is not None and stage.escalate_when(expr):
330
- return Guidance.ESCALATE, "escalate_when fired"
331
- if stage.rollback_when is not None and stage.rollback_when(expr):
332
- return Guidance.ROLLBACK, "rollback_when fired"
333
- if stage.advance_when is not None and stage.advance_when(expr):
334
- return Guidance.ADVANCE, "advance_when fired"
335
- if stage.retry_when is not None and stage.retry_when(expr):
336
- 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"
337
466
 
338
467
  # Fallback classification using observation health alone
339
468
  if any(o.health == Health.ABLATED for o in expr.observations):
@@ -385,11 +514,12 @@ class Flow:
385
514
  return self._status_result("rolled back")
386
515
 
387
516
  def reset(self) -> None:
388
- """Reset to the first stage. Clears all monitors and transitions."""
517
+ """Reset to the first stage. Clears monitors, transitions, and issues."""
389
518
  self._index = 0
390
519
  self._monitors = {self.stages[0].name: self.stages[0].create_monitor()}
391
520
  self._transitions = []
392
521
  self._entered_at = datetime.now()
522
+ self._issues.clear()
393
523
 
394
524
  def _status_result(self, reason: str) -> FlowResult:
395
525
  if self.is_complete:
@@ -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
@@ -18,6 +18,7 @@ from typing import Optional
18
18
 
19
19
  from .confidence import Confidence
20
20
  from .health import Health, Thresholds, classify
21
+ from .issue import Issue, IssueLevel, IssueCode
21
22
 
22
23
 
23
24
  class Absence(Enum):
@@ -240,6 +241,7 @@ class Expression:
240
241
  confidence: Confidence = Confidence.MODERATE
241
242
  label: str = ""
242
243
  step: Optional[int] = None
244
+ issues: list[Issue] = field(default_factory=list)
243
245
 
244
246
  def __post_init__(self) -> None:
245
247
  if self.observations:
@@ -302,13 +304,16 @@ class Expression:
302
304
  return " ".join(parts) if parts else "[∅]"
303
305
 
304
306
  def to_dict(self) -> dict:
305
- return {
307
+ d = {
306
308
  "confidence": self.confidence.value,
307
309
  "label": self.label,
308
310
  "step": self.step,
309
311
  "observations": [o.to_dict() for o in self.observations],
310
312
  "corrections": [c.to_dict() for c in self.corrections],
311
313
  }
314
+ if self.issues:
315
+ d["issues"] = [i.to_dict() for i in self.issues]
316
+ return d
312
317
 
313
318
  def to_json(self, indent: int = 2) -> str:
314
319
  return json.dumps(self.to_dict(), indent=indent)
@@ -321,6 +326,7 @@ class Expression:
321
326
  confidence=Confidence(d["confidence"]) if "confidence" in d else Confidence.MODERATE,
322
327
  label=d.get("label", ""),
323
328
  step=d.get("step"),
329
+ issues=[Issue.from_dict(i) for i in d.get("issues", [])],
324
330
  )
325
331
 
326
332
  @classmethod
@@ -416,18 +422,25 @@ class Parser:
416
422
  confidences = confidences or {}
417
423
  provenance = provenance or []
418
424
  absences = absences or {}
425
+ issues: list[Issue] = []
419
426
 
420
427
  observations = []
421
428
  for name, val in values.items():
422
429
  if name in absences:
423
430
  continue # handled below with the other absences
424
431
  if name not in self.baselines:
425
- warnings.warn(
426
- f"Parser.parse: component {name!r} has no baseline — "
427
- "using the observed value as baseline (sigma=0). "
428
- "Add it to Parser.baselines or check for a typo.",
429
- stacklevel=2,
432
+ detail = (
433
+ f"component {name!r} has no baseline — using the observed "
434
+ "value as baseline (sigma=0). Add it to Parser.baselines "
435
+ "or check for a typo."
430
436
  )
437
+ issues.append(Issue(
438
+ level=IssueLevel.WARNING,
439
+ code=IssueCode.BASELINE_MISSING,
440
+ component=name,
441
+ detail=detail,
442
+ ))
443
+ warnings.warn(f"Parser.parse: {detail}", stacklevel=2)
431
444
  baseline = self.baselines.get(name, val)
432
445
  conf = confidences.get(name, Confidence.MODERATE)
433
446
  ct = self._thresholds_for(name)
@@ -476,6 +489,7 @@ class Parser:
476
489
  observations=observations,
477
490
  corrections=corrections,
478
491
  confidence=net_conf,
492
+ issues=issues,
479
493
  label=label,
480
494
  step=step,
481
495
  )