causal-memory-layer 0.4.0__py3-none-any.whl

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.
cml/ctag.py ADDED
@@ -0,0 +1,274 @@
1
+ """
2
+ cml.ctag — Causal Tag (CTAG) computation (v0.4)
3
+
4
+ 16-bit semantic marker:
5
+
6
+ b15..b12 DOM (4 bits) — trust domain
7
+ b11..b8 CLASS (4 bits) — action class
8
+ b7..b4 GEN (4 bits) — generation/epoch (mod 16)
9
+ b3..b1 LHINT (3 bits) — lineage hint derived from parent_cause
10
+ b0 SEAL (1 bit) — chain continuation flag
11
+
12
+ Reference: vcml/CTAG.md
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import warnings
18
+ from typing import Optional
19
+
20
+
21
+ # ---------------------------------------------------------------------------
22
+ # DOM table
23
+ # ---------------------------------------------------------------------------
24
+
25
+ class DOM:
26
+ UNKNOWN = 0
27
+ KERNEL = 1
28
+ PLATFORM = 2
29
+ SERVICE = 3
30
+ USER = 4
31
+ ADMIN = 5
32
+ CI_CD = 6
33
+ TENANT_A = 7
34
+ TENANT_B = 8
35
+ TENANT_C = 9
36
+ SANDBOX = 10
37
+ UNTRUSTED = 11
38
+ THIRD_PARTY = 12
39
+ AGENT = 13
40
+ BREAK_GLASS = 14
41
+ RESERVED = 15
42
+
43
+ _BY_NAME = {
44
+ "UNKNOWN": 0, "KERNEL": 1, "PLATFORM": 2, "SERVICE": 3,
45
+ "USER": 4, "ADMIN": 5, "CI_CD": 6,
46
+ "TENANT_A": 7, "TENANT_B": 8, "TENANT_C": 9,
47
+ "SANDBOX": 10, "UNTRUSTED": 11, "THIRD_PARTY": 12,
48
+ "AGENT": 13, "BREAK_GLASS": 14, "RESERVED": 15,
49
+ }
50
+ _BY_VAL = {v: k for k, v in _BY_NAME.items()}
51
+
52
+ @staticmethod
53
+ def name(val: int) -> str:
54
+ return DOM._BY_VAL.get(val, f"DOM({val})")
55
+
56
+ @staticmethod
57
+ def from_name(name: str) -> int:
58
+ return DOM._BY_NAME[name.upper()]
59
+
60
+
61
+ # ---------------------------------------------------------------------------
62
+ # CLASS table
63
+ # ---------------------------------------------------------------------------
64
+
65
+ class CLASS:
66
+ NONE = 0
67
+ READ = 1
68
+ WRITE = 2
69
+ EXEC = 3
70
+ NET_OUT = 4
71
+ NET_IN = 5
72
+ IPC = 6
73
+ PRIV = 7
74
+ CONFIG = 8
75
+ SECRET = 9
76
+ CRYPTO = 10
77
+ FIN_TX = 11
78
+ DATA_EGRESS = 12
79
+ ML_ACTION = 13
80
+ OVERRIDE = 14
81
+ SYSTEM = 15
82
+
83
+ _BY_NAME = {
84
+ "NONE": 0, "READ": 1, "WRITE": 2, "EXEC": 3,
85
+ "NET_OUT": 4, "NET_IN": 5, "IPC": 6, "PRIV": 7,
86
+ "CONFIG": 8, "SECRET": 9, "CRYPTO": 10, "FIN_TX": 11,
87
+ "DATA_EGRESS": 12, "ML_ACTION": 13, "OVERRIDE": 14, "SYSTEM": 15,
88
+ }
89
+ _BY_VAL = {v: k for k, v in _BY_NAME.items()}
90
+
91
+ @staticmethod
92
+ def name(val: int) -> str:
93
+ return CLASS._BY_VAL.get(val, f"CLASS({val})")
94
+
95
+ @staticmethod
96
+ def from_name(name: str) -> int:
97
+ return CLASS._BY_NAME[name.upper()]
98
+
99
+ @staticmethod
100
+ def from_action(action: str) -> int:
101
+ mapping = {
102
+ "exec": CLASS.EXEC,
103
+ "open": CLASS.READ,
104
+ "read": CLASS.READ,
105
+ "write": CLASS.WRITE,
106
+ "connect": CLASS.NET_OUT,
107
+ "send": CLASS.NET_OUT,
108
+ }
109
+ return mapping.get(action.lower(), CLASS.NONE)
110
+
111
+
112
+ # ---------------------------------------------------------------------------
113
+ # FNV-1a 64-bit
114
+ # ---------------------------------------------------------------------------
115
+
116
+ _FNV_OFFSET = 0xcbf29ce484222325
117
+ _FNV_PRIME = 0x100000001b3
118
+ _U64_MASK = 0xFFFFFFFFFFFFFFFF
119
+
120
+
121
+ def _fnv1a_64(data: str) -> int:
122
+ h = _FNV_OFFSET
123
+ for byte in data.encode("utf-8"):
124
+ h ^= byte
125
+ h = (h * _FNV_PRIME) & _U64_MASK
126
+ return h
127
+
128
+
129
+ # ---------------------------------------------------------------------------
130
+ # LHINT formula (canonical v0.4)
131
+ # ---------------------------------------------------------------------------
132
+
133
+ def compute_lhint(parent_cause_id: str, dom: int, cls: int, gen: int) -> int:
134
+ """
135
+ LHINT = (FNV1a64(parent_cause_id) XOR (dom<<4) XOR (cls<<0) XOR (gen<<2)) & 0b111
136
+ """
137
+ h = _fnv1a_64(parent_cause_id)
138
+ return int((h ^ (dom << 4) ^ (cls << 0) ^ (gen << 2)) & 0b111)
139
+
140
+
141
+ # ---------------------------------------------------------------------------
142
+ # GEN bump rules
143
+ # ---------------------------------------------------------------------------
144
+
145
+ _GEN_BUMP_ACTIONS = {"exec", "priv"}
146
+ _GEN_BUMP_CLASSES = {CLASS.EXEC, CLASS.PRIV}
147
+
148
+
149
+ def should_bump_gen(action: str, cls: int, prev_dom: int, new_dom: int,
150
+ entering_break_glass: bool = False,
151
+ warn_on_mismatch: bool = True) -> bool:
152
+ """Determine if the GEN counter should bump.
153
+
154
+ Both action string and CLASS enum are checked intentionally: the CLASS
155
+ enum is the authoritative source, but the action string provides a
156
+ fallback when the caller hasn't mapped action→class yet. A semantic
157
+ contradiction (e.g. action="open" with CLASS.EXEC) indicates a
158
+ misconfigured caller; the bump is still applied to err on the side of
159
+ caution (new generation = more conservative audit trail).
160
+
161
+ Args:
162
+ warn_on_mismatch: Emit a ``UserWarning`` when the action string and
163
+ ``cls`` disagree. Set to ``False`` when the caller intentionally
164
+ overrides the default class mapping (e.g. an "open" that requires
165
+ elevated privileges and is classified as ``CLASS.PRIV``).
166
+ """
167
+ # Validate action/CLASS consistency
168
+ expected_cls = CLASS.from_action(action)
169
+ if warn_on_mismatch and expected_cls != CLASS.NONE and expected_cls != cls:
170
+ warnings.warn(
171
+ f"Action '{action}' maps to {CLASS.name(expected_cls)} but "
172
+ f"cls={CLASS.name(cls)}; possible misconfiguration "
173
+ f"(bump applied conservatively).",
174
+ stacklevel=2,
175
+ )
176
+ if entering_break_glass:
177
+ return True
178
+ if action.lower() in _GEN_BUMP_ACTIONS:
179
+ return True
180
+ if cls in _GEN_BUMP_CLASSES:
181
+ return True
182
+ if prev_dom != new_dom:
183
+ return True
184
+ return False
185
+
186
+
187
+ # ---------------------------------------------------------------------------
188
+ # Main CTAG computation
189
+ # ---------------------------------------------------------------------------
190
+
191
+ def compute_ctag(
192
+ dom: int,
193
+ cls: int,
194
+ gen: int,
195
+ parent_cause_id: Optional[str],
196
+ seal: bool = False,
197
+ ) -> int:
198
+ """
199
+ Compute the 16-bit CTAG value.
200
+
201
+ If parent_cause_id is None (root event), LHINT is 0.
202
+ """
203
+ if parent_cause_id is not None:
204
+ lhint = compute_lhint(parent_cause_id, dom, cls, gen)
205
+ else:
206
+ lhint = 0
207
+
208
+ seal_bit = 1 if seal else 0
209
+
210
+ ctag = (
211
+ ((dom & 0xF) << 12) |
212
+ ((cls & 0xF) << 8) |
213
+ ((gen & 0xF) << 4) |
214
+ ((lhint & 0x7) << 1) |
215
+ seal_bit
216
+ )
217
+ return ctag & 0xFFFF
218
+
219
+
220
+ def decode_ctag(ctag: int) -> dict:
221
+ """Decode a 16-bit CTAG value into its components."""
222
+ dom = (ctag >> 12) & 0xF
223
+ cls = (ctag >> 8) & 0xF
224
+ gen = (ctag >> 4) & 0xF
225
+ lhint = (ctag >> 1) & 0x7
226
+ seal = ctag & 0x1
227
+ return {
228
+ "raw": f"0x{ctag:04X}",
229
+ "dom": dom,
230
+ "dom_name": DOM.name(dom),
231
+ "class": cls,
232
+ "class_name": CLASS.name(cls),
233
+ "gen": gen,
234
+ "lhint": lhint,
235
+ "seal": bool(seal),
236
+ }
237
+
238
+
239
+ # ---------------------------------------------------------------------------
240
+ # CTAG propagation helper
241
+ # ---------------------------------------------------------------------------
242
+
243
+ class CTAGState:
244
+ """
245
+ Tracks CTAG state across a causal chain.
246
+ Manages GEN bumping and DOM transitions.
247
+ """
248
+
249
+ def __init__(self, dom: int = DOM.USER, gen: int = 0):
250
+ self.dom = dom
251
+ self.gen = gen
252
+
253
+ def next(
254
+ self,
255
+ action: str,
256
+ cls: int,
257
+ parent_cause_id: Optional[str],
258
+ new_dom: Optional[int] = None,
259
+ seal: bool = False,
260
+ ) -> int:
261
+ target_dom = new_dom if new_dom is not None else self.dom
262
+ if should_bump_gen(action, cls, self.dom, target_dom):
263
+ self.gen = (self.gen + 1) % 16
264
+ self.dom = target_dom
265
+
266
+ seal_auto = cls in (CLASS.OVERRIDE,)
267
+ ctag = compute_ctag(
268
+ dom=self.dom,
269
+ cls=cls,
270
+ gen=self.gen,
271
+ parent_cause_id=parent_cause_id,
272
+ seal=seal or seal_auto,
273
+ )
274
+ return ctag
@@ -0,0 +1,4 @@
1
+ """Experimental CML research helpers.
2
+
3
+ This package is intentionally non-normative. APIs here may change while research concepts mature into stable CML semantics.
4
+ """
@@ -0,0 +1,177 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import re
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from .cause_band_trajectory import is_oscillating, recovered_to_safe, trajectory_direction
9
+
10
+ BAND_RANK = {
11
+ "safe_range": 0,
12
+ "warning_range": 1,
13
+ "danger_range": 2,
14
+ "critical_range": 3,
15
+ }
16
+
17
+ RANGE_DRIFT = "CML-AUDIT-RANGE-DRIFT"
18
+ PERSISTENT_DEVIATION = "CML-AUDIT-RANGE-PERSISTENT_DEVIATION"
19
+ CRITICAL_EXIT = "CML-AUDIT-RANGE-CRITICAL_EXIT"
20
+
21
+ DEFAULT_FIXTURE = Path("benchmarks/experimental/07_range_drift_intent.json")
22
+
23
+
24
+ def resolve_fixture_path(path: Path) -> Path:
25
+ """Resolve fixture path safely, preventing directory traversal and absolute paths.
26
+
27
+ Accepts:
28
+ - Absolute path: rejected (security)
29
+ - Path with .. traversal: rejected (security)
30
+ - Full repo-relative path (e.g., 'benchmarks/experimental/07_*.json'): used as-is if exists
31
+ - Filename only (e.g., '07_range_drift_intent.json'): resolved within benchmarks/experimental/
32
+ """
33
+ base_dir = DEFAULT_FIXTURE.parent.resolve()
34
+
35
+ if path.is_absolute():
36
+ raise SystemExit(f"Fixture path not allowed: {path}")
37
+ if any(part == ".." for part in path.parts):
38
+ raise SystemExit(f"Fixture path not allowed: {path}")
39
+
40
+ # Try to use path as-is first (handles repo-relative full paths)
41
+ candidate = Path(path)
42
+ if candidate.exists():
43
+ resolved = candidate.resolve()
44
+ # Verify it's still within or under the safety zone
45
+ try:
46
+ resolved.relative_to(base_dir.parent) # Allow within repo
47
+ except ValueError as exc:
48
+ raise SystemExit(f"Fixture path not allowed: {path}") from exc
49
+ return resolved
50
+
51
+ # Fall back to treating it as a filename within base_dir
52
+ candidate = base_dir / path.name
53
+ if not candidate.exists():
54
+ raise SystemExit(f"Fixture not found: {candidate}")
55
+
56
+ resolved = candidate.resolve()
57
+ try:
58
+ resolved.relative_to(base_dir)
59
+ except ValueError as exc:
60
+ raise SystemExit(f"Fixture path not allowed: {path}") from exc
61
+ return resolved
62
+
63
+
64
+ def parse_duration_threshold(raw: Any, default: int = 3) -> int:
65
+ if isinstance(raw, int) and raw > 0:
66
+ return raw
67
+ if isinstance(raw, str):
68
+ match = re.match(r"\s*(\d+)", raw)
69
+ if match:
70
+ value = int(match.group(1))
71
+ if value > 0:
72
+ return value
73
+ return default
74
+
75
+
76
+ def load_fixture(path: Path) -> dict[str, Any]:
77
+ safe_path = resolve_fixture_path(path)
78
+ try:
79
+ raw = json.loads(safe_path.read_text(encoding="utf-8"))
80
+ except FileNotFoundError as exc:
81
+ raise SystemExit(f"Fixture not found: {safe_path}") from exc
82
+ except json.JSONDecodeError as exc:
83
+ raise SystemExit(f"Invalid JSON in {safe_path}: {exc}") from exc
84
+ if not isinstance(raw, dict):
85
+ raise SystemExit(f"Fixture must be a JSON object: {safe_path}")
86
+ return raw
87
+
88
+
89
+ def max_consecutive_outside_safe(ranks: list[int]) -> int:
90
+ current = 0
91
+ best = 0
92
+ for rank in ranks:
93
+ if rank > BAND_RANK["safe_range"]:
94
+ current += 1
95
+ best = max(best, current)
96
+ else:
97
+ current = 0
98
+ return best
99
+
100
+
101
+ def evaluate_fixture(raw: dict[str, Any]) -> dict[str, Any]:
102
+ trajectory = raw.get("trajectory")
103
+ if not isinstance(trajectory, list) or not trajectory:
104
+ raise SystemExit("Fixture must contain a non-empty trajectory list")
105
+
106
+ bands: list[str] = []
107
+ ranks: list[int] = []
108
+ invalid_bands: list[str] = []
109
+
110
+ for step in trajectory:
111
+ if not isinstance(step, dict):
112
+ raise SystemExit("Each trajectory step must be an object")
113
+ band = step.get("band")
114
+ if not isinstance(band, str):
115
+ raise SystemExit("Each trajectory step must contain a string band")
116
+ bands.append(band)
117
+ if band not in BAND_RANK:
118
+ invalid_bands.append(band)
119
+ continue
120
+ ranks.append(BAND_RANK[band])
121
+
122
+ if invalid_bands:
123
+ raise SystemExit(f"Unknown Cause Band values: {sorted(set(invalid_bands))}")
124
+
125
+ policy = raw.get("cause_band_policy") if isinstance(raw.get("cause_band_policy"), dict) else {}
126
+ duration_threshold = parse_duration_threshold(policy.get("duration_threshold"), default=3)
127
+
128
+ findings: list[str] = []
129
+ if any(rank >= BAND_RANK["warning_range"] for rank in ranks):
130
+ findings.append(RANGE_DRIFT)
131
+
132
+ max_consecutive = max_consecutive_outside_safe(ranks)
133
+ if max_consecutive >= duration_threshold:
134
+ findings.append(PERSISTENT_DEVIATION)
135
+
136
+ if any(rank >= BAND_RANK["critical_range"] for rank in ranks):
137
+ findings.append(CRITICAL_EXIT)
138
+
139
+ expected_future = raw.get("expected_future_cause_band_behavior")
140
+ expected_codes: list[str] = []
141
+ if isinstance(expected_future, dict) and isinstance(expected_future.get("expected_codes"), list):
142
+ expected_codes = sorted(str(code) for code in expected_future["expected_codes"])
143
+
144
+ predicted_codes = sorted(findings)
145
+ return {
146
+ "case_id": raw.get("case_id"),
147
+ "status": raw.get("status", "experimental"),
148
+ "duration_threshold": duration_threshold,
149
+ "bands": bands,
150
+ "trajectory_direction": trajectory_direction(ranks),
151
+ "recovered_to_safe": recovered_to_safe(ranks),
152
+ "oscillating": is_oscillating(ranks),
153
+ "max_consecutive_outside_safe": max_consecutive,
154
+ "predicted_codes": predicted_codes,
155
+ "expected_future_codes": expected_codes,
156
+ "matches_expected_future": predicted_codes == expected_codes if expected_codes else None,
157
+ }
158
+
159
+
160
+ def render_text(result: dict[str, Any]) -> str:
161
+ expected = result["expected_future_codes"] or ["<none>"]
162
+ lines = [
163
+ "Experimental Cause Band evaluation",
164
+ f"case_id={result['case_id']}",
165
+ f"status={result['status']}",
166
+ f"duration_threshold={result['duration_threshold']}",
167
+ f"bands={' -> '.join(result['bands'])}",
168
+ f"trajectory_direction={result['trajectory_direction']}",
169
+ f"recovered_to_safe={result['recovered_to_safe']}",
170
+ f"oscillating={result['oscillating']}",
171
+ f"max_consecutive_outside_safe={result['max_consecutive_outside_safe']}",
172
+ f"predicted_codes={','.join(result['predicted_codes']) or '-'}",
173
+ f"expected_future_codes={','.join(expected)}",
174
+ ]
175
+ if result["matches_expected_future"] is not None:
176
+ lines.append(f"matches_expected_future={result['matches_expected_future']}")
177
+ return "\n".join(lines)
@@ -0,0 +1,16 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+
6
+ def extract_fixture_payload(raw: dict[str, Any]) -> dict[str, Any]:
7
+ """Return a Cause Band fixture from either a fixture file or example sidecar.
8
+
9
+ Some demo files wrap the experimental Cause Band fixture under
10
+ `cause_band_sidecar` so the same JSON can describe a broader agent trace
11
+ and still carry a runnable Cause Band payload.
12
+ """
13
+ sidecar = raw.get("cause_band_sidecar")
14
+ if isinstance(sidecar, dict):
15
+ return sidecar
16
+ return raw
@@ -0,0 +1,47 @@
1
+ from __future__ import annotations
2
+
3
+ SAFE_RANK = 0
4
+
5
+
6
+ def recovered_to_safe(ranks: list[int]) -> bool:
7
+ seen_non_safe = False
8
+ for rank in ranks:
9
+ if rank > SAFE_RANK:
10
+ seen_non_safe = True
11
+ elif seen_non_safe and rank == SAFE_RANK:
12
+ return True
13
+ return False
14
+
15
+
16
+ def is_oscillating(ranks: list[int]) -> bool:
17
+ seen_non_safe = False
18
+ seen_recovery = False
19
+ for rank in ranks:
20
+ if rank > SAFE_RANK:
21
+ if seen_recovery:
22
+ return True
23
+ seen_non_safe = True
24
+ elif seen_non_safe and rank == SAFE_RANK:
25
+ seen_recovery = True
26
+ return False
27
+
28
+
29
+ def trajectory_direction(ranks: list[int]) -> str:
30
+ if len(ranks) < 2:
31
+ return "stable"
32
+ if is_oscillating(ranks):
33
+ return "oscillating"
34
+
35
+ deltas = [right - left for left, right in zip(ranks, ranks[1:])]
36
+ has_up = any(delta > 0 for delta in deltas)
37
+ has_down = any(delta < 0 for delta in deltas)
38
+
39
+ if has_up and not has_down:
40
+ return "degrading"
41
+ if has_down and not has_up:
42
+ return "recovering"
43
+ if has_up and has_down:
44
+ if ranks[-1] == SAFE_RANK:
45
+ return "recovering"
46
+ return "mixed"
47
+ return "stable"
@@ -0,0 +1 @@
1
+ """Optional CML integrations."""
@@ -0,0 +1 @@
1
+ """MCP integration for CML agent-audit tools."""
@@ -0,0 +1,55 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from cml import AuditEngine
6
+ from cml.experimental.cause_band import evaluate_fixture
7
+ from cml.experimental.cause_band_payload import extract_fixture_payload
8
+ from cml.record import CausalRecord
9
+
10
+
11
+ def health() -> dict[str, Any]:
12
+ return {
13
+ "service": "cml-agent-audit-mcp",
14
+ "status": "ok",
15
+ "tools": [
16
+ "health",
17
+ "audit_trace",
18
+ "evaluate_cause_band",
19
+ ],
20
+ "scope": "experimental MCP integration skeleton",
21
+ }
22
+
23
+
24
+ def _records_from_payload(payload: Any) -> list[CausalRecord]:
25
+ if isinstance(payload, dict):
26
+ if isinstance(payload.get("records"), list):
27
+ raw_records = payload["records"]
28
+ elif isinstance(payload.get("agent_trace"), list):
29
+ raw_records = payload["agent_trace"]
30
+ else:
31
+ raise ValueError("Trace payload must contain a 'records' or 'agent_trace' list")
32
+ elif isinstance(payload, list):
33
+ raw_records = payload
34
+ else:
35
+ raise ValueError("Trace payload must be a list or object")
36
+
37
+ records: list[CausalRecord] = []
38
+ for index, raw in enumerate(raw_records):
39
+ if not isinstance(raw, dict):
40
+ raise ValueError(f"Trace record at index {index} must be an object")
41
+ records.append(CausalRecord.from_dict(raw))
42
+ return records
43
+
44
+
45
+ def audit_trace(payload: Any) -> dict[str, Any]:
46
+ """Run the core CML audit engine over JSON-compatible records."""
47
+ records = _records_from_payload(payload)
48
+ return AuditEngine().run(records).to_dict()
49
+
50
+
51
+ def evaluate_cause_band(payload: dict[str, Any]) -> dict[str, Any]:
52
+ """Evaluate a Cause Band fixture or an example containing cause_band_sidecar."""
53
+ if not isinstance(payload, dict):
54
+ raise ValueError("Cause Band payload must be an object")
55
+ return evaluate_fixture(extract_fixture_payload(payload))
@@ -0,0 +1,57 @@
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+ from typing import Any
5
+
6
+ from . import core
7
+
8
+ _MISSING_MCP_EXTRA_MESSAGE = """\
9
+ The CML MCP server requires the optional MCP dependency.
10
+
11
+ Install CML with MCP support:
12
+
13
+ pip install "causal-memory-layer[mcp]"
14
+
15
+ For local development from the repository root:
16
+
17
+ pip install -e ".[mcp]"
18
+ """
19
+
20
+ try:
21
+ from mcp.server.fastmcp import FastMCP
22
+ except ModuleNotFoundError as exc:
23
+ if exc.name != "mcp":
24
+ raise
25
+ FastMCP = None # type: ignore[assignment]
26
+
27
+
28
+ if FastMCP is not None:
29
+ mcp = FastMCP("cml-agent-audit")
30
+
31
+ @mcp.tool()
32
+ def health() -> dict[str, Any]:
33
+ """Return basic CML MCP integration status."""
34
+ return core.health()
35
+
36
+ @mcp.tool()
37
+ def audit_trace(payload: Any) -> dict[str, Any]:
38
+ """Audit JSON-compatible CML records or an agent_trace payload."""
39
+ return core.audit_trace(payload)
40
+
41
+ @mcp.tool()
42
+ def evaluate_cause_band(payload: dict[str, Any]) -> dict[str, Any]:
43
+ """Evaluate a Cause Band fixture or payload containing cause_band_sidecar."""
44
+ return core.evaluate_cause_band(payload)
45
+ else:
46
+ mcp = None
47
+
48
+
49
+ def main() -> None:
50
+ if mcp is None:
51
+ print(_MISSING_MCP_EXTRA_MESSAGE, file=sys.stderr)
52
+ raise SystemExit(2)
53
+ mcp.run()
54
+
55
+
56
+ if __name__ == "__main__":
57
+ main()