margin 0.11.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.11.0/margin.egg-info → margin-0.12.0}/PKG-INFO +23 -2
- {margin-0.11.0 → margin-0.12.0}/README.md +22 -1
- {margin-0.11.0 → margin-0.12.0}/adapters/agent/__init__.py +18 -4
- margin-0.12.0/adapters/agent/advisory.py +151 -0
- margin-0.12.0/adapters/agent/metrics.py +134 -0
- {margin-0.11.0 → margin-0.12.0}/adapters/agent/wrap.py +89 -1
- {margin-0.11.0 → margin-0.12.0}/adapters/mcp/__init__.py +9 -3
- {margin-0.11.0 → margin-0.12.0}/adapters/mcp/server.py +96 -19
- {margin-0.11.0 → margin-0.12.0}/adapters/mcp/state.py +40 -16
- {margin-0.11.0 → margin-0.12.0/margin.egg-info}/PKG-INFO +23 -2
- {margin-0.11.0 → margin-0.12.0}/margin.egg-info/SOURCES.txt +2 -0
- {margin-0.11.0 → margin-0.12.0}/pyproject.toml +1 -1
- margin-0.12.0/tests/test_agent_adapter.py +213 -0
- margin-0.11.0/adapters/agent/metrics.py +0 -78
- {margin-0.11.0 → margin-0.12.0}/LICENSE +0 -0
- {margin-0.11.0 → margin-0.12.0}/NOTICE +0 -0
- {margin-0.11.0 → margin-0.12.0}/adapters/__init__.py +0 -0
- {margin-0.11.0 → margin-0.12.0}/adapters/agent/flow.py +0 -0
- {margin-0.11.0 → margin-0.12.0}/adapters/dataframe/__init__.py +0 -0
- {margin-0.11.0 → margin-0.12.0}/adapters/dataframe/pandas_hook.py +0 -0
- {margin-0.11.0 → margin-0.12.0}/adapters/dataframe/quality.py +0 -0
- {margin-0.11.0 → margin-0.12.0}/adapters/fastapi/__init__.py +0 -0
- {margin-0.11.0 → margin-0.12.0}/adapters/fastapi/endpoints.py +0 -0
- {margin-0.11.0 → margin-0.12.0}/adapters/fastapi/middleware.py +0 -0
- {margin-0.11.0 → margin-0.12.0}/adapters/mcp/__main__.py +0 -0
- {margin-0.11.0 → margin-0.12.0}/adapters/numpy/__init__.py +0 -0
- {margin-0.11.0 → margin-0.12.0}/adapters/numpy/array_health.py +0 -0
- {margin-0.11.0 → margin-0.12.0}/adapters/pytest/__init__.py +0 -0
- {margin-0.11.0 → margin-0.12.0}/adapters/pytest/plugin.py +0 -0
- {margin-0.11.0 → margin-0.12.0}/adapters/pytest/suite.py +0 -0
- {margin-0.11.0 → margin-0.12.0}/margin/__init__.py +0 -0
- {margin-0.11.0 → margin-0.12.0}/margin/__main__.py +0 -0
- {margin-0.11.0 → margin-0.12.0}/margin/algebra.py +0 -0
- {margin-0.11.0 → margin-0.12.0}/margin/anomaly.py +0 -0
- {margin-0.11.0 → margin-0.12.0}/margin/bridge.py +0 -0
- {margin-0.11.0 → margin-0.12.0}/margin/calibrate.py +0 -0
- {margin-0.11.0 → margin-0.12.0}/margin/causal.py +0 -0
- {margin-0.11.0 → margin-0.12.0}/margin/composite.py +0 -0
- {margin-0.11.0 → margin-0.12.0}/margin/confidence.py +0 -0
- {margin-0.11.0 → margin-0.12.0}/margin/config.py +0 -0
- {margin-0.11.0 → margin-0.12.0}/margin/contract.py +0 -0
- {margin-0.11.0 → margin-0.12.0}/margin/controller.py +0 -0
- {margin-0.11.0 → margin-0.12.0}/margin/correlate.py +0 -0
- {margin-0.11.0 → margin-0.12.0}/margin/diff.py +0 -0
- {margin-0.11.0 → margin-0.12.0}/margin/drift.py +0 -0
- {margin-0.11.0 → margin-0.12.0}/margin/events.py +0 -0
- {margin-0.11.0 → margin-0.12.0}/margin/fingerprint.py +0 -0
- {margin-0.11.0 → margin-0.12.0}/margin/flow.py +0 -0
- {margin-0.11.0 → margin-0.12.0}/margin/forecast.py +0 -0
- {margin-0.11.0 → margin-0.12.0}/margin/health.py +0 -0
- {margin-0.11.0 → margin-0.12.0}/margin/intent.py +0 -0
- {margin-0.11.0 → margin-0.12.0}/margin/issue.py +0 -0
- {margin-0.11.0 → margin-0.12.0}/margin/ledger.py +0 -0
- {margin-0.11.0 → margin-0.12.0}/margin/loop.py +0 -0
- {margin-0.11.0 → margin-0.12.0}/margin/observation.py +0 -0
- {margin-0.11.0 → margin-0.12.0}/margin/persist.py +0 -0
- {margin-0.11.0 → margin-0.12.0}/margin/policy/__init__.py +0 -0
- {margin-0.11.0 → margin-0.12.0}/margin/policy/compose.py +0 -0
- {margin-0.11.0 → margin-0.12.0}/margin/policy/core.py +0 -0
- {margin-0.11.0 → margin-0.12.0}/margin/policy/temporal.py +0 -0
- {margin-0.11.0 → margin-0.12.0}/margin/policy/trace.py +0 -0
- {margin-0.11.0 → margin-0.12.0}/margin/policy/tuning.py +0 -0
- {margin-0.11.0 → margin-0.12.0}/margin/policy/validate.py +0 -0
- {margin-0.11.0 → margin-0.12.0}/margin/predicates.py +0 -0
- {margin-0.11.0 → margin-0.12.0}/margin/provenance.py +0 -0
- {margin-0.11.0 → margin-0.12.0}/margin/streaming.py +0 -0
- {margin-0.11.0 → margin-0.12.0}/margin/transitions.py +0 -0
- {margin-0.11.0 → margin-0.12.0}/margin/uncertain.py +0 -0
- {margin-0.11.0 → margin-0.12.0}/margin/validity.py +0 -0
- {margin-0.11.0 → margin-0.12.0}/margin.egg-info/dependency_links.txt +0 -0
- {margin-0.11.0 → margin-0.12.0}/margin.egg-info/entry_points.txt +0 -0
- {margin-0.11.0 → margin-0.12.0}/margin.egg-info/requires.txt +0 -0
- {margin-0.11.0 → margin-0.12.0}/margin.egg-info/top_level.txt +0 -0
- {margin-0.11.0 → margin-0.12.0}/setup.cfg +0 -0
- {margin-0.11.0 → margin-0.12.0}/tests/test_absence.py +0 -0
- {margin-0.11.0 → margin-0.12.0}/tests/test_algebra.py +0 -0
- {margin-0.11.0 → margin-0.12.0}/tests/test_anomaly.py +0 -0
- {margin-0.11.0 → margin-0.12.0}/tests/test_bridge.py +0 -0
- {margin-0.11.0 → margin-0.12.0}/tests/test_calibrate.py +0 -0
- {margin-0.11.0 → margin-0.12.0}/tests/test_causal.py +0 -0
- {margin-0.11.0 → margin-0.12.0}/tests/test_composite.py +0 -0
- {margin-0.11.0 → margin-0.12.0}/tests/test_confidence.py +0 -0
- {margin-0.11.0 → margin-0.12.0}/tests/test_config.py +0 -0
- {margin-0.11.0 → margin-0.12.0}/tests/test_contract.py +0 -0
- {margin-0.11.0 → margin-0.12.0}/tests/test_controller.py +0 -0
- {margin-0.11.0 → margin-0.12.0}/tests/test_correlate.py +0 -0
- {margin-0.11.0 → margin-0.12.0}/tests/test_diff.py +0 -0
- {margin-0.11.0 → margin-0.12.0}/tests/test_drift.py +0 -0
- {margin-0.11.0 → margin-0.12.0}/tests/test_events.py +0 -0
- {margin-0.11.0 → margin-0.12.0}/tests/test_fingerprint_ext.py +0 -0
- {margin-0.11.0 → margin-0.12.0}/tests/test_float_tolerance.py +0 -0
- {margin-0.11.0 → margin-0.12.0}/tests/test_flow.py +0 -0
- {margin-0.11.0 → margin-0.12.0}/tests/test_forecast.py +0 -0
- {margin-0.11.0 → margin-0.12.0}/tests/test_health.py +0 -0
- {margin-0.11.0 → margin-0.12.0}/tests/test_intent.py +0 -0
- {margin-0.11.0 → margin-0.12.0}/tests/test_issue.py +0 -0
- {margin-0.11.0 → margin-0.12.0}/tests/test_ledger.py +0 -0
- {margin-0.11.0 → margin-0.12.0}/tests/test_loop.py +0 -0
- {margin-0.11.0 → margin-0.12.0}/tests/test_observation.py +0 -0
- {margin-0.11.0 → margin-0.12.0}/tests/test_persist.py +0 -0
- {margin-0.11.0 → margin-0.12.0}/tests/test_pipeline_features.py +0 -0
- {margin-0.11.0 → margin-0.12.0}/tests/test_policy.py +0 -0
- {margin-0.11.0 → margin-0.12.0}/tests/test_policy_compose.py +0 -0
- {margin-0.11.0 → margin-0.12.0}/tests/test_policy_temporal.py +0 -0
- {margin-0.11.0 → margin-0.12.0}/tests/test_policy_trace.py +0 -0
- {margin-0.11.0 → margin-0.12.0}/tests/test_policy_tuning.py +0 -0
- {margin-0.11.0 → margin-0.12.0}/tests/test_policy_validate.py +0 -0
- {margin-0.11.0 → margin-0.12.0}/tests/test_predicates.py +0 -0
- {margin-0.11.0 → margin-0.12.0}/tests/test_provenance.py +0 -0
- {margin-0.11.0 → margin-0.12.0}/tests/test_reverse_bridge.py +0 -0
- {margin-0.11.0 → margin-0.12.0}/tests/test_staleness.py +0 -0
- {margin-0.11.0 → margin-0.12.0}/tests/test_streaming.py +0 -0
- {margin-0.11.0 → margin-0.12.0}/tests/test_transitions.py +0 -0
- {margin-0.11.0 → margin-0.12.0}/tests/test_uncertain.py +0 -0
- {margin-0.11.0 → margin-0.12.0}/tests/test_validity.py +0 -0
- {margin-0.11.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
|
|
@@ -64,7 +64,28 @@ print(guidance.to_atom())
|
|
|
64
64
|
|
|
65
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.
|
|
66
66
|
|
|
67
|
-
|
|
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
|
|
68
89
|
|
|
69
90
|
Install the MCP extra and you get an stdio server any model's client (Claude Desktop, Claude Code, Cursor, Cline, Windsurf, …) can spawn:
|
|
70
91
|
|
|
@@ -33,7 +33,28 @@ 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
|
-
|
|
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
|
|
37
58
|
|
|
38
59
|
Install the MCP extra and you get an stdio server any model's client (Claude Desktop, Claude Code, Cursor, Cline, Windsurf, …) can spawn:
|
|
39
60
|
|
|
@@ -23,12 +23,26 @@ you want typed live guidance (ADVANCE/HOLD/RETRY/ROLLBACK) between them.
|
|
|
23
23
|
|
|
24
24
|
Zero SDK dependencies. See module docstrings for wiring to Anthropic / OpenAI.
|
|
25
25
|
"""
|
|
26
|
-
from .metrics import
|
|
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
|
+
)
|
|
27
32
|
from .flow import AGENT_STAGES, agent_flow
|
|
28
|
-
from .wrap import AgentStep, record_step, agent_intent
|
|
33
|
+
from .wrap import AgentStep, record_step, host_observe, agent_intent
|
|
34
|
+
from .advisory import Advisory, AdvisoryResult, derive_advisory
|
|
29
35
|
|
|
30
36
|
__all__ = [
|
|
31
|
-
|
|
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
|
|
32
43
|
"AGENT_STAGES", "agent_flow",
|
|
33
|
-
|
|
44
|
+
# Step recording + host feeder
|
|
45
|
+
"AgentStep", "record_step", "host_observe", "agent_intent",
|
|
46
|
+
# Advisory
|
|
47
|
+
"Advisory", "AdvisoryResult", "derive_advisory",
|
|
34
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,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
|
+
)
|
|
@@ -34,7 +34,8 @@ of lines, e.g. for Anthropic::
|
|
|
34
34
|
from __future__ import annotations
|
|
35
35
|
|
|
36
36
|
from dataclasses import dataclass
|
|
37
|
-
from
|
|
37
|
+
from pathlib import Path
|
|
38
|
+
from typing import Any, Optional
|
|
38
39
|
|
|
39
40
|
from margin import (
|
|
40
41
|
Expression, Intent, Requirement, Health, Ledger, Record, Op,
|
|
@@ -95,6 +96,93 @@ def record_step(
|
|
|
95
96
|
return record
|
|
96
97
|
|
|
97
98
|
|
|
99
|
+
def host_observe(
|
|
100
|
+
values: dict[str, float],
|
|
101
|
+
*,
|
|
102
|
+
state_path: Optional[str | Path] = None,
|
|
103
|
+
profile: str = "default",
|
|
104
|
+
) -> dict[str, Any]:
|
|
105
|
+
"""
|
|
106
|
+
Record an agent step from the **host** side (the dispatcher that knows the
|
|
107
|
+
real metrics), persisting through the same snapshot the margin-mcp server
|
|
108
|
+
uses. Returns a dict with the expression atom, flow atom + guidance,
|
|
109
|
+
advisory, and summary counts of drift/anomaly trajectory.
|
|
110
|
+
|
|
111
|
+
Use this when your harness has ground-truth metrics (real latency, real
|
|
112
|
+
token counts, real tool-call outcomes) and wants to feed margin *instead
|
|
113
|
+
of* relying on the model to self-report via the ``margin_observe`` tool.
|
|
114
|
+
|
|
115
|
+
Snapshot path resolution:
|
|
116
|
+
1. ``state_path`` argument if given
|
|
117
|
+
2. ``MARGIN_MCP_STATE`` environment variable
|
|
118
|
+
3. ``~/.margin/mcp-state.json``
|
|
119
|
+
|
|
120
|
+
A margin-mcp server pointed at the same path sees the updates on its next
|
|
121
|
+
resource read \u2014 so the model can ``read margin://state`` and see what the
|
|
122
|
+
host observed, with no tool call at all.
|
|
123
|
+
|
|
124
|
+
Example::
|
|
125
|
+
|
|
126
|
+
from adapters.agent import host_observe
|
|
127
|
+
|
|
128
|
+
def dispatch_with_margin(messages):
|
|
129
|
+
t0 = time.perf_counter()
|
|
130
|
+
resp = client.messages.create(...)
|
|
131
|
+
advisory = host_observe({
|
|
132
|
+
"latency_ms": (time.perf_counter() - t0) * 1000,
|
|
133
|
+
"tokens_used": resp.usage.input_tokens + resp.usage.output_tokens,
|
|
134
|
+
"tool_success_rate": 1.0,
|
|
135
|
+
"retry_count": 0,
|
|
136
|
+
"confidence": 0.85,
|
|
137
|
+
}, profile="reasoning")
|
|
138
|
+
return resp, advisory
|
|
139
|
+
"""
|
|
140
|
+
# Imports are local so ``from adapters.agent import host_observe`` doesn't
|
|
141
|
+
# eagerly import adapters.mcp.state (which itself is an adapter concern).
|
|
142
|
+
from adapters.mcp.state import load_session, save_session, state_path as _state_path
|
|
143
|
+
from .advisory import derive_advisory
|
|
144
|
+
|
|
145
|
+
if state_path is not None:
|
|
146
|
+
import os
|
|
147
|
+
os.environ["MARGIN_MCP_STATE"] = str(state_path)
|
|
148
|
+
|
|
149
|
+
monitor, flow, ledger = load_session(with_ledger=True)
|
|
150
|
+
monitor.update(dict(values))
|
|
151
|
+
flow_result = flow.observe(dict(values))
|
|
152
|
+
|
|
153
|
+
# Append to the ledger for downstream replay / diff.
|
|
154
|
+
step_index = len(ledger.records)
|
|
155
|
+
record_step(ledger, step_index, monitor.expression) if monitor.expression else None
|
|
156
|
+
|
|
157
|
+
save_session(monitor, flow, ledger)
|
|
158
|
+
|
|
159
|
+
# Trajectory from Monitor (may be None if insufficient samples)
|
|
160
|
+
drift_map = {}
|
|
161
|
+
anomaly_map = {}
|
|
162
|
+
for name in getattr(monitor.parser, "baselines", {}):
|
|
163
|
+
dc = monitor.drift(name) if hasattr(monitor, "drift") else None
|
|
164
|
+
if dc is not None:
|
|
165
|
+
drift_map[name] = dc
|
|
166
|
+
ac = monitor.anomaly(name) if hasattr(monitor, "anomaly") else None
|
|
167
|
+
if ac is not None:
|
|
168
|
+
anomaly_map[name] = ac
|
|
169
|
+
|
|
170
|
+
advisory = derive_advisory(
|
|
171
|
+
monitor.expression, flow_result=flow_result, drift_map=drift_map,
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
return {
|
|
175
|
+
"expression": monitor.expression.to_string() if monitor.expression else None,
|
|
176
|
+
"flow": flow_result.to_atom(),
|
|
177
|
+
"guidance": flow_result.guidance.value,
|
|
178
|
+
"advisory": advisory.to_dict(),
|
|
179
|
+
"drift": {n: getattr(dc.state, "value", str(dc.state)) for n, dc in drift_map.items()},
|
|
180
|
+
"anomaly": {n: getattr(ac.state, "value", str(ac.state)) for n, ac in anomaly_map.items()},
|
|
181
|
+
"ledger_size": len(ledger.records),
|
|
182
|
+
"state_path": str(_state_path()),
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
|
|
98
186
|
def agent_intent(deadline_seconds: Optional[float] = None) -> Intent:
|
|
99
187
|
"""
|
|
100
188
|
Build a default Intent covering 'the agent run is still feasible' \u2014 a
|
|
@@ -9,13 +9,19 @@ calls so state survives restarts. The filesystem is the runtime \u2014 inspect
|
|
|
9
9
|
|
|
10
10
|
Tools exposed to the model:
|
|
11
11
|
|
|
12
|
-
margin_observe(values={...}) record an agent step \u2192 expression +
|
|
12
|
+
margin_observe(values={...}) record an agent step \u2192 expression +
|
|
13
|
+
flow guidance + typed Advisory
|
|
13
14
|
margin_reset() start a fresh session
|
|
14
15
|
|
|
15
|
-
Resources the model can read between turns:
|
|
16
|
+
Resources the model can read between turns (no tool call needed):
|
|
16
17
|
|
|
17
|
-
margin://state current Expression + FlowResult
|
|
18
|
+
margin://state current Expression + FlowResult + drift +
|
|
19
|
+
anomaly + Advisory as JSON
|
|
18
20
|
margin://issues current IssueBuffer.diagnose() output
|
|
21
|
+
margin://ledger last N Ledger records \u2014 trajectory across turns
|
|
22
|
+
|
|
23
|
+
For harnesses with ground-truth metrics, prefer ``adapters.agent.host_observe``
|
|
24
|
+
over the ``margin_observe`` tool so the model reads state rather than reports it.
|
|
19
25
|
|
|
20
26
|
Install the optional MCP dependency and run the server:
|
|
21
27
|
|