margin 0.10.0__tar.gz → 0.12.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.10.0/margin.egg-info → margin-0.12.0}/PKG-INFO +47 -1
- {margin-0.10.0 → margin-0.12.0}/README.md +44 -0
- margin-0.12.0/adapters/__init__.py +0 -0
- margin-0.12.0/adapters/agent/__init__.py +48 -0
- margin-0.12.0/adapters/agent/advisory.py +151 -0
- margin-0.12.0/adapters/agent/flow.py +72 -0
- margin-0.12.0/adapters/agent/metrics.py +134 -0
- margin-0.12.0/adapters/agent/wrap.py +202 -0
- margin-0.12.0/adapters/dataframe/__init__.py +7 -0
- margin-0.12.0/adapters/dataframe/pandas_hook.py +99 -0
- margin-0.12.0/adapters/dataframe/quality.py +110 -0
- margin-0.12.0/adapters/fastapi/__init__.py +6 -0
- margin-0.12.0/adapters/fastapi/endpoints.py +99 -0
- margin-0.12.0/adapters/fastapi/middleware.py +116 -0
- margin-0.12.0/adapters/mcp/__init__.py +56 -0
- margin-0.12.0/adapters/mcp/__main__.py +5 -0
- margin-0.12.0/adapters/mcp/server.py +254 -0
- margin-0.12.0/adapters/mcp/state.py +120 -0
- margin-0.12.0/adapters/numpy/__init__.py +9 -0
- margin-0.12.0/adapters/numpy/array_health.py +220 -0
- margin-0.12.0/adapters/pytest/__init__.py +7 -0
- margin-0.12.0/adapters/pytest/plugin.py +255 -0
- margin-0.12.0/adapters/pytest/suite.py +104 -0
- {margin-0.10.0 → margin-0.12.0/margin.egg-info}/PKG-INFO +47 -1
- {margin-0.10.0 → margin-0.12.0}/margin.egg-info/SOURCES.txt +24 -0
- margin-0.12.0/margin.egg-info/entry_points.txt +2 -0
- margin-0.12.0/margin.egg-info/requires.txt +3 -0
- margin-0.12.0/margin.egg-info/top_level.txt +2 -0
- {margin-0.10.0 → margin-0.12.0}/pyproject.toml +9 -2
- margin-0.12.0/tests/test_agent_adapter.py +213 -0
- margin-0.10.0/margin.egg-info/top_level.txt +0 -1
- {margin-0.10.0 → margin-0.12.0}/LICENSE +0 -0
- {margin-0.10.0 → margin-0.12.0}/NOTICE +0 -0
- {margin-0.10.0 → margin-0.12.0}/margin/__init__.py +0 -0
- {margin-0.10.0 → margin-0.12.0}/margin/__main__.py +0 -0
- {margin-0.10.0 → margin-0.12.0}/margin/algebra.py +0 -0
- {margin-0.10.0 → margin-0.12.0}/margin/anomaly.py +0 -0
- {margin-0.10.0 → margin-0.12.0}/margin/bridge.py +0 -0
- {margin-0.10.0 → margin-0.12.0}/margin/calibrate.py +0 -0
- {margin-0.10.0 → margin-0.12.0}/margin/causal.py +0 -0
- {margin-0.10.0 → margin-0.12.0}/margin/composite.py +0 -0
- {margin-0.10.0 → margin-0.12.0}/margin/confidence.py +0 -0
- {margin-0.10.0 → margin-0.12.0}/margin/config.py +0 -0
- {margin-0.10.0 → margin-0.12.0}/margin/contract.py +0 -0
- {margin-0.10.0 → margin-0.12.0}/margin/controller.py +0 -0
- {margin-0.10.0 → margin-0.12.0}/margin/correlate.py +0 -0
- {margin-0.10.0 → margin-0.12.0}/margin/diff.py +0 -0
- {margin-0.10.0 → margin-0.12.0}/margin/drift.py +0 -0
- {margin-0.10.0 → margin-0.12.0}/margin/events.py +0 -0
- {margin-0.10.0 → margin-0.12.0}/margin/fingerprint.py +0 -0
- {margin-0.10.0 → margin-0.12.0}/margin/flow.py +0 -0
- {margin-0.10.0 → margin-0.12.0}/margin/forecast.py +0 -0
- {margin-0.10.0 → margin-0.12.0}/margin/health.py +0 -0
- {margin-0.10.0 → margin-0.12.0}/margin/intent.py +0 -0
- {margin-0.10.0 → margin-0.12.0}/margin/issue.py +0 -0
- {margin-0.10.0 → margin-0.12.0}/margin/ledger.py +0 -0
- {margin-0.10.0 → margin-0.12.0}/margin/loop.py +0 -0
- {margin-0.10.0 → margin-0.12.0}/margin/observation.py +0 -0
- {margin-0.10.0 → margin-0.12.0}/margin/persist.py +0 -0
- {margin-0.10.0 → margin-0.12.0}/margin/policy/__init__.py +0 -0
- {margin-0.10.0 → margin-0.12.0}/margin/policy/compose.py +0 -0
- {margin-0.10.0 → margin-0.12.0}/margin/policy/core.py +0 -0
- {margin-0.10.0 → margin-0.12.0}/margin/policy/temporal.py +0 -0
- {margin-0.10.0 → margin-0.12.0}/margin/policy/trace.py +0 -0
- {margin-0.10.0 → margin-0.12.0}/margin/policy/tuning.py +0 -0
- {margin-0.10.0 → margin-0.12.0}/margin/policy/validate.py +0 -0
- {margin-0.10.0 → margin-0.12.0}/margin/predicates.py +0 -0
- {margin-0.10.0 → margin-0.12.0}/margin/provenance.py +0 -0
- {margin-0.10.0 → margin-0.12.0}/margin/streaming.py +0 -0
- {margin-0.10.0 → margin-0.12.0}/margin/transitions.py +0 -0
- {margin-0.10.0 → margin-0.12.0}/margin/uncertain.py +0 -0
- {margin-0.10.0 → margin-0.12.0}/margin/validity.py +0 -0
- {margin-0.10.0 → margin-0.12.0}/margin.egg-info/dependency_links.txt +0 -0
- {margin-0.10.0 → margin-0.12.0}/setup.cfg +0 -0
- {margin-0.10.0 → margin-0.12.0}/tests/test_absence.py +0 -0
- {margin-0.10.0 → margin-0.12.0}/tests/test_algebra.py +0 -0
- {margin-0.10.0 → margin-0.12.0}/tests/test_anomaly.py +0 -0
- {margin-0.10.0 → margin-0.12.0}/tests/test_bridge.py +0 -0
- {margin-0.10.0 → margin-0.12.0}/tests/test_calibrate.py +0 -0
- {margin-0.10.0 → margin-0.12.0}/tests/test_causal.py +0 -0
- {margin-0.10.0 → margin-0.12.0}/tests/test_composite.py +0 -0
- {margin-0.10.0 → margin-0.12.0}/tests/test_confidence.py +0 -0
- {margin-0.10.0 → margin-0.12.0}/tests/test_config.py +0 -0
- {margin-0.10.0 → margin-0.12.0}/tests/test_contract.py +0 -0
- {margin-0.10.0 → margin-0.12.0}/tests/test_controller.py +0 -0
- {margin-0.10.0 → margin-0.12.0}/tests/test_correlate.py +0 -0
- {margin-0.10.0 → margin-0.12.0}/tests/test_diff.py +0 -0
- {margin-0.10.0 → margin-0.12.0}/tests/test_drift.py +0 -0
- {margin-0.10.0 → margin-0.12.0}/tests/test_events.py +0 -0
- {margin-0.10.0 → margin-0.12.0}/tests/test_fingerprint_ext.py +0 -0
- {margin-0.10.0 → margin-0.12.0}/tests/test_float_tolerance.py +0 -0
- {margin-0.10.0 → margin-0.12.0}/tests/test_flow.py +0 -0
- {margin-0.10.0 → margin-0.12.0}/tests/test_forecast.py +0 -0
- {margin-0.10.0 → margin-0.12.0}/tests/test_health.py +0 -0
- {margin-0.10.0 → margin-0.12.0}/tests/test_intent.py +0 -0
- {margin-0.10.0 → margin-0.12.0}/tests/test_issue.py +0 -0
- {margin-0.10.0 → margin-0.12.0}/tests/test_ledger.py +0 -0
- {margin-0.10.0 → margin-0.12.0}/tests/test_loop.py +0 -0
- {margin-0.10.0 → margin-0.12.0}/tests/test_observation.py +0 -0
- {margin-0.10.0 → margin-0.12.0}/tests/test_persist.py +0 -0
- {margin-0.10.0 → margin-0.12.0}/tests/test_pipeline_features.py +0 -0
- {margin-0.10.0 → margin-0.12.0}/tests/test_policy.py +0 -0
- {margin-0.10.0 → margin-0.12.0}/tests/test_policy_compose.py +0 -0
- {margin-0.10.0 → margin-0.12.0}/tests/test_policy_temporal.py +0 -0
- {margin-0.10.0 → margin-0.12.0}/tests/test_policy_trace.py +0 -0
- {margin-0.10.0 → margin-0.12.0}/tests/test_policy_tuning.py +0 -0
- {margin-0.10.0 → margin-0.12.0}/tests/test_policy_validate.py +0 -0
- {margin-0.10.0 → margin-0.12.0}/tests/test_predicates.py +0 -0
- {margin-0.10.0 → margin-0.12.0}/tests/test_provenance.py +0 -0
- {margin-0.10.0 → margin-0.12.0}/tests/test_reverse_bridge.py +0 -0
- {margin-0.10.0 → margin-0.12.0}/tests/test_staleness.py +0 -0
- {margin-0.10.0 → margin-0.12.0}/tests/test_streaming.py +0 -0
- {margin-0.10.0 → margin-0.12.0}/tests/test_transitions.py +0 -0
- {margin-0.10.0 → margin-0.12.0}/tests/test_uncertain.py +0 -0
- {margin-0.10.0 → margin-0.12.0}/tests/test_validity.py +0 -0
- {margin-0.10.0 → margin-0.12.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.12.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
|
|
@@ -25,6 +25,8 @@ Requires-Python: >=3.10
|
|
|
25
25
|
Description-Content-Type: text/markdown
|
|
26
26
|
License-File: LICENSE
|
|
27
27
|
License-File: NOTICE
|
|
28
|
+
Provides-Extra: mcp
|
|
29
|
+
Requires-Dist: mcp>=1.0; extra == "mcp"
|
|
28
30
|
Dynamic: license-file
|
|
29
31
|
|
|
30
32
|
# margin
|
|
@@ -62,6 +64,49 @@ print(guidance.to_atom())
|
|
|
62
64
|
|
|
63
65
|
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
66
|
|
|
67
|
+
## Host-fed observation (recommended for production)
|
|
68
|
+
|
|
69
|
+
When your harness has ground-truth metrics (real latency, real token counts, real tool outcomes), feed margin from the *host* side so the model reads typed state rather than self-reports:
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
from adapters.agent import host_observe
|
|
73
|
+
|
|
74
|
+
advisory = host_observe({
|
|
75
|
+
"latency_ms": (time.perf_counter() - t0) * 1000,
|
|
76
|
+
"tokens_used": resp.usage.input_tokens + resp.usage.output_tokens,
|
|
77
|
+
"tool_success_rate": 1.0,
|
|
78
|
+
"retry_count": 0,
|
|
79
|
+
"confidence": 0.85,
|
|
80
|
+
}, profile="reasoning") # "default" | "reasoning" | "fast"
|
|
81
|
+
# → {"expression": "…", "flow": "flow:agent:plan[1/3] → ADVANCE",
|
|
82
|
+
# "advisory": {"action": "PROCEED", "reason": "…", "confidence": 0.7},
|
|
83
|
+
# "drift": {...}, "anomaly": {...}, "ledger_size": 3}
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
The `Advisory` enum (PROCEED / HOLD_AND_REFLECT / DECOMPOSE / ASK_CLARIFYING / HAND_OFF / RETRY) is margin's typed hint for what the *model* should do next — distinct from `Correction.op` (RESTORE / SUPPRESS / AMPLIFY), which is for controllers.
|
|
87
|
+
|
|
88
|
+
## Works with any MCP-capable client
|
|
89
|
+
|
|
90
|
+
Install the MCP extra and you get an stdio server any model's client (Claude Desktop, Claude Code, Cursor, Cline, Windsurf, …) can spawn:
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
pip install margin[mcp]
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
```json
|
|
97
|
+
{
|
|
98
|
+
"mcpServers": {
|
|
99
|
+
"margin": {
|
|
100
|
+
"command": "margin-mcp"
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
The model now has a `margin_observe(values)` tool and can read `margin://state` / `margin://issues` between turns to reason about its own typed self-state. Full details: [adapters/mcp/README.md](adapters/mcp/README.md).
|
|
107
|
+
|
|
108
|
+
---
|
|
109
|
+
|
|
65
110
|
Not building an agent? Margin works on any scalar with a threshold:
|
|
66
111
|
|
|
67
112
|
```python
|
|
@@ -235,6 +280,7 @@ Core adapters ship in the package. Each is a Parser factory + optional Flow / In
|
|
|
235
280
|
| Adapter | What it monitors |
|
|
236
281
|
|---|---|
|
|
237
282
|
| [**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. |
|
|
283
|
+
| [**mcp**](https://github.com/Cope-Labs/margin/tree/main/adapters/mcp/) | stdio Model Context Protocol server. Exposes `margin_observe` / `margin_reset` tools and `margin://state` / `margin://issues` resources to any MCP client. `pip install margin[mcp]`. |
|
|
238
284
|
| [**dataframe**](https://github.com/Cope-Labs/margin/tree/main/adapters/dataframe/) | Data quality (completeness, null rate, drift, freshness, schema) |
|
|
239
285
|
| [**numpy**](https://github.com/Cope-Labs/margin/tree/main/adapters/numpy/) | Array health (NaN rate, drift, distribution shape) |
|
|
240
286
|
| [**fastapi**](https://github.com/Cope-Labs/margin/tree/main/adapters/fastapi/) | Endpoint health (latency, error rate, throughput, queue depth) — ASGI middleware |
|
|
@@ -33,6 +33,49 @@ print(guidance.to_atom())
|
|
|
33
33
|
|
|
34
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
35
|
|
|
36
|
+
## Host-fed observation (recommended for production)
|
|
37
|
+
|
|
38
|
+
When your harness has ground-truth metrics (real latency, real token counts, real tool outcomes), feed margin from the *host* side so the model reads typed state rather than self-reports:
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
from adapters.agent import host_observe
|
|
42
|
+
|
|
43
|
+
advisory = host_observe({
|
|
44
|
+
"latency_ms": (time.perf_counter() - t0) * 1000,
|
|
45
|
+
"tokens_used": resp.usage.input_tokens + resp.usage.output_tokens,
|
|
46
|
+
"tool_success_rate": 1.0,
|
|
47
|
+
"retry_count": 0,
|
|
48
|
+
"confidence": 0.85,
|
|
49
|
+
}, profile="reasoning") # "default" | "reasoning" | "fast"
|
|
50
|
+
# → {"expression": "…", "flow": "flow:agent:plan[1/3] → ADVANCE",
|
|
51
|
+
# "advisory": {"action": "PROCEED", "reason": "…", "confidence": 0.7},
|
|
52
|
+
# "drift": {...}, "anomaly": {...}, "ledger_size": 3}
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
The `Advisory` enum (PROCEED / HOLD_AND_REFLECT / DECOMPOSE / ASK_CLARIFYING / HAND_OFF / RETRY) is margin's typed hint for what the *model* should do next — distinct from `Correction.op` (RESTORE / SUPPRESS / AMPLIFY), which is for controllers.
|
|
56
|
+
|
|
57
|
+
## Works with any MCP-capable client
|
|
58
|
+
|
|
59
|
+
Install the MCP extra and you get an stdio server any model's client (Claude Desktop, Claude Code, Cursor, Cline, Windsurf, …) can spawn:
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
pip install margin[mcp]
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
```json
|
|
66
|
+
{
|
|
67
|
+
"mcpServers": {
|
|
68
|
+
"margin": {
|
|
69
|
+
"command": "margin-mcp"
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
The model now has a `margin_observe(values)` tool and can read `margin://state` / `margin://issues` between turns to reason about its own typed self-state. Full details: [adapters/mcp/README.md](adapters/mcp/README.md).
|
|
76
|
+
|
|
77
|
+
---
|
|
78
|
+
|
|
36
79
|
Not building an agent? Margin works on any scalar with a threshold:
|
|
37
80
|
|
|
38
81
|
```python
|
|
@@ -206,6 +249,7 @@ Core adapters ship in the package. Each is a Parser factory + optional Flow / In
|
|
|
206
249
|
| Adapter | What it monitors |
|
|
207
250
|
|---|---|
|
|
208
251
|
| [**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. |
|
|
252
|
+
| [**mcp**](https://github.com/Cope-Labs/margin/tree/main/adapters/mcp/) | stdio Model Context Protocol server. Exposes `margin_observe` / `margin_reset` tools and `margin://state` / `margin://issues` resources to any MCP client. `pip install margin[mcp]`. |
|
|
209
253
|
| [**dataframe**](https://github.com/Cope-Labs/margin/tree/main/adapters/dataframe/) | Data quality (completeness, null rate, drift, freshness, schema) |
|
|
210
254
|
| [**numpy**](https://github.com/Cope-Labs/margin/tree/main/adapters/numpy/) | Array health (NaN rate, drift, distribution shape) |
|
|
211
255
|
| [**fastapi**](https://github.com/Cope-Labs/margin/tree/main/adapters/fastapi/) | Endpoint health (latency, error rate, throughput, queue depth) — ASGI middleware |
|
|
File without changes
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""
|
|
2
|
+
margin adapter for agent / LLM-pipeline self-awareness.
|
|
3
|
+
|
|
4
|
+
The primitives (Confidence, Absence, Issue, Flow, Intent, Contract) that margin
|
|
5
|
+
exposes are *exactly* the vocabulary an LLM agent needs when it has to answer
|
|
6
|
+
"how am I doing, and should I continue?" at each step of its run.
|
|
7
|
+
|
|
8
|
+
This adapter packages sensible defaults so you can go from zero to a typed
|
|
9
|
+
health read-out of an agent step in three lines:
|
|
10
|
+
|
|
11
|
+
from adapters.agent import agent_parser, AgentStep
|
|
12
|
+
|
|
13
|
+
parser = agent_parser()
|
|
14
|
+
step = AgentStep(latency_ms=2400, tokens_used=1800,
|
|
15
|
+
tool_success_rate=0.9, retry_count=1, confidence=0.72)
|
|
16
|
+
print(parser.parse(step.to_values()).to_string())
|
|
17
|
+
# [latency_ms:INTACT(+0.40\u03c3)] [tokens_used:INTACT(+0.75\u03c3)]
|
|
18
|
+
# [tool_success_rate:INTACT(-0.05\u03c3)] [retry_count:DEGRADED(...)]
|
|
19
|
+
# [confidence:INTACT(-0.10\u03c3)]
|
|
20
|
+
|
|
21
|
+
Use :func:`agent_flow` when your agent runs in stages (plan \u2192 act \u2192 reflect) and
|
|
22
|
+
you want typed live guidance (ADVANCE/HOLD/RETRY/ROLLBACK) between them.
|
|
23
|
+
|
|
24
|
+
Zero SDK dependencies. See module docstrings for wiring to Anthropic / OpenAI.
|
|
25
|
+
"""
|
|
26
|
+
from .metrics import (
|
|
27
|
+
AGENT_BASELINES, AGENT_THRESHOLDS,
|
|
28
|
+
REASONING_BASELINES, REASONING_THRESHOLDS,
|
|
29
|
+
FAST_BASELINES, FAST_THRESHOLDS,
|
|
30
|
+
AGENT_PROFILES, agent_parser,
|
|
31
|
+
)
|
|
32
|
+
from .flow import AGENT_STAGES, agent_flow
|
|
33
|
+
from .wrap import AgentStep, record_step, host_observe, agent_intent
|
|
34
|
+
from .advisory import Advisory, AdvisoryResult, derive_advisory
|
|
35
|
+
|
|
36
|
+
__all__ = [
|
|
37
|
+
# Thresholds + profiles
|
|
38
|
+
"AGENT_BASELINES", "AGENT_THRESHOLDS",
|
|
39
|
+
"REASONING_BASELINES", "REASONING_THRESHOLDS",
|
|
40
|
+
"FAST_BASELINES", "FAST_THRESHOLDS",
|
|
41
|
+
"AGENT_PROFILES", "agent_parser",
|
|
42
|
+
# Flow
|
|
43
|
+
"AGENT_STAGES", "agent_flow",
|
|
44
|
+
# Step recording + host feeder
|
|
45
|
+
"AgentStep", "record_step", "host_observe", "agent_intent",
|
|
46
|
+
# Advisory
|
|
47
|
+
"Advisory", "AdvisoryResult", "derive_advisory",
|
|
48
|
+
]
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Advisory: a typed next-action hint for the model, derived from an Expression
|
|
3
|
+
(+ optional FlowResult + drift map).
|
|
4
|
+
|
|
5
|
+
Where :class:`margin.Correction` names the *controller-level* op (RESTORE /
|
|
6
|
+
SUPPRESS / AMPLIFY / NOOP), ``Advisory`` names what the *model* should
|
|
7
|
+
consider doing with its next turn:
|
|
8
|
+
|
|
9
|
+
PROCEED continue as-is, the run looks healthy
|
|
10
|
+
HOLD_AND_REFLECT pause, re-examine inputs before producing output
|
|
11
|
+
DECOMPOSE break the problem into smaller sub-steps
|
|
12
|
+
ASK_CLARIFYING state is too ambiguous to proceed; query the user
|
|
13
|
+
HAND_OFF escalate to a human or a stronger model
|
|
14
|
+
RETRY redo the last step with tighter constraints
|
|
15
|
+
|
|
16
|
+
Advisories are **suggestions with reasons**, not prescriptions. The derivation
|
|
17
|
+
below is a deliberately simple heuristic over current Health, Flow guidance,
|
|
18
|
+
and drift trajectory. Callers are expected to treat ``AdvisoryResult.action``
|
|
19
|
+
as one signal among several, not a hard directive.
|
|
20
|
+
"""
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
from dataclasses import dataclass
|
|
24
|
+
from enum import Enum
|
|
25
|
+
from typing import Optional
|
|
26
|
+
|
|
27
|
+
from margin import Health, Expression
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class Advisory(Enum):
|
|
31
|
+
"""Typed next-action hint for the model."""
|
|
32
|
+
PROCEED = "PROCEED"
|
|
33
|
+
HOLD_AND_REFLECT = "HOLD_AND_REFLECT"
|
|
34
|
+
DECOMPOSE = "DECOMPOSE"
|
|
35
|
+
ASK_CLARIFYING = "ASK_CLARIFYING"
|
|
36
|
+
HAND_OFF = "HAND_OFF"
|
|
37
|
+
RETRY = "RETRY"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class AdvisoryResult:
|
|
42
|
+
"""
|
|
43
|
+
One advisory decision.
|
|
44
|
+
|
|
45
|
+
action: the typed suggestion
|
|
46
|
+
reason: human-readable justification (references specific components / sigma)
|
|
47
|
+
confidence: how confident the derivation is in this advisory (0.0\u20131.0)
|
|
48
|
+
"""
|
|
49
|
+
action: Advisory
|
|
50
|
+
reason: str
|
|
51
|
+
confidence: float = 0.7
|
|
52
|
+
|
|
53
|
+
def to_dict(self) -> dict:
|
|
54
|
+
return {"action": self.action.value, "reason": self.reason,
|
|
55
|
+
"confidence": round(self.confidence, 3)}
|
|
56
|
+
|
|
57
|
+
def to_atom(self) -> str:
|
|
58
|
+
return f"advisory:{self.action.value}({self.confidence:.2f}) — {self.reason}"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _obs(expr: Expression, name: str):
|
|
62
|
+
for o in expr.observations:
|
|
63
|
+
if o.name == name:
|
|
64
|
+
return o
|
|
65
|
+
return None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def derive_advisory(
|
|
69
|
+
expression: Expression,
|
|
70
|
+
flow_result=None,
|
|
71
|
+
drift_map: Optional[dict] = None,
|
|
72
|
+
) -> AdvisoryResult:
|
|
73
|
+
"""
|
|
74
|
+
Derive a :class:`AdvisoryResult` from observed state.
|
|
75
|
+
|
|
76
|
+
Precedence, highest-to-lowest:
|
|
77
|
+
1. Flow guidance \u2014 ESCALATE/RETRY/ROLLBACK map directly.
|
|
78
|
+
2. Confidence OOD \u2192 ASK_CLARIFYING (we can't classify certainty).
|
|
79
|
+
3. Any component ABLATED \u2192 HOLD_AND_REFLECT.
|
|
80
|
+
4. Accelerating worsening drift on any component \u2192 HOLD_AND_REFLECT.
|
|
81
|
+
5. Confidence + tokens both DEGRADED \u2192 DECOMPOSE (signal + cost both soft).
|
|
82
|
+
6. Any DEGRADED component \u2192 PROCEED with reason (soft-yellow).
|
|
83
|
+
7. Otherwise \u2192 PROCEED (all intact).
|
|
84
|
+
"""
|
|
85
|
+
# 1. Flow guidance wins if present.
|
|
86
|
+
if flow_result is not None:
|
|
87
|
+
g = getattr(flow_result.guidance, "value", None)
|
|
88
|
+
if g == "ESCALATE":
|
|
89
|
+
return AdvisoryResult(Advisory.HAND_OFF,
|
|
90
|
+
"flow guidance is ESCALATE", 0.9)
|
|
91
|
+
if g == "RETRY":
|
|
92
|
+
return AdvisoryResult(Advisory.RETRY,
|
|
93
|
+
"flow guidance is RETRY", 0.9)
|
|
94
|
+
if g == "ROLLBACK":
|
|
95
|
+
return AdvisoryResult(Advisory.HOLD_AND_REFLECT,
|
|
96
|
+
"flow guidance is ROLLBACK", 0.8)
|
|
97
|
+
|
|
98
|
+
# 2. Confidence OOD \u2192 ASK_CLARIFYING.
|
|
99
|
+
conf = _obs(expression, "confidence")
|
|
100
|
+
if conf is not None and conf.health == Health.OOD:
|
|
101
|
+
return AdvisoryResult(
|
|
102
|
+
Advisory.ASK_CLARIFYING,
|
|
103
|
+
"confidence is OOD \u2014 cannot classify certainty",
|
|
104
|
+
0.8,
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
# 3. Any ABLATED \u2192 HOLD_AND_REFLECT.
|
|
108
|
+
ablated = [o for o in expression.observations if o.health == Health.ABLATED]
|
|
109
|
+
if ablated:
|
|
110
|
+
names = ", ".join(o.name for o in ablated)
|
|
111
|
+
return AdvisoryResult(
|
|
112
|
+
Advisory.HOLD_AND_REFLECT,
|
|
113
|
+
f"ablated: {names}",
|
|
114
|
+
0.8,
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
# 4. Accelerating worsening drift.
|
|
118
|
+
if drift_map:
|
|
119
|
+
for name, dc in drift_map.items():
|
|
120
|
+
state = getattr(getattr(dc, "state", None), "value", "")
|
|
121
|
+
direction = getattr(getattr(dc, "direction", None), "value", "")
|
|
122
|
+
if state == "ACCELERATING" and direction in ("WORSENING", "DEGRADING"):
|
|
123
|
+
return AdvisoryResult(
|
|
124
|
+
Advisory.HOLD_AND_REFLECT,
|
|
125
|
+
f"{name} drift is accelerating-worsening",
|
|
126
|
+
0.65,
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
# 5. Confidence + tokens both DEGRADED \u2192 DECOMPOSE.
|
|
130
|
+
tokens = _obs(expression, "tokens_used")
|
|
131
|
+
if (conf is not None and tokens is not None
|
|
132
|
+
and conf.health == Health.DEGRADED
|
|
133
|
+
and tokens.health == Health.DEGRADED):
|
|
134
|
+
return AdvisoryResult(
|
|
135
|
+
Advisory.DECOMPOSE,
|
|
136
|
+
"confidence and tokens_used both degraded \u2014 shrink the step",
|
|
137
|
+
0.65,
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
# 6. Any DEGRADED \u2192 soft-proceed.
|
|
141
|
+
degraded = [o for o in expression.observations if o.health == Health.DEGRADED]
|
|
142
|
+
if degraded:
|
|
143
|
+
names = ", ".join(o.name for o in degraded)
|
|
144
|
+
return AdvisoryResult(
|
|
145
|
+
Advisory.PROCEED,
|
|
146
|
+
f"degraded but acceptable: {names}",
|
|
147
|
+
0.7,
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
# 7. All INTACT \u2192 confident proceed.
|
|
151
|
+
return AdvisoryResult(Advisory.PROCEED, "all components INTACT", 0.9)
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""
|
|
2
|
+
A Flow template for a typical three-stage agent loop:
|
|
3
|
+
|
|
4
|
+
PLAN \u2192 ACT \u2192 REFLECT \u2192 (COMPLETE)
|
|
5
|
+
|
|
6
|
+
Each stage uses the same agent_parser, but advances on different predicates:
|
|
7
|
+
|
|
8
|
+
PLAN \u2192 a concrete plan is produced (confidence above threshold)
|
|
9
|
+
ACT \u2192 tool calls are succeeding (tool_success_rate holds)
|
|
10
|
+
REFLECT \u2192 the run is converging (retries haven't exploded)
|
|
11
|
+
|
|
12
|
+
Flow.observe() returns typed live guidance (ADVANCE / HOLD / RETRY / ROLLBACK /
|
|
13
|
+
ESCALATE) at every step, and emits typed Issues for predicate failures and
|
|
14
|
+
degraded stage entries. This means your agent gets a typed answer to
|
|
15
|
+
"continue, retry, or escalate?" after every observation \u2014 not a string you have
|
|
16
|
+
to parse.
|
|
17
|
+
"""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from margin import Flow, Stage, Expression
|
|
21
|
+
|
|
22
|
+
from .metrics import agent_parser
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _has_plan(expr: Expression) -> bool:
|
|
26
|
+
"""PLAN advances once the model's stated confidence crosses intact."""
|
|
27
|
+
conf = next((o for o in expr.observations if o.name == "confidence"), None)
|
|
28
|
+
return conf is not None and conf.health.value == "INTACT"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _tools_holding(expr: Expression) -> bool:
|
|
32
|
+
"""ACT advances once tool success rate is intact and retries aren't ablated."""
|
|
33
|
+
success = next((o for o in expr.observations if o.name == "tool_success_rate"), None)
|
|
34
|
+
retries = next((o for o in expr.observations if o.name == "retry_count"), None)
|
|
35
|
+
if success is None or success.health.value != "INTACT":
|
|
36
|
+
return False
|
|
37
|
+
if retries is not None and retries.health.value == "ABLATED":
|
|
38
|
+
return False
|
|
39
|
+
return True
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _converging(expr: Expression) -> bool:
|
|
43
|
+
"""REFLECT advances once retry_count is intact and confidence is intact."""
|
|
44
|
+
retries = next((o for o in expr.observations if o.name == "retry_count"), None)
|
|
45
|
+
conf = next((o for o in expr.observations if o.name == "confidence"), None)
|
|
46
|
+
return (
|
|
47
|
+
retries is not None and retries.health.value == "INTACT"
|
|
48
|
+
and conf is not None and conf.health.value == "INTACT"
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
AGENT_STAGES = ("plan", "act", "reflect")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def agent_flow(label: str = "agent") -> Flow:
|
|
56
|
+
"""
|
|
57
|
+
Build a typical three-stage agent Flow with shared thresholds.
|
|
58
|
+
|
|
59
|
+
Each Flow.observe() call returns a FlowResult whose ``guidance`` is one of
|
|
60
|
+
ADVANCE / HOLD / RETRY / ROLLBACK / ESCALATE / COMPLETE. Use
|
|
61
|
+
``result.to_atom()`` to get a single-line summary suitable for logging,
|
|
62
|
+
e.g. ``flow:agent:act[2/3] \u2192 ADVANCE``.
|
|
63
|
+
"""
|
|
64
|
+
parser = agent_parser()
|
|
65
|
+
return Flow(
|
|
66
|
+
stages=[
|
|
67
|
+
Stage(name="plan", parser=parser, advance_when=_has_plan),
|
|
68
|
+
Stage(name="act", parser=parser, advance_when=_tools_holding),
|
|
69
|
+
Stage(name="reflect", parser=parser, advance_when=_converging),
|
|
70
|
+
],
|
|
71
|
+
label=label,
|
|
72
|
+
)
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Default baselines and thresholds for common agent / LLM-pipeline metrics.
|
|
3
|
+
|
|
4
|
+
Polarity is the critical part: for cost-like metrics (latency, tokens, retries)
|
|
5
|
+
more is worse, so ``higher_is_better=False``. For quality metrics (confidence,
|
|
6
|
+
tool success rate) more is better. Margin's sigma normalisation threads that
|
|
7
|
+
polarity correctly through every comparison downstream.
|
|
8
|
+
|
|
9
|
+
The baselines below are deliberately conservative defaults for a mid-size
|
|
10
|
+
Claude / GPT call with occasional tool use. Override per-component for your
|
|
11
|
+
specific agent.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from typing import Optional
|
|
16
|
+
|
|
17
|
+
from margin import Parser, Thresholds
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
AGENT_BASELINES: dict[str, float] = {
|
|
21
|
+
"latency_ms": 1500.0, # typical end-to-end call
|
|
22
|
+
"tokens_used": 800.0, # input + output
|
|
23
|
+
"tool_success_rate": 0.95, # fraction of tool calls that succeeded
|
|
24
|
+
"retry_count": 0.0, # zero retries is the happy path
|
|
25
|
+
"confidence": 0.80, # model-stated or rubric-derived
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
AGENT_THRESHOLDS: dict[str, Thresholds] = {
|
|
30
|
+
"latency_ms": Thresholds(
|
|
31
|
+
intact=3000.0, ablated=10000.0, higher_is_better=False,
|
|
32
|
+
),
|
|
33
|
+
"tokens_used": Thresholds(
|
|
34
|
+
intact=3000.0, ablated=10000.0, higher_is_better=False,
|
|
35
|
+
),
|
|
36
|
+
"tool_success_rate": Thresholds(
|
|
37
|
+
intact=0.80, ablated=0.50, higher_is_better=True,
|
|
38
|
+
),
|
|
39
|
+
"retry_count": Thresholds(
|
|
40
|
+
intact=1.0, ablated=3.0, higher_is_better=False,
|
|
41
|
+
),
|
|
42
|
+
"confidence": Thresholds(
|
|
43
|
+
intact=0.60, ablated=0.30, higher_is_better=True,
|
|
44
|
+
),
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# ---------------------------------------------------------------------------
|
|
49
|
+
# Profile baselines \u2014 v0.12 additive. Default preserves v0.11 behaviour.
|
|
50
|
+
# ---------------------------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
REASONING_BASELINES: dict[str, float] = {
|
|
53
|
+
"latency_ms": 8000.0, # reasoning models think for seconds per turn
|
|
54
|
+
"tokens_used": 8000.0, # long-context turns consume a lot
|
|
55
|
+
"tool_success_rate": 0.95,
|
|
56
|
+
"retry_count": 0.0,
|
|
57
|
+
"confidence": 0.80,
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
REASONING_THRESHOLDS: dict[str, Thresholds] = {
|
|
61
|
+
"latency_ms": Thresholds(intact=20000.0, ablated=60000.0, higher_is_better=False),
|
|
62
|
+
"tokens_used": Thresholds(intact=20000.0, ablated=80000.0, higher_is_better=False),
|
|
63
|
+
"tool_success_rate": Thresholds(intact=0.80, ablated=0.50, higher_is_better=True),
|
|
64
|
+
"retry_count": Thresholds(intact=2.0, ablated=5.0, higher_is_better=False),
|
|
65
|
+
"confidence": Thresholds(intact=0.55, ablated=0.25, higher_is_better=True),
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
FAST_BASELINES: dict[str, float] = {
|
|
69
|
+
"latency_ms": 400.0, # Haiku-class / short-turn
|
|
70
|
+
"tokens_used": 300.0,
|
|
71
|
+
"tool_success_rate": 0.95,
|
|
72
|
+
"retry_count": 0.0,
|
|
73
|
+
"confidence": 0.80,
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
FAST_THRESHOLDS: dict[str, Thresholds] = {
|
|
77
|
+
"latency_ms": Thresholds(intact=1500.0, ablated=5000.0, higher_is_better=False),
|
|
78
|
+
"tokens_used": Thresholds(intact=1500.0, ablated=5000.0, higher_is_better=False),
|
|
79
|
+
"tool_success_rate": Thresholds(intact=0.80, ablated=0.50, higher_is_better=True),
|
|
80
|
+
"retry_count": Thresholds(intact=1.0, ablated=3.0, higher_is_better=False),
|
|
81
|
+
"confidence": Thresholds(intact=0.60, ablated=0.30, higher_is_better=True),
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
AGENT_PROFILES: dict[str, tuple[dict[str, float], dict[str, Thresholds]]] = {
|
|
85
|
+
"default": (AGENT_BASELINES, AGENT_THRESHOLDS),
|
|
86
|
+
"reasoning": (REASONING_BASELINES, REASONING_THRESHOLDS),
|
|
87
|
+
"fast": (FAST_BASELINES, FAST_THRESHOLDS),
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def agent_parser(
|
|
92
|
+
baselines: Optional[dict[str, float]] = None,
|
|
93
|
+
thresholds: Optional[dict[str, Thresholds]] = None,
|
|
94
|
+
profile: str = "default",
|
|
95
|
+
) -> Parser:
|
|
96
|
+
"""
|
|
97
|
+
Build a Parser with sensible agent-metric defaults.
|
|
98
|
+
|
|
99
|
+
``profile`` picks a baseline set:
|
|
100
|
+
|
|
101
|
+
"default" v0.11 thresholds \u2014 small-to-mid Claude / GPT calls
|
|
102
|
+
"reasoning" long-context reasoning models (Opus-1M, o-series):
|
|
103
|
+
~8s/8k-token baselines, intact up to 20s/20k tokens
|
|
104
|
+
"fast" Haiku-class short turns: ~400ms/300-token baselines
|
|
105
|
+
|
|
106
|
+
``baselines`` and ``thresholds`` merge on top of the chosen profile, so you
|
|
107
|
+
can start from a profile and tweak the few components you care about::
|
|
108
|
+
|
|
109
|
+
parser = agent_parser(
|
|
110
|
+
profile="reasoning",
|
|
111
|
+
thresholds={"confidence": Thresholds(
|
|
112
|
+
intact=0.75, ablated=0.50, higher_is_better=True,
|
|
113
|
+
)},
|
|
114
|
+
)
|
|
115
|
+
"""
|
|
116
|
+
if profile not in AGENT_PROFILES:
|
|
117
|
+
raise ValueError(
|
|
118
|
+
f"unknown profile {profile!r}; known: {sorted(AGENT_PROFILES)}"
|
|
119
|
+
)
|
|
120
|
+
profile_baselines, profile_thresholds = AGENT_PROFILES[profile]
|
|
121
|
+
merged_baselines = dict(profile_baselines)
|
|
122
|
+
if baselines:
|
|
123
|
+
merged_baselines.update(baselines)
|
|
124
|
+
merged_thresholds = dict(profile_thresholds)
|
|
125
|
+
if thresholds:
|
|
126
|
+
merged_thresholds.update(thresholds)
|
|
127
|
+
# Default per-component thresholds cover every baseline, so the Parser-level
|
|
128
|
+
# fallback is only hit if the caller adds an unrelated component.
|
|
129
|
+
fallback = Thresholds(intact=0.0, ablated=0.0)
|
|
130
|
+
return Parser(
|
|
131
|
+
baselines=merged_baselines,
|
|
132
|
+
thresholds=fallback,
|
|
133
|
+
component_thresholds=merged_thresholds,
|
|
134
|
+
)
|