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.
- {margin-0.9.27/margin.egg-info → margin-0.9.28}/PKG-INFO +1 -1
- {margin-0.9.27 → margin-0.9.28}/margin/__init__.py +5 -0
- margin-0.9.28/margin/flow.py +390 -0
- {margin-0.9.27 → margin-0.9.28/margin.egg-info}/PKG-INFO +1 -1
- {margin-0.9.27 → margin-0.9.28}/margin.egg-info/SOURCES.txt +2 -0
- {margin-0.9.27 → margin-0.9.28}/pyproject.toml +1 -1
- margin-0.9.28/tests/test_flow.py +418 -0
- {margin-0.9.27 → margin-0.9.28}/LICENSE +0 -0
- {margin-0.9.27 → margin-0.9.28}/NOTICE +0 -0
- {margin-0.9.27 → margin-0.9.28}/README.md +0 -0
- {margin-0.9.27 → margin-0.9.28}/margin/__main__.py +0 -0
- {margin-0.9.27 → margin-0.9.28}/margin/algebra.py +0 -0
- {margin-0.9.27 → margin-0.9.28}/margin/anomaly.py +0 -0
- {margin-0.9.27 → margin-0.9.28}/margin/bridge.py +0 -0
- {margin-0.9.27 → margin-0.9.28}/margin/calibrate.py +0 -0
- {margin-0.9.27 → margin-0.9.28}/margin/causal.py +0 -0
- {margin-0.9.27 → margin-0.9.28}/margin/composite.py +0 -0
- {margin-0.9.27 → margin-0.9.28}/margin/confidence.py +0 -0
- {margin-0.9.27 → margin-0.9.28}/margin/config.py +0 -0
- {margin-0.9.27 → margin-0.9.28}/margin/contract.py +0 -0
- {margin-0.9.27 → margin-0.9.28}/margin/controller.py +0 -0
- {margin-0.9.27 → margin-0.9.28}/margin/correlate.py +0 -0
- {margin-0.9.27 → margin-0.9.28}/margin/diff.py +0 -0
- {margin-0.9.27 → margin-0.9.28}/margin/drift.py +0 -0
- {margin-0.9.27 → margin-0.9.28}/margin/events.py +0 -0
- {margin-0.9.27 → margin-0.9.28}/margin/fingerprint.py +0 -0
- {margin-0.9.27 → margin-0.9.28}/margin/forecast.py +0 -0
- {margin-0.9.27 → margin-0.9.28}/margin/health.py +0 -0
- {margin-0.9.27 → margin-0.9.28}/margin/intent.py +0 -0
- {margin-0.9.27 → margin-0.9.28}/margin/ledger.py +0 -0
- {margin-0.9.27 → margin-0.9.28}/margin/loop.py +0 -0
- {margin-0.9.27 → margin-0.9.28}/margin/observation.py +0 -0
- {margin-0.9.27 → margin-0.9.28}/margin/persist.py +0 -0
- {margin-0.9.27 → margin-0.9.28}/margin/policy/__init__.py +0 -0
- {margin-0.9.27 → margin-0.9.28}/margin/policy/compose.py +0 -0
- {margin-0.9.27 → margin-0.9.28}/margin/policy/core.py +0 -0
- {margin-0.9.27 → margin-0.9.28}/margin/policy/temporal.py +0 -0
- {margin-0.9.27 → margin-0.9.28}/margin/policy/trace.py +0 -0
- {margin-0.9.27 → margin-0.9.28}/margin/policy/tuning.py +0 -0
- {margin-0.9.27 → margin-0.9.28}/margin/policy/validate.py +0 -0
- {margin-0.9.27 → margin-0.9.28}/margin/predicates.py +0 -0
- {margin-0.9.27 → margin-0.9.28}/margin/provenance.py +0 -0
- {margin-0.9.27 → margin-0.9.28}/margin/streaming.py +0 -0
- {margin-0.9.27 → margin-0.9.28}/margin/transitions.py +0 -0
- {margin-0.9.27 → margin-0.9.28}/margin/uncertain.py +0 -0
- {margin-0.9.27 → margin-0.9.28}/margin/validity.py +0 -0
- {margin-0.9.27 → margin-0.9.28}/margin.egg-info/dependency_links.txt +0 -0
- {margin-0.9.27 → margin-0.9.28}/margin.egg-info/top_level.txt +0 -0
- {margin-0.9.27 → margin-0.9.28}/setup.cfg +0 -0
- {margin-0.9.27 → margin-0.9.28}/tests/test_absence.py +0 -0
- {margin-0.9.27 → margin-0.9.28}/tests/test_algebra.py +0 -0
- {margin-0.9.27 → margin-0.9.28}/tests/test_anomaly.py +0 -0
- {margin-0.9.27 → margin-0.9.28}/tests/test_bridge.py +0 -0
- {margin-0.9.27 → margin-0.9.28}/tests/test_calibrate.py +0 -0
- {margin-0.9.27 → margin-0.9.28}/tests/test_causal.py +0 -0
- {margin-0.9.27 → margin-0.9.28}/tests/test_composite.py +0 -0
- {margin-0.9.27 → margin-0.9.28}/tests/test_confidence.py +0 -0
- {margin-0.9.27 → margin-0.9.28}/tests/test_config.py +0 -0
- {margin-0.9.27 → margin-0.9.28}/tests/test_contract.py +0 -0
- {margin-0.9.27 → margin-0.9.28}/tests/test_controller.py +0 -0
- {margin-0.9.27 → margin-0.9.28}/tests/test_correlate.py +0 -0
- {margin-0.9.27 → margin-0.9.28}/tests/test_diff.py +0 -0
- {margin-0.9.27 → margin-0.9.28}/tests/test_drift.py +0 -0
- {margin-0.9.27 → margin-0.9.28}/tests/test_events.py +0 -0
- {margin-0.9.27 → margin-0.9.28}/tests/test_fingerprint_ext.py +0 -0
- {margin-0.9.27 → margin-0.9.28}/tests/test_float_tolerance.py +0 -0
- {margin-0.9.27 → margin-0.9.28}/tests/test_forecast.py +0 -0
- {margin-0.9.27 → margin-0.9.28}/tests/test_health.py +0 -0
- {margin-0.9.27 → margin-0.9.28}/tests/test_healthcare.py +0 -0
- {margin-0.9.27 → margin-0.9.28}/tests/test_intent.py +0 -0
- {margin-0.9.27 → margin-0.9.28}/tests/test_ledger.py +0 -0
- {margin-0.9.27 → margin-0.9.28}/tests/test_loop.py +0 -0
- {margin-0.9.27 → margin-0.9.28}/tests/test_neuro.py +0 -0
- {margin-0.9.27 → margin-0.9.28}/tests/test_observation.py +0 -0
- {margin-0.9.27 → margin-0.9.28}/tests/test_persist.py +0 -0
- {margin-0.9.27 → margin-0.9.28}/tests/test_pipeline_features.py +0 -0
- {margin-0.9.27 → margin-0.9.28}/tests/test_policy.py +0 -0
- {margin-0.9.27 → margin-0.9.28}/tests/test_policy_compose.py +0 -0
- {margin-0.9.27 → margin-0.9.28}/tests/test_policy_temporal.py +0 -0
- {margin-0.9.27 → margin-0.9.28}/tests/test_policy_trace.py +0 -0
- {margin-0.9.27 → margin-0.9.28}/tests/test_policy_tuning.py +0 -0
- {margin-0.9.27 → margin-0.9.28}/tests/test_policy_validate.py +0 -0
- {margin-0.9.27 → margin-0.9.28}/tests/test_predicates.py +0 -0
- {margin-0.9.27 → margin-0.9.28}/tests/test_printer3d.py +0 -0
- {margin-0.9.27 → margin-0.9.28}/tests/test_provenance.py +0 -0
- {margin-0.9.27 → margin-0.9.28}/tests/test_reverse_bridge.py +0 -0
- {margin-0.9.27 → margin-0.9.28}/tests/test_ros2.py +0 -0
- {margin-0.9.27 → margin-0.9.28}/tests/test_staleness.py +0 -0
- {margin-0.9.27 → margin-0.9.28}/tests/test_streaming.py +0 -0
- {margin-0.9.27 → margin-0.9.28}/tests/test_transformer.py +0 -0
- {margin-0.9.27 → margin-0.9.28}/tests/test_transitions.py +0 -0
- {margin-0.9.27 → margin-0.9.28}/tests/test_uncertain.py +0 -0
- {margin-0.9.27 → margin-0.9.28}/tests/test_validity.py +0 -0
- {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.
|
|
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.
|
|
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.
|
|
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
|
|
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
|