mirror of
https://github.com/semantica-agi/semantica.git
synced 2026-08-29 04:26:20 +00:00
Part A - high-stakes trust blockers: - Invalidation tombstones via ProvenanceManager.invalidate() (archive-then-append, never mutates or deletes) instead of hard delete - Hash-chained integrity: sequence_id/previous_checksum chain every entry to its predecessor; new verify_chain() detects wholesale row deletion that a lone per-row checksum cannot - Typed Agent (AgentRecord: agent_type/is_automated) and Activity (ActivityRecord: start/end timing), wired through all 18 *_provenance.py wrappers - Split parent_entity_id into previous_version_id (correction) vs derived_from_id (cross-source derivation), additive alongside the legacy combined field - Downstream/descendant lineage traversal (get_descendants/trace_descendants, reverse BFS) closing the dead direction="downstream" code path in the Explorer's provenance route - Qualified Association+hadRole and Invalidation in export_prov() - New CLI: provenance invalidate|verify-chain|descendants Part B - general PROV-O spec completeness: - Qualified Generation/Usage/Derivation in export_prov() - wasAssociatedWith, actedOnBehalfOf, wasInformedBy relations - Bitemporal fields (valid_from/valid_until/revision_type/supersedes) plus revision_history()/query_recorded_between(), closing the deprecated kg.ProvenanceTracker's "no direct equivalent yet" migration gaps - prov:Bundle/hadMember membership via bundle_id - Configurable base_uri (--base-uri CLI flag), shared by RDFExporter's NamespaceManager and OWLExporter's default ontology_uri so KG/OWL/PROV exports co-resolve under one namespace instead of three hardcoded ones Bugs fixed along the way: - agent_id was a dead field: no track_* method read it from kwargs - track_entities_batch silently absorbed typed kwargs into the metadata blob - compute_checksum() had to exclude entity_id itself: hashing it made track_entity's versioning-archive relabel permanently orphan any entry already chained from the pre-relabel checksum, a false-positive "broken chain" for a legitimate rename - InMemoryStorage.get_chain_head() ignored the committed head whenever the current transaction had staged entries, corrupting the next chain link - several new ProvenanceEntry fields were wired into the dataclass and export_prov() but not into SQLiteStorage's DDL/INSERT/row-mapping; InMemoryStorage masked the gap. Added a permanent round-trip regression test to catch this class of bug for future field additions Flagged, not fixed (separate pre-existing issues, out of scope for #825): - pipeline/pipeline_provenance.py imports a nonexistent module and wraps a Pipeline dataclass with no run() method - most *_provenance.py wrappers' backing classes are themselves missing or incomplete (context_manager, deduplicator, normalizer, etc.) - kg_provenance.py passes entity_type inside metadata={} instead of as a top-level track_entity() kwarg across most of its call sites
388 lines
13 KiB
Python
388 lines
13 KiB
Python
"""
|
|
Test Provenance Storage Backends
|
|
|
|
Tests for InMemoryStorage and SQLiteStorage backends.
|
|
"""
|
|
|
|
import pytest
|
|
import tempfile
|
|
import os
|
|
from semantica.provenance.schemas import ProvenanceEntry
|
|
from semantica.provenance.storage import InMemoryStorage, SQLiteStorage
|
|
|
|
|
|
class TestInMemoryStorage:
|
|
"""Test InMemoryStorage backend."""
|
|
|
|
def test_store_and_retrieve(self):
|
|
"""Test storing and retrieving entries."""
|
|
storage = InMemoryStorage()
|
|
|
|
entry = ProvenanceEntry(
|
|
entity_id="entity_1",
|
|
entity_type="entity",
|
|
activity_id="extraction"
|
|
)
|
|
|
|
storage.store(entry)
|
|
retrieved = storage.retrieve("entity_1")
|
|
|
|
assert retrieved is not None
|
|
assert retrieved.entity_id == "entity_1"
|
|
|
|
def test_retrieve_nonexistent(self):
|
|
"""Test retrieving non-existent entry."""
|
|
storage = InMemoryStorage()
|
|
|
|
retrieved = storage.retrieve("nonexistent")
|
|
|
|
assert retrieved is None
|
|
|
|
def test_retrieve_all(self):
|
|
"""Test retrieving all entries."""
|
|
storage = InMemoryStorage()
|
|
|
|
entry1 = ProvenanceEntry(
|
|
entity_id="entity_1",
|
|
entity_type="entity",
|
|
activity_id="extraction"
|
|
)
|
|
entry2 = ProvenanceEntry(
|
|
entity_id="entity_2",
|
|
entity_type="chunk",
|
|
activity_id="chunking"
|
|
)
|
|
|
|
storage.store(entry1)
|
|
storage.store(entry2)
|
|
|
|
all_entries = storage.retrieve_all()
|
|
|
|
assert len(all_entries) == 2
|
|
|
|
def test_retrieve_by_type(self):
|
|
"""Test retrieving entries by type."""
|
|
storage = InMemoryStorage()
|
|
|
|
entry1 = ProvenanceEntry(
|
|
entity_id="entity_1",
|
|
entity_type="entity",
|
|
activity_id="extraction"
|
|
)
|
|
entry2 = ProvenanceEntry(
|
|
entity_id="chunk_1",
|
|
entity_type="chunk",
|
|
activity_id="chunking"
|
|
)
|
|
|
|
storage.store(entry1)
|
|
storage.store(entry2)
|
|
|
|
entities = storage.retrieve_all(entity_type="entity")
|
|
|
|
assert len(entities) == 1
|
|
assert entities[0].entity_type == "entity"
|
|
|
|
def test_trace_lineage(self):
|
|
"""Test tracing lineage."""
|
|
storage = InMemoryStorage()
|
|
|
|
# Create parent-child chain
|
|
entry1 = ProvenanceEntry(
|
|
entity_id="entity_1",
|
|
entity_type="entity",
|
|
activity_id="extraction"
|
|
)
|
|
entry2 = ProvenanceEntry(
|
|
entity_id="entity_2",
|
|
entity_type="entity",
|
|
activity_id="transformation",
|
|
parent_entity_id="entity_1"
|
|
)
|
|
entry3 = ProvenanceEntry(
|
|
entity_id="entity_3",
|
|
entity_type="entity",
|
|
activity_id="transformation",
|
|
parent_entity_id="entity_2"
|
|
)
|
|
|
|
storage.store(entry1)
|
|
storage.store(entry2)
|
|
storage.store(entry3)
|
|
|
|
lineage = storage.trace_lineage("entity_3")
|
|
|
|
assert len(lineage) == 3
|
|
entity_ids = [e.entity_id for e in lineage]
|
|
assert "entity_1" in entity_ids
|
|
assert "entity_2" in entity_ids
|
|
assert "entity_3" in entity_ids
|
|
|
|
def test_clear(self):
|
|
"""Test clearing storage."""
|
|
storage = InMemoryStorage()
|
|
|
|
entry = ProvenanceEntry(
|
|
entity_id="entity_1",
|
|
entity_type="entity",
|
|
activity_id="extraction"
|
|
)
|
|
|
|
storage.store(entry)
|
|
count = storage.clear()
|
|
|
|
assert count == 1
|
|
assert len(storage.retrieve_all()) == 0
|
|
|
|
def test_get_chain_head(self):
|
|
"""Issue #825, Part A item 2 — chain head reporting."""
|
|
storage = InMemoryStorage()
|
|
assert storage.get_chain_head() is None
|
|
|
|
entry1 = ProvenanceEntry(
|
|
entity_id="e1", entity_type="entity", activity_id="act",
|
|
sequence_id=1, checksum="checksum_1",
|
|
)
|
|
storage.store(entry1)
|
|
assert storage.get_chain_head() == (1, "checksum_1")
|
|
|
|
entry2 = ProvenanceEntry(
|
|
entity_id="e2", entity_type="entity", activity_id="act",
|
|
sequence_id=2, checksum="checksum_2",
|
|
)
|
|
storage.store(entry2)
|
|
assert storage.get_chain_head() == (2, "checksum_2")
|
|
|
|
def test_trace_descendants(self):
|
|
"""Issue #825, Part A item 5 — reverse (downstream) lineage traversal."""
|
|
storage = InMemoryStorage()
|
|
storage.store(ProvenanceEntry(entity_id="a", entity_type="entity", activity_id="act"))
|
|
storage.store(ProvenanceEntry(
|
|
entity_id="b", entity_type="entity", activity_id="act", parent_entity_id="a"
|
|
))
|
|
storage.store(ProvenanceEntry(
|
|
entity_id="c", entity_type="entity", activity_id="act", parent_entity_id="b"
|
|
))
|
|
storage.store(ProvenanceEntry(
|
|
entity_id="d", entity_type="entity", activity_id="act", used_entities=["a"]
|
|
))
|
|
|
|
descendants = storage.trace_descendants("a")
|
|
entity_ids = {e.entity_id for e in descendants}
|
|
assert entity_ids == {"b", "c", "d"}
|
|
|
|
def test_trace_descendants_respects_max_depth(self):
|
|
storage = InMemoryStorage()
|
|
storage.store(ProvenanceEntry(entity_id="a", entity_type="entity", activity_id="act"))
|
|
storage.store(ProvenanceEntry(
|
|
entity_id="b", entity_type="entity", activity_id="act", parent_entity_id="a"
|
|
))
|
|
storage.store(ProvenanceEntry(
|
|
entity_id="c", entity_type="entity", activity_id="act", parent_entity_id="b"
|
|
))
|
|
|
|
descendants = storage.trace_descendants("a", max_depth=1)
|
|
entity_ids = {e.entity_id for e in descendants}
|
|
assert entity_ids == {"b"}
|
|
|
|
def test_trace_descendants_empty_for_leaf(self):
|
|
storage = InMemoryStorage()
|
|
storage.store(ProvenanceEntry(entity_id="leaf", entity_type="entity", activity_id="act"))
|
|
assert storage.trace_descendants("leaf") == []
|
|
|
|
|
|
class TestSQLiteStorage:
|
|
"""Test SQLiteStorage backend."""
|
|
|
|
def test_all_fields_round_trip_through_sqlite(self):
|
|
"""Regression test: every ProvenanceEntry field, including issue #825
|
|
Part A and Part B additions, must survive a SQLite store/retrieve
|
|
round trip byte-for-byte. Part B's activity/actedOnBehalfOf/
|
|
informedBy/revision/bundle fields were initially added to the
|
|
dataclass and to export_prov() without updating SQLiteStorage's DDL/
|
|
INSERT/_row_to_entry — InMemoryStorage stores the dataclass directly
|
|
so it masked the gap, but SQLite silently dropped every one of those
|
|
fields on write."""
|
|
with tempfile.NamedTemporaryFile(delete=False, suffix=".db") as tmp:
|
|
db_path = tmp.name
|
|
|
|
try:
|
|
storage = SQLiteStorage(db_path)
|
|
entry = ProvenanceEntry(
|
|
entity_id="entity_1",
|
|
entity_type="entity",
|
|
activity_id="extraction",
|
|
agent_id="alice",
|
|
agent_type="person",
|
|
is_automated=False,
|
|
role="approver",
|
|
sequence_id=1,
|
|
previous_checksum="prevchk",
|
|
parent_entity_id="parent_1",
|
|
used_entities=["u1", "u2"],
|
|
previous_version_id="entity_1:v:1",
|
|
derived_from_id="source_1",
|
|
invalidated=True,
|
|
invalidated_at_time="2026-01-01T00:00:00",
|
|
invalidated_by="reviewer_jane",
|
|
invalidation_reason="retracted",
|
|
activity_started_at_time="2026-01-01T00:00:00",
|
|
activity_ended_at_time="2026-01-01T00:00:05",
|
|
acted_on_behalf_of="org1",
|
|
informed_by_activities=["act_a", "act_b"],
|
|
valid_from="2026-01-01",
|
|
valid_until="2026-06-01",
|
|
revision_type="correction",
|
|
supersedes="old_claim",
|
|
bundle_id="ingestion_run_1",
|
|
)
|
|
storage.store(entry)
|
|
retrieved = storage.retrieve("entity_1")
|
|
|
|
assert retrieved == entry
|
|
finally:
|
|
if os.path.exists(db_path):
|
|
os.unlink(db_path)
|
|
|
|
def test_store_and_retrieve(self):
|
|
"""Test storing and retrieving entries."""
|
|
with tempfile.NamedTemporaryFile(delete=False, suffix=".db") as tmp:
|
|
db_path = tmp.name
|
|
|
|
try:
|
|
storage = SQLiteStorage(db_path)
|
|
|
|
entry = ProvenanceEntry(
|
|
entity_id="entity_1",
|
|
entity_type="entity",
|
|
activity_id="extraction"
|
|
)
|
|
|
|
storage.store(entry)
|
|
retrieved = storage.retrieve("entity_1")
|
|
|
|
assert retrieved is not None
|
|
assert retrieved.entity_id == "entity_1"
|
|
finally:
|
|
if os.path.exists(db_path):
|
|
os.unlink(db_path)
|
|
|
|
def test_persistence(self):
|
|
"""Test data persistence across connections."""
|
|
with tempfile.NamedTemporaryFile(delete=False, suffix=".db") as tmp:
|
|
db_path = tmp.name
|
|
|
|
try:
|
|
# Store entry
|
|
storage1 = SQLiteStorage(db_path)
|
|
entry = ProvenanceEntry(
|
|
entity_id="entity_1",
|
|
entity_type="entity",
|
|
activity_id="extraction"
|
|
)
|
|
storage1.store(entry)
|
|
|
|
# Retrieve with new connection
|
|
storage2 = SQLiteStorage(db_path)
|
|
retrieved = storage2.retrieve("entity_1")
|
|
|
|
assert retrieved is not None
|
|
assert retrieved.entity_id == "entity_1"
|
|
finally:
|
|
if os.path.exists(db_path):
|
|
os.unlink(db_path)
|
|
|
|
def test_trace_lineage(self):
|
|
"""Test tracing lineage in SQLite."""
|
|
with tempfile.NamedTemporaryFile(delete=False, suffix=".db") as tmp:
|
|
db_path = tmp.name
|
|
|
|
try:
|
|
storage = SQLiteStorage(db_path)
|
|
|
|
# Create parent-child chain
|
|
entry1 = ProvenanceEntry(
|
|
entity_id="entity_1",
|
|
entity_type="entity",
|
|
activity_id="extraction"
|
|
)
|
|
entry2 = ProvenanceEntry(
|
|
entity_id="entity_2",
|
|
entity_type="entity",
|
|
activity_id="transformation",
|
|
parent_entity_id="entity_1"
|
|
)
|
|
|
|
storage.store(entry1)
|
|
storage.store(entry2)
|
|
|
|
lineage = storage.trace_lineage("entity_2")
|
|
|
|
assert len(lineage) == 2
|
|
finally:
|
|
if os.path.exists(db_path):
|
|
os.unlink(db_path)
|
|
|
|
def test_get_chain_head(self):
|
|
"""Issue #825, Part A item 2 — chain head reporting."""
|
|
with tempfile.NamedTemporaryFile(delete=False, suffix=".db") as tmp:
|
|
db_path = tmp.name
|
|
|
|
try:
|
|
storage = SQLiteStorage(db_path)
|
|
assert storage.get_chain_head() is None
|
|
|
|
entry1 = ProvenanceEntry(
|
|
entity_id="e1", entity_type="entity", activity_id="act",
|
|
sequence_id=1, checksum="checksum_1",
|
|
)
|
|
storage.store(entry1)
|
|
assert storage.get_chain_head() == (1, "checksum_1")
|
|
|
|
entry2 = ProvenanceEntry(
|
|
entity_id="e2", entity_type="entity", activity_id="act",
|
|
sequence_id=2, checksum="checksum_2",
|
|
)
|
|
storage.store(entry2)
|
|
assert storage.get_chain_head() == (2, "checksum_2")
|
|
finally:
|
|
if os.path.exists(db_path):
|
|
os.unlink(db_path)
|
|
|
|
def test_trace_descendants(self):
|
|
"""Issue #825, Part A item 5 — reverse (downstream) lineage traversal."""
|
|
with tempfile.NamedTemporaryFile(delete=False, suffix=".db") as tmp:
|
|
db_path = tmp.name
|
|
|
|
try:
|
|
storage = SQLiteStorage(db_path)
|
|
storage.store(ProvenanceEntry(entity_id="a", entity_type="entity", activity_id="act"))
|
|
storage.store(ProvenanceEntry(
|
|
entity_id="b", entity_type="entity", activity_id="act", parent_entity_id="a"
|
|
))
|
|
storage.store(ProvenanceEntry(
|
|
entity_id="c", entity_type="entity", activity_id="act", parent_entity_id="b"
|
|
))
|
|
storage.store(ProvenanceEntry(
|
|
entity_id="d", entity_type="entity", activity_id="act", used_entities=["a"]
|
|
))
|
|
|
|
descendants = storage.trace_descendants("a")
|
|
entity_ids = {e.entity_id for e in descendants}
|
|
assert entity_ids == {"b", "c", "d"}
|
|
finally:
|
|
if os.path.exists(db_path):
|
|
os.unlink(db_path)
|
|
|
|
def test_trace_descendants_empty_for_leaf(self):
|
|
with tempfile.NamedTemporaryFile(delete=False, suffix=".db") as tmp:
|
|
db_path = tmp.name
|
|
|
|
try:
|
|
storage = SQLiteStorage(db_path)
|
|
storage.store(ProvenanceEntry(entity_id="leaf", entity_type="entity", activity_id="act"))
|
|
assert storage.trace_descendants("leaf") == []
|
|
finally:
|
|
if os.path.exists(db_path):
|
|
os.unlink(db_path)
|