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/record.py ADDED
@@ -0,0 +1,196 @@
1
+ """
2
+ cml.record — Causal Record model (vCML FORMAT v0)
3
+
4
+ Defines CausalRecord: the minimal semantic unit of causal memory.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import time
11
+ import uuid
12
+ from dataclasses import dataclass, field, asdict
13
+ from typing import Optional, Union
14
+
15
+
16
+ # ---------------------------------------------------------------------------
17
+ # Action constants (canonical boundary types)
18
+ # ---------------------------------------------------------------------------
19
+
20
+ class Action:
21
+ EXEC = "exec"
22
+ OPEN = "open"
23
+ READ = "read"
24
+ WRITE = "write"
25
+ CONNECT = "connect"
26
+ SEND = "send"
27
+
28
+
29
+ # ---------------------------------------------------------------------------
30
+ # Actor
31
+ # ---------------------------------------------------------------------------
32
+
33
+ @dataclass
34
+ class Actor:
35
+ pid: int
36
+ uid: int
37
+ ppid: Optional[int] = None
38
+ gid: Optional[int] = None
39
+ comm: Optional[str] = None
40
+
41
+ def to_dict(self) -> dict:
42
+ d = {"pid": self.pid, "uid": self.uid}
43
+ if self.ppid is not None:
44
+ d["ppid"] = self.ppid
45
+ if self.gid is not None:
46
+ d["gid"] = self.gid
47
+ if self.comm is not None:
48
+ d["comm"] = self.comm
49
+ return d
50
+
51
+ @staticmethod
52
+ def from_dict(d: dict) -> "Actor":
53
+ return Actor(
54
+ pid=d["pid"],
55
+ uid=d["uid"],
56
+ ppid=d.get("ppid"),
57
+ gid=d.get("gid"),
58
+ comm=d.get("comm"),
59
+ )
60
+
61
+
62
+ # ---------------------------------------------------------------------------
63
+ # CausalRecord
64
+ # ---------------------------------------------------------------------------
65
+
66
+ @dataclass
67
+ class CausalRecord:
68
+ """
69
+ The minimal causal record as defined by vCML FORMAT v0.
70
+
71
+ Immutable once created (append-only log semantics).
72
+ """
73
+ id: str
74
+ timestamp: int # nanoseconds
75
+ actor: Actor
76
+ action: str # see Action constants
77
+ object: Union[str, dict] # path, address, fd, etc.
78
+ permitted_by: str # semantic permission reference
79
+ parent_cause: Optional[str] = None # id of parent causal record
80
+ ctag: Optional[int] = None # 16-bit CTAG (v0.4+)
81
+ integrity: Optional[str] = None # hash/sig placeholder (v0.5+)
82
+
83
+ # ------------------------------------------------------------------
84
+ # Convenience
85
+ # ------------------------------------------------------------------
86
+
87
+ @staticmethod
88
+ def new(
89
+ actor: Actor,
90
+ action: str,
91
+ object_: Union[str, dict],
92
+ permitted_by: str,
93
+ parent_cause: Optional[str] = None,
94
+ ctag: Optional[int] = None,
95
+ ) -> "CausalRecord":
96
+ return CausalRecord(
97
+ id=str(uuid.uuid4()),
98
+ timestamp=time.time_ns(),
99
+ actor=actor,
100
+ action=action,
101
+ object=object_,
102
+ permitted_by=permitted_by,
103
+ parent_cause=parent_cause,
104
+ ctag=ctag,
105
+ )
106
+
107
+ # ------------------------------------------------------------------
108
+ # Serialization
109
+ # ------------------------------------------------------------------
110
+
111
+ def to_dict(self) -> dict:
112
+ d: dict = {
113
+ "id": self.id,
114
+ "timestamp": self.timestamp,
115
+ "actor": self.actor.to_dict(),
116
+ "action": self.action,
117
+ "object": self.object,
118
+ "permitted_by": self.permitted_by,
119
+ "parent_cause": self.parent_cause,
120
+ }
121
+ if self.ctag is not None:
122
+ d["ctag"] = self.ctag
123
+ if self.integrity is not None:
124
+ d["integrity"] = self.integrity
125
+ return d
126
+
127
+ def to_jsonl(self) -> str:
128
+ return json.dumps(self.to_dict(), separators=(",", ":"))
129
+
130
+ @staticmethod
131
+ def from_dict(d: dict) -> "CausalRecord":
132
+ required = ("id", "timestamp", "actor", "action", "object", "permitted_by")
133
+ missing = [k for k in required if k not in d]
134
+ if missing:
135
+ raise ValueError(
136
+ f"CausalRecord missing required field(s): {', '.join(missing)}"
137
+ )
138
+ actor_raw = d["actor"]
139
+ if not isinstance(actor_raw, dict):
140
+ raise ValueError(f"'actor' must be a dict, got {type(actor_raw).__name__}")
141
+ try:
142
+ actor = Actor.from_dict(actor_raw)
143
+ except KeyError as e:
144
+ raise ValueError(f"'actor' missing required field: {e.args[0]}") from e
145
+ return CausalRecord(
146
+ id=d["id"],
147
+ timestamp=d["timestamp"],
148
+ actor=actor,
149
+ action=d["action"],
150
+ object=d["object"],
151
+ permitted_by=d["permitted_by"],
152
+ parent_cause=d.get("parent_cause"),
153
+ ctag=d.get("ctag"),
154
+ integrity=d.get("integrity"),
155
+ )
156
+
157
+ @staticmethod
158
+ def from_json(line: str) -> "CausalRecord":
159
+ return CausalRecord.from_dict(json.loads(line))
160
+
161
+ # ------------------------------------------------------------------
162
+ # Semantic helpers
163
+ # ------------------------------------------------------------------
164
+
165
+ def is_root(self, prefix: str = "root_event:") -> bool:
166
+ """True if this record is an explicit root event.
167
+
168
+ Uses the default root_event_prefix ("root_event:"). If your audit
169
+ pipeline uses a custom AuditConfig.root_event_prefix, pass it here:
170
+ record.is_root(prefix=config.root_event_prefix)
171
+ The audit engine always calls cfg.is_root(record) instead, which
172
+ already respects the configured prefix.
173
+ """
174
+ return (
175
+ self.parent_cause is None
176
+ and isinstance(self.permitted_by, str)
177
+ and self.permitted_by.startswith(prefix)
178
+ )
179
+
180
+
181
+ # ---------------------------------------------------------------------------
182
+ # Log loader
183
+ # ---------------------------------------------------------------------------
184
+
185
+ def load_jsonl(path: str) -> list[CausalRecord]:
186
+ records = []
187
+ with open(path, "r") as f:
188
+ for line in f:
189
+ line = line.strip()
190
+ if line:
191
+ records.append(CausalRecord.from_json(line))
192
+ return records
193
+
194
+
195
+ def records_to_index(records: list[CausalRecord]) -> dict[str, CausalRecord]:
196
+ return {r.id: r for r in records}
cml/report.py ADDED
@@ -0,0 +1,132 @@
1
+ """
2
+ cml.report — Audit report generation
3
+
4
+ Produces human-readable (Markdown) and machine-readable (JSON) reports
5
+ from AuditResult objects.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ from datetime import datetime, timezone
12
+ from typing import Optional
13
+
14
+ from .audit import AuditResult, Finding, Severity
15
+ from .record import CausalRecord
16
+ from .chain import reconstruct_chain
17
+
18
+
19
+ # ---------------------------------------------------------------------------
20
+ # JSON report
21
+ # ---------------------------------------------------------------------------
22
+
23
+ def to_json(result: AuditResult, indent: int = 2) -> str:
24
+ return json.dumps(result.to_dict(), indent=indent)
25
+
26
+
27
+ # ---------------------------------------------------------------------------
28
+ # Markdown report
29
+ # ---------------------------------------------------------------------------
30
+
31
+ _SEV_EMOJI = {
32
+ Severity.OK: "✅",
33
+ Severity.WARN: "⚠️",
34
+ Severity.FAIL: "🔴",
35
+ }
36
+
37
+
38
+ def to_markdown(
39
+ result: AuditResult,
40
+ title: str = "vCML Audit Report",
41
+ log_path: Optional[str] = None,
42
+ index: Optional[dict[str, CausalRecord]] = None,
43
+ ) -> str:
44
+ now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
45
+ lines = [
46
+ f"# {title}",
47
+ "",
48
+ f"**Generated:** {now}",
49
+ ]
50
+ if log_path:
51
+ lines.append(f"**Log:** `{log_path}`")
52
+ lines += [
53
+ "",
54
+ "---",
55
+ "",
56
+ "## Summary",
57
+ "",
58
+ f"| Metric | Value |",
59
+ f"|--------|-------|",
60
+ f"| Total records | {result.total} |",
61
+ f"| Failures | {result.failures} |",
62
+ f"| Warnings | {result.warnings} |",
63
+ f"| Overall | {'**PASS**' if result.passed() else '**FAIL**'} |",
64
+ "",
65
+ ]
66
+
67
+ if not result.findings:
68
+ lines += ["## Findings", "", "_No issues found._", ""]
69
+ return "\n".join(lines)
70
+
71
+ # Group by severity
72
+ fails = [f for f in result.findings if f.severity == Severity.FAIL]
73
+ warns = [f for f in result.findings if f.severity == Severity.WARN]
74
+
75
+ if fails:
76
+ lines += ["## Failures", ""]
77
+ for f in fails:
78
+ lines += _finding_block(f, index)
79
+
80
+ if warns:
81
+ lines += ["## Warnings", ""]
82
+ for f in warns:
83
+ lines += _finding_block(f, index)
84
+
85
+ return "\n".join(lines)
86
+
87
+
88
+ def _finding_block(
89
+ finding: Finding,
90
+ index: Optional[dict[str, CausalRecord]],
91
+ ) -> list[str]:
92
+ emoji = _SEV_EMOJI.get(finding.severity, "")
93
+ lines = [
94
+ f"### {emoji} `{finding.code}`",
95
+ "",
96
+ f"- **Record:** `{finding.record_id}`",
97
+ f"- **Message:** {finding.message}",
98
+ ]
99
+
100
+ if finding.chain_ids:
101
+ lines.append(f"- **Related records:** {', '.join(f'`{i}`' for i in finding.chain_ids)}")
102
+
103
+ if index and finding.record_id in index:
104
+ chain = reconstruct_chain(finding.record_id, index)
105
+ if len(chain) > 1:
106
+ lines += ["", "**Reconstructed chain:**", "```"]
107
+ for r in chain:
108
+ arrow = "→ " if r.id != chain[0].id else " "
109
+ lines.append(
110
+ f"{arrow}[{r.id[:8]}] {r.action:8s} "
111
+ f"| permitted_by={r.permitted_by} "
112
+ f"| parent={r.parent_cause or 'null'}"
113
+ )
114
+ lines.append("```")
115
+
116
+ lines.append("")
117
+ return lines
118
+
119
+
120
+ # ---------------------------------------------------------------------------
121
+ # Plain text summary (for terminal)
122
+ # ---------------------------------------------------------------------------
123
+
124
+ def to_text(result: AuditResult) -> str:
125
+ lines = [
126
+ f"CML Audit: {result.total} records | "
127
+ f"FAIL={result.failures} WARN={result.warnings} OK={result.ok}",
128
+ "Status: " + ("PASS" if result.passed() else "FAIL"),
129
+ ]
130
+ for f in result.findings:
131
+ lines.append(f" [{f.severity}] {f.code} @ {f.record_id}: {f.message}")
132
+ return "\n".join(lines)
cml/safety_eval.py ADDED
@@ -0,0 +1,189 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from dataclasses import dataclass, field
5
+ from pathlib import Path
6
+
7
+ from .audit import AuditConfig, AuditEngine
8
+ from .record import CausalRecord
9
+
10
+
11
+ @dataclass
12
+ class SafetyEvalCase:
13
+ case_id: str
14
+ description: str
15
+ expected_passed: bool
16
+ expected_codes: list[str]
17
+ records: list[CausalRecord]
18
+ config: AuditConfig = field(default_factory=AuditConfig)
19
+
20
+
21
+ @dataclass
22
+ class SafetyEvalResult:
23
+ case_id: str
24
+ description: str
25
+ expected_passed: bool
26
+ predicted_passed: bool
27
+ expected_codes: list[str]
28
+ predicted_codes: list[str]
29
+ matched: bool
30
+
31
+
32
+ @dataclass
33
+ class SafetyEvalSummary:
34
+ total_cases: int
35
+ matched_cases: int
36
+ mismatches: int
37
+ expected_passed: int
38
+ expected_failed: int
39
+ predicted_passed: int
40
+ predicted_failed: int
41
+
42
+
43
+ _ALLOWED_KEYS = {"case_id", "description", "expected_passed", "expected_codes", "records", "config"}
44
+
45
+
46
+ def _config_from_raw(raw: dict | None) -> AuditConfig:
47
+ if raw is None:
48
+ return AuditConfig()
49
+ if not isinstance(raw, dict):
50
+ raise ValueError("config must be an object")
51
+ return AuditConfig._apply_raw(AuditConfig(), raw)
52
+
53
+
54
+ def load_safety_eval_cases(fixtures_root: Path) -> list[SafetyEvalCase]:
55
+ fixtures_root = Path(fixtures_root)
56
+ if not fixtures_root.exists():
57
+ raise FileNotFoundError(f"Fixtures directory not found: {fixtures_root}")
58
+
59
+ cases: list[SafetyEvalCase] = []
60
+ seen: set[str] = set()
61
+ for path in sorted(fixtures_root.glob("*.json")):
62
+ raw = json.loads(path.read_text(encoding="utf-8"))
63
+ if not isinstance(raw, dict):
64
+ raise ValueError(f"{path} must contain a JSON object")
65
+ unknown = set(raw) - _ALLOWED_KEYS
66
+ if unknown:
67
+ raise ValueError(f"{path} contains unsupported keys: {sorted(unknown)}")
68
+
69
+ case_id = raw.get("case_id")
70
+ description = raw.get("description")
71
+ expected_passed = raw.get("expected_passed")
72
+ expected_codes = raw.get("expected_codes")
73
+ records = raw.get("records")
74
+
75
+ if not isinstance(case_id, str) or not case_id.strip():
76
+ raise ValueError(f"{path} is missing a valid case_id")
77
+ if case_id in seen:
78
+ raise ValueError(f"Duplicate case_id: {case_id}")
79
+ seen.add(case_id)
80
+ if not isinstance(description, str) or not description.strip():
81
+ raise ValueError(f"{path} is missing a valid description")
82
+ if not isinstance(expected_passed, bool):
83
+ raise ValueError(f"{path} is missing a boolean expected_passed")
84
+ if not isinstance(expected_codes, list) or any(not isinstance(code, str) for code in expected_codes):
85
+ raise ValueError(f"{path} must define expected_codes as a list of strings")
86
+ if not isinstance(records, list) or not records:
87
+ raise ValueError(f"{path} must define a non-empty records list")
88
+
89
+ cases.append(
90
+ SafetyEvalCase(
91
+ case_id=case_id,
92
+ description=description,
93
+ expected_passed=expected_passed,
94
+ expected_codes=sorted(expected_codes),
95
+ records=[CausalRecord.from_dict(item) for item in records],
96
+ config=_config_from_raw(raw.get("config")),
97
+ )
98
+ )
99
+ return cases
100
+
101
+
102
+ def run_safety_eval(fixtures_root: Path) -> tuple[list[SafetyEvalResult], SafetyEvalSummary]:
103
+ cases = load_safety_eval_cases(fixtures_root)
104
+ results: list[SafetyEvalResult] = []
105
+
106
+ expected_passed = 0
107
+ predicted_passed = 0
108
+ matched_cases = 0
109
+
110
+ for case in cases:
111
+ audit_result = AuditEngine(case.config).run(case.records)
112
+ predicted_codes = sorted(f.code for f in audit_result.findings)
113
+ predicted_ok = audit_result.passed()
114
+ matched = predicted_ok == case.expected_passed and predicted_codes == case.expected_codes
115
+ if case.expected_passed:
116
+ expected_passed += 1
117
+ if predicted_ok:
118
+ predicted_passed += 1
119
+ if matched:
120
+ matched_cases += 1
121
+ results.append(
122
+ SafetyEvalResult(
123
+ case_id=case.case_id,
124
+ description=case.description,
125
+ expected_passed=case.expected_passed,
126
+ predicted_passed=predicted_ok,
127
+ expected_codes=case.expected_codes,
128
+ predicted_codes=predicted_codes,
129
+ matched=matched,
130
+ )
131
+ )
132
+
133
+ total_cases = len(cases)
134
+ return results, SafetyEvalSummary(
135
+ total_cases=total_cases,
136
+ matched_cases=matched_cases,
137
+ mismatches=total_cases - matched_cases,
138
+ expected_passed=expected_passed,
139
+ expected_failed=total_cases - expected_passed,
140
+ predicted_passed=predicted_passed,
141
+ predicted_failed=total_cases - predicted_passed,
142
+ )
143
+
144
+
145
+ def render_text_report(results: list[SafetyEvalResult], summary: SafetyEvalSummary) -> str:
146
+ lines = [
147
+ "CML safety-eval benchmark",
148
+ f"total_cases={summary.total_cases} matched={summary.matched_cases} mismatches={summary.mismatches}",
149
+ f"expected_passed={summary.expected_passed} expected_failed={summary.expected_failed}",
150
+ f"predicted_passed={summary.predicted_passed} predicted_failed={summary.predicted_failed}",
151
+ "",
152
+ ]
153
+ for result in results:
154
+ status = "PASS" if result.matched else "FAIL"
155
+ lines.append(
156
+ f"- {result.case_id}: {status} expected_passed={result.expected_passed} predicted_passed={result.predicted_passed} expected_codes={','.join(result.expected_codes) or '-'} predicted_codes={','.join(result.predicted_codes) or '-'}"
157
+ )
158
+ return "\n".join(lines)
159
+
160
+
161
+ def render_markdown_report(results: list[SafetyEvalResult], summary: SafetyEvalSummary) -> str:
162
+ lines = [
163
+ "# CML Safety-Eval Results",
164
+ "",
165
+ "Deterministic benchmark report generated from `benchmarks/fixtures`.",
166
+ "",
167
+ "## Summary",
168
+ "",
169
+ f"- Total cases: **{summary.total_cases}**",
170
+ f"- Matched cases: **{summary.matched_cases}**",
171
+ f"- Mismatches: **{summary.mismatches}**",
172
+ f"- Expected passed / failed: **{summary.expected_passed} / {summary.expected_failed}**",
173
+ f"- Predicted passed / failed: **{summary.predicted_passed} / {summary.predicted_failed}**",
174
+ "",
175
+ "## Per-case results",
176
+ "",
177
+ "| case_id | expected | predicted | status | expected_codes | predicted_codes |",
178
+ "|---|---|---|---|---|---|",
179
+ ]
180
+ for result in results:
181
+ expected = "pass" if result.expected_passed else "fail"
182
+ predicted = "pass" if result.predicted_passed else "fail"
183
+ status = "PASS" if result.matched else "FAIL"
184
+ expected_codes = "<none>" if not result.expected_codes else ", ".join(result.expected_codes)
185
+ predicted_codes = "<none>" if not result.predicted_codes else ", ".join(result.predicted_codes)
186
+ lines.append(
187
+ f"| {result.case_id} | {expected} | {predicted} | {status} | {expected_codes} | {predicted_codes} |"
188
+ )
189
+ return "\n".join(lines) + "\n"