margin 0.9.27__tar.gz → 0.9.28__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.27/margin.egg-info → margin-0.9.28}/PKG-INFO +1 -1
  2. {margin-0.9.27 → margin-0.9.28}/margin/__init__.py +5 -0
  3. margin-0.9.28/margin/flow.py +390 -0
  4. {margin-0.9.27 → margin-0.9.28/margin.egg-info}/PKG-INFO +1 -1
  5. {margin-0.9.27 → margin-0.9.28}/margin.egg-info/SOURCES.txt +2 -0
  6. {margin-0.9.27 → margin-0.9.28}/pyproject.toml +1 -1
  7. margin-0.9.28/tests/test_flow.py +418 -0
  8. {margin-0.9.27 → margin-0.9.28}/LICENSE +0 -0
  9. {margin-0.9.27 → margin-0.9.28}/NOTICE +0 -0
  10. {margin-0.9.27 → margin-0.9.28}/README.md +0 -0
  11. {margin-0.9.27 → margin-0.9.28}/margin/__main__.py +0 -0
  12. {margin-0.9.27 → margin-0.9.28}/margin/algebra.py +0 -0
  13. {margin-0.9.27 → margin-0.9.28}/margin/anomaly.py +0 -0
  14. {margin-0.9.27 → margin-0.9.28}/margin/bridge.py +0 -0
  15. {margin-0.9.27 → margin-0.9.28}/margin/calibrate.py +0 -0
  16. {margin-0.9.27 → margin-0.9.28}/margin/causal.py +0 -0
  17. {margin-0.9.27 → margin-0.9.28}/margin/composite.py +0 -0
  18. {margin-0.9.27 → margin-0.9.28}/margin/confidence.py +0 -0
  19. {margin-0.9.27 → margin-0.9.28}/margin/config.py +0 -0
  20. {margin-0.9.27 → margin-0.9.28}/margin/contract.py +0 -0
  21. {margin-0.9.27 → margin-0.9.28}/margin/controller.py +0 -0
  22. {margin-0.9.27 → margin-0.9.28}/margin/correlate.py +0 -0
  23. {margin-0.9.27 → margin-0.9.28}/margin/diff.py +0 -0
  24. {margin-0.9.27 → margin-0.9.28}/margin/drift.py +0 -0
  25. {margin-0.9.27 → margin-0.9.28}/margin/events.py +0 -0
  26. {margin-0.9.27 → margin-0.9.28}/margin/fingerprint.py +0 -0
  27. {margin-0.9.27 → margin-0.9.28}/margin/forecast.py +0 -0
  28. {margin-0.9.27 → margin-0.9.28}/margin/health.py +0 -0
  29. {margin-0.9.27 → margin-0.9.28}/margin/intent.py +0 -0
  30. {margin-0.9.27 → margin-0.9.28}/margin/ledger.py +0 -0
  31. {margin-0.9.27 → margin-0.9.28}/margin/loop.py +0 -0
  32. {margin-0.9.27 → margin-0.9.28}/margin/observation.py +0 -0
  33. {margin-0.9.27 → margin-0.9.28}/margin/persist.py +0 -0
  34. {margin-0.9.27 → margin-0.9.28}/margin/policy/__init__.py +0 -0
  35. {margin-0.9.27 → margin-0.9.28}/margin/policy/compose.py +0 -0
  36. {margin-0.9.27 → margin-0.9.28}/margin/policy/core.py +0 -0
  37. {margin-0.9.27 → margin-0.9.28}/margin/policy/temporal.py +0 -0
  38. {margin-0.9.27 → margin-0.9.28}/margin/policy/trace.py +0 -0
  39. {margin-0.9.27 → margin-0.9.28}/margin/policy/tuning.py +0 -0
  40. {margin-0.9.27 → margin-0.9.28}/margin/policy/validate.py +0 -0
  41. {margin-0.9.27 → margin-0.9.28}/margin/predicates.py +0 -0
  42. {margin-0.9.27 → margin-0.9.28}/margin/provenance.py +0 -0
  43. {margin-0.9.27 → margin-0.9.28}/margin/streaming.py +0 -0
  44. {margin-0.9.27 → margin-0.9.28}/margin/transitions.py +0 -0
  45. {margin-0.9.27 → margin-0.9.28}/margin/uncertain.py +0 -0
  46. {margin-0.9.27 → margin-0.9.28}/margin/validity.py +0 -0
  47. {margin-0.9.27 → margin-0.9.28}/margin.egg-info/dependency_links.txt +0 -0
  48. {margin-0.9.27 → margin-0.9.28}/margin.egg-info/top_level.txt +0 -0
  49. {margin-0.9.27 → margin-0.9.28}/setup.cfg +0 -0
  50. {margin-0.9.27 → margin-0.9.28}/tests/test_absence.py +0 -0
  51. {margin-0.9.27 → margin-0.9.28}/tests/test_algebra.py +0 -0
  52. {margin-0.9.27 → margin-0.9.28}/tests/test_anomaly.py +0 -0
  53. {margin-0.9.27 → margin-0.9.28}/tests/test_bridge.py +0 -0
  54. {margin-0.9.27 → margin-0.9.28}/tests/test_calibrate.py +0 -0
  55. {margin-0.9.27 → margin-0.9.28}/tests/test_causal.py +0 -0
  56. {margin-0.9.27 → margin-0.9.28}/tests/test_composite.py +0 -0
  57. {margin-0.9.27 → margin-0.9.28}/tests/test_confidence.py +0 -0
  58. {margin-0.9.27 → margin-0.9.28}/tests/test_config.py +0 -0
  59. {margin-0.9.27 → margin-0.9.28}/tests/test_contract.py +0 -0
  60. {margin-0.9.27 → margin-0.9.28}/tests/test_controller.py +0 -0
  61. {margin-0.9.27 → margin-0.9.28}/tests/test_correlate.py +0 -0
  62. {margin-0.9.27 → margin-0.9.28}/tests/test_diff.py +0 -0
  63. {margin-0.9.27 → margin-0.9.28}/tests/test_drift.py +0 -0
  64. {margin-0.9.27 → margin-0.9.28}/tests/test_events.py +0 -0
  65. {margin-0.9.27 → margin-0.9.28}/tests/test_fingerprint_ext.py +0 -0
  66. {margin-0.9.27 → margin-0.9.28}/tests/test_float_tolerance.py +0 -0
  67. {margin-0.9.27 → margin-0.9.28}/tests/test_forecast.py +0 -0
  68. {margin-0.9.27 → margin-0.9.28}/tests/test_health.py +0 -0
  69. {margin-0.9.27 → margin-0.9.28}/tests/test_healthcare.py +0 -0
  70. {margin-0.9.27 → margin-0.9.28}/tests/test_intent.py +0 -0
  71. {margin-0.9.27 → margin-0.9.28}/tests/test_ledger.py +0 -0
  72. {margin-0.9.27 → margin-0.9.28}/tests/test_loop.py +0 -0
  73. {margin-0.9.27 → margin-0.9.28}/tests/test_neuro.py +0 -0
  74. {margin-0.9.27 → margin-0.9.28}/tests/test_observation.py +0 -0
  75. {margin-0.9.27 → margin-0.9.28}/tests/test_persist.py +0 -0
  76. {margin-0.9.27 → margin-0.9.28}/tests/test_pipeline_features.py +0 -0
  77. {margin-0.9.27 → margin-0.9.28}/tests/test_policy.py +0 -0
  78. {margin-0.9.27 → margin-0.9.28}/tests/test_policy_compose.py +0 -0
  79. {margin-0.9.27 → margin-0.9.28}/tests/test_policy_temporal.py +0 -0
  80. {margin-0.9.27 → margin-0.9.28}/tests/test_policy_trace.py +0 -0
  81. {margin-0.9.27 → margin-0.9.28}/tests/test_policy_tuning.py +0 -0
  82. {margin-0.9.27 → margin-0.9.28}/tests/test_policy_validate.py +0 -0
  83. {margin-0.9.27 → margin-0.9.28}/tests/test_predicates.py +0 -0
  84. {margin-0.9.27 → margin-0.9.28}/tests/test_printer3d.py +0 -0
  85. {margin-0.9.27 → margin-0.9.28}/tests/test_provenance.py +0 -0
  86. {margin-0.9.27 → margin-0.9.28}/tests/test_reverse_bridge.py +0 -0
  87. {margin-0.9.27 → margin-0.9.28}/tests/test_ros2.py +0 -0
  88. {margin-0.9.27 → margin-0.9.28}/tests/test_staleness.py +0 -0
  89. {margin-0.9.27 → margin-0.9.28}/tests/test_streaming.py +0 -0
  90. {margin-0.9.27 → margin-0.9.28}/tests/test_transformer.py +0 -0
  91. {margin-0.9.27 → margin-0.9.28}/tests/test_transitions.py +0 -0
  92. {margin-0.9.27 → margin-0.9.28}/tests/test_uncertain.py +0 -0
  93. {margin-0.9.27 → margin-0.9.28}/tests/test_validity.py +0 -0
  94. {margin-0.9.27 → margin-0.9.28}/tests/test_windowing.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: margin
3
- Version: 0.9.27
3
+ Version: 0.9.28
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
+ # Flow
124
+ from .flow import Flow, Stage, Guidance, FlowResult, GUIDANCE_SEVERITY
125
+
123
126
  __all__ = [
124
127
  # Foundation
125
128
  "Confidence",
@@ -190,6 +193,8 @@ __all__ = [
190
193
  "save_monitor", "load_monitor", "replay", "replay_csv",
191
194
  # Loop
192
195
  "StepResult", "step", "run", "FullStepResult", "full_step",
196
+ # Flow
197
+ "Flow", "Stage", "Guidance", "FlowResult", "GUIDANCE_SEVERITY",
193
198
  # margin-poc compat aliases
194
199
  "EpistemicSource", "UncertaintyMode",
195
200
  "CallConfidence", "CircuitHealth", "GapThresholds", "MarginParser",
@@ -0,0 +1,390 @@
1
+ """
2
+ Flow: ordered sequence of stages with typed guidance.
3
+
4
+ A Flow is an ordered list of Stage objects, each with its own Parser and
5
+ per-stage Monitor. Observations are routed to the current stage; the Flow
6
+ emits typed Guidance (ADVANCE / HOLD / RETRY / ROLLBACK / ESCALATE /
7
+ COMPLETE) based on stage health and user-supplied advance/retry/escalate
8
+ predicates.
9
+
10
+ Minimal use::
11
+
12
+ from margin import Flow, Stage, Parser, Thresholds, Guidance
13
+
14
+ ferment = Flow(stages=[
15
+ Stage(
16
+ name="inoculation",
17
+ parser=Parser(baselines={"pH": 6.5, "DO": 90}, thresholds=Thresholds(...)),
18
+ advance_when=lambda expr: expr.health_of("DO") == Health.INTACT,
19
+ ),
20
+ Stage(
21
+ name="lag_phase",
22
+ parser=Parser(baselines={"pH": 6.3, "biomass": 0.1}, thresholds=Thresholds(...)),
23
+ advance_when=lambda expr: (expr.health_of("biomass") == Health.INTACT
24
+ and expr.observations[0].sigma > 0.5),
25
+ ),
26
+ # ...
27
+ ], label="fermentation")
28
+
29
+ result = ferment.observe({"pH": 6.5, "DO": 88})
30
+ # → FlowResult(stage="inoculation", guidance=HOLD, reason=...)
31
+
32
+ # When ready:
33
+ result = ferment.observe({"pH": 6.4, "DO": 92})
34
+ # → FlowResult(stage="inoculation", guidance=ADVANCE, ...)
35
+ ferment.advance()
36
+
37
+ # Compact rendering:
38
+ result.to_atom()
39
+ # → "flow:fermentation:inoculation[1/5] → ADVANCE"
40
+
41
+ Contracts can be used in predicates::
42
+
43
+ from margin import Contract, HealthTarget, Health, Ledger
44
+ contract = Contract(terms=[HealthTarget(component="biomass", target=Health.INTACT)])
45
+ ledger = Ledger()
46
+ stage = Stage(
47
+ name="stationary",
48
+ parser=parser,
49
+ advance_when=lambda expr: contract.evaluate(ledger, expr).passed,
50
+ )
51
+
52
+ This keeps Flow lightweight (no mandatory Ledger) while letting advanced
53
+ users plug in the full Contract machinery when they need it.
54
+ """
55
+
56
+ from __future__ import annotations
57
+
58
+ from dataclasses import dataclass, field
59
+ from datetime import datetime
60
+ from enum import Enum
61
+ from typing import Callable, Dict, List, Optional
62
+
63
+ from .health import Health
64
+ from .observation import Expression, Parser
65
+ from .streaming import Monitor
66
+
67
+
68
+ # -----------------------------------------------------------------------
69
+ # Guidance
70
+ # -----------------------------------------------------------------------
71
+
72
+ class Guidance(Enum):
73
+ """
74
+ Recommendation for the current flow state.
75
+
76
+ ADVANCE: exit criteria satisfied — ready to move to the next stage
77
+ HOLD: stage running healthy, exit criteria not yet met
78
+ RETRY: stage degraded but recoverable; continue observing
79
+ ROLLBACK: this stage cannot proceed; return to previous stage
80
+ ESCALATE: cannot proceed automatically — needs human intervention
81
+ COMPLETE: all stages are done
82
+ """
83
+ ADVANCE = "ADVANCE"
84
+ HOLD = "HOLD"
85
+ RETRY = "RETRY"
86
+ ROLLBACK = "ROLLBACK"
87
+ ESCALATE = "ESCALATE"
88
+ COMPLETE = "COMPLETE"
89
+
90
+
91
+ GUIDANCE_SEVERITY: Dict[Guidance, int] = {
92
+ Guidance.COMPLETE: 0,
93
+ Guidance.ADVANCE: 0,
94
+ Guidance.HOLD: 1,
95
+ Guidance.RETRY: 2,
96
+ Guidance.ROLLBACK: 3,
97
+ Guidance.ESCALATE: 4,
98
+ }
99
+
100
+
101
+ # -----------------------------------------------------------------------
102
+ # Stage
103
+ # -----------------------------------------------------------------------
104
+
105
+ StagePredicate = Callable[[Expression], bool]
106
+
107
+
108
+ @dataclass
109
+ class Stage:
110
+ """
111
+ One stage in a process flow.
112
+
113
+ name: human-readable stage identifier
114
+ parser: Parser for measurements observed during this stage
115
+ advance_when: predicate(expr) → True means exit criteria met
116
+ retry_when: predicate(expr) → True means stage is degraded-but-recoverable
117
+ escalate_when: predicate(expr) → True means stage cannot proceed
118
+ rollback_when: predicate(expr) → True means return to the previous stage
119
+ window: observation window size for the stage's Monitor (default 20)
120
+
121
+ All predicates are optional. When none fire, Guidance defaults to HOLD.
122
+ If no explicit predicates are given, the default classifier uses
123
+ observation health (any ABLATED → ESCALATE, any DEGRADED → RETRY, else HOLD).
124
+ """
125
+
126
+ name: str
127
+ parser: Parser
128
+ advance_when: Optional[StagePredicate] = None
129
+ retry_when: Optional[StagePredicate] = None
130
+ escalate_when: Optional[StagePredicate] = None
131
+ rollback_when: Optional[StagePredicate] = None
132
+ window: int = 20
133
+
134
+ def create_monitor(self) -> Monitor:
135
+ """Fresh Monitor bound to this stage's parser and window."""
136
+ return Monitor(self.parser, window=self.window)
137
+
138
+
139
+ # -----------------------------------------------------------------------
140
+ # FlowResult
141
+ # -----------------------------------------------------------------------
142
+
143
+ @dataclass
144
+ class FlowResult:
145
+ """Returned from Flow.observe() and flow advancement helpers."""
146
+ stage: str
147
+ stage_index: int
148
+ total_stages: int
149
+ expression: Optional[Expression]
150
+ guidance: Guidance
151
+ reason: str
152
+
153
+ def to_atom(self) -> str:
154
+ """
155
+ Compact flow notation.
156
+
157
+ "flow:fermentation:lag_phase[2/5] → ADVANCE"
158
+ "flow:fermentation:COMPLETE[5/5]"
159
+ """
160
+ if self.guidance == Guidance.COMPLETE:
161
+ return f"flow:{self.stage}[{self.total_stages}/{self.total_stages}]"
162
+ return (
163
+ f"flow:{self.stage}[{self.stage_index + 1}/{self.total_stages}]"
164
+ f" → {self.guidance.value}"
165
+ )
166
+
167
+ def to_dict(self) -> dict:
168
+ return {
169
+ "stage": self.stage,
170
+ "stage_index": self.stage_index,
171
+ "total_stages": self.total_stages,
172
+ "guidance": self.guidance.value,
173
+ "reason": self.reason,
174
+ "expression": self.expression.to_dict() if self.expression else None,
175
+ }
176
+
177
+ def __repr__(self) -> str:
178
+ return f"FlowResult({self.to_atom()} — {self.reason})"
179
+
180
+
181
+ # -----------------------------------------------------------------------
182
+ # Flow
183
+ # -----------------------------------------------------------------------
184
+
185
+ class Flow:
186
+ """
187
+ An ordered sequence of stages with typed per-step guidance.
188
+
189
+ Each stage has its own Monitor, created fresh when the flow enters the
190
+ stage. Observations are routed to the current stage only; earlier-stage
191
+ state is preserved in the transition log.
192
+
193
+ Flow does not auto-advance. ``observe()`` returns guidance; the caller
194
+ (or a policy layer above) decides whether to call ``advance()``. This
195
+ keeps the flow deterministic and side-effect free, which matters for
196
+ replay, backtesting, and safety-critical use cases.
197
+ """
198
+
199
+ def __init__(
200
+ self,
201
+ stages: List[Stage],
202
+ label: str = "",
203
+ ):
204
+ if not stages:
205
+ raise ValueError("Flow requires at least one stage")
206
+ names = [s.name for s in stages]
207
+ if len(set(names)) != len(names):
208
+ raise ValueError(f"Flow stages must have unique names, got {names}")
209
+ self.stages: List[Stage] = list(stages)
210
+ self.label = label
211
+ self._index = 0
212
+ self._monitors: Dict[str, Monitor] = {
213
+ stages[0].name: stages[0].create_monitor()
214
+ }
215
+ self._transitions: List[dict] = []
216
+ self._entered_at: datetime = datetime.now()
217
+
218
+ # ------------------------------------------------------------------
219
+ # Inspection
220
+ # ------------------------------------------------------------------
221
+
222
+ @property
223
+ def current(self) -> Optional[Stage]:
224
+ """Current stage, or None if the flow is complete."""
225
+ if self.is_complete:
226
+ return None
227
+ return self.stages[self._index]
228
+
229
+ @property
230
+ def current_monitor(self) -> Optional[Monitor]:
231
+ """Monitor for the current stage, or None if complete."""
232
+ if self.is_complete:
233
+ return None
234
+ return self._monitors[self.current.name]
235
+
236
+ @property
237
+ def is_complete(self) -> bool:
238
+ return self._index >= len(self.stages)
239
+
240
+ @property
241
+ def stage_index(self) -> int:
242
+ return self._index
243
+
244
+ @property
245
+ def transitions(self) -> List[dict]:
246
+ """History of stage transitions."""
247
+ return list(self._transitions)
248
+
249
+ # ------------------------------------------------------------------
250
+ # Observation
251
+ # ------------------------------------------------------------------
252
+
253
+ def observe(self, values: Dict[str, float], **kw) -> FlowResult:
254
+ """
255
+ Route an observation to the current stage and return guidance.
256
+
257
+ ``values`` and any kwargs are forwarded to ``Monitor.update()``, so
258
+ confidences, absences, and provenance all pass through.
259
+ """
260
+ if self.is_complete:
261
+ return FlowResult(
262
+ stage=self.label or "flow",
263
+ stage_index=len(self.stages),
264
+ total_stages=len(self.stages),
265
+ expression=None,
266
+ guidance=Guidance.COMPLETE,
267
+ reason="all stages complete",
268
+ )
269
+
270
+ stage = self.current
271
+ monitor = self._monitors[stage.name]
272
+ expr = monitor.update(values, **kw)
273
+ guidance, reason = self._classify(stage, expr)
274
+
275
+ return FlowResult(
276
+ stage=stage.name,
277
+ stage_index=self._index,
278
+ total_stages=len(self.stages),
279
+ expression=expr,
280
+ guidance=guidance,
281
+ reason=reason,
282
+ )
283
+
284
+ def _classify(self, stage: Stage, expr: Expression) -> tuple[Guidance, str]:
285
+ """Evaluate stage predicates in severity order."""
286
+ # 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"
295
+
296
+ # Fallback classification using observation health alone
297
+ if any(o.health == Health.ABLATED for o in expr.observations):
298
+ return Guidance.ESCALATE, "component ABLATED"
299
+ if any(o.health == Health.DEGRADED for o in expr.observations):
300
+ return Guidance.RETRY, "component DEGRADED"
301
+ return Guidance.HOLD, "healthy, exit criteria not met"
302
+
303
+ # ------------------------------------------------------------------
304
+ # Stage transitions
305
+ # ------------------------------------------------------------------
306
+
307
+ def advance(self) -> FlowResult:
308
+ """
309
+ Move to the next stage. Creates a fresh Monitor for that stage.
310
+ Raises if the flow is already complete.
311
+ """
312
+ if self.is_complete:
313
+ raise ValueError("flow is already complete")
314
+ prev = self.stages[self._index].name
315
+ self._index += 1
316
+ self._transitions.append({
317
+ "from": prev,
318
+ "to": self.stages[self._index].name if not self.is_complete else "(complete)",
319
+ "kind": "advance",
320
+ "at": datetime.now().isoformat(),
321
+ })
322
+ if not self.is_complete:
323
+ self._monitors[self.current.name] = self.current.create_monitor()
324
+ return self._status_result("advanced")
325
+
326
+ def rollback(self) -> FlowResult:
327
+ """
328
+ Return to the previous stage. Creates a fresh Monitor for that stage
329
+ (prior observations during the rollback target are discarded).
330
+ Raises if already at the first stage.
331
+ """
332
+ if self._index == 0:
333
+ raise ValueError("cannot rollback from first stage")
334
+ prev = self.current.name if not self.is_complete else "(complete)"
335
+ self._index -= 1
336
+ self._monitors[self.current.name] = self.current.create_monitor()
337
+ self._transitions.append({
338
+ "from": prev,
339
+ "to": self.current.name,
340
+ "kind": "rollback",
341
+ "at": datetime.now().isoformat(),
342
+ })
343
+ return self._status_result("rolled back")
344
+
345
+ def reset(self) -> None:
346
+ """Reset to the first stage. Clears all monitors and transitions."""
347
+ self._index = 0
348
+ self._monitors = {self.stages[0].name: self.stages[0].create_monitor()}
349
+ self._transitions = []
350
+ self._entered_at = datetime.now()
351
+
352
+ def _status_result(self, reason: str) -> FlowResult:
353
+ if self.is_complete:
354
+ return FlowResult(
355
+ stage=self.label or "flow",
356
+ stage_index=len(self.stages),
357
+ total_stages=len(self.stages),
358
+ expression=None,
359
+ guidance=Guidance.COMPLETE,
360
+ reason=reason,
361
+ )
362
+ return FlowResult(
363
+ stage=self.current.name,
364
+ stage_index=self._index,
365
+ total_stages=len(self.stages),
366
+ expression=None,
367
+ guidance=Guidance.HOLD,
368
+ reason=reason,
369
+ )
370
+
371
+ # ------------------------------------------------------------------
372
+ # Serialization
373
+ # ------------------------------------------------------------------
374
+
375
+ def to_dict(self) -> dict:
376
+ """Serialize the flow's current position and transition log."""
377
+ return {
378
+ "label": self.label,
379
+ "stages": [s.name for s in self.stages],
380
+ "index": self._index,
381
+ "transitions": list(self._transitions),
382
+ "entered_at": self._entered_at.isoformat(),
383
+ }
384
+
385
+ def __repr__(self) -> str:
386
+ if self.is_complete:
387
+ pos = f"COMPLETE[{len(self.stages)}/{len(self.stages)}]"
388
+ else:
389
+ pos = f"{self.current.name}[{self._index + 1}/{len(self.stages)}]"
390
+ return f"Flow(label={self.label!r}, {pos})"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: margin
3
- Version: 0.9.27
3
+ Version: 0.9.28
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
@@ -19,6 +19,7 @@ margin/diff.py
19
19
  margin/drift.py
20
20
  margin/events.py
21
21
  margin/fingerprint.py
22
+ margin/flow.py
22
23
  margin/forecast.py
23
24
  margin/health.py
24
25
  margin/intent.py
@@ -60,6 +61,7 @@ tests/test_drift.py
60
61
  tests/test_events.py
61
62
  tests/test_fingerprint_ext.py
62
63
  tests/test_float_tolerance.py
64
+ tests/test_flow.py
63
65
  tests/test_forecast.py
64
66
  tests/test_health.py
65
67
  tests/test_healthcare.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "margin"
7
- version = "0.9.27"
7
+ version = "0.9.28"
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"}
@@ -0,0 +1,418 @@
1
+ """Tests for Flow: ordered stages with typed guidance (v0.9.28)."""
2
+ import pytest
3
+
4
+ from margin import (
5
+ Flow, Stage, Guidance,
6
+ Parser, Thresholds, Health, Confidence,
7
+ Expression,
8
+ )
9
+
10
+
11
+ # ---------------------------------------------------------------------------
12
+ # Helpers
13
+ # ---------------------------------------------------------------------------
14
+
15
+ def _parser(baselines: dict, intact: float = 0.8, ablated: float = 0.2,
16
+ higher: bool = True) -> Parser:
17
+ return Parser(
18
+ baselines=baselines,
19
+ thresholds=Thresholds(intact=intact, ablated=ablated, higher_is_better=higher),
20
+ )
21
+
22
+
23
+ # ---------------------------------------------------------------------------
24
+ # Guidance enum
25
+ # ---------------------------------------------------------------------------
26
+
27
+ class TestGuidanceEnum:
28
+ def test_members(self):
29
+ assert Guidance.ADVANCE.value == "ADVANCE"
30
+ assert Guidance.HOLD.value == "HOLD"
31
+ assert Guidance.RETRY.value == "RETRY"
32
+ assert Guidance.ROLLBACK.value == "ROLLBACK"
33
+ assert Guidance.ESCALATE.value == "ESCALATE"
34
+ assert Guidance.COMPLETE.value == "COMPLETE"
35
+
36
+
37
+ # ---------------------------------------------------------------------------
38
+ # Flow construction
39
+ # ---------------------------------------------------------------------------
40
+
41
+ class TestFlowConstruction:
42
+ def test_requires_at_least_one_stage(self):
43
+ with pytest.raises(ValueError, match="at least one stage"):
44
+ Flow(stages=[])
45
+
46
+ def test_rejects_duplicate_stage_names(self):
47
+ s1 = Stage(name="x", parser=_parser({"m": 1.0}))
48
+ s2 = Stage(name="x", parser=_parser({"m": 1.0}))
49
+ with pytest.raises(ValueError, match="unique names"):
50
+ Flow(stages=[s1, s2])
51
+
52
+ def test_starts_at_first_stage(self):
53
+ s = Stage(name="init", parser=_parser({"m": 1.0}))
54
+ f = Flow(stages=[s])
55
+ assert f.current is s
56
+ assert f.stage_index == 0
57
+ assert not f.is_complete
58
+
59
+
60
+ # ---------------------------------------------------------------------------
61
+ # Observation routing
62
+ # ---------------------------------------------------------------------------
63
+
64
+ class TestObserve:
65
+ def test_observe_returns_flow_result(self):
66
+ s = Stage(name="init", parser=_parser({"m": 1.0}))
67
+ f = Flow(stages=[s])
68
+ result = f.observe({"m": 1.0})
69
+ assert result.stage == "init"
70
+ assert result.stage_index == 0
71
+ assert result.total_stages == 1
72
+ assert result.expression is not None
73
+
74
+ def test_observe_routed_only_to_current_stage(self):
75
+ s1 = Stage(name="a", parser=_parser({"m": 1.0}))
76
+ s2 = Stage(name="b", parser=_parser({"m": 2.0}))
77
+ f = Flow(stages=[s1, s2])
78
+ f.observe({"m": 1.0})
79
+ assert f._monitors["a"].expression is not None
80
+ assert "b" not in f._monitors or f._monitors.get("b") is None
81
+
82
+ def test_healthy_default_guidance_hold(self):
83
+ s = Stage(name="init", parser=_parser({"m": 1.0}))
84
+ f = Flow(stages=[s])
85
+ result = f.observe({"m": 1.0})
86
+ assert result.guidance == Guidance.HOLD
87
+
88
+ def test_ablated_component_defaults_to_escalate(self):
89
+ s = Stage(name="init", parser=_parser({"m": 1.0}, intact=0.8, ablated=0.5))
90
+ f = Flow(stages=[s])
91
+ # Value well below ablated threshold
92
+ result = f.observe({"m": 0.1})
93
+ assert result.guidance == Guidance.ESCALATE
94
+
95
+ def test_degraded_component_defaults_to_retry(self):
96
+ s = Stage(name="init", parser=_parser({"m": 1.0}, intact=0.8, ablated=0.2))
97
+ f = Flow(stages=[s])
98
+ # Value between ablated and intact
99
+ result = f.observe({"m": 0.5})
100
+ assert result.guidance == Guidance.RETRY
101
+
102
+
103
+ # ---------------------------------------------------------------------------
104
+ # Stage predicates
105
+ # ---------------------------------------------------------------------------
106
+
107
+ class TestStagePredicates:
108
+ def test_advance_when_fires(self):
109
+ s = Stage(
110
+ name="init",
111
+ parser=_parser({"m": 1.0}),
112
+ advance_when=lambda expr: True,
113
+ )
114
+ f = Flow(stages=[s])
115
+ result = f.observe({"m": 1.0})
116
+ assert result.guidance == Guidance.ADVANCE
117
+
118
+ def test_advance_when_inspects_expression(self):
119
+ s = Stage(
120
+ name="init",
121
+ parser=_parser({"m": 1.0}),
122
+ advance_when=lambda expr: (expr.health_of("m") == Health.INTACT),
123
+ )
124
+ f = Flow(stages=[s])
125
+ assert f.observe({"m": 1.0}).guidance == Guidance.ADVANCE
126
+ # reset for degraded run
127
+ f.reset()
128
+ assert f.observe({"m": 0.5}).guidance == Guidance.RETRY # default fallback
129
+
130
+ def test_escalate_when_overrides_advance(self):
131
+ s = Stage(
132
+ name="init",
133
+ parser=_parser({"m": 1.0}),
134
+ advance_when=lambda expr: True,
135
+ escalate_when=lambda expr: True,
136
+ )
137
+ f = Flow(stages=[s])
138
+ # escalate is checked first
139
+ assert f.observe({"m": 1.0}).guidance == Guidance.ESCALATE
140
+
141
+ def test_rollback_when_fires(self):
142
+ s = Stage(
143
+ name="init",
144
+ parser=_parser({"m": 1.0}),
145
+ rollback_when=lambda expr: True,
146
+ )
147
+ f = Flow(stages=[s])
148
+ assert f.observe({"m": 1.0}).guidance == Guidance.ROLLBACK
149
+
150
+ def test_retry_when_fires(self):
151
+ s = Stage(
152
+ name="init",
153
+ parser=_parser({"m": 1.0}),
154
+ retry_when=lambda expr: True,
155
+ )
156
+ f = Flow(stages=[s])
157
+ result = f.observe({"m": 1.0})
158
+ assert result.guidance == Guidance.RETRY
159
+
160
+
161
+ # ---------------------------------------------------------------------------
162
+ # Advancement
163
+ # ---------------------------------------------------------------------------
164
+
165
+ class TestAdvance:
166
+ def _three_stage_flow(self) -> Flow:
167
+ return Flow(stages=[
168
+ Stage(name="a", parser=_parser({"m": 1.0})),
169
+ Stage(name="b", parser=_parser({"m": 2.0})),
170
+ Stage(name="c", parser=_parser({"m": 3.0})),
171
+ ], label="abc")
172
+
173
+ def test_advance_moves_to_next_stage(self):
174
+ f = self._three_stage_flow()
175
+ assert f.current.name == "a"
176
+ f.advance()
177
+ assert f.current.name == "b"
178
+ assert f.stage_index == 1
179
+
180
+ def test_advance_creates_fresh_monitor(self):
181
+ f = self._three_stage_flow()
182
+ f.observe({"m": 1.0})
183
+ first_monitor = f.current_monitor
184
+ f.advance()
185
+ assert f.current_monitor is not first_monitor # fresh instance
186
+ assert f.current_monitor.step == 0
187
+
188
+ def test_advance_from_last_completes(self):
189
+ f = self._three_stage_flow()
190
+ f.advance() # a → b
191
+ f.advance() # b → c
192
+ f.advance() # c → complete
193
+ assert f.is_complete
194
+ assert f.current is None
195
+
196
+ def test_advance_when_complete_raises(self):
197
+ s = Stage(name="init", parser=_parser({"m": 1.0}))
198
+ f = Flow(stages=[s])
199
+ f.advance()
200
+ with pytest.raises(ValueError, match="already complete"):
201
+ f.advance()
202
+
203
+ def test_observe_after_complete_returns_complete(self):
204
+ s = Stage(name="init", parser=_parser({"m": 1.0}))
205
+ f = Flow(stages=[s])
206
+ f.advance()
207
+ result = f.observe({"m": 1.0})
208
+ assert result.guidance == Guidance.COMPLETE
209
+ assert result.expression is None
210
+
211
+ def test_transitions_log_advance(self):
212
+ f = self._three_stage_flow()
213
+ f.advance()
214
+ assert len(f.transitions) == 1
215
+ t = f.transitions[0]
216
+ assert t["from"] == "a"
217
+ assert t["to"] == "b"
218
+ assert t["kind"] == "advance"
219
+
220
+ def test_transitions_log_complete(self):
221
+ s = Stage(name="init", parser=_parser({"m": 1.0}))
222
+ f = Flow(stages=[s])
223
+ f.advance()
224
+ t = f.transitions[0]
225
+ assert t["to"] == "(complete)"
226
+
227
+
228
+ # ---------------------------------------------------------------------------
229
+ # Rollback
230
+ # ---------------------------------------------------------------------------
231
+
232
+ class TestRollback:
233
+ def test_rollback_from_first_raises(self):
234
+ s = Stage(name="a", parser=_parser({"m": 1.0}))
235
+ f = Flow(stages=[s])
236
+ with pytest.raises(ValueError, match="first stage"):
237
+ f.rollback()
238
+
239
+ def test_rollback_returns_to_previous(self):
240
+ f = Flow(stages=[
241
+ Stage(name="a", parser=_parser({"m": 1.0})),
242
+ Stage(name="b", parser=_parser({"m": 2.0})),
243
+ ])
244
+ f.advance()
245
+ assert f.current.name == "b"
246
+ f.rollback()
247
+ assert f.current.name == "a"
248
+
249
+ def test_rollback_creates_fresh_monitor(self):
250
+ f = Flow(stages=[
251
+ Stage(name="a", parser=_parser({"m": 1.0})),
252
+ Stage(name="b", parser=_parser({"m": 2.0})),
253
+ ])
254
+ f.observe({"m": 1.0})
255
+ original_monitor = f._monitors["a"]
256
+ f.advance()
257
+ f.rollback()
258
+ assert f._monitors["a"] is not original_monitor
259
+
260
+ def test_rollback_logs_transition(self):
261
+ f = Flow(stages=[
262
+ Stage(name="a", parser=_parser({"m": 1.0})),
263
+ Stage(name="b", parser=_parser({"m": 2.0})),
264
+ ])
265
+ f.advance()
266
+ f.rollback()
267
+ assert f.transitions[-1]["kind"] == "rollback"
268
+ assert f.transitions[-1]["from"] == "b"
269
+ assert f.transitions[-1]["to"] == "a"
270
+
271
+
272
+ # ---------------------------------------------------------------------------
273
+ # Reset
274
+ # ---------------------------------------------------------------------------
275
+
276
+ class TestReset:
277
+ def test_reset_returns_to_first_stage(self):
278
+ f = Flow(stages=[
279
+ Stage(name="a", parser=_parser({"m": 1.0})),
280
+ Stage(name="b", parser=_parser({"m": 2.0})),
281
+ ])
282
+ f.advance()
283
+ f.reset()
284
+ assert f.current.name == "a"
285
+
286
+ def test_reset_clears_transitions(self):
287
+ f = Flow(stages=[
288
+ Stage(name="a", parser=_parser({"m": 1.0})),
289
+ Stage(name="b", parser=_parser({"m": 2.0})),
290
+ ])
291
+ f.advance()
292
+ f.reset()
293
+ assert f.transitions == []
294
+
295
+
296
+ # ---------------------------------------------------------------------------
297
+ # Serialization and rendering
298
+ # ---------------------------------------------------------------------------
299
+
300
+ class TestSerialization:
301
+ def test_to_atom_running(self):
302
+ f = Flow(stages=[
303
+ Stage(name="lag", parser=_parser({"m": 1.0})),
304
+ Stage(name="exp", parser=_parser({"m": 1.0})),
305
+ ], label="ferment")
306
+ result = f.observe({"m": 1.0})
307
+ atom = result.to_atom()
308
+ assert "lag" in atom
309
+ assert "1/2" in atom
310
+ assert "HOLD" in atom
311
+
312
+ def test_to_atom_complete(self):
313
+ s = Stage(name="only", parser=_parser({"m": 1.0}))
314
+ f = Flow(stages=[s], label="linear")
315
+ f.advance()
316
+ result = f.observe({"m": 1.0})
317
+ assert result.guidance == Guidance.COMPLETE
318
+
319
+ def test_flow_to_dict(self):
320
+ f = Flow(stages=[
321
+ Stage(name="a", parser=_parser({"m": 1.0})),
322
+ Stage(name="b", parser=_parser({"m": 2.0})),
323
+ ], label="test")
324
+ d = f.to_dict()
325
+ assert d["label"] == "test"
326
+ assert d["stages"] == ["a", "b"]
327
+ assert d["index"] == 0
328
+
329
+ def test_flow_result_to_dict(self):
330
+ s = Stage(name="a", parser=_parser({"m": 1.0}))
331
+ f = Flow(stages=[s])
332
+ result = f.observe({"m": 1.0})
333
+ d = result.to_dict()
334
+ assert d["stage"] == "a"
335
+ assert d["guidance"] == "HOLD"
336
+ assert "expression" in d
337
+
338
+
339
+ # ---------------------------------------------------------------------------
340
+ # Realistic scenario: fermentation
341
+ # ---------------------------------------------------------------------------
342
+
343
+ class TestFermentationScenario:
344
+ def test_advances_through_lag_then_exponential(self):
345
+ """Simulate a tiny fermentation: lag → exponential → stationary."""
346
+ lag = Stage(
347
+ name="lag",
348
+ parser=Parser(
349
+ baselines={"biomass": 0.1},
350
+ thresholds=Thresholds(intact=0.08, ablated=0.02),
351
+ ),
352
+ advance_when=lambda expr: any(
353
+ o.name == "biomass" and o.value >= 0.15
354
+ for o in expr.observations
355
+ ),
356
+ )
357
+ expo = Stage(
358
+ name="exponential",
359
+ parser=Parser(
360
+ baselines={"biomass": 0.5},
361
+ thresholds=Thresholds(intact=0.4, ablated=0.2),
362
+ ),
363
+ advance_when=lambda expr: any(
364
+ o.name == "biomass" and o.value >= 0.8
365
+ for o in expr.observations
366
+ ),
367
+ )
368
+ stationary = Stage(
369
+ name="stationary",
370
+ parser=Parser(
371
+ baselines={"biomass": 1.0},
372
+ thresholds=Thresholds(intact=0.8, ablated=0.3),
373
+ ),
374
+ )
375
+
376
+ flow = Flow(stages=[lag, expo, stationary], label="ferment")
377
+
378
+ # Lag phase
379
+ assert flow.observe({"biomass": 0.10}).guidance == Guidance.HOLD
380
+ assert flow.observe({"biomass": 0.12}).guidance == Guidance.HOLD
381
+ assert flow.observe({"biomass": 0.16}).guidance == Guidance.ADVANCE
382
+ flow.advance()
383
+
384
+ # Exponential
385
+ assert flow.current.name == "exponential"
386
+ assert flow.observe({"biomass": 0.5}).guidance == Guidance.HOLD
387
+ assert flow.observe({"biomass": 0.85}).guidance == Guidance.ADVANCE
388
+ flow.advance()
389
+
390
+ # Stationary — no advance_when, default HOLD when healthy
391
+ assert flow.current.name == "stationary"
392
+ assert flow.observe({"biomass": 1.0}).guidance == Guidance.HOLD
393
+ flow.advance()
394
+ assert flow.is_complete
395
+
396
+ def test_stage_specific_thresholds_dont_bleed(self):
397
+ """Each stage has its own Parser; a value that's intact in stage A
398
+ could be degraded in stage B."""
399
+ s1 = Stage(
400
+ name="lag",
401
+ parser=Parser(baselines={"biomass": 0.1},
402
+ thresholds=Thresholds(intact=0.05, ablated=0.01)),
403
+ )
404
+ s2 = Stage(
405
+ name="exp",
406
+ parser=Parser(baselines={"biomass": 0.8},
407
+ thresholds=Thresholds(intact=0.7, ablated=0.2)),
408
+ )
409
+ f = Flow(stages=[s1, s2])
410
+
411
+ # 0.1 is intact in lag
412
+ result = f.observe({"biomass": 0.1})
413
+ assert result.expression.health_of("biomass") == Health.INTACT
414
+
415
+ f.advance()
416
+ # 0.1 is ablated in exp
417
+ result = f.observe({"biomass": 0.1})
418
+ assert result.expression.health_of("biomass") == Health.ABLATED
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