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.

Files changed (116) hide show
  1. {margin-0.11.0/margin.egg-info → margin-0.12.0}/PKG-INFO +23 -2
  2. {margin-0.11.0 → margin-0.12.0}/README.md +22 -1
  3. {margin-0.11.0 → margin-0.12.0}/adapters/agent/__init__.py +18 -4
  4. margin-0.12.0/adapters/agent/advisory.py +151 -0
  5. margin-0.12.0/adapters/agent/metrics.py +134 -0
  6. {margin-0.11.0 → margin-0.12.0}/adapters/agent/wrap.py +89 -1
  7. {margin-0.11.0 → margin-0.12.0}/adapters/mcp/__init__.py +9 -3
  8. {margin-0.11.0 → margin-0.12.0}/adapters/mcp/server.py +96 -19
  9. {margin-0.11.0 → margin-0.12.0}/adapters/mcp/state.py +40 -16
  10. {margin-0.11.0 → margin-0.12.0/margin.egg-info}/PKG-INFO +23 -2
  11. {margin-0.11.0 → margin-0.12.0}/margin.egg-info/SOURCES.txt +2 -0
  12. {margin-0.11.0 → margin-0.12.0}/pyproject.toml +1 -1
  13. margin-0.12.0/tests/test_agent_adapter.py +213 -0
  14. margin-0.11.0/adapters/agent/metrics.py +0 -78
  15. {margin-0.11.0 → margin-0.12.0}/LICENSE +0 -0
  16. {margin-0.11.0 → margin-0.12.0}/NOTICE +0 -0
  17. {margin-0.11.0 → margin-0.12.0}/adapters/__init__.py +0 -0
  18. {margin-0.11.0 → margin-0.12.0}/adapters/agent/flow.py +0 -0
  19. {margin-0.11.0 → margin-0.12.0}/adapters/dataframe/__init__.py +0 -0
  20. {margin-0.11.0 → margin-0.12.0}/adapters/dataframe/pandas_hook.py +0 -0
  21. {margin-0.11.0 → margin-0.12.0}/adapters/dataframe/quality.py +0 -0
  22. {margin-0.11.0 → margin-0.12.0}/adapters/fastapi/__init__.py +0 -0
  23. {margin-0.11.0 → margin-0.12.0}/adapters/fastapi/endpoints.py +0 -0
  24. {margin-0.11.0 → margin-0.12.0}/adapters/fastapi/middleware.py +0 -0
  25. {margin-0.11.0 → margin-0.12.0}/adapters/mcp/__main__.py +0 -0
  26. {margin-0.11.0 → margin-0.12.0}/adapters/numpy/__init__.py +0 -0
  27. {margin-0.11.0 → margin-0.12.0}/adapters/numpy/array_health.py +0 -0
  28. {margin-0.11.0 → margin-0.12.0}/adapters/pytest/__init__.py +0 -0
  29. {margin-0.11.0 → margin-0.12.0}/adapters/pytest/plugin.py +0 -0
  30. {margin-0.11.0 → margin-0.12.0}/adapters/pytest/suite.py +0 -0
  31. {margin-0.11.0 → margin-0.12.0}/margin/__init__.py +0 -0
  32. {margin-0.11.0 → margin-0.12.0}/margin/__main__.py +0 -0
  33. {margin-0.11.0 → margin-0.12.0}/margin/algebra.py +0 -0
  34. {margin-0.11.0 → margin-0.12.0}/margin/anomaly.py +0 -0
  35. {margin-0.11.0 → margin-0.12.0}/margin/bridge.py +0 -0
  36. {margin-0.11.0 → margin-0.12.0}/margin/calibrate.py +0 -0
  37. {margin-0.11.0 → margin-0.12.0}/margin/causal.py +0 -0
  38. {margin-0.11.0 → margin-0.12.0}/margin/composite.py +0 -0
  39. {margin-0.11.0 → margin-0.12.0}/margin/confidence.py +0 -0
  40. {margin-0.11.0 → margin-0.12.0}/margin/config.py +0 -0
  41. {margin-0.11.0 → margin-0.12.0}/margin/contract.py +0 -0
  42. {margin-0.11.0 → margin-0.12.0}/margin/controller.py +0 -0
  43. {margin-0.11.0 → margin-0.12.0}/margin/correlate.py +0 -0
  44. {margin-0.11.0 → margin-0.12.0}/margin/diff.py +0 -0
  45. {margin-0.11.0 → margin-0.12.0}/margin/drift.py +0 -0
  46. {margin-0.11.0 → margin-0.12.0}/margin/events.py +0 -0
  47. {margin-0.11.0 → margin-0.12.0}/margin/fingerprint.py +0 -0
  48. {margin-0.11.0 → margin-0.12.0}/margin/flow.py +0 -0
  49. {margin-0.11.0 → margin-0.12.0}/margin/forecast.py +0 -0
  50. {margin-0.11.0 → margin-0.12.0}/margin/health.py +0 -0
  51. {margin-0.11.0 → margin-0.12.0}/margin/intent.py +0 -0
  52. {margin-0.11.0 → margin-0.12.0}/margin/issue.py +0 -0
  53. {margin-0.11.0 → margin-0.12.0}/margin/ledger.py +0 -0
  54. {margin-0.11.0 → margin-0.12.0}/margin/loop.py +0 -0
  55. {margin-0.11.0 → margin-0.12.0}/margin/observation.py +0 -0
  56. {margin-0.11.0 → margin-0.12.0}/margin/persist.py +0 -0
  57. {margin-0.11.0 → margin-0.12.0}/margin/policy/__init__.py +0 -0
  58. {margin-0.11.0 → margin-0.12.0}/margin/policy/compose.py +0 -0
  59. {margin-0.11.0 → margin-0.12.0}/margin/policy/core.py +0 -0
  60. {margin-0.11.0 → margin-0.12.0}/margin/policy/temporal.py +0 -0
  61. {margin-0.11.0 → margin-0.12.0}/margin/policy/trace.py +0 -0
  62. {margin-0.11.0 → margin-0.12.0}/margin/policy/tuning.py +0 -0
  63. {margin-0.11.0 → margin-0.12.0}/margin/policy/validate.py +0 -0
  64. {margin-0.11.0 → margin-0.12.0}/margin/predicates.py +0 -0
  65. {margin-0.11.0 → margin-0.12.0}/margin/provenance.py +0 -0
  66. {margin-0.11.0 → margin-0.12.0}/margin/streaming.py +0 -0
  67. {margin-0.11.0 → margin-0.12.0}/margin/transitions.py +0 -0
  68. {margin-0.11.0 → margin-0.12.0}/margin/uncertain.py +0 -0
  69. {margin-0.11.0 → margin-0.12.0}/margin/validity.py +0 -0
  70. {margin-0.11.0 → margin-0.12.0}/margin.egg-info/dependency_links.txt +0 -0
  71. {margin-0.11.0 → margin-0.12.0}/margin.egg-info/entry_points.txt +0 -0
  72. {margin-0.11.0 → margin-0.12.0}/margin.egg-info/requires.txt +0 -0
  73. {margin-0.11.0 → margin-0.12.0}/margin.egg-info/top_level.txt +0 -0
  74. {margin-0.11.0 → margin-0.12.0}/setup.cfg +0 -0
  75. {margin-0.11.0 → margin-0.12.0}/tests/test_absence.py +0 -0
  76. {margin-0.11.0 → margin-0.12.0}/tests/test_algebra.py +0 -0
  77. {margin-0.11.0 → margin-0.12.0}/tests/test_anomaly.py +0 -0
  78. {margin-0.11.0 → margin-0.12.0}/tests/test_bridge.py +0 -0
  79. {margin-0.11.0 → margin-0.12.0}/tests/test_calibrate.py +0 -0
  80. {margin-0.11.0 → margin-0.12.0}/tests/test_causal.py +0 -0
  81. {margin-0.11.0 → margin-0.12.0}/tests/test_composite.py +0 -0
  82. {margin-0.11.0 → margin-0.12.0}/tests/test_confidence.py +0 -0
  83. {margin-0.11.0 → margin-0.12.0}/tests/test_config.py +0 -0
  84. {margin-0.11.0 → margin-0.12.0}/tests/test_contract.py +0 -0
  85. {margin-0.11.0 → margin-0.12.0}/tests/test_controller.py +0 -0
  86. {margin-0.11.0 → margin-0.12.0}/tests/test_correlate.py +0 -0
  87. {margin-0.11.0 → margin-0.12.0}/tests/test_diff.py +0 -0
  88. {margin-0.11.0 → margin-0.12.0}/tests/test_drift.py +0 -0
  89. {margin-0.11.0 → margin-0.12.0}/tests/test_events.py +0 -0
  90. {margin-0.11.0 → margin-0.12.0}/tests/test_fingerprint_ext.py +0 -0
  91. {margin-0.11.0 → margin-0.12.0}/tests/test_float_tolerance.py +0 -0
  92. {margin-0.11.0 → margin-0.12.0}/tests/test_flow.py +0 -0
  93. {margin-0.11.0 → margin-0.12.0}/tests/test_forecast.py +0 -0
  94. {margin-0.11.0 → margin-0.12.0}/tests/test_health.py +0 -0
  95. {margin-0.11.0 → margin-0.12.0}/tests/test_intent.py +0 -0
  96. {margin-0.11.0 → margin-0.12.0}/tests/test_issue.py +0 -0
  97. {margin-0.11.0 → margin-0.12.0}/tests/test_ledger.py +0 -0
  98. {margin-0.11.0 → margin-0.12.0}/tests/test_loop.py +0 -0
  99. {margin-0.11.0 → margin-0.12.0}/tests/test_observation.py +0 -0
  100. {margin-0.11.0 → margin-0.12.0}/tests/test_persist.py +0 -0
  101. {margin-0.11.0 → margin-0.12.0}/tests/test_pipeline_features.py +0 -0
  102. {margin-0.11.0 → margin-0.12.0}/tests/test_policy.py +0 -0
  103. {margin-0.11.0 → margin-0.12.0}/tests/test_policy_compose.py +0 -0
  104. {margin-0.11.0 → margin-0.12.0}/tests/test_policy_temporal.py +0 -0
  105. {margin-0.11.0 → margin-0.12.0}/tests/test_policy_trace.py +0 -0
  106. {margin-0.11.0 → margin-0.12.0}/tests/test_policy_tuning.py +0 -0
  107. {margin-0.11.0 → margin-0.12.0}/tests/test_policy_validate.py +0 -0
  108. {margin-0.11.0 → margin-0.12.0}/tests/test_predicates.py +0 -0
  109. {margin-0.11.0 → margin-0.12.0}/tests/test_provenance.py +0 -0
  110. {margin-0.11.0 → margin-0.12.0}/tests/test_reverse_bridge.py +0 -0
  111. {margin-0.11.0 → margin-0.12.0}/tests/test_staleness.py +0 -0
  112. {margin-0.11.0 → margin-0.12.0}/tests/test_streaming.py +0 -0
  113. {margin-0.11.0 → margin-0.12.0}/tests/test_transitions.py +0 -0
  114. {margin-0.11.0 → margin-0.12.0}/tests/test_uncertain.py +0 -0
  115. {margin-0.11.0 → margin-0.12.0}/tests/test_validity.py +0 -0
  116. {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.11.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
- ### Works with any MCP-capable client
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
- ### Works with any MCP-capable client
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 AGENT_BASELINES, AGENT_THRESHOLDS, agent_parser
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
- "AGENT_BASELINES", "AGENT_THRESHOLDS", "agent_parser",
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
- "AgentStep", "record_step", "agent_intent",
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 typing import Optional
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 + guidance
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 as JSON
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