Skip to content

Commit 7d16b74

Browse files
cschanhniemOpenCode
andauthored
feat: Phase V V2 evidence bundle manifest schema (EBM-) -- portable artifact index for external handoff; VALID_MANIFEST_SCHEMA_TYPES (19), VALID_MANIFEST_STATUSES (complete/partial/empty); ManifestEntry (schema_type/artifact_id/description); status=complete when all 19 types present; dry_lab_only=True; 43 tests (#999) (#999)
Co-authored-by: OpenCode <opencode@example.com>
1 parent d1cbc10 commit 7d16b74

3 files changed

Lines changed: 397 additions & 1 deletion

File tree

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
"""EBM- evidence bundle manifest schema.
2+
3+
Portable listing of all evidence artifact IDs and schema types for a batch.
4+
Enables reproducibility checks and external handoff; machine-readable index
5+
a scientist can use to request any artifact.
6+
"""
7+
8+
from __future__ import annotations
9+
10+
from dataclasses import dataclass
11+
12+
VALID_MANIFEST_SCHEMA_TYPES: frozenset[str] = frozenset({
13+
"BSP", "PSC", "BOS", "CPS", "CBA", "CRG",
14+
"RSR", "PQT", "BTI", "CBA2", "BEG", "SAT", "PCC",
15+
"FET", "BRC", "CSV", "PTR", "ECI", "PRG",
16+
})
17+
18+
VALID_MANIFEST_STATUSES: frozenset[str] = frozenset({
19+
"complete", "partial", "empty",
20+
})
21+
22+
23+
@dataclass
24+
class ManifestEntry:
25+
schema_type: str
26+
artifact_id: str
27+
description: str
28+
29+
30+
@dataclass
31+
class EvidenceBundleManifest:
32+
ebm_id: str
33+
batch_id: str
34+
pipeline_version: str
35+
entries: list[ManifestEntry]
36+
n_entries: int
37+
schema_types_included: list[str]
38+
manifest_status: str
39+
dry_lab_only: bool
40+
limitations: list[str]
41+
created_at: str
42+
43+
44+
def _compute_status(n_entries: int) -> str:
45+
if n_entries == 0:
46+
return "empty"
47+
n_required = len(VALID_MANIFEST_SCHEMA_TYPES)
48+
if n_entries >= n_required:
49+
return "complete"
50+
return "partial"
51+
52+
53+
def validate_evidence_bundle_manifest(ebm: EvidenceBundleManifest) -> None:
54+
if not ebm.ebm_id.startswith("EBM-"):
55+
raise ValueError(f"ebm_id must start with 'EBM-': {ebm.ebm_id!r}")
56+
if not ebm.batch_id:
57+
raise ValueError("batch_id must be non-empty")
58+
if not ebm.pipeline_version:
59+
raise ValueError("pipeline_version must be non-empty")
60+
for entry in ebm.entries:
61+
if entry.schema_type not in VALID_MANIFEST_SCHEMA_TYPES:
62+
raise ValueError(
63+
f"schema_type {entry.schema_type!r} not in VALID_MANIFEST_SCHEMA_TYPES"
64+
)
65+
if not entry.artifact_id:
66+
raise ValueError(
67+
f"artifact_id must be non-empty for schema_type {entry.schema_type!r}"
68+
)
69+
if ebm.manifest_status not in VALID_MANIFEST_STATUSES:
70+
raise ValueError(
71+
f"manifest_status {ebm.manifest_status!r} not in VALID_MANIFEST_STATUSES"
72+
)
73+
if not ebm.dry_lab_only:
74+
raise ValueError("dry_lab_only must be True")
75+
if not ebm.limitations:
76+
raise ValueError("limitations must be non-empty")
77+
if not ebm.created_at:
78+
raise ValueError("created_at must be non-empty")
79+
if ebm.n_entries != len(ebm.entries):
80+
raise ValueError("n_entries must equal len(entries)")
81+
expected_types = sorted({e.schema_type for e in ebm.entries})
82+
if ebm.schema_types_included != expected_types:
83+
raise ValueError("schema_types_included mismatch")
84+
85+
86+
def build_evidence_bundle_manifest(
87+
*,
88+
ebm_id: str,
89+
batch_id: str,
90+
pipeline_version: str,
91+
entry_dicts: list[dict],
92+
limitations: list[str],
93+
created_at: str,
94+
) -> EvidenceBundleManifest:
95+
"""Build an evidence bundle manifest.
96+
97+
entry_dicts: list of dicts with keys: schema_type, artifact_id, description.
98+
"""
99+
entries = [
100+
ManifestEntry(
101+
schema_type=d["schema_type"],
102+
artifact_id=d["artifact_id"],
103+
description=d.get("description", ""),
104+
)
105+
for d in entry_dicts
106+
]
107+
schema_types = sorted({e.schema_type for e in entries})
108+
status = _compute_status(len(schema_types))
109+
110+
ebm = EvidenceBundleManifest(
111+
ebm_id=ebm_id,
112+
batch_id=batch_id,
113+
pipeline_version=pipeline_version,
114+
entries=entries,
115+
n_entries=len(entries),
116+
schema_types_included=schema_types,
117+
manifest_status=status,
118+
dry_lab_only=True,
119+
limitations=limitations,
120+
created_at=created_at,
121+
)
122+
validate_evidence_bundle_manifest(ebm)
123+
return ebm
124+
125+
126+
def format_evidence_bundle_manifest(ebm: EvidenceBundleManifest) -> str:
127+
lines = [
128+
f"Evidence Bundle Manifest — {ebm.ebm_id}",
129+
f"Batch: {ebm.batch_id} | Pipeline: {ebm.pipeline_version}",
130+
f"Status: {ebm.manifest_status} | Entries: {ebm.n_entries}",
131+
f"Schema types: {', '.join(ebm.schema_types_included)}",
132+
]
133+
if ebm.entries:
134+
lines.append("Artifacts:")
135+
for entry in ebm.entries:
136+
desc = f" — {entry.description}" if entry.description else ""
137+
lines.append(f" {entry.schema_type}: {entry.artifact_id}{desc}")
138+
lines.append(f"Created: {ebm.created_at}")
139+
lines.append(f"Limitations: {'; '.join(ebm.limitations)}")
140+
lines.append(f"dry_lab_only: {ebm.dry_lab_only}")
141+
return "\n".join(lines)
Lines changed: 255 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,255 @@
1+
"""Tests for EBM- evidence bundle manifest schema."""
2+
3+
import pytest
4+
from openamp_foundry.evidence.evidence_bundle_manifest import (
5+
EvidenceBundleManifest,
6+
ManifestEntry,
7+
VALID_MANIFEST_SCHEMA_TYPES,
8+
VALID_MANIFEST_STATUSES,
9+
build_evidence_bundle_manifest,
10+
format_evidence_bundle_manifest,
11+
validate_evidence_bundle_manifest,
12+
)
13+
14+
# ---------------------------------------------------------------------------
15+
# Helpers
16+
# ---------------------------------------------------------------------------
17+
18+
_TWO_ENTRIES = [
19+
{"schema_type": "BSP", "artifact_id": "A1", "description": "batch selection"},
20+
{"schema_type": "PCC", "artifact_id": "A2", "description": "completeness cert"},
21+
]
22+
23+
24+
def _build(**kwargs):
25+
defaults = dict(
26+
ebm_id="EBM-001",
27+
batch_id="BATCH-01",
28+
pipeline_version="v1.0",
29+
entry_dicts=_TWO_ENTRIES,
30+
limitations=["dry-lab only"],
31+
created_at="2026-07-10",
32+
)
33+
defaults.update(kwargs)
34+
return build_evidence_bundle_manifest(**defaults)
35+
36+
37+
# ---------------------------------------------------------------------------
38+
# 1. Constants
39+
# ---------------------------------------------------------------------------
40+
41+
42+
def test_valid_manifest_schema_types_is_frozenset():
43+
assert isinstance(VALID_MANIFEST_SCHEMA_TYPES, frozenset)
44+
45+
46+
def test_valid_manifest_schema_types_contains_bsp():
47+
assert "BSP" in VALID_MANIFEST_SCHEMA_TYPES
48+
49+
50+
def test_valid_manifest_schema_types_contains_pcc():
51+
assert "PCC" in VALID_MANIFEST_SCHEMA_TYPES
52+
53+
54+
def test_valid_manifest_schema_types_contains_prg():
55+
assert "PRG" in VALID_MANIFEST_SCHEMA_TYPES
56+
57+
58+
def test_valid_manifest_schema_types_contains_eci():
59+
assert "ECI" in VALID_MANIFEST_SCHEMA_TYPES
60+
61+
62+
def test_valid_manifest_schema_types_count():
63+
assert len(VALID_MANIFEST_SCHEMA_TYPES) == 19
64+
65+
66+
def test_valid_manifest_statuses_is_frozenset():
67+
assert isinstance(VALID_MANIFEST_STATUSES, frozenset)
68+
69+
70+
def test_valid_manifest_statuses_contains_complete():
71+
assert "complete" in VALID_MANIFEST_STATUSES
72+
73+
74+
def test_valid_manifest_statuses_contains_partial():
75+
assert "partial" in VALID_MANIFEST_STATUSES
76+
77+
78+
def test_valid_manifest_statuses_contains_empty():
79+
assert "empty" in VALID_MANIFEST_STATUSES
80+
81+
82+
# ---------------------------------------------------------------------------
83+
# 2. build – happy paths
84+
# ---------------------------------------------------------------------------
85+
86+
87+
def test_build_returns_evidence_bundle_manifest():
88+
assert isinstance(_build(), EvidenceBundleManifest)
89+
90+
91+
def test_build_ebm_id_stored():
92+
assert _build().ebm_id == "EBM-001"
93+
94+
95+
def test_build_batch_id_stored():
96+
assert _build().batch_id == "BATCH-01"
97+
98+
99+
def test_build_pipeline_version_stored():
100+
assert _build().pipeline_version == "v1.0"
101+
102+
103+
def test_build_dry_lab_only_true():
104+
assert _build().dry_lab_only is True
105+
106+
107+
def test_build_n_entries_matches_input():
108+
assert _build().n_entries == 2
109+
110+
111+
def test_build_partial_status_when_subset():
112+
assert _build().manifest_status == "partial"
113+
114+
115+
def test_build_empty_status_when_no_entries():
116+
r = _build(entry_dicts=[])
117+
assert r.manifest_status == "empty"
118+
119+
120+
def test_build_schema_types_included_sorted():
121+
r = _build()
122+
assert r.schema_types_included == sorted(r.schema_types_included)
123+
124+
125+
def test_build_schema_types_deduplicates():
126+
entries = [
127+
{"schema_type": "BSP", "artifact_id": "A1", "description": "d1"},
128+
{"schema_type": "BSP", "artifact_id": "A2", "description": "d2"},
129+
]
130+
r = _build(entry_dicts=entries)
131+
assert r.schema_types_included == ["BSP"]
132+
assert r.n_entries == 2
133+
134+
135+
def test_build_entries_are_manifest_entry():
136+
for e in _build().entries:
137+
assert isinstance(e, ManifestEntry)
138+
139+
140+
def test_build_entry_artifact_id_stored():
141+
r = _build()
142+
assert r.entries[0].artifact_id == "A1"
143+
144+
145+
def test_build_entry_description_stored():
146+
r = _build()
147+
assert r.entries[0].description == "batch selection"
148+
149+
150+
def test_build_empty_description_defaults_to_empty_string():
151+
entries = [{"schema_type": "BSP", "artifact_id": "A1"}]
152+
r = _build(entry_dicts=entries)
153+
assert r.entries[0].description == ""
154+
155+
156+
def test_build_limitations_stored():
157+
assert _build().limitations == ["dry-lab only"]
158+
159+
160+
def test_build_created_at_stored():
161+
assert _build().created_at == "2026-07-10"
162+
163+
164+
def test_build_complete_status_when_all_types_present():
165+
all_entries = [
166+
{"schema_type": t, "artifact_id": f"ART-{t}", "description": ""}
167+
for t in VALID_MANIFEST_SCHEMA_TYPES
168+
]
169+
r = _build(entry_dicts=all_entries)
170+
assert r.manifest_status == "complete"
171+
172+
173+
# ---------------------------------------------------------------------------
174+
# 3. validate – rejection cases
175+
# ---------------------------------------------------------------------------
176+
177+
178+
def test_validate_rejects_bad_ebm_id_prefix():
179+
with pytest.raises(ValueError, match="EBM-"):
180+
_build(ebm_id="BAD-001")
181+
182+
183+
def test_validate_rejects_empty_batch_id():
184+
with pytest.raises(ValueError):
185+
_build(batch_id="")
186+
187+
188+
def test_validate_rejects_empty_pipeline_version():
189+
with pytest.raises(ValueError):
190+
_build(pipeline_version="")
191+
192+
193+
def test_validate_rejects_unknown_schema_type():
194+
entries = [{"schema_type": "UNKNOWN", "artifact_id": "A1", "description": "x"}]
195+
with pytest.raises(ValueError, match="VALID_MANIFEST_SCHEMA_TYPES"):
196+
_build(entry_dicts=entries)
197+
198+
199+
def test_validate_rejects_empty_artifact_id():
200+
entries = [{"schema_type": "BSP", "artifact_id": "", "description": "x"}]
201+
with pytest.raises(ValueError, match="artifact_id"):
202+
_build(entry_dicts=entries)
203+
204+
205+
def test_validate_rejects_empty_limitations():
206+
with pytest.raises(ValueError, match="limitations"):
207+
_build(limitations=[])
208+
209+
210+
def test_validate_rejects_empty_created_at():
211+
with pytest.raises(ValueError):
212+
_build(created_at="")
213+
214+
215+
# ---------------------------------------------------------------------------
216+
# 4. format
217+
# ---------------------------------------------------------------------------
218+
219+
220+
def test_format_contains_ebm_id():
221+
assert "EBM-001" in format_evidence_bundle_manifest(_build())
222+
223+
224+
def test_format_contains_batch_id():
225+
assert "BATCH-01" in format_evidence_bundle_manifest(_build())
226+
227+
228+
def test_format_contains_status():
229+
assert "partial" in format_evidence_bundle_manifest(_build())
230+
231+
232+
def test_format_contains_schema_types():
233+
formatted = format_evidence_bundle_manifest(_build())
234+
assert "BSP" in formatted
235+
assert "PCC" in formatted
236+
237+
238+
def test_format_contains_artifact_ids():
239+
assert "A1" in format_evidence_bundle_manifest(_build())
240+
241+
242+
def test_format_contains_description():
243+
assert "batch selection" in format_evidence_bundle_manifest(_build())
244+
245+
246+
def test_format_contains_limitations():
247+
assert "dry-lab only" in format_evidence_bundle_manifest(_build())
248+
249+
250+
def test_format_contains_dry_lab_only():
251+
assert "dry_lab_only: True" in format_evidence_bundle_manifest(_build())
252+
253+
254+
def test_format_is_string():
255+
assert isinstance(format_evidence_bundle_manifest(_build()), str)

0 commit comments

Comments
 (0)