From dec04f4b1bd594f126dccad6cb6f3a7ec771b7b3 Mon Sep 17 00:00:00 2001 From: Krivoblotsky Date: Mon, 14 Sep 2026 11:18:49 +0300 Subject: [PATCH] test: format-1.1 cross-SDK fixture + coverage/scopes/digest tests; mem inspect shows 1.1 fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Conformance/fixtures/sample-1.1.mem (+ generate_sample_1_1.py): a format-1.1 bundle with every manifest field (specURL, coverage, scopes, bundleDigest) and three scoped contexts; both SDKs validate it (recomputing the digest), import it, and re-export byte-identical streams + CHECKSUMS. sample.mem stays at 1.0 as the backward-compatibility fixture. - tests/test_fixture_1_1.py: validates, hash-checks, imports/re-exports, and guards the fixture against drift by rebuilding it from the generator. - tests/test_manifest_v11.py: evidence-pack digest, incremental scopes (delta episodes ∪ all contexts), forward compatibility with unknown manifest keys, seeded property test (coverage == min/max eventTime, scopes sorted+unique incl. a non-ASCII id, digest == sha256(CHECKSUMS)). - cli: 'mem inspect' prints spec / coverage / scopes / digest; CLI test incl. a tampered CHECKSUMS caught via bundleDigest. 89 passed. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 5 ++ Conformance/README.md | 7 ++ Conformance/fixtures/generate_sample_1_1.py | 69 ++++++++++++++++ Conformance/fixtures/sample-1.1.mem/CHECKSUMS | 2 + .../sample-1.1.mem/items/context.jsonl | 3 + .../sample-1.1.mem/items/episode.jsonl | 11 +++ .../fixtures/sample-1.1.mem/manifest.json | 1 + portable_memory/cli.py | 9 +++ tests/test_cli.py | 19 +++++ tests/test_fixture_1_1.py | 80 +++++++++++++++++++ tests/test_manifest_v11.py | 64 +++++++++++++++ 11 files changed, 270 insertions(+) create mode 100644 Conformance/fixtures/generate_sample_1_1.py create mode 100644 Conformance/fixtures/sample-1.1.mem/CHECKSUMS create mode 100644 Conformance/fixtures/sample-1.1.mem/items/context.jsonl create mode 100644 Conformance/fixtures/sample-1.1.mem/items/episode.jsonl create mode 100644 Conformance/fixtures/sample-1.1.mem/manifest.json create mode 100644 tests/test_fixture_1_1.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 0322a9c..0f7354c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,11 @@ independent of the on-disk **format** version (`format` in the manifest). recomputes `bundleDigest` when present. 1.0 bundles remain valid — the shipped 1.0 fixtures double as backward-compatibility tests. `MemCoverage` is exported from the package. +- **`mem inspect`** prints the 1.1 summaries — spec URL, coverage, scopes, archive digest. +- **`Conformance/fixtures/sample-1.1.mem`** — a format-1.1 cross-SDK fixture (Python-written; + both SDKs validate it, recompute its `bundleDigest`, import it, and re-export + byte-identical streams). Tests: evidence-pack digest, incremental `scopes`, forward + compatibility with unknown manifest keys, seeded coverage/scopes property tests. ## [0.2.0] - 2026-09-14 diff --git a/Conformance/README.md b/Conformance/README.md index 4a6a89a..e051c24 100644 --- a/Conformance/README.md +++ b/Conformance/README.md @@ -63,6 +63,13 @@ standard export prompt) and the exact `items/episode.jsonl` bytes both reference must derive from it. An implementation of the adapter in another language is parity- complete when it reproduces that file. See [`fixtures/transfer/README.md`](fixtures/transfer/README.md). +`fixtures/sample-1.1.mem/` is a **format 1.1** bundle written by the Python SDK: every 1.1 +manifest field is present (`specURL`, `coverage`, `scopes`, `bundleDigest`) and three +scoped `context` records exercise the `scopes` union. Both SDKs validate it — recomputing +the digest the other SDK wrote — import it, and re-export byte-identical streams and +`CHECKSUMS`. `fixtures/sample.mem/` stays at format 1.0 as the backward-compatibility +fixture. Regenerate with `fixtures/generate_sample_1_1.py`. + ## Vectors `vectors/` holds the language-neutral conformance oracle — `canonical-json.json` diff --git a/Conformance/fixtures/generate_sample_1_1.py b/Conformance/fixtures/generate_sample_1_1.py new file mode 100644 index 0000000..ebe6d0e --- /dev/null +++ b/Conformance/fixtures/generate_sample_1_1.py @@ -0,0 +1,69 @@ +#!/usr/bin/env python3 +"""Regenerate ``Conformance/fixtures/sample-1.1.mem`` — the format-1.1 cross-SDK fixture. + + python3 Conformance/fixtures/generate_sample_1_1.py + +Content (deterministic): the eleven episodes of the transfer-text fixture parsed with +``now = 1700000000`` and ``source = "chatgpt"``, three of them scoped to contexts by their +section, plus three ``context`` records (one referenced only by a context record, so the +manifest's ``scopes`` proves it unions episode ``contextID``s with exported contexts). The +manifest therefore carries every 1.1 field: ``specURL``, ``coverage``, ``scopes`` and +``bundleDigest``. ``createdAt`` is the generation time and is not asserted by tests. + +Both reference SDKs must validate the bundle (recomputing the Python-written +``bundleDigest``), import it, and re-export byte-identical streams + CHECKSUMS. Copy the +regenerated directory to the sibling repository so the fixtures stay byte-identical. +""" +from __future__ import annotations + +import shutil +import sys +from datetime import datetime, timezone +from pathlib import Path + +HERE = Path(__file__).resolve().parent +sys.path.insert(0, str(HERE.parents[1])) + +from portable_memory import BundleExporter, PortableContext # noqa: E402 +from portable_memory.adapters.transfer import TransferTextAdapter # noqa: E402 +from portable_memory.inmemory import InMemoryStore # noqa: E402 + +FIXED_NOW = datetime.fromtimestamp(1_700_000_000, tz=timezone.utc) # 2023-11-14T22:13:20Z +SECTION_TO_CONTEXT = {"Communication preferences": "ctx_comms", "INSTRUCTIONS": "ctx_instructions"} + + +class FixtureStore(InMemoryStore): + def __init__(self) -> None: + super().__init__(generator="portable-memory-fixture/1.1") + self.contexts: list[PortableContext] = [] + + def export_contexts(self) -> list[PortableContext]: + return sorted(self.contexts, key=lambda c: c.id) + + +def build_store() -> FixtureStore: + store = FixtureStore() + text = (HERE / "transfer" / "sample-export.txt").read_text(encoding="utf-8") + for e in TransferTextAdapter.parse_episodes(text, source="chatgpt", now=FIXED_NOW): + e.context_id = SECTION_TO_CONTEXT.get(e.metadata.get("transfer_section", "")) + store.import_episode(e, None) + for cid, label, parent in ( + ("ctx_root", "Personal", None), + ("ctx_comms", "Communication preferences", "ctx_root"), + ("ctx_instructions", "Instructions", "ctx_root"), + ): + store.contexts.append(PortableContext(id=cid, label=label, archived=False, created_at=FIXED_NOW, parent_id=parent)) + return store + + +def main() -> None: + out = HERE / "sample-1.1.mem" + if out.exists(): + shutil.rmtree(out) + m = BundleExporter().export(build_store(), out) + print(f"wrote {out.name}: format {m.format}, counts {m.counts}, scopes {m.scopes}, " + f"coverage {m.coverage.from_.isoformat()} → {m.coverage.to.isoformat()}, digest {m.bundle_digest[:16]}…") + + +if __name__ == "__main__": + main() diff --git a/Conformance/fixtures/sample-1.1.mem/CHECKSUMS b/Conformance/fixtures/sample-1.1.mem/CHECKSUMS new file mode 100644 index 0000000..8ab080b --- /dev/null +++ b/Conformance/fixtures/sample-1.1.mem/CHECKSUMS @@ -0,0 +1,2 @@ +ad795d191c686a6a8afdf4ff0586fdffa3265ccdaf1887bf40da2bd826888370 items/context.jsonl +663704e69bdaa1d9a4b697c3efa99ebf3a44a695c105988a745915b5f725b92f items/episode.jsonl diff --git a/Conformance/fixtures/sample-1.1.mem/items/context.jsonl b/Conformance/fixtures/sample-1.1.mem/items/context.jsonl new file mode 100644 index 0000000..f075032 --- /dev/null +++ b/Conformance/fixtures/sample-1.1.mem/items/context.jsonl @@ -0,0 +1,3 @@ +{"archived":false,"createdAt":"2023-11-14T22:13:20Z","id":"ctx_comms","label":"Communication preferences","parentID":"ctx_root"} +{"archived":false,"createdAt":"2023-11-14T22:13:20Z","id":"ctx_instructions","label":"Instructions","parentID":"ctx_root"} +{"archived":false,"createdAt":"2023-11-14T22:13:20Z","id":"ctx_root","label":"Personal"} diff --git a/Conformance/fixtures/sample-1.1.mem/items/episode.jsonl b/Conformance/fixtures/sample-1.1.mem/items/episode.jsonl new file mode 100644 index 0000000..231623b --- /dev/null +++ b/Conformance/fixtures/sample-1.1.mem/items/episode.jsonl @@ -0,0 +1,11 @@ +{"accessCount":0,"actors":[],"categories":[],"confidence":0.7,"details":"Started learning Rust for a side project.","eventTime":"2026-02-02T00:00:00Z","extractionState":"done","id":"tx_34ae29f0cbd63418a690fea2","importance":0.5,"ingestionTime":"2023-11-14T22:13:20Z","lifecycleState":"HOT","mentionTime":"2026-02-02T00:00:00Z","metadata":{"transfer_date_raw":"2026-02-02","transfer_line":"8","transfer_source":"chatgpt"},"pinned":false,"sensitivity":"low","sourceType":"note","summary":"Started learning Rust for a side project.","vaultRefs":[]} +{"accessCount":0,"actors":[],"categories":[],"confidence":0.7,"details":"Leads \"Portable Memory\", an open standard for carrying AI memory between vendors (.mem bundles).","eventTime":"2026-01-15T00:00:00Z","extractionState":"done","id":"tx_5cb2cbf1ccc77b98a1e3a195","importance":0.5,"ingestionTime":"2023-11-14T22:13:20Z","lifecycleState":"HOT","mentionTime":"2026-01-15T00:00:00Z","metadata":{"transfer_date_raw":"2026-01-15","transfer_line":"3","transfer_source":"chatgpt"},"pinned":false,"sensitivity":"low","sourceType":"note","summary":"Leads \"Portable Memory\", an open standard for carrying AI memory between vendors (.mem bundles).","vaultRefs":[]} +{"accessCount":0,"actors":[],"categories":["Communication preferences"],"confidence":0.7,"contextID":"ctx_comms","details":"Wants answers in English, even when asked in Ukrainian.","eventTime":"2025-12-20T00:00:00Z","extractionState":"done","id":"tx_60d2875ca86f4c69061f6a53","importance":0.5,"ingestionTime":"2023-11-14T22:13:20Z","lifecycleState":"HOT","mentionTime":"2025-12-20T00:00:00Z","metadata":{"transfer_date_raw":"2025-12-20","transfer_line":"13","transfer_section":"Communication preferences","transfer_source":"chatgpt"},"pinned":false,"sensitivity":"low","sourceType":"note","summary":"Wants answers in English, even when asked in Ukrainian.","vaultRefs":[]} +{"accessCount":0,"actors":[],"categories":["INSTRUCTIONS"],"confidence":0.7,"contextID":"ctx_instructions","details":"Always show the command to run before explaining it.","eventTime":"2026-03-01T00:00:00Z","extractionState":"done","id":"tx_70a667e4f69f892bfffe4838","importance":0.5,"ingestionTime":"2023-11-14T22:13:20Z","lifecycleState":"HOT","mentionTime":"2026-03-01T00:00:00Z","metadata":{"transfer_date_raw":"2026-03-01","transfer_line":"16","transfer_section":"INSTRUCTIONS","transfer_source":"chatgpt"},"pinned":false,"sensitivity":"low","sourceType":"note","summary":"Always show the command to run before explaining it.","vaultRefs":[]} +{"accessCount":0,"actors":[],"categories":["Communication preferences"],"confidence":0.7,"contextID":"ctx_comms","details":"Tone: direct, no filler, no emoji.\nException: emoji are fine in casual chats.","eventTime":"2023-11-14T22:13:20Z","extractionState":"done","id":"tx_7c8ea528caa741e4c2eb08f7","importance":0.5,"ingestionTime":"2023-11-14T22:13:20Z","lifecycleState":"HOT","mentionTime":"2023-11-14T22:13:20Z","metadata":{"transfer_line":"11","transfer_section":"Communication preferences","transfer_source":"chatgpt"},"pinned":false,"sensitivity":"low","sourceType":"note","summary":"Tone: direct, no filler, no emoji.","vaultRefs":[]} +{"accessCount":0,"actors":[],"categories":[],"confidence":0.7,"details":"Uses Swift and Python; likes pure-stdlib Python and Swift 6 strict concurrency.","eventTime":"2026-01-01T00:00:00Z","extractionState":"done","id":"tx_7d9aef9ba97b3ddc1ec6ae93","importance":0.5,"ingestionTime":"2023-11-14T22:13:20Z","lifecycleState":"HOT","mentionTime":"2026-01-01T00:00:00Z","metadata":{"transfer_date_raw":"Jan 2026","transfer_line":"4","transfer_source":"chatgpt"},"pinned":false,"sensitivity":"low","sourceType":"note","summary":"Uses Swift and Python; likes pure-stdlib Python and Swift 6 strict concurrency.","vaultRefs":[]} +{"accessCount":0,"actors":[],"categories":[],"confidence":0.7,"details":"Prefers concise answers: code first, explanation second.","eventTime":"2025-11-03T00:00:00Z","extractionState":"done","id":"tx_84967bfa7df993d22a8f5392","importance":0.5,"ingestionTime":"2023-11-14T22:13:20Z","lifecycleState":"HOT","mentionTime":"2025-11-03T00:00:00Z","metadata":{"transfer_date_raw":"2025-11-03","transfer_line":"1","transfer_source":"chatgpt"},"pinned":false,"sensitivity":"low","sourceType":"note","summary":"Prefers concise answers: code first, explanation second.","vaultRefs":[]} +{"accessCount":0,"actors":[],"categories":[],"confidence":0.7,"details":"Has a dog named Bit.","eventTime":"2023-11-14T22:13:20Z","extractionState":"done","id":"tx_b90facebe577cefe83f1728f","importance":0.5,"ingestionTime":"2023-11-14T22:13:20Z","lifecycleState":"HOT","mentionTime":"2023-11-14T22:13:20Z","metadata":{"transfer_line":"7","transfer_source":"chatgpt"},"pinned":false,"sensitivity":"low","sourceType":"note","summary":"Has a dog named Bit.","vaultRefs":[]} +{"accessCount":0,"actors":[],"categories":[],"confidence":0.7,"details":"Works as Director of AI & Research at a Mac software company.","eventTime":"2025-11-03T00:00:00Z","extractionState":"done","id":"tx_c468af7a39eb66f2974e08eb","importance":0.5,"ingestionTime":"2023-11-14T22:13:20Z","lifecycleState":"HOT","mentionTime":"2025-11-03T00:00:00Z","metadata":{"transfer_date_raw":"2025-11-03","transfer_line":"2","transfer_source":"chatgpt"},"pinned":false,"sensitivity":"low","sourceType":"note","summary":"Works as Director of AI & Research at a Mac software company.","vaultRefs":[]} +{"accessCount":0,"actors":[],"categories":[],"confidence":0.7,"details":"Lives in Київ (Kyiv); timezone Europe/Kyiv.","eventTime":"2023-11-14T22:13:20Z","extractionState":"done","id":"tx_cb0d7d896468a7a89a7faef9","importance":0.5,"ingestionTime":"2023-11-14T22:13:20Z","lifecycleState":"HOT","mentionTime":"2023-11-14T22:13:20Z","metadata":{"transfer_date_raw":"date unknown","transfer_line":"5","transfer_source":"chatgpt"},"pinned":false,"sensitivity":"low","sourceType":"note","summary":"Lives in Київ (Kyiv); timezone Europe/Kyiv.","vaultRefs":[]} +{"accessCount":0,"actors":[],"categories":[],"confidence":0.7,"details":"Prefers metric units and 24-hour time.","eventTime":"2023-11-14T22:13:20Z","extractionState":"done","id":"tx_ccf4713324e1ccebf5c8635a","importance":0.5,"ingestionTime":"2023-11-14T22:13:20Z","lifecycleState":"HOT","mentionTime":"2023-11-14T22:13:20Z","metadata":{"transfer_line":"6","transfer_source":"chatgpt"},"pinned":false,"sensitivity":"low","sourceType":"note","summary":"Prefers metric units and 24-hour time.","vaultRefs":[]} diff --git a/Conformance/fixtures/sample-1.1.mem/manifest.json b/Conformance/fixtures/sample-1.1.mem/manifest.json new file mode 100644 index 0000000..ad4c8b3 --- /dev/null +++ b/Conformance/fixtures/sample-1.1.mem/manifest.json @@ -0,0 +1 @@ +{"bundleDigest":"d9b8b8899dec2e16182a36cd377fe786cddae4b4e12b16c537b218c64340a6fd","capabilities":["bitemporal","tombstones","redaction","evidence-pack","ext","passthrough"],"conformanceLevel":"L2","counts":{"context":3,"episode":11},"coverage":{"from":"2023-11-14T22:13:20Z","to":"2026-03-01T00:00:00Z"},"createdAt":"2026-09-14T08:15:02Z","embeddingDim":0,"embeddingModel":"","embeddingsIncluded":false,"exportMode":"full","files":[{"bytes":341,"path":"items/context.jsonl","sha256":"ad795d191c686a6a8afdf4ff0586fdffa3265ccdaf1887bf40da2bd826888370"},{"bytes":6489,"path":"items/episode.jsonl","sha256":"663704e69bdaa1d9a4b697c3efa99ebf3a44a695c105988a745915b5f725b92f"}],"format":"1.1.0","generator":"portable-memory-fixture/1.1","schemaVersion":0,"scopes":["ctx_comms","ctx_instructions","ctx_root"],"specURL":"https://github.com/MacPaw/portable-memory/blob/main/Spec/portable-memory-spec.md"} \ No newline at end of file diff --git a/portable_memory/cli.py b/portable_memory/cli.py index cfcb506..ce223af 100644 --- a/portable_memory/cli.py +++ b/portable_memory/cli.py @@ -105,6 +105,15 @@ def _cmd_inspect(args: argparse.Namespace) -> int: print(f"conformance: {m.conformance_level.value}") print(f"created: {format_timestamp(m.created_at)}") print(f"mode: {m.export_mode.value}" + (f" since {format_timestamp(m.since)}" if m.since else "")) + # Format 1.1 summaries — what the archive covers, in which scopes, and its one-line digest. + if m.spec_url: + print(f"spec: {m.spec_url}") + if m.coverage is not None: + print(f"coverage: {format_timestamp(m.coverage.from_)} → {format_timestamp(m.coverage.to)}") + if m.scopes: + print("scopes: " + ", ".join(m.scopes)) + if m.bundle_digest: + print(f"digest: {m.bundle_digest}") print(f"integrity: {'OK' if result.ok else 'ISSUES (' + str(len(result.issues)) + ')'}") counts = {k: v for k, v in sorted(m.counts.items()) if v} print("counts: " + (", ".join(f"{k}: {v}" for k, v in counts.items()) or "(empty)")) diff --git a/tests/test_cli.py b/tests/test_cli.py index f11fedf..2f80016 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -94,3 +94,22 @@ def test_version_and_no_command(capsys): assert exc.value.code == 0 assert capsys.readouterr().out.startswith("portable-memory ") assert main([]) == 2 + + +def test_inspect_shows_format_1_1_fields_and_validate_catches_digest_tamper(tmp_path, capsys): + import shutil + fixture = Path(__file__).resolve().parents[1] / "Conformance" / "fixtures" / "sample-1.1.mem" + assert main(["inspect", str(fixture)]) == 0 + o = capsys.readouterr().out + assert "format: 1.1.0" in o + assert "spec: https://github.com/MacPaw/portable-memory/blob/main/Spec/portable-memory-spec.md" in o + assert "coverage: 2023-11-14T22:13:20Z → 2026-03-01T00:00:00Z" in o + assert "scopes: ctx_comms, ctx_instructions, ctx_root" in o + assert "digest: d9b8b8899dec2e16" in o and "integrity: OK" in o + + out = tmp_path / "c.mem" + shutil.copytree(fixture, out) + with open(out / "CHECKSUMS", "ab") as fh: + fh.write(b"0" * 64 + b" items/evil.jsonl\n") + assert main(["validate", str(out)]) == 1 + assert "bundleDigest mismatch" in capsys.readouterr().err diff --git a/tests/test_fixture_1_1.py b/tests/test_fixture_1_1.py new file mode 100644 index 0000000..97fb0d8 --- /dev/null +++ b/tests/test_fixture_1_1.py @@ -0,0 +1,80 @@ +"""The format-1.1 cross-SDK fixture ``Conformance/fixtures/sample-1.1.mem``. + +Written by the Python SDK (``Conformance/fixtures/generate_sample_1_1.py``); the Swift suite +validates the very same bytes — recomputing the Python-written ``bundleDigest`` — imports +them, and re-exports byte-identical streams. ``sample.mem`` stays at format 1.0 as the +backward-compatibility fixture.""" +from __future__ import annotations + +import importlib.util +import json +from datetime import datetime, timezone +from pathlib import Path + +from portable_memory import BundleExporter, BundleImporter, BundleValidator, MemFormat, PortableContext, sha256_hex +from portable_memory.inmemory import InMemoryStore + +FIXED = datetime.fromtimestamp(1_700_000_000, tz=timezone.utc) +ROOT = Path(__file__).resolve().parents[1] +FIXTURE = ROOT / "Conformance" / "fixtures" / "sample-1.1.mem" +EXPECTED_SCOPES = ["ctx_comms", "ctx_instructions", "ctx_root"] + + +class ScopedStore(InMemoryStore): + """The public in-memory store plus contexts, so the fixture round-trips completely.""" + + def __init__(self) -> None: + super().__init__(generator="test/1.0") + self.contexts: dict[str, PortableContext] = {} + + def export_contexts(self) -> list[PortableContext]: + return [self.contexts[k] for k in sorted(self.contexts)] + + def import_context(self, c: PortableContext) -> None: + self.contexts[c.id] = c + + +def test_fixture_validates_and_declares_every_1_1_field(): + res = BundleValidator().validate(FIXTURE) + assert res.ok, res.issues + m = res.manifest + assert m.format == "1.1.0" and m.spec_url == MemFormat.SPEC_URL + assert m.scopes == EXPECTED_SCOPES # episode contextIDs ∪ context ids (ctx_root only via a context record) + assert m.coverage.from_ == FIXED and m.coverage.to == datetime(2026, 3, 1, tzinfo=timezone.utc) + assert m.bundle_digest == sha256_hex((FIXTURE / "CHECKSUMS").read_bytes()) + assert m.counts == {"context": 3, "episode": 11} + + +def test_fixture_file_hashes_match_manifest(): + manifest = json.loads((FIXTURE / "manifest.json").read_bytes()) + assert manifest["files"], "fixture manifest must list files" + for entry in manifest["files"]: + data = (FIXTURE / entry["path"]).read_bytes() + assert sha256_hex(data) == entry["sha256"] and len(data) == entry["bytes"], entry["path"] + + +def test_fixture_imports_and_reexports_byte_identically(tmp_path): + store = ScopedStore() + BundleImporter().import_bundle(store, FIXTURE, reembed=False) + assert len(store.episodes) == 11 and sorted(store.contexts) == EXPECTED_SCOPES + assert {e.context_id for e in store.episodes.values() if e.context_id} == {"ctx_comms", "ctx_instructions"} + m = BundleExporter().export(store, tmp_path / "re.mem") + for name in ("items/episode.jsonl", "items/context.jsonl", "CHECKSUMS"): + assert (tmp_path / "re.mem" / name).read_bytes() == (FIXTURE / name).read_bytes(), name + original = json.loads((FIXTURE / "manifest.json").read_bytes()) + assert m.bundle_digest == original["bundleDigest"] # same CHECKSUMS bytes → same archive digest + assert m.scopes == original["scopes"] + assert [m.coverage.from_.isoformat(), m.coverage.to.isoformat()] == [ + datetime.fromisoformat(original["coverage"][k].replace("Z", "+00:00")).isoformat() for k in ("from", "to") + ] + + +def test_fixture_matches_its_generator(tmp_path): + """Guards against fixture drift: rebuilding the generator's store reproduces the bytes.""" + spec = importlib.util.spec_from_file_location("gen11", ROOT / "Conformance" / "fixtures" / "generate_sample_1_1.py") + gen = importlib.util.module_from_spec(spec) + spec.loader.exec_module(gen) + m = BundleExporter().export(gen.build_store(), tmp_path / "g.mem") + for name in ("items/episode.jsonl", "items/context.jsonl", "CHECKSUMS"): + assert (tmp_path / "g.mem" / name).read_bytes() == (FIXTURE / name).read_bytes(), name + assert m.bundle_digest == json.loads((FIXTURE / "manifest.json").read_bytes())["bundleDigest"] diff --git a/tests/test_manifest_v11.py b/tests/test_manifest_v11.py index c34d1e6..3ffe837 100644 --- a/tests/test_manifest_v11.py +++ b/tests/test_manifest_v11.py @@ -131,3 +131,67 @@ def test_manifest_matches_json_schema(tmp_path): BundleExporter().export(_store(), out) schema = json.loads((ROOT / "Schemas" / "manifest.schema.json").read_text(encoding="utf-8")) jsonschema.Draft202012Validator(schema).validate(json.loads((out / "manifest.json").read_bytes())) + + +def test_evidence_pack_manifest_carries_digest(tmp_path): + out = tmp_path / "ev.mem" + m = BundleExporter().export_evidence_pack(_store(), out) + assert m.spec_url == MemFormat.SPEC_URL + assert m.bundle_digest == sha256_hex((out / "CHECKSUMS").read_bytes()) + assert m.coverage is None and m.scopes is None # an evidence pack carries no episodes + + +def test_incremental_scopes_union_delta_episodes_with_all_contexts(tmp_path): + s = ScopedStore() + old, new = TransferTextAdapter.parse_episodes("[2024-01-01] - old\n[2026-06-01] - new\n", now=FIXED) + old.ingestion_time, old.context_id = utc(2024, 1, 2), "ctx_old" + new.ingestion_time, new.context_id = utc(2026, 6, 2), "ctx_new" + s.import_episode(old, None) + s.import_episode(new, None) + s.contexts.append(PortableContext(id="ctx_always", label="Always", archived=False, created_at=FIXED, parent_id=None)) + m = BundleExporter().export(s, tmp_path / "i.mem", mode=ExportMode.INCREMENTAL, since=utc(2025, 1, 1)) + assert m.scopes == ["ctx_always", "ctx_new"] # delta episodes only; contexts are always emitted in full + + +def test_forward_compat_unknown_manifest_keys_are_ignored(tmp_path): + """A future (1.2) bundle may carry manifest keys this reader has never heard of.""" + out = tmp_path / "fwd.mem" + BundleExporter().export(_store(), out) + manifest = json.loads((out / "manifest.json").read_bytes()) + manifest["futureField"] = {"x": 1} + manifest["visibility"] = {"private": 3} + (out / "manifest.json").write_text(json.dumps(manifest, sort_keys=True), encoding="utf-8") + res = BundleValidator().validate(out) + assert res.ok, res.issues + assert res.manifest.bundle_digest == manifest["bundleDigest"] + store = InMemoryStore() + BundleImporter().import_bundle(store, out, reembed=False) + assert len(store.episodes) == 11 + + +def test_coverage_and_scopes_properties_random(tmp_path): + """coverage == (min, max) eventTime; scopes == sorted unique context ids; digest == sha256(CHECKSUMS).""" + import random + rng = random.Random(20260914) + pool = ["ctx_a", "ctx_b", "ctx_c", "ctx_д"] # a non-ASCII id exercises code-point ordering + for rnd in range(25): + s = ScopedStore() + k = rng.randint(1, 9) + text = "".join(f"[{rng.randint(2000, 2030):04d}-{rng.randint(1, 12):02d}-{rng.randint(1, 28):02d}] - m{rnd}-{i}\n" for i in range(k)) + eps = TransferTextAdapter.parse_episodes(text, now=FIXED) + expected_ids: set[str] = set() + for e in eps: + if rng.random() < 0.5: + e.context_id = rng.choice(pool) + expected_ids.add(e.context_id) + s.import_episode(e, None) + if rng.random() < 0.5: + s.contexts.append(PortableContext(id="ctx_only", label="L", archived=False, created_at=FIXED, parent_id=None)) + expected_ids.add("ctx_only") + out = tmp_path / f"p{rnd}.mem" + m = BundleExporter().export(s, out) + times = [e.event_time for e in eps] + assert (m.coverage.from_, m.coverage.to) == (min(times), max(times)) + assert m.scopes == (sorted(expected_ids) or None) + assert m.bundle_digest == sha256_hex((out / "CHECKSUMS").read_bytes()) + assert BundleValidator().validate(out).ok