Files
semantica/tests/provenance/test_storage.py
T
KaifAhmad1 db4361ad46 feat(provenance): close PROV-O compliance gaps and high-stakes trust blockers (closes #825)
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
2026-08-03 22:28:48 +05:30

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)