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.
- api/__init__.py +1 -0
- api/server.py +515 -0
- api/store.py +189 -0
- causal_memory_layer-0.4.0.dist-info/METADATA +303 -0
- causal_memory_layer-0.4.0.dist-info/RECORD +29 -0
- causal_memory_layer-0.4.0.dist-info/WHEEL +5 -0
- causal_memory_layer-0.4.0.dist-info/entry_points.txt +3 -0
- causal_memory_layer-0.4.0.dist-info/licenses/LICENSE +21 -0
- causal_memory_layer-0.4.0.dist-info/licenses/LICENSE_COMMERCIAL.md +74 -0
- causal_memory_layer-0.4.0.dist-info/top_level.txt +3 -0
- cli/__init__.py +1 -0
- cli/audit.py +83 -0
- cli/chain.py +113 -0
- cli/main.py +141 -0
- cml/__init__.py +50 -0
- cml/audit.py +440 -0
- cml/chain.py +115 -0
- cml/ctag.py +274 -0
- cml/experimental/__init__.py +4 -0
- cml/experimental/cause_band.py +177 -0
- cml/experimental/cause_band_payload.py +16 -0
- cml/experimental/cause_band_trajectory.py +47 -0
- cml/integrations/__init__.py +1 -0
- cml/integrations/mcp/__init__.py +1 -0
- cml/integrations/mcp/core.py +55 -0
- cml/integrations/mcp/server.py +57 -0
- cml/record.py +196 -0
- cml/report.py +132 -0
- cml/safety_eval.py +189 -0
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"
|