margin 0.9.28__tar.gz → 0.9.29__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 (94) hide show
  1. {margin-0.9.28/margin.egg-info → margin-0.9.29}/PKG-INFO +1 -1
  2. {margin-0.9.28 → margin-0.9.29}/margin/flow.py +49 -5
  3. {margin-0.9.28 → margin-0.9.29/margin.egg-info}/PKG-INFO +1 -1
  4. {margin-0.9.28 → margin-0.9.29}/pyproject.toml +1 -1
  5. {margin-0.9.28 → margin-0.9.29}/tests/test_flow.py +88 -7
  6. {margin-0.9.28 → margin-0.9.29}/LICENSE +0 -0
  7. {margin-0.9.28 → margin-0.9.29}/NOTICE +0 -0
  8. {margin-0.9.28 → margin-0.9.29}/README.md +0 -0
  9. {margin-0.9.28 → margin-0.9.29}/margin/__init__.py +0 -0
  10. {margin-0.9.28 → margin-0.9.29}/margin/__main__.py +0 -0
  11. {margin-0.9.28 → margin-0.9.29}/margin/algebra.py +0 -0
  12. {margin-0.9.28 → margin-0.9.29}/margin/anomaly.py +0 -0
  13. {margin-0.9.28 → margin-0.9.29}/margin/bridge.py +0 -0
  14. {margin-0.9.28 → margin-0.9.29}/margin/calibrate.py +0 -0
  15. {margin-0.9.28 → margin-0.9.29}/margin/causal.py +0 -0
  16. {margin-0.9.28 → margin-0.9.29}/margin/composite.py +0 -0
  17. {margin-0.9.28 → margin-0.9.29}/margin/confidence.py +0 -0
  18. {margin-0.9.28 → margin-0.9.29}/margin/config.py +0 -0
  19. {margin-0.9.28 → margin-0.9.29}/margin/contract.py +0 -0
  20. {margin-0.9.28 → margin-0.9.29}/margin/controller.py +0 -0
  21. {margin-0.9.28 → margin-0.9.29}/margin/correlate.py +0 -0
  22. {margin-0.9.28 → margin-0.9.29}/margin/diff.py +0 -0
  23. {margin-0.9.28 → margin-0.9.29}/margin/drift.py +0 -0
  24. {margin-0.9.28 → margin-0.9.29}/margin/events.py +0 -0
  25. {margin-0.9.28 → margin-0.9.29}/margin/fingerprint.py +0 -0
  26. {margin-0.9.28 → margin-0.9.29}/margin/forecast.py +0 -0
  27. {margin-0.9.28 → margin-0.9.29}/margin/health.py +0 -0
  28. {margin-0.9.28 → margin-0.9.29}/margin/intent.py +0 -0
  29. {margin-0.9.28 → margin-0.9.29}/margin/ledger.py +0 -0
  30. {margin-0.9.28 → margin-0.9.29}/margin/loop.py +0 -0
  31. {margin-0.9.28 → margin-0.9.29}/margin/observation.py +0 -0
  32. {margin-0.9.28 → margin-0.9.29}/margin/persist.py +0 -0
  33. {margin-0.9.28 → margin-0.9.29}/margin/policy/__init__.py +0 -0
  34. {margin-0.9.28 → margin-0.9.29}/margin/policy/compose.py +0 -0
  35. {margin-0.9.28 → margin-0.9.29}/margin/policy/core.py +0 -0
  36. {margin-0.9.28 → margin-0.9.29}/margin/policy/temporal.py +0 -0
  37. {margin-0.9.28 → margin-0.9.29}/margin/policy/trace.py +0 -0
  38. {margin-0.9.28 → margin-0.9.29}/margin/policy/tuning.py +0 -0
  39. {margin-0.9.28 → margin-0.9.29}/margin/policy/validate.py +0 -0
  40. {margin-0.9.28 → margin-0.9.29}/margin/predicates.py +0 -0
  41. {margin-0.9.28 → margin-0.9.29}/margin/provenance.py +0 -0
  42. {margin-0.9.28 → margin-0.9.29}/margin/streaming.py +0 -0
  43. {margin-0.9.28 → margin-0.9.29}/margin/transitions.py +0 -0
  44. {margin-0.9.28 → margin-0.9.29}/margin/uncertain.py +0 -0
  45. {margin-0.9.28 → margin-0.9.29}/margin/validity.py +0 -0
  46. {margin-0.9.28 → margin-0.9.29}/margin.egg-info/SOURCES.txt +0 -0
  47. {margin-0.9.28 → margin-0.9.29}/margin.egg-info/dependency_links.txt +0 -0
  48. {margin-0.9.28 → margin-0.9.29}/margin.egg-info/top_level.txt +0 -0
  49. {margin-0.9.28 → margin-0.9.29}/setup.cfg +0 -0
  50. {margin-0.9.28 → margin-0.9.29}/tests/test_absence.py +0 -0
  51. {margin-0.9.28 → margin-0.9.29}/tests/test_algebra.py +0 -0
  52. {margin-0.9.28 → margin-0.9.29}/tests/test_anomaly.py +0 -0
  53. {margin-0.9.28 → margin-0.9.29}/tests/test_bridge.py +0 -0
  54. {margin-0.9.28 → margin-0.9.29}/tests/test_calibrate.py +0 -0
  55. {margin-0.9.28 → margin-0.9.29}/tests/test_causal.py +0 -0
  56. {margin-0.9.28 → margin-0.9.29}/tests/test_composite.py +0 -0
  57. {margin-0.9.28 → margin-0.9.29}/tests/test_confidence.py +0 -0
  58. {margin-0.9.28 → margin-0.9.29}/tests/test_config.py +0 -0
  59. {margin-0.9.28 → margin-0.9.29}/tests/test_contract.py +0 -0
  60. {margin-0.9.28 → margin-0.9.29}/tests/test_controller.py +0 -0
  61. {margin-0.9.28 → margin-0.9.29}/tests/test_correlate.py +0 -0
  62. {margin-0.9.28 → margin-0.9.29}/tests/test_diff.py +0 -0
  63. {margin-0.9.28 → margin-0.9.29}/tests/test_drift.py +0 -0
  64. {margin-0.9.28 → margin-0.9.29}/tests/test_events.py +0 -0
  65. {margin-0.9.28 → margin-0.9.29}/tests/test_fingerprint_ext.py +0 -0
  66. {margin-0.9.28 → margin-0.9.29}/tests/test_float_tolerance.py +0 -0
  67. {margin-0.9.28 → margin-0.9.29}/tests/test_forecast.py +0 -0
  68. {margin-0.9.28 → margin-0.9.29}/tests/test_health.py +0 -0
  69. {margin-0.9.28 → margin-0.9.29}/tests/test_healthcare.py +0 -0
  70. {margin-0.9.28 → margin-0.9.29}/tests/test_intent.py +0 -0
  71. {margin-0.9.28 → margin-0.9.29}/tests/test_ledger.py +0 -0
  72. {margin-0.9.28 → margin-0.9.29}/tests/test_loop.py +0 -0
  73. {margin-0.9.28 → margin-0.9.29}/tests/test_neuro.py +0 -0
  74. {margin-0.9.28 → margin-0.9.29}/tests/test_observation.py +0 -0
  75. {margin-0.9.28 → margin-0.9.29}/tests/test_persist.py +0 -0
  76. {margin-0.9.28 → margin-0.9.29}/tests/test_pipeline_features.py +0 -0
  77. {margin-0.9.28 → margin-0.9.29}/tests/test_policy.py +0 -0
  78. {margin-0.9.28 → margin-0.9.29}/tests/test_policy_compose.py +0 -0
  79. {margin-0.9.28 → margin-0.9.29}/tests/test_policy_temporal.py +0 -0
  80. {margin-0.9.28 → margin-0.9.29}/tests/test_policy_trace.py +0 -0
  81. {margin-0.9.28 → margin-0.9.29}/tests/test_policy_tuning.py +0 -0
  82. {margin-0.9.28 → margin-0.9.29}/tests/test_policy_validate.py +0 -0
  83. {margin-0.9.28 → margin-0.9.29}/tests/test_predicates.py +0 -0
  84. {margin-0.9.28 → margin-0.9.29}/tests/test_printer3d.py +0 -0
  85. {margin-0.9.28 → margin-0.9.29}/tests/test_provenance.py +0 -0
  86. {margin-0.9.28 → margin-0.9.29}/tests/test_reverse_bridge.py +0 -0
  87. {margin-0.9.28 → margin-0.9.29}/tests/test_ros2.py +0 -0
  88. {margin-0.9.28 → margin-0.9.29}/tests/test_staleness.py +0 -0
  89. {margin-0.9.28 → margin-0.9.29}/tests/test_streaming.py +0 -0
  90. {margin-0.9.28 → margin-0.9.29}/tests/test_transformer.py +0 -0
  91. {margin-0.9.28 → margin-0.9.29}/tests/test_transitions.py +0 -0
  92. {margin-0.9.28 → margin-0.9.29}/tests/test_uncertain.py +0 -0
  93. {margin-0.9.28 → margin-0.9.29}/tests/test_validity.py +0 -0
  94. {margin-0.9.28 → margin-0.9.29}/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.29
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
@@ -55,7 +55,7 @@ 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
@@ -143,6 +143,7 @@ class Stage:
143
143
  @dataclass
144
144
  class FlowResult:
145
145
  """Returned from Flow.observe() and flow advancement helpers."""
146
+ flow_label: str
146
147
  stage: str
147
148
  stage_index: int
148
149
  total_stages: int
@@ -152,20 +153,29 @@ class FlowResult:
152
153
 
153
154
  def to_atom(self) -> str:
154
155
  """
155
- Compact flow notation.
156
+ Compact flow notation — consistent format across running and complete states.
157
+
158
+ With a flow label::
156
159
 
157
160
  "flow:fermentation:lag_phase[2/5] → ADVANCE"
158
- "flow:fermentation:COMPLETE[5/5]"
161
+ "flow:fermentation[5/5]" # complete
162
+
163
+ Without a flow label (empty)::
164
+
165
+ "flow:lag_phase[2/5] → ADVANCE"
166
+ "flow[5/5]" # complete
159
167
  """
168
+ prefix = f"flow:{self.flow_label}" if self.flow_label else "flow"
160
169
  if self.guidance == Guidance.COMPLETE:
161
- return f"flow:{self.stage}[{self.total_stages}/{self.total_stages}]"
170
+ return f"{prefix}[{self.total_stages}/{self.total_stages}]"
162
171
  return (
163
- f"flow:{self.stage}[{self.stage_index + 1}/{self.total_stages}]"
172
+ f"{prefix}:{self.stage}[{self.stage_index + 1}/{self.total_stages}]"
164
173
  f" → {self.guidance.value}"
165
174
  )
166
175
 
167
176
  def to_dict(self) -> dict:
168
177
  return {
178
+ "flow_label": self.flow_label,
169
179
  "stage": self.stage,
170
180
  "stage_index": self.stage_index,
171
181
  "total_stages": self.total_stages,
@@ -194,6 +204,36 @@ class Flow:
194
204
  (or a policy layer above) decides whether to call ``advance()``. This
195
205
  keeps the flow deterministic and side-effect free, which matters for
196
206
  replay, backtesting, and safety-critical use cases.
207
+
208
+ Thread safety
209
+ -------------
210
+ Flow is **not thread-safe**. ``observe()`` mutates the current stage's
211
+ Monitor window; ``advance()`` and ``rollback()`` mutate the stage index.
212
+ If you need to observe a flow from multiple threads, wrap it in a lock
213
+ at the call site. Per-entity flows in a request/response context (one
214
+ Flow instance per tracked entity) are the intended pattern.
215
+
216
+ Predicate ordering
217
+ ------------------
218
+ ``_classify`` evaluates predicates in strict severity order:
219
+
220
+ 1. ``escalate_when`` (highest — safety gate)
221
+ 2. ``rollback_when``
222
+ 3. ``advance_when``
223
+ 4. ``retry_when``
224
+
225
+ A true ``escalate_when`` dominates a true ``advance_when`` — this is
226
+ intentional. When a stage is in an escalation condition, we do not
227
+ advance even if exit criteria are also met. Safety-first semantics.
228
+
229
+ Memory note
230
+ -----------
231
+ Each ``advance()`` creates a fresh Monitor for the new stage without
232
+ pruning the prior stage's Monitor. For a 50-stage pipeline, 50 Monitor
233
+ instances accumulate in ``self._monitors``. This is intentional for
234
+ debuggability but may matter for very long flows. A pruning option
235
+ may be added in a future release; for now, construct a new Flow if
236
+ memory footprint matters.
197
237
  """
198
238
 
199
239
  def __init__(
@@ -259,6 +299,7 @@ class Flow:
259
299
  """
260
300
  if self.is_complete:
261
301
  return FlowResult(
302
+ flow_label=self.label,
262
303
  stage=self.label or "flow",
263
304
  stage_index=len(self.stages),
264
305
  total_stages=len(self.stages),
@@ -273,6 +314,7 @@ class Flow:
273
314
  guidance, reason = self._classify(stage, expr)
274
315
 
275
316
  return FlowResult(
317
+ flow_label=self.label,
276
318
  stage=stage.name,
277
319
  stage_index=self._index,
278
320
  total_stages=len(self.stages),
@@ -352,6 +394,7 @@ class Flow:
352
394
  def _status_result(self, reason: str) -> FlowResult:
353
395
  if self.is_complete:
354
396
  return FlowResult(
397
+ flow_label=self.label,
355
398
  stage=self.label or "flow",
356
399
  stage_index=len(self.stages),
357
400
  total_stages=len(self.stages),
@@ -360,6 +403,7 @@ class Flow:
360
403
  reason=reason,
361
404
  )
362
405
  return FlowResult(
406
+ flow_label=self.label,
363
407
  stage=self.current.name,
364
408
  stage_index=self._index,
365
409
  total_stages=len(self.stages),
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: margin
3
- Version: 0.9.28
3
+ Version: 0.9.29
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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "margin"
7
- version = "0.9.28"
7
+ version = "0.9.29"
8
8
  description = "Typed health classification, uncertainty algebra, and correction auditing for any system that measures things and needs to explain what happened."
9
9
  readme = "README.md"
10
10
  license = {text = "MIT"}
@@ -4,7 +4,7 @@ import pytest
4
4
  from margin import (
5
5
  Flow, Stage, Guidance,
6
6
  Parser, Thresholds, Health, Confidence,
7
- Expression,
7
+ Expression, Absence,
8
8
  )
9
9
 
10
10
 
@@ -298,23 +298,50 @@ class TestReset:
298
298
  # ---------------------------------------------------------------------------
299
299
 
300
300
  class TestSerialization:
301
- def test_to_atom_running(self):
301
+ def test_to_atom_running_includes_flow_label(self):
302
302
  f = Flow(stages=[
303
303
  Stage(name="lag", parser=_parser({"m": 1.0})),
304
304
  Stage(name="exp", parser=_parser({"m": 1.0})),
305
305
  ], label="ferment")
306
306
  result = f.observe({"m": 1.0})
307
307
  atom = result.to_atom()
308
- assert "lag" in atom
309
- assert "1/2" in atom
310
- assert "HOLD" in atom
308
+ # Must include: flow_label, stage_name, position, guidance
309
+ assert atom == "flow:ferment:lag[1/2] → HOLD"
311
310
 
312
- def test_to_atom_complete(self):
311
+ def test_to_atom_running_no_label(self):
312
+ f = Flow(stages=[
313
+ Stage(name="lag", parser=_parser({"m": 1.0})),
314
+ Stage(name="exp", parser=_parser({"m": 1.0})),
315
+ ])
316
+ result = f.observe({"m": 1.0})
317
+ assert result.to_atom() == "flow:lag[1/2] → HOLD"
318
+
319
+ def test_to_atom_complete_includes_flow_label(self):
313
320
  s = Stage(name="only", parser=_parser({"m": 1.0}))
314
321
  f = Flow(stages=[s], label="linear")
315
322
  f.advance()
316
323
  result = f.observe({"m": 1.0})
317
324
  assert result.guidance == Guidance.COMPLETE
325
+ assert result.to_atom() == "flow:linear[1/1]"
326
+
327
+ def test_to_atom_complete_no_label(self):
328
+ s = Stage(name="only", parser=_parser({"m": 1.0}))
329
+ f = Flow(stages=[s])
330
+ f.advance()
331
+ result = f.observe({"m": 1.0})
332
+ assert result.to_atom() == "flow[1/1]"
333
+
334
+ def test_concurrent_flows_are_distinguishable(self):
335
+ """Two flows with same stage names but different labels must
336
+ produce distinguishable atoms — without this, operator dashboards
337
+ can't tell flows apart."""
338
+ f1 = Flow(stages=[Stage(name="step", parser=_parser({"m": 1.0}))], label="service_a")
339
+ f2 = Flow(stages=[Stage(name="step", parser=_parser({"m": 1.0}))], label="service_b")
340
+ r1 = f1.observe({"m": 1.0})
341
+ r2 = f2.observe({"m": 1.0})
342
+ assert r1.to_atom() != r2.to_atom()
343
+ assert "service_a" in r1.to_atom()
344
+ assert "service_b" in r2.to_atom()
318
345
 
319
346
  def test_flow_to_dict(self):
320
347
  f = Flow(stages=[
@@ -328,14 +355,68 @@ class TestSerialization:
328
355
 
329
356
  def test_flow_result_to_dict(self):
330
357
  s = Stage(name="a", parser=_parser({"m": 1.0}))
331
- f = Flow(stages=[s])
358
+ f = Flow(stages=[s], label="t")
332
359
  result = f.observe({"m": 1.0})
333
360
  d = result.to_dict()
334
361
  assert d["stage"] == "a"
362
+ assert d["flow_label"] == "t"
335
363
  assert d["guidance"] == "HOLD"
336
364
  assert "expression" in d
337
365
 
338
366
 
367
+ # ---------------------------------------------------------------------------
368
+ # Observe passthrough — absences, confidences
369
+ # ---------------------------------------------------------------------------
370
+
371
+ class TestObservePassthrough:
372
+ def test_absences_flow_through(self):
373
+ """Absences supplied to Flow.observe() must reach the Monitor and
374
+ produce an OOD observation with typed absence in the Expression."""
375
+ stage = Stage(
376
+ name="s",
377
+ parser=Parser(
378
+ baselines={"m": 1.0, "n": 1.0},
379
+ thresholds=Thresholds(intact=0.8, ablated=0.2),
380
+ ),
381
+ )
382
+ f = Flow(stages=[stage])
383
+ result = f.observe({"m": 1.0}, absences={"n": Absence.SENSOR_FAILED})
384
+ obs_by_name = {o.name: o for o in result.expression.observations}
385
+ assert obs_by_name["m"].is_absent is False
386
+ assert obs_by_name["n"].is_absent is True
387
+ assert obs_by_name["n"].absence == Absence.SENSOR_FAILED
388
+
389
+ def test_confidences_flow_through(self):
390
+ stage = Stage(name="s", parser=_parser({"m": 1.0}))
391
+ f = Flow(stages=[stage])
392
+ result = f.observe({"m": 1.0}, confidences={"m": Confidence.LOW})
393
+ m = [o for o in result.expression.observations if o.name == "m"][0]
394
+ assert m.confidence == Confidence.LOW
395
+
396
+
397
+ # ---------------------------------------------------------------------------
398
+ # Edge cases
399
+ # ---------------------------------------------------------------------------
400
+
401
+ class TestEdgeCases:
402
+ def test_empty_values_dict(self):
403
+ """Empty observation should return HOLD and not crash."""
404
+ stage = Stage(name="s", parser=_parser({"m": 1.0}))
405
+ f = Flow(stages=[stage])
406
+ result = f.observe({})
407
+ assert result.guidance == Guidance.HOLD
408
+ assert result.expression is not None
409
+ assert len(result.expression.observations) == 0
410
+
411
+ def test_observe_complete_flow_safe(self):
412
+ """observe() on a complete flow returns COMPLETE, doesn't raise."""
413
+ f = Flow(stages=[Stage(name="a", parser=_parser({"m": 1.0}))], label="L")
414
+ f.advance()
415
+ r = f.observe({"m": 1.0})
416
+ assert r.guidance == Guidance.COMPLETE
417
+ assert r.to_atom() == "flow:L[1/1]"
418
+
419
+
339
420
  # ---------------------------------------------------------------------------
340
421
  # Realistic scenario: fermentation
341
422
  # ---------------------------------------------------------------------------
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes