margin 0.9.31__tar.gz → 0.10.0__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.31/margin.egg-info → margin-0.10.0}/PKG-INFO +96 -25
- {margin-0.9.31 → margin-0.10.0}/README.md +95 -24
- {margin-0.9.31 → margin-0.10.0}/margin/__init__.py +44 -21
- {margin-0.9.31 → margin-0.10.0}/margin/__main__.py +19 -0
- {margin-0.9.31 → margin-0.10.0}/margin/causal.py +13 -0
- {margin-0.9.31 → margin-0.10.0}/margin/composite.py +4 -0
- {margin-0.9.31 → margin-0.10.0}/margin/diff.py +54 -16
- {margin-0.9.31 → margin-0.10.0}/margin/loop.py +44 -0
- {margin-0.9.31 → margin-0.10.0}/margin/provenance.py +0 -3
- {margin-0.9.31 → margin-0.10.0}/margin/uncertain.py +0 -6
- {margin-0.9.31 → margin-0.10.0/margin.egg-info}/PKG-INFO +96 -25
- {margin-0.9.31 → margin-0.10.0}/margin.egg-info/SOURCES.txt +0 -5
- {margin-0.9.31 → margin-0.10.0}/pyproject.toml +1 -1
- margin-0.9.31/tests/test_healthcare.py +0 -210
- margin-0.9.31/tests/test_neuro.py +0 -159
- margin-0.9.31/tests/test_printer3d.py +0 -151
- margin-0.9.31/tests/test_ros2.py +0 -190
- margin-0.9.31/tests/test_transformer.py +0 -301
- {margin-0.9.31 → margin-0.10.0}/LICENSE +0 -0
- {margin-0.9.31 → margin-0.10.0}/NOTICE +0 -0
- {margin-0.9.31 → margin-0.10.0}/margin/algebra.py +0 -0
- {margin-0.9.31 → margin-0.10.0}/margin/anomaly.py +0 -0
- {margin-0.9.31 → margin-0.10.0}/margin/bridge.py +0 -0
- {margin-0.9.31 → margin-0.10.0}/margin/calibrate.py +0 -0
- {margin-0.9.31 → margin-0.10.0}/margin/confidence.py +0 -0
- {margin-0.9.31 → margin-0.10.0}/margin/config.py +0 -0
- {margin-0.9.31 → margin-0.10.0}/margin/contract.py +0 -0
- {margin-0.9.31 → margin-0.10.0}/margin/controller.py +0 -0
- {margin-0.9.31 → margin-0.10.0}/margin/correlate.py +0 -0
- {margin-0.9.31 → margin-0.10.0}/margin/drift.py +0 -0
- {margin-0.9.31 → margin-0.10.0}/margin/events.py +0 -0
- {margin-0.9.31 → margin-0.10.0}/margin/fingerprint.py +0 -0
- {margin-0.9.31 → margin-0.10.0}/margin/flow.py +0 -0
- {margin-0.9.31 → margin-0.10.0}/margin/forecast.py +0 -0
- {margin-0.9.31 → margin-0.10.0}/margin/health.py +0 -0
- {margin-0.9.31 → margin-0.10.0}/margin/intent.py +0 -0
- {margin-0.9.31 → margin-0.10.0}/margin/issue.py +0 -0
- {margin-0.9.31 → margin-0.10.0}/margin/ledger.py +0 -0
- {margin-0.9.31 → margin-0.10.0}/margin/observation.py +0 -0
- {margin-0.9.31 → margin-0.10.0}/margin/persist.py +0 -0
- {margin-0.9.31 → margin-0.10.0}/margin/policy/__init__.py +0 -0
- {margin-0.9.31 → margin-0.10.0}/margin/policy/compose.py +0 -0
- {margin-0.9.31 → margin-0.10.0}/margin/policy/core.py +0 -0
- {margin-0.9.31 → margin-0.10.0}/margin/policy/temporal.py +0 -0
- {margin-0.9.31 → margin-0.10.0}/margin/policy/trace.py +0 -0
- {margin-0.9.31 → margin-0.10.0}/margin/policy/tuning.py +0 -0
- {margin-0.9.31 → margin-0.10.0}/margin/policy/validate.py +0 -0
- {margin-0.9.31 → margin-0.10.0}/margin/predicates.py +0 -0
- {margin-0.9.31 → margin-0.10.0}/margin/streaming.py +0 -0
- {margin-0.9.31 → margin-0.10.0}/margin/transitions.py +0 -0
- {margin-0.9.31 → margin-0.10.0}/margin/validity.py +0 -0
- {margin-0.9.31 → margin-0.10.0}/margin.egg-info/dependency_links.txt +0 -0
- {margin-0.9.31 → margin-0.10.0}/margin.egg-info/top_level.txt +0 -0
- {margin-0.9.31 → margin-0.10.0}/setup.cfg +0 -0
- {margin-0.9.31 → margin-0.10.0}/tests/test_absence.py +0 -0
- {margin-0.9.31 → margin-0.10.0}/tests/test_algebra.py +0 -0
- {margin-0.9.31 → margin-0.10.0}/tests/test_anomaly.py +0 -0
- {margin-0.9.31 → margin-0.10.0}/tests/test_bridge.py +0 -0
- {margin-0.9.31 → margin-0.10.0}/tests/test_calibrate.py +0 -0
- {margin-0.9.31 → margin-0.10.0}/tests/test_causal.py +0 -0
- {margin-0.9.31 → margin-0.10.0}/tests/test_composite.py +0 -0
- {margin-0.9.31 → margin-0.10.0}/tests/test_confidence.py +0 -0
- {margin-0.9.31 → margin-0.10.0}/tests/test_config.py +0 -0
- {margin-0.9.31 → margin-0.10.0}/tests/test_contract.py +0 -0
- {margin-0.9.31 → margin-0.10.0}/tests/test_controller.py +0 -0
- {margin-0.9.31 → margin-0.10.0}/tests/test_correlate.py +0 -0
- {margin-0.9.31 → margin-0.10.0}/tests/test_diff.py +0 -0
- {margin-0.9.31 → margin-0.10.0}/tests/test_drift.py +0 -0
- {margin-0.9.31 → margin-0.10.0}/tests/test_events.py +0 -0
- {margin-0.9.31 → margin-0.10.0}/tests/test_fingerprint_ext.py +0 -0
- {margin-0.9.31 → margin-0.10.0}/tests/test_float_tolerance.py +0 -0
- {margin-0.9.31 → margin-0.10.0}/tests/test_flow.py +0 -0
- {margin-0.9.31 → margin-0.10.0}/tests/test_forecast.py +0 -0
- {margin-0.9.31 → margin-0.10.0}/tests/test_health.py +0 -0
- {margin-0.9.31 → margin-0.10.0}/tests/test_intent.py +0 -0
- {margin-0.9.31 → margin-0.10.0}/tests/test_issue.py +0 -0
- {margin-0.9.31 → margin-0.10.0}/tests/test_ledger.py +0 -0
- {margin-0.9.31 → margin-0.10.0}/tests/test_loop.py +0 -0
- {margin-0.9.31 → margin-0.10.0}/tests/test_observation.py +0 -0
- {margin-0.9.31 → margin-0.10.0}/tests/test_persist.py +0 -0
- {margin-0.9.31 → margin-0.10.0}/tests/test_pipeline_features.py +0 -0
- {margin-0.9.31 → margin-0.10.0}/tests/test_policy.py +0 -0
- {margin-0.9.31 → margin-0.10.0}/tests/test_policy_compose.py +0 -0
- {margin-0.9.31 → margin-0.10.0}/tests/test_policy_temporal.py +0 -0
- {margin-0.9.31 → margin-0.10.0}/tests/test_policy_trace.py +0 -0
- {margin-0.9.31 → margin-0.10.0}/tests/test_policy_tuning.py +0 -0
- {margin-0.9.31 → margin-0.10.0}/tests/test_policy_validate.py +0 -0
- {margin-0.9.31 → margin-0.10.0}/tests/test_predicates.py +0 -0
- {margin-0.9.31 → margin-0.10.0}/tests/test_provenance.py +0 -0
- {margin-0.9.31 → margin-0.10.0}/tests/test_reverse_bridge.py +0 -0
- {margin-0.9.31 → margin-0.10.0}/tests/test_staleness.py +0 -0
- {margin-0.9.31 → margin-0.10.0}/tests/test_streaming.py +0 -0
- {margin-0.9.31 → margin-0.10.0}/tests/test_transitions.py +0 -0
- {margin-0.9.31 → margin-0.10.0}/tests/test_uncertain.py +0 -0
- {margin-0.9.31 → margin-0.10.0}/tests/test_validity.py +0 -0
- {margin-0.9.31 → margin-0.10.0}/tests/test_windowing.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: margin
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.10.0
|
|
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
|
|
@@ -34,9 +34,35 @@ Dynamic: license-file
|
|
|
34
34
|
[](https://pepy.tech/project/margin)
|
|
35
35
|
[](https://opensource.org/licenses/MIT)
|
|
36
36
|
|
|
37
|
-
**Typed
|
|
37
|
+
**Typed primitives for system self-awareness.**
|
|
38
38
|
|
|
39
|
-
|
|
39
|
+
When a system has to answer *"how am I doing, and what should I do about it?"* — an LLM agent between steps, an ML pipeline mid-inference, a data job deciding whether to ship — margin gives it the vocabulary. Typed `Health`, `Confidence`, `Absence`, `Issue`, `Flow`, `Intent`, `Contract` — composable, serializable, zero-dependency Python.
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
from adapters.agent import agent_parser, AgentStep, agent_flow
|
|
43
|
+
|
|
44
|
+
parser = agent_parser()
|
|
45
|
+
flow = agent_flow() # plan → act → reflect
|
|
46
|
+
|
|
47
|
+
# Record what you already measure after one LLM + tool step:
|
|
48
|
+
step = AgentStep(latency_ms=2400, tokens_used=1800,
|
|
49
|
+
tool_success_rate=0.9, retry_count=1, confidence=0.72)
|
|
50
|
+
|
|
51
|
+
expr = parser.parse(step.to_values())
|
|
52
|
+
guidance = flow.observe(step.to_values())
|
|
53
|
+
|
|
54
|
+
print(expr.to_string())
|
|
55
|
+
# [latency_ms:INTACT(-0.60σ)] [tokens_used:INTACT(-1.25σ)]
|
|
56
|
+
# [tool_success_rate:INTACT(-0.05σ)] [retry_count:INTACT(+0.00σ)]
|
|
57
|
+
# [confidence:INTACT(-0.10σ)]
|
|
58
|
+
|
|
59
|
+
print(guidance.to_atom())
|
|
60
|
+
# flow:agent:plan[1/3] → ADVANCE
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Every metric is polarity-aware (more tokens = worse, higher confidence = better), sigma-normalised so you can compare them, and emits typed diagnostics when something goes wrong. Nothing stringly-typed; your decision loop reads typed enums.
|
|
64
|
+
|
|
65
|
+
Not building an agent? Margin works on any scalar with a threshold:
|
|
40
66
|
|
|
41
67
|
```python
|
|
42
68
|
from margin import Parser, Thresholds
|
|
@@ -48,13 +74,12 @@ parser = Parser(
|
|
|
48
74
|
"error_rate": Thresholds(intact=0.005, ablated=0.05, higher_is_better=False),
|
|
49
75
|
},
|
|
50
76
|
)
|
|
51
|
-
|
|
52
77
|
expr = parser.parse({"throughput": 480.0, "error_rate": 0.03})
|
|
53
78
|
print(expr.to_string())
|
|
54
79
|
# [throughput:INTACT(-0.04σ)] [error_rate:DEGRADED(-14.00σ)]
|
|
55
80
|
```
|
|
56
81
|
|
|
57
|
-
Throughput and error rate on the same scale. One
|
|
82
|
+
Throughput and error rate on the same scale. One higher-is-better, the other lower-is-better. Both classified correctly. Sigma-normalised so you can compare them.
|
|
58
83
|
|
|
59
84
|
## Install
|
|
60
85
|
|
|
@@ -86,6 +111,8 @@ Then the correction loop:
|
|
|
86
111
|
- **Causal** — dependency graphs ("api is DEGRADED because db is ABLATED")
|
|
87
112
|
- **Auto-correlation** — discover which components move together from data, with lag detection
|
|
88
113
|
- **Streaming** — incremental trackers: `Monitor.update(values)` updates health + drift + anomaly + correlation in one call
|
|
114
|
+
- **Flow** — ordered stages with typed live guidance: ADVANCE / HOLD / RETRY / ROLLBACK / ESCALATE for multi-stage processes
|
|
115
|
+
- **Issue** — typed diagnostics: library surfaces its own misconfigurations, degraded assumptions, recovered failures
|
|
89
116
|
- **Config** — define everything in YAML/JSON: `margin.load_config("margin.yaml")`
|
|
90
117
|
- **Persistence** — save/restore Monitor state across restarts, batch replay from CSV
|
|
91
118
|
- **Intent** — goal feasibility: `intent.evaluate_monitor(monitor)` → FEASIBLE / AT_RISK / INFEASIBLE with ETA
|
|
@@ -123,6 +150,55 @@ Thresholds(intact=0.02, ablated=0.10, higher_is_better=False)
|
|
|
123
150
|
|
|
124
151
|
One flag. Threads through every comparison, every sigma calculation, every correction decision, every recovery ratio. You never think about it again.
|
|
125
152
|
|
|
153
|
+
## Multi-stage process guidance
|
|
154
|
+
|
|
155
|
+
For processes where each stage has distinct entry/quality/exit criteria — deployment pipelines, fermentation phases, clinical trials, incident response:
|
|
156
|
+
|
|
157
|
+
```python
|
|
158
|
+
from margin import Flow, Stage, Parser, Thresholds
|
|
159
|
+
|
|
160
|
+
flow = Flow(stages=[
|
|
161
|
+
Stage(
|
|
162
|
+
name="lag",
|
|
163
|
+
parser=Parser(baselines={"biomass": 0.1}, thresholds=Thresholds(intact=0.08, ablated=0.02)),
|
|
164
|
+
advance_when=lambda expr: expr.observations[0].value >= 0.15,
|
|
165
|
+
),
|
|
166
|
+
Stage(name="exponential", parser=..., advance_when=...),
|
|
167
|
+
Stage(name="stationary", parser=...),
|
|
168
|
+
], label="ferment")
|
|
169
|
+
|
|
170
|
+
result = flow.observe({"biomass": 0.12})
|
|
171
|
+
print(result.to_atom())
|
|
172
|
+
# flow:ferment:lag[1/3] → HOLD
|
|
173
|
+
|
|
174
|
+
# When the advance_when predicate fires:
|
|
175
|
+
result = flow.observe({"biomass": 0.18})
|
|
176
|
+
# result.guidance == Guidance.ADVANCE
|
|
177
|
+
flow.advance()
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
Predicate exceptions are caught as ERROR issues, guidance degrades to HOLD, the flow stays alive — safety-first semantics for safety-critical contexts.
|
|
181
|
+
|
|
182
|
+
## Typed diagnostics
|
|
183
|
+
|
|
184
|
+
The library surfaces its own operational state the same way it classifies observed state:
|
|
185
|
+
|
|
186
|
+
```python
|
|
187
|
+
monitor.update({"mystery": 1.0}) # emits WARNING: BASELINE_MISSING
|
|
188
|
+
monitor.drift("unknown") # emits WARNING: UNKNOWN_COMPONENT
|
|
189
|
+
|
|
190
|
+
# Query it programmatically
|
|
191
|
+
monitor.issues.by_code("BASELINE_MISSING") # [Issue(...)]
|
|
192
|
+
monitor.issues.at_least(IssueLevel.WARNING) # all severe enough
|
|
193
|
+
monitor.diagnose() # JSON-safe dashboard summary
|
|
194
|
+
|
|
195
|
+
# Bridge to your logger
|
|
196
|
+
import logging
|
|
197
|
+
monitor.issues.attach_logger(logging.getLogger("margin"))
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
**Harvest-safe on hard fail:** if `Monitor.update()` or `Flow.observe()` raises, a `FATAL(INTERNAL_ERROR)` issue is recorded *before* the exception propagates. `monitor.issues` survives crashes intact.
|
|
201
|
+
|
|
126
202
|
## Auto-calibrate from data
|
|
127
203
|
|
|
128
204
|
Don't guess thresholds. Derive them from healthy measurements:
|
|
@@ -147,33 +223,28 @@ parser = parser_from_calibration(
|
|
|
147
223
|
| **Contract** | Are we meeting our goals? | `HealthTarget`, `SustainHealth`, `RecoveryThreshold` |
|
|
148
224
|
| **Causal** | Why did this happen? | `CausalGraph`, `CausalLink`, `Explanation` |
|
|
149
225
|
| **Intent** | Can we still get there? | `Intent`, `Feasibility`, `IntentResult` |
|
|
226
|
+
| **Flow** | What stage are we in, and what should we do? | `Flow`, `Stage`, `Guidance`, `FlowResult` |
|
|
227
|
+
| **Issue** | What did the library notice about itself? | `Issue`, `IssueLevel`, `IssueBuffer`, `IssueCode` |
|
|
150
228
|
|
|
151
|
-
`full_step()` orchestrates
|
|
229
|
+
`full_step()` orchestrates the first seven in one call. `Flow` composes them into a sequence. `Issue` is ambient — every layer emits diagnostics into the same buffer.
|
|
152
230
|
|
|
153
|
-
##
|
|
231
|
+
## Adapters
|
|
154
232
|
|
|
155
|
-
|
|
233
|
+
Core adapters ship in the package. Each is a Parser factory + optional Flow / Intent / Monitor helpers with domain-appropriate thresholds:
|
|
156
234
|
|
|
157
|
-
| Adapter | What it monitors |
|
|
158
|
-
|
|
159
|
-
| [**
|
|
160
|
-
| [**
|
|
161
|
-
| [**
|
|
162
|
-
| [**
|
|
163
|
-
| [**
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
| [**fitness**](https://github.com/Cope-Labs/margin/tree/main/adapters/fitness/) | Wearables (resting HR, HRV, sleep, steps, stress) | mixed |
|
|
167
|
-
| [**transformer**](https://github.com/Cope-Labs/margin/tree/main/adapters/transformer/) | ML circuit interpretability (pythia-6.9b) | higher |
|
|
168
|
-
| [**fastapi**](https://github.com/Cope-Labs/margin/tree/main/adapters/fastapi/) | Endpoint health (latency, error rate, throughput, queue depth) | mixed |
|
|
169
|
-
| [**database**](https://github.com/Cope-Labs/margin/tree/main/adapters/database/) | DB health (pool usage, query latency, replication lag, deadlocks) | mixed |
|
|
170
|
-
| [**celery**](https://github.com/Cope-Labs/margin/tree/main/adapters/celery/) | Task queue (queue depth, failure rate, worker utilization, retries) | mixed |
|
|
171
|
-
| [**dataframe**](https://github.com/Cope-Labs/margin/tree/main/adapters/dataframe/) | Data quality (completeness, null rate, drift, freshness, schema) | mixed |
|
|
172
|
-
| [**pytest**](https://github.com/Cope-Labs/margin/tree/main/adapters/pytest/) | Test suite health (pass rate, flake rate, coverage, duration) | mixed |
|
|
235
|
+
| Adapter | What it monitors |
|
|
236
|
+
|---|---|
|
|
237
|
+
| [**agent**](https://github.com/Cope-Labs/margin/tree/main/adapters/agent/) | LLM / agent step health — latency, tokens, tool success rate, retries, confidence. Includes a three-stage `plan → act → reflect` Flow and a default feasibility Intent. |
|
|
238
|
+
| [**dataframe**](https://github.com/Cope-Labs/margin/tree/main/adapters/dataframe/) | Data quality (completeness, null rate, drift, freshness, schema) |
|
|
239
|
+
| [**numpy**](https://github.com/Cope-Labs/margin/tree/main/adapters/numpy/) | Array health (NaN rate, drift, distribution shape) |
|
|
240
|
+
| [**fastapi**](https://github.com/Cope-Labs/margin/tree/main/adapters/fastapi/) | Endpoint health (latency, error rate, throughput, queue depth) — ASGI middleware |
|
|
241
|
+
| [**pytest**](https://github.com/Cope-Labs/margin/tree/main/adapters/pytest/) | Test suite health (pass rate, flake rate, coverage, duration) — conftest plugin |
|
|
242
|
+
|
|
243
|
+
Additional domain profiles (healthcare, ros2, transformer circuit interpretability, homeassistant, aquarium, greenhouse, evcharging, fitness, infrastructure, weather, printer3d, neuro, godot, celery, database) live in [`contrib/`](https://github.com/Cope-Labs/margin/tree/main/contrib/) — reference material, not shipped in the pip package.
|
|
173
244
|
|
|
174
245
|
## Docs
|
|
175
246
|
|
|
176
|
-
Full specification: [margin-language.md](https://github.com/Cope-Labs/margin/blob/main/margin/margin-language.md)
|
|
247
|
+
Full specification: [margin-language.md](https://github.com/Cope-Labs/margin/blob/main/margin/margin-language.md) — a typed grammar for system self-awareness.
|
|
177
248
|
|
|
178
249
|
## License
|
|
179
250
|
|
|
@@ -5,9 +5,35 @@
|
|
|
5
5
|
[](https://pepy.tech/project/margin)
|
|
6
6
|
[](https://opensource.org/licenses/MIT)
|
|
7
7
|
|
|
8
|
-
**Typed
|
|
8
|
+
**Typed primitives for system self-awareness.**
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
When a system has to answer *"how am I doing, and what should I do about it?"* — an LLM agent between steps, an ML pipeline mid-inference, a data job deciding whether to ship — margin gives it the vocabulary. Typed `Health`, `Confidence`, `Absence`, `Issue`, `Flow`, `Intent`, `Contract` — composable, serializable, zero-dependency Python.
|
|
11
|
+
|
|
12
|
+
```python
|
|
13
|
+
from adapters.agent import agent_parser, AgentStep, agent_flow
|
|
14
|
+
|
|
15
|
+
parser = agent_parser()
|
|
16
|
+
flow = agent_flow() # plan → act → reflect
|
|
17
|
+
|
|
18
|
+
# Record what you already measure after one LLM + tool step:
|
|
19
|
+
step = AgentStep(latency_ms=2400, tokens_used=1800,
|
|
20
|
+
tool_success_rate=0.9, retry_count=1, confidence=0.72)
|
|
21
|
+
|
|
22
|
+
expr = parser.parse(step.to_values())
|
|
23
|
+
guidance = flow.observe(step.to_values())
|
|
24
|
+
|
|
25
|
+
print(expr.to_string())
|
|
26
|
+
# [latency_ms:INTACT(-0.60σ)] [tokens_used:INTACT(-1.25σ)]
|
|
27
|
+
# [tool_success_rate:INTACT(-0.05σ)] [retry_count:INTACT(+0.00σ)]
|
|
28
|
+
# [confidence:INTACT(-0.10σ)]
|
|
29
|
+
|
|
30
|
+
print(guidance.to_atom())
|
|
31
|
+
# flow:agent:plan[1/3] → ADVANCE
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Every metric is polarity-aware (more tokens = worse, higher confidence = better), sigma-normalised so you can compare them, and emits typed diagnostics when something goes wrong. Nothing stringly-typed; your decision loop reads typed enums.
|
|
35
|
+
|
|
36
|
+
Not building an agent? Margin works on any scalar with a threshold:
|
|
11
37
|
|
|
12
38
|
```python
|
|
13
39
|
from margin import Parser, Thresholds
|
|
@@ -19,13 +45,12 @@ parser = Parser(
|
|
|
19
45
|
"error_rate": Thresholds(intact=0.005, ablated=0.05, higher_is_better=False),
|
|
20
46
|
},
|
|
21
47
|
)
|
|
22
|
-
|
|
23
48
|
expr = parser.parse({"throughput": 480.0, "error_rate": 0.03})
|
|
24
49
|
print(expr.to_string())
|
|
25
50
|
# [throughput:INTACT(-0.04σ)] [error_rate:DEGRADED(-14.00σ)]
|
|
26
51
|
```
|
|
27
52
|
|
|
28
|
-
Throughput and error rate on the same scale. One
|
|
53
|
+
Throughput and error rate on the same scale. One higher-is-better, the other lower-is-better. Both classified correctly. Sigma-normalised so you can compare them.
|
|
29
54
|
|
|
30
55
|
## Install
|
|
31
56
|
|
|
@@ -57,6 +82,8 @@ Then the correction loop:
|
|
|
57
82
|
- **Causal** — dependency graphs ("api is DEGRADED because db is ABLATED")
|
|
58
83
|
- **Auto-correlation** — discover which components move together from data, with lag detection
|
|
59
84
|
- **Streaming** — incremental trackers: `Monitor.update(values)` updates health + drift + anomaly + correlation in one call
|
|
85
|
+
- **Flow** — ordered stages with typed live guidance: ADVANCE / HOLD / RETRY / ROLLBACK / ESCALATE for multi-stage processes
|
|
86
|
+
- **Issue** — typed diagnostics: library surfaces its own misconfigurations, degraded assumptions, recovered failures
|
|
60
87
|
- **Config** — define everything in YAML/JSON: `margin.load_config("margin.yaml")`
|
|
61
88
|
- **Persistence** — save/restore Monitor state across restarts, batch replay from CSV
|
|
62
89
|
- **Intent** — goal feasibility: `intent.evaluate_monitor(monitor)` → FEASIBLE / AT_RISK / INFEASIBLE with ETA
|
|
@@ -94,6 +121,55 @@ Thresholds(intact=0.02, ablated=0.10, higher_is_better=False)
|
|
|
94
121
|
|
|
95
122
|
One flag. Threads through every comparison, every sigma calculation, every correction decision, every recovery ratio. You never think about it again.
|
|
96
123
|
|
|
124
|
+
## Multi-stage process guidance
|
|
125
|
+
|
|
126
|
+
For processes where each stage has distinct entry/quality/exit criteria — deployment pipelines, fermentation phases, clinical trials, incident response:
|
|
127
|
+
|
|
128
|
+
```python
|
|
129
|
+
from margin import Flow, Stage, Parser, Thresholds
|
|
130
|
+
|
|
131
|
+
flow = Flow(stages=[
|
|
132
|
+
Stage(
|
|
133
|
+
name="lag",
|
|
134
|
+
parser=Parser(baselines={"biomass": 0.1}, thresholds=Thresholds(intact=0.08, ablated=0.02)),
|
|
135
|
+
advance_when=lambda expr: expr.observations[0].value >= 0.15,
|
|
136
|
+
),
|
|
137
|
+
Stage(name="exponential", parser=..., advance_when=...),
|
|
138
|
+
Stage(name="stationary", parser=...),
|
|
139
|
+
], label="ferment")
|
|
140
|
+
|
|
141
|
+
result = flow.observe({"biomass": 0.12})
|
|
142
|
+
print(result.to_atom())
|
|
143
|
+
# flow:ferment:lag[1/3] → HOLD
|
|
144
|
+
|
|
145
|
+
# When the advance_when predicate fires:
|
|
146
|
+
result = flow.observe({"biomass": 0.18})
|
|
147
|
+
# result.guidance == Guidance.ADVANCE
|
|
148
|
+
flow.advance()
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
Predicate exceptions are caught as ERROR issues, guidance degrades to HOLD, the flow stays alive — safety-first semantics for safety-critical contexts.
|
|
152
|
+
|
|
153
|
+
## Typed diagnostics
|
|
154
|
+
|
|
155
|
+
The library surfaces its own operational state the same way it classifies observed state:
|
|
156
|
+
|
|
157
|
+
```python
|
|
158
|
+
monitor.update({"mystery": 1.0}) # emits WARNING: BASELINE_MISSING
|
|
159
|
+
monitor.drift("unknown") # emits WARNING: UNKNOWN_COMPONENT
|
|
160
|
+
|
|
161
|
+
# Query it programmatically
|
|
162
|
+
monitor.issues.by_code("BASELINE_MISSING") # [Issue(...)]
|
|
163
|
+
monitor.issues.at_least(IssueLevel.WARNING) # all severe enough
|
|
164
|
+
monitor.diagnose() # JSON-safe dashboard summary
|
|
165
|
+
|
|
166
|
+
# Bridge to your logger
|
|
167
|
+
import logging
|
|
168
|
+
monitor.issues.attach_logger(logging.getLogger("margin"))
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
**Harvest-safe on hard fail:** if `Monitor.update()` or `Flow.observe()` raises, a `FATAL(INTERNAL_ERROR)` issue is recorded *before* the exception propagates. `monitor.issues` survives crashes intact.
|
|
172
|
+
|
|
97
173
|
## Auto-calibrate from data
|
|
98
174
|
|
|
99
175
|
Don't guess thresholds. Derive them from healthy measurements:
|
|
@@ -118,33 +194,28 @@ parser = parser_from_calibration(
|
|
|
118
194
|
| **Contract** | Are we meeting our goals? | `HealthTarget`, `SustainHealth`, `RecoveryThreshold` |
|
|
119
195
|
| **Causal** | Why did this happen? | `CausalGraph`, `CausalLink`, `Explanation` |
|
|
120
196
|
| **Intent** | Can we still get there? | `Intent`, `Feasibility`, `IntentResult` |
|
|
197
|
+
| **Flow** | What stage are we in, and what should we do? | `Flow`, `Stage`, `Guidance`, `FlowResult` |
|
|
198
|
+
| **Issue** | What did the library notice about itself? | `Issue`, `IssueLevel`, `IssueBuffer`, `IssueCode` |
|
|
121
199
|
|
|
122
|
-
`full_step()` orchestrates
|
|
200
|
+
`full_step()` orchestrates the first seven in one call. `Flow` composes them into a sequence. `Issue` is ambient — every layer emits diagnostics into the same buffer.
|
|
123
201
|
|
|
124
|
-
##
|
|
202
|
+
## Adapters
|
|
125
203
|
|
|
126
|
-
|
|
204
|
+
Core adapters ship in the package. Each is a Parser factory + optional Flow / Intent / Monitor helpers with domain-appropriate thresholds:
|
|
127
205
|
|
|
128
|
-
| Adapter | What it monitors |
|
|
129
|
-
|
|
130
|
-
| [**
|
|
131
|
-
| [**
|
|
132
|
-
| [**
|
|
133
|
-
| [**
|
|
134
|
-
| [**
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
| [**fitness**](https://github.com/Cope-Labs/margin/tree/main/adapters/fitness/) | Wearables (resting HR, HRV, sleep, steps, stress) | mixed |
|
|
138
|
-
| [**transformer**](https://github.com/Cope-Labs/margin/tree/main/adapters/transformer/) | ML circuit interpretability (pythia-6.9b) | higher |
|
|
139
|
-
| [**fastapi**](https://github.com/Cope-Labs/margin/tree/main/adapters/fastapi/) | Endpoint health (latency, error rate, throughput, queue depth) | mixed |
|
|
140
|
-
| [**database**](https://github.com/Cope-Labs/margin/tree/main/adapters/database/) | DB health (pool usage, query latency, replication lag, deadlocks) | mixed |
|
|
141
|
-
| [**celery**](https://github.com/Cope-Labs/margin/tree/main/adapters/celery/) | Task queue (queue depth, failure rate, worker utilization, retries) | mixed |
|
|
142
|
-
| [**dataframe**](https://github.com/Cope-Labs/margin/tree/main/adapters/dataframe/) | Data quality (completeness, null rate, drift, freshness, schema) | mixed |
|
|
143
|
-
| [**pytest**](https://github.com/Cope-Labs/margin/tree/main/adapters/pytest/) | Test suite health (pass rate, flake rate, coverage, duration) | mixed |
|
|
206
|
+
| Adapter | What it monitors |
|
|
207
|
+
|---|---|
|
|
208
|
+
| [**agent**](https://github.com/Cope-Labs/margin/tree/main/adapters/agent/) | LLM / agent step health — latency, tokens, tool success rate, retries, confidence. Includes a three-stage `plan → act → reflect` Flow and a default feasibility Intent. |
|
|
209
|
+
| [**dataframe**](https://github.com/Cope-Labs/margin/tree/main/adapters/dataframe/) | Data quality (completeness, null rate, drift, freshness, schema) |
|
|
210
|
+
| [**numpy**](https://github.com/Cope-Labs/margin/tree/main/adapters/numpy/) | Array health (NaN rate, drift, distribution shape) |
|
|
211
|
+
| [**fastapi**](https://github.com/Cope-Labs/margin/tree/main/adapters/fastapi/) | Endpoint health (latency, error rate, throughput, queue depth) — ASGI middleware |
|
|
212
|
+
| [**pytest**](https://github.com/Cope-Labs/margin/tree/main/adapters/pytest/) | Test suite health (pass rate, flake rate, coverage, duration) — conftest plugin |
|
|
213
|
+
|
|
214
|
+
Additional domain profiles (healthcare, ros2, transformer circuit interpretability, homeassistant, aquarium, greenhouse, evcharging, fitness, infrastructure, weather, printer3d, neuro, godot, celery, database) live in [`contrib/`](https://github.com/Cope-Labs/margin/tree/main/contrib/) — reference material, not shipped in the pip package.
|
|
144
215
|
|
|
145
216
|
## Docs
|
|
146
217
|
|
|
147
|
-
Full specification: [margin-language.md](https://github.com/Cope-Labs/margin/blob/main/margin/margin-language.md)
|
|
218
|
+
Full specification: [margin-language.md](https://github.com/Cope-Labs/margin/blob/main/margin/margin-language.md) — a typed grammar for system self-awareness.
|
|
148
219
|
|
|
149
220
|
## License
|
|
150
221
|
|
|
@@ -18,10 +18,10 @@ Structure:
|
|
|
18
18
|
from .confidence import Confidence
|
|
19
19
|
from .validity import Validity, ValidityMode
|
|
20
20
|
from .provenance import (
|
|
21
|
-
new_id, _new_id, are_correlated, merge,
|
|
21
|
+
new_id, _new_id, are_correlated, merge,
|
|
22
22
|
ProvenanceNode, ProvenanceGraph, create_root_provenance,
|
|
23
23
|
)
|
|
24
|
-
from .uncertain import UncertainValue, Source
|
|
24
|
+
from .uncertain import UncertainValue, Source
|
|
25
25
|
from .algebra import add, subtract, multiply, divide, scale, compare, weighted_average
|
|
26
26
|
from .health import Health, Thresholds, classify, SEVERITY
|
|
27
27
|
from .observation import Absence, Op, Observation, Correction, Expression, Parser
|
|
@@ -200,24 +200,47 @@ __all__ = [
|
|
|
200
200
|
"Issue", "IssueLevel", "IssueCode", "IssueBuffer", "ISSUE_SEVERITY",
|
|
201
201
|
# Flow
|
|
202
202
|
"Flow", "Stage", "Guidance", "FlowResult", "GUIDANCE_SEVERITY",
|
|
203
|
-
# margin-poc compat aliases
|
|
204
|
-
"EpistemicSource", "UncertaintyMode",
|
|
205
|
-
"CallConfidence", "CircuitHealth", "GapThresholds", "MarginParser",
|
|
206
|
-
"CircuitUtterance", "CorrectionOp", "MarginExpression",
|
|
207
|
-
"CorrectionRequest", "CorrectionRecord", "CorrectionLedger",
|
|
208
|
-
"classify_gap_health", "compare_to_threshold", "merge_provenance",
|
|
209
203
|
]
|
|
210
204
|
|
|
211
|
-
#
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
205
|
+
# -----------------------------------------------------------------------
|
|
206
|
+
# margin-poc backward-compat aliases
|
|
207
|
+
#
|
|
208
|
+
# These names come from the pre-margin proof-of-concept and are kept
|
|
209
|
+
# importable so downstream code doesn't break on upgrade. They are
|
|
210
|
+
# NOT part of the public surface (excluded from __all__) and emit a
|
|
211
|
+
# DeprecationWarning on access. Scheduled for removal at 1.0.
|
|
212
|
+
# -----------------------------------------------------------------------
|
|
213
|
+
|
|
214
|
+
_DEPRECATED_ALIASES: dict[str, str] = {
|
|
215
|
+
"CallConfidence": "Confidence",
|
|
216
|
+
"CircuitHealth": "Health",
|
|
217
|
+
"GapThresholds": "Thresholds",
|
|
218
|
+
"MarginParser": "Parser",
|
|
219
|
+
"CircuitUtterance": "Observation",
|
|
220
|
+
"CorrectionOp": "Op",
|
|
221
|
+
"MarginExpression": "Expression",
|
|
222
|
+
"CorrectionRequest": "Correction",
|
|
223
|
+
"CorrectionRecord": "Record",
|
|
224
|
+
"CorrectionLedger": "Ledger",
|
|
225
|
+
"classify_gap_health": "classify",
|
|
226
|
+
"compare_to_threshold": "compare",
|
|
227
|
+
"merge_provenance": "merge",
|
|
228
|
+
"EpistemicSource": "Source",
|
|
229
|
+
"UncertaintyMode": "UncertaintyMode", # no canonical replacement; plain stub
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def __getattr__(name: str):
|
|
234
|
+
import warnings as _warnings
|
|
235
|
+
canonical = _DEPRECATED_ALIASES.get(name)
|
|
236
|
+
if canonical is None:
|
|
237
|
+
raise AttributeError(f"module 'margin' has no attribute {name!r}")
|
|
238
|
+
_warnings.warn(
|
|
239
|
+
f"margin.{name} is a deprecated margin-poc alias and will be removed in 1.0; "
|
|
240
|
+
f"use margin.{canonical} instead.",
|
|
241
|
+
DeprecationWarning,
|
|
242
|
+
stacklevel=2,
|
|
243
|
+
)
|
|
244
|
+
if name == "UncertaintyMode":
|
|
245
|
+
return type("UncertaintyMode", (), {"ABSOLUTE": "absolute", "RELATIVE": "relative"})()
|
|
246
|
+
return globals()[canonical]
|
|
@@ -47,6 +47,9 @@ def cmd_monitor(args):
|
|
|
47
47
|
except ValueError:
|
|
48
48
|
pass
|
|
49
49
|
if not values:
|
|
50
|
+
preview = line[:60] + ("…" if len(line) > 60 else "")
|
|
51
|
+
print(f"margin monitor: skipping unparseable line: {preview!r}",
|
|
52
|
+
file=sys.stderr)
|
|
50
53
|
continue
|
|
51
54
|
|
|
52
55
|
expr = monitor.update(values, now=datetime.now())
|
|
@@ -69,6 +72,12 @@ def cmd_monitor(args):
|
|
|
69
72
|
print("\n".join(parts))
|
|
70
73
|
sys.stdout.flush()
|
|
71
74
|
|
|
75
|
+
# Surface any ERROR/FATAL issues emitted during this update to stderr.
|
|
76
|
+
for issue in expr.issues:
|
|
77
|
+
if issue.level.value in ("ERROR", "FATAL"):
|
|
78
|
+
print(f" ! {issue.to_atom()} {issue.detail}", file=sys.stderr)
|
|
79
|
+
sys.stderr.flush()
|
|
80
|
+
|
|
72
81
|
|
|
73
82
|
def cmd_replay(args):
|
|
74
83
|
"""Replay a CSV through Monitor and print analysis."""
|
|
@@ -119,6 +128,14 @@ def cmd_replay(args):
|
|
|
119
128
|
for c in monitor.correlations.strongest(5):
|
|
120
129
|
print(f" {c.component_a} ~ {c.component_b}: r={c.coefficient:.3f}")
|
|
121
130
|
|
|
131
|
+
# Issue summary from the monitor's buffer
|
|
132
|
+
if getattr(monitor, "issues", None) and len(monitor.issues) > 0:
|
|
133
|
+
counts = monitor.issues.counts()
|
|
134
|
+
non_zero = {k: v for k, v in counts.items() if v > 0}
|
|
135
|
+
if non_zero:
|
|
136
|
+
print()
|
|
137
|
+
print("Issues:", ", ".join(f"{k}={v}" for k, v in non_zero.items()))
|
|
138
|
+
|
|
122
139
|
# Write full output if requested
|
|
123
140
|
if args.output:
|
|
124
141
|
Path(args.output).write_text(json.dumps(snapshots, indent=2))
|
|
@@ -148,6 +165,8 @@ def cmd_status(args):
|
|
|
148
165
|
|
|
149
166
|
expr = parser.parse(values)
|
|
150
167
|
print(expr.to_string())
|
|
168
|
+
for issue in expr.issues:
|
|
169
|
+
print(f" {issue.to_atom()} {issue.detail}", file=sys.stderr)
|
|
151
170
|
|
|
152
171
|
|
|
153
172
|
def main():
|
|
@@ -314,6 +314,19 @@ class Explanation:
|
|
|
314
314
|
cause_strs = [c.to_string() for c in self.causes]
|
|
315
315
|
return f"{self.component}:{h} ← " + ", ".join(cause_strs)
|
|
316
316
|
|
|
317
|
+
def to_atom(self) -> str:
|
|
318
|
+
"""Compact single-line form for log aggregation.
|
|
319
|
+
|
|
320
|
+
``component:HEALTH`` when there's no known cause,
|
|
321
|
+
``component:HEALTH ← source:HEALTH`` otherwise (strongest cause only).
|
|
322
|
+
"""
|
|
323
|
+
h = self.health.value if self.health else "?"
|
|
324
|
+
rc = self.root_cause
|
|
325
|
+
if rc is None:
|
|
326
|
+
return f"{self.component}:{h}"
|
|
327
|
+
src_h = rc.source_health.value if rc.source_health else "?"
|
|
328
|
+
return f"{self.component}:{h} ← {rc.source}:{src_h}"
|
|
329
|
+
|
|
317
330
|
def to_dict(self) -> dict:
|
|
318
331
|
return {
|
|
319
332
|
"component": self.component,
|
|
@@ -93,6 +93,8 @@ class CompositeObservation:
|
|
|
93
93
|
"""
|
|
94
94
|
Flatten to a single Observation using the aggregate health.
|
|
95
95
|
Uses the worst sub-observation's value/baseline for sigma.
|
|
96
|
+
If the worst sub-observation carries an Absence, it propagates to the
|
|
97
|
+
flattened Observation so downstream consumers don't silently lose the reason.
|
|
96
98
|
"""
|
|
97
99
|
w = self.worst
|
|
98
100
|
if w is None:
|
|
@@ -110,6 +112,8 @@ class CompositeObservation:
|
|
|
110
112
|
higher_is_better=w.higher_is_better,
|
|
111
113
|
provenance=w.provenance,
|
|
112
114
|
measured_at=self.measured_at,
|
|
115
|
+
absence=w.absence,
|
|
116
|
+
absence_detail=w.absence_detail,
|
|
113
117
|
)
|
|
114
118
|
|
|
115
119
|
def to_atom(self) -> str:
|
|
@@ -8,7 +8,7 @@ from dataclasses import dataclass, field
|
|
|
8
8
|
from typing import Optional
|
|
9
9
|
|
|
10
10
|
from .health import Health, SEVERITY
|
|
11
|
-
from .observation import Observation, Expression
|
|
11
|
+
from .observation import Absence, Observation, Expression
|
|
12
12
|
from .confidence import Confidence
|
|
13
13
|
|
|
14
14
|
|
|
@@ -17,22 +17,38 @@ class ComponentChange:
|
|
|
17
17
|
"""
|
|
18
18
|
What changed for a single component between two expressions.
|
|
19
19
|
|
|
20
|
-
name:
|
|
21
|
-
health_before:
|
|
22
|
-
health_after:
|
|
23
|
-
sigma_before:
|
|
24
|
-
sigma_after:
|
|
20
|
+
name: component identifier
|
|
21
|
+
health_before: Health in the earlier expression (None if component was missing)
|
|
22
|
+
health_after: Health in the later expression (None if component disappeared)
|
|
23
|
+
sigma_before: sigma in the earlier expression
|
|
24
|
+
sigma_after: sigma in the later expression
|
|
25
|
+
absence_before: Absence reason in the earlier expression (None if present)
|
|
26
|
+
absence_after: Absence reason in the later expression (None if present)
|
|
25
27
|
"""
|
|
26
28
|
name: str
|
|
27
29
|
health_before: Optional[Health]
|
|
28
30
|
health_after: Optional[Health]
|
|
29
31
|
sigma_before: Optional[float]
|
|
30
32
|
sigma_after: Optional[float]
|
|
33
|
+
absence_before: Optional[Absence] = None
|
|
34
|
+
absence_after: Optional[Absence] = None
|
|
31
35
|
|
|
32
36
|
@property
|
|
33
37
|
def health_changed(self) -> bool:
|
|
34
38
|
return self.health_before != self.health_after
|
|
35
39
|
|
|
40
|
+
@property
|
|
41
|
+
def absence_changed(self) -> bool:
|
|
42
|
+
return self.absence_before != self.absence_after
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def became_absent(self) -> bool:
|
|
46
|
+
return self.absence_before is None and self.absence_after is not None
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def became_present(self) -> bool:
|
|
50
|
+
return self.absence_before is not None and self.absence_after is None
|
|
51
|
+
|
|
36
52
|
@property
|
|
37
53
|
def sigma_delta(self) -> Optional[float]:
|
|
38
54
|
"""Change in sigma. Positive = healthier. None if either side missing."""
|
|
@@ -64,19 +80,26 @@ class ComponentChange:
|
|
|
64
80
|
return False
|
|
65
81
|
return SEVERITY.get(self.health_after, 0) < SEVERITY.get(self.health_before, 0)
|
|
66
82
|
|
|
83
|
+
def _side(self, health: Optional[Health], absence: Optional[Absence]) -> str:
|
|
84
|
+
if absence is not None:
|
|
85
|
+
return f"ABSENT({absence.value})"
|
|
86
|
+
if health is not None:
|
|
87
|
+
return health.value
|
|
88
|
+
return "missing"
|
|
89
|
+
|
|
67
90
|
def to_string(self) -> str:
|
|
68
|
-
b = self.health_before
|
|
69
|
-
a = self.health_after
|
|
70
|
-
if
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
return f"{self.name}: {a} (
|
|
76
|
-
return f"{self.name}: {
|
|
91
|
+
b = self._side(self.health_before, self.absence_before)
|
|
92
|
+
a = self._side(self.health_after, self.absence_after)
|
|
93
|
+
if self.health_changed or self.absence_changed:
|
|
94
|
+
return f"{self.name}: {b} → {a}"
|
|
95
|
+
sd = self.sigma_delta
|
|
96
|
+
if sd is not None and abs(sd) > 0.001:
|
|
97
|
+
sign = "+" if sd >= 0 else ""
|
|
98
|
+
return f"{self.name}: {a} ({sign}{sd:.2f}σ)"
|
|
99
|
+
return f"{self.name}: {a} (unchanged)"
|
|
77
100
|
|
|
78
101
|
def to_dict(self) -> dict:
|
|
79
|
-
|
|
102
|
+
d = {
|
|
80
103
|
"name": self.name,
|
|
81
104
|
"health_before": self.health_before.value if self.health_before else None,
|
|
82
105
|
"health_after": self.health_after.value if self.health_after else None,
|
|
@@ -87,6 +110,13 @@ class ComponentChange:
|
|
|
87
110
|
"worsened": self.worsened,
|
|
88
111
|
"improved": self.improved,
|
|
89
112
|
}
|
|
113
|
+
if self.absence_before is not None or self.absence_after is not None:
|
|
114
|
+
d["absence_before"] = self.absence_before.value if self.absence_before else None
|
|
115
|
+
d["absence_after"] = self.absence_after.value if self.absence_after else None
|
|
116
|
+
d["absence_changed"] = self.absence_changed
|
|
117
|
+
d["became_absent"] = self.became_absent
|
|
118
|
+
d["became_present"] = self.became_present
|
|
119
|
+
return d
|
|
90
120
|
|
|
91
121
|
|
|
92
122
|
|
|
@@ -127,6 +157,12 @@ class Diff:
|
|
|
127
157
|
def disappeared(self) -> list[ComponentChange]:
|
|
128
158
|
return [c for c in self.changes if c.disappeared]
|
|
129
159
|
|
|
160
|
+
def became_absent(self) -> list[ComponentChange]:
|
|
161
|
+
return [c for c in self.changes if c.became_absent]
|
|
162
|
+
|
|
163
|
+
def became_present(self) -> list[ComponentChange]:
|
|
164
|
+
return [c for c in self.changes if c.became_present]
|
|
165
|
+
|
|
130
166
|
def to_string(self) -> str:
|
|
131
167
|
if not self.changes:
|
|
132
168
|
return "(no components)"
|
|
@@ -169,6 +205,8 @@ def diff(before: Expression, after: Expression) -> Diff:
|
|
|
169
205
|
health_after=a.health if a else None,
|
|
170
206
|
sigma_before=b.sigma if b else None,
|
|
171
207
|
sigma_after=a.sigma if a else None,
|
|
208
|
+
absence_before=b.absence if b else None,
|
|
209
|
+
absence_after=a.absence if a else None,
|
|
172
210
|
))
|
|
173
211
|
|
|
174
212
|
return Diff(
|