Files
semantica/tests/provenance/test_manager.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

1527 lines
65 KiB
Python

"""
Test Unified Provenance Manager
Tests for ProvenanceManager functionality including entity tracking,
chunk tracking, source tracking, and lineage tracing.
"""
import pytest
from unittest.mock import patch
from datetime import datetime
from semantica.provenance import ProvenanceManager, SourceReference, ProvenanceEntry
from semantica.provenance.storage import InMemoryStorage, SQLiteStorage
class TestProvenanceManager:
"""Test ProvenanceManager functionality."""
@pytest.fixture(autouse=True)
def reset_default_storage_path(self):
original = ProvenanceManager._default_storage_path
try:
yield
finally:
ProvenanceManager._default_storage_path = original
def test_default_storage_path_pattern(self, tmp_path):
"""Test global default storage pattern, config kwarg, and test-isolation context manager."""
from semantica.provenance import default_storage_path, InMemoryStorage, SQLiteStorage
# 1. Default should fallback to InMemoryStorage when no path/config
prov_mgr = ProvenanceManager()
assert isinstance(prov_mgr.storage, InMemoryStorage)
# 2. Config kwarg should extract storage_path gracefully (CLI bug fix)
cfg_path = str(tmp_path / "cfg_test.db")
cfg = {"provenance": {"storage_path": cfg_path}}
prov_mgr = ProvenanceManager(config=cfg)
assert isinstance(prov_mgr.storage, SQLiteStorage)
# 3. Explicit storage_path arg overrides config and default
explicit_path = str(tmp_path / "explicit.db")
prov_mgr = ProvenanceManager(storage_path=explicit_path, config=cfg)
assert isinstance(prov_mgr.storage, SQLiteStorage)
# 4. default_storage_path context manager should temporarily set default and restore on exit
ctx_path = str(tmp_path / "ctx_test.db")
with default_storage_path(ctx_path):
assert ProvenanceManager._default_storage_path == ctx_path
prov_mgr = ProvenanceManager()
assert isinstance(prov_mgr.storage, SQLiteStorage)
# Should be restored after context exit
assert ProvenanceManager._default_storage_path is None
prov_mgr = ProvenanceManager()
assert isinstance(prov_mgr.storage, InMemoryStorage)
# 5. Guaranteed restoration even on exception
exc_path = str(tmp_path / "exception_test.db")
try:
with ProvenanceManager.default_storage_path(exc_path):
assert ProvenanceManager._default_storage_path == exc_path
raise RuntimeError("Test exception inside context")
except RuntimeError:
pass
assert ProvenanceManager._default_storage_path is None
# 6. Nested contexts should restore correctly from stack
outer_path = str(tmp_path / "outer.db")
inner_path = str(tmp_path / "inner.db")
with default_storage_path(outer_path):
assert ProvenanceManager._default_storage_path == outer_path
with default_storage_path(inner_path):
assert ProvenanceManager._default_storage_path == inner_path
assert ProvenanceManager._default_storage_path == outer_path
assert ProvenanceManager._default_storage_path is None
def test_default_storage_path_concurrency_safety(self, tmp_path):
"""Test that default_storage_path context manager is thread-safe under concurrent use."""
import threading
import time
from semantica.provenance import default_storage_path, SQLiteStorage
errors = []
def _worker(path, delay):
try:
with default_storage_path(path):
time.sleep(delay)
prov_mgr = ProvenanceManager()
assert isinstance(prov_mgr.storage, SQLiteStorage)
assert prov_mgr.storage.db_path == path
except Exception as e:
errors.append(e)
path_a = str(tmp_path / "thread_a.db")
path_b = str(tmp_path / "thread_b.db")
t1 = threading.Thread(target=_worker, args=(path_a, 0.05))
t2 = threading.Thread(target=_worker, args=(path_b, 0.01))
t1.start()
t2.start()
t1.join()
t2.join()
assert not errors, f"Errors in concurrent context worker: {errors}"
assert ProvenanceManager._default_storage_path is None
def test_isolation_part_1_with_context_manager(self, tmp_path):
"""Part 1: Prove context manager sets default storage path for ProvenanceManager created inside context."""
from semantica.provenance import default_storage_path, SQLiteStorage
iso_db = str(tmp_path / "iso_part1.db")
with default_storage_path(iso_db):
prov_mgr = ProvenanceManager()
assert isinstance(prov_mgr.storage, SQLiteStorage)
assert prov_mgr.storage.db_path == iso_db
def test_isolation_part_2_no_leakage_after_context(self):
"""Part 2: Prove that in a SEPARATE test function after context exit, ProvenanceManager falls back to InMemoryStorage without leakage."""
from semantica.provenance import InMemoryStorage
assert ProvenanceManager._default_storage_path is None
prov_mgr = ProvenanceManager()
assert isinstance(prov_mgr.storage, InMemoryStorage)
def test_orchestrator_config_wiring(self, tmp_path):
"""Test Semantica orchestrator configures ProvenanceManager._default_storage_path."""
from semantica.core import Semantica
from semantica.provenance import ProvenanceManager, InMemoryStorage, SQLiteStorage
# 1. No path set in config means InMemoryStorage fallback
_ = Semantica()
prov_mgr = ProvenanceManager()
assert isinstance(prov_mgr.storage, InMemoryStorage)
# 2. Config with provenance.storage_path sets default storage path
test_db = str(tmp_path / "orch_test.db")
_ = Semantica(config={"provenance": {"storage_path": test_db}})
assert ProvenanceManager._default_storage_path == test_db
prov_mgr = ProvenanceManager()
assert isinstance(prov_mgr.storage, SQLiteStorage)
def test_initialization(self):
"""Test manager initialization."""
prov_mgr = ProvenanceManager()
assert prov_mgr is not None
assert prov_mgr.storage is not None
def test_track_entity(self):
"""Test tracking entity provenance."""
prov_mgr = ProvenanceManager()
entry = prov_mgr.track_entity(
entity_id="entity_1",
source="doc_1",
metadata={"confidence": 0.9}
)
assert entry is not None
assert entry.entity_id == "entity_1"
assert entry.source_document == "doc_1"
assert entry.checksum is not None
def test_track_relationship(self):
"""Test tracking relationship provenance."""
prov_mgr = ProvenanceManager()
entry = prov_mgr.track_relationship(
relationship_id="rel_1",
source="doc_1",
metadata={"type": "founded"}
)
assert entry is not None
assert entry.entity_id == "rel_1"
assert entry.entity_type == "relationship"
def test_track_chunk(self):
"""Test tracking chunk provenance."""
prov_mgr = ProvenanceManager()
entry = prov_mgr.track_chunk(
chunk_id="chunk_1",
source_document="doc_1",
source_path="/path/to/doc.pdf",
start_index=0,
end_index=500
)
assert entry is not None
assert entry.entity_id == "chunk_1"
assert entry.entity_type == "chunk"
assert entry.start_index == 0
assert entry.end_index == 500
def test_track_property_source(self):
"""Test tracking property source."""
prov_mgr = ProvenanceManager()
source = SourceReference(
document="DOI:10.1038/...",
page=4,
confidence=0.92
)
entry = prov_mgr.track_property_source(
entity_id="entity_1",
property_name="biomass_increase",
value="463%",
source=source
)
assert entry is not None
assert entry.entity_type == "property"
assert entry.metadata["property_name"] == "biomass_increase"
def test_get_lineage(self):
"""Test getting complete lineage."""
prov_mgr = ProvenanceManager()
# Create lineage chain
prov_mgr.track_entity("entity_1", "doc_1")
prov_mgr.track_chunk(
chunk_id="chunk_1",
source_document="doc_1",
parent_chunk_id="entity_1"
)
lineage = prov_mgr.get_lineage("chunk_1")
assert lineage is not None
assert "lineage_chain" in lineage
assert len(lineage["lineage_chain"]) > 0
def test_get_lineage_via_derived_from_metadata(self):
"""metadata['derived_from'] should link entities into the lineage chain
even when they share a source URL rather than one being a known entity_id."""
prov_mgr = ProvenanceManager()
prov_mgr.track_entity(
entity_id="doc:X",
source="https://example.com/api",
metadata={"content_type": "drug_label"},
)
prov_mgr.track_entity(
entity_id="decision:Y",
source="https://example.com/api",
metadata={"derived_from": "doc:X"},
)
lineage = prov_mgr.get_lineage("decision:Y")
assert lineage["entity_count"] == 2
entity_ids = [e["entity_id"] for e in lineage["lineage_chain"]]
assert "doc:X" in entity_ids
assert "decision:Y" in entity_ids
def test_derived_from_does_not_override_explicit_parent(self):
"""An explicit parent_entity_id kwarg should win over metadata['derived_from']."""
prov_mgr = ProvenanceManager()
prov_mgr.track_entity(entity_id="explicit_parent", source="doc_1")
prov_mgr.track_entity(entity_id="ignored_parent", source="doc_1")
entry = prov_mgr.track_entity(
entity_id="child",
source="doc_1",
metadata={"derived_from": "ignored_parent"},
parent_entity_id="explicit_parent",
)
assert entry.parent_entity_id == "explicit_parent"
def test_derived_from_takes_precedence_over_source_as_entity_id(self):
"""If `source` happens to also be a known entity_id, an explicit
metadata['derived_from'] should still win over that fallback linking."""
prov_mgr = ProvenanceManager()
prov_mgr.track_entity(entity_id="source_as_entity", source="doc_0")
prov_mgr.track_entity(entity_id="real_parent", source="doc_0")
entry = prov_mgr.track_entity(
entity_id="child",
source="source_as_entity", # resolvable as an entity_id
metadata={"derived_from": "real_parent"},
)
assert entry.parent_entity_id == "real_parent"
def test_derived_from_nonexistent_entity_does_not_crash(self):
"""derived_from pointing at an entity that was never tracked should be
stored as the parent link without raising, and lineage traversal should
stop gracefully instead of erroring."""
prov_mgr = ProvenanceManager()
entry = prov_mgr.track_entity(
entity_id="orphan_child",
source="doc_1",
metadata={"derived_from": "never_tracked"},
)
assert entry.parent_entity_id == "never_tracked"
lineage = prov_mgr.get_lineage("orphan_child")
entity_ids = [e["entity_id"] for e in lineage["lineage_chain"]]
assert entity_ids == ["orphan_child"]
def test_derived_from_non_string_is_ignored(self):
"""A non-string derived_from value (e.g. accidentally passing an int or
list) should be ignored rather than raising or being used as a parent id."""
prov_mgr = ProvenanceManager()
entry = prov_mgr.track_entity(
entity_id="entity_bad_derived_from",
source="doc_1",
metadata={"derived_from": 12345},
)
assert entry.parent_entity_id is None
def test_derived_from_empty_string_is_ignored(self):
"""An empty-string derived_from is falsy and should not be treated as a parent link."""
prov_mgr = ProvenanceManager()
entry = prov_mgr.track_entity(
entity_id="entity_empty_derived_from",
source="doc_1",
metadata={"derived_from": ""},
)
assert entry.parent_entity_id is None
def test_derived_from_self_reference_does_not_infinite_loop(self):
"""An entity that (incorrectly) declares itself as its own derived_from
parent should not cause get_lineage to hang or infinitely recurse."""
prov_mgr = ProvenanceManager()
prov_mgr.track_entity(
entity_id="self_ref",
source="doc_1",
metadata={"derived_from": "self_ref"},
)
lineage = prov_mgr.get_lineage("self_ref")
entity_ids = [e["entity_id"] for e in lineage["lineage_chain"]]
assert entity_ids == ["self_ref"]
def test_derived_from_multi_hop_chain(self):
"""derived_from links should chain transitively: A <- B <- C should
all appear when tracing lineage from C."""
prov_mgr = ProvenanceManager()
prov_mgr.track_entity(entity_id="grandparent", source="doc_1")
prov_mgr.track_entity(
entity_id="parent",
source="doc_1",
metadata={"derived_from": "grandparent"},
)
prov_mgr.track_entity(
entity_id="child",
source="doc_1",
metadata={"derived_from": "parent"},
)
lineage = prov_mgr.get_lineage("child")
assert lineage["entity_count"] == 3
entity_ids = {e["entity_id"] for e in lineage["lineage_chain"]}
assert entity_ids == {"grandparent", "parent", "child"}
def test_derived_from_without_metadata_dict_does_not_crash(self):
"""track_entity called with no metadata at all should behave as before
(no parent link derived), exercising the `metadata and isinstance(...)` guard."""
prov_mgr = ProvenanceManager()
entry = prov_mgr.track_entity(entity_id="no_metadata_entity", source="doc_1")
assert entry.parent_entity_id is None
def test_get_lineage_metadata_prefers_queried_entity_over_ancestors(self):
"""Aggregated lineage metadata should let the queried entity's own
values win over ancestor values on conflicting keys, matching the
documented "most recent entry's metadata takes precedence" intent."""
prov_mgr = ProvenanceManager()
prov_mgr.track_entity(
entity_id="ancestor",
source="doc_1",
metadata={"status": "draft", "shared_only_on_ancestor": True},
)
prov_mgr.track_entity(
entity_id="descendant",
source="doc_1",
metadata={"status": "final", "derived_from": "ancestor"},
)
lineage = prov_mgr.get_lineage("descendant")
assert lineage["metadata"]["status"] == "final"
assert lineage["metadata"]["shared_only_on_ancestor"] is True
def test_derived_from_accepts_non_dict_mapping(self):
"""metadata['derived_from'] should be honored for any Mapping
implementation, not just a concrete dict (e.g. types.MappingProxyType
or a custom collections.abc.Mapping)."""
from types import MappingProxyType
prov_mgr = ProvenanceManager()
prov_mgr.track_entity(entity_id="mapping_parent", source="doc_1")
entry = prov_mgr.track_entity(
entity_id="mapping_child",
source="doc_1",
metadata=MappingProxyType({"derived_from": "mapping_parent"}),
)
assert entry.parent_entity_id == "mapping_parent"
lineage = prov_mgr.get_lineage("mapping_child")
assert lineage["entity_count"] == 2
def test_batch_entity_tracking(self):
"""Test batch entity tracking."""
prov_mgr = ProvenanceManager()
entities = [
{"id": "entity_1", "confidence": 0.9},
{"id": "entity_2", "confidence": 0.85}
]
count = prov_mgr.track_entities_batch(entities, "doc_1")
assert count == 2
def test_batch_chunk_tracking(self):
"""Test batch chunk tracking."""
prov_mgr = ProvenanceManager()
chunks = [
{"id": "chunk_1", "start_index": 0, "end_index": 100},
{"id": "chunk_2", "start_index": 100, "end_index": 200}
]
count = prov_mgr.track_chunks_batch(chunks, "doc_1")
assert count == 2
def test_get_statistics(self):
"""Test getting provenance statistics."""
prov_mgr = ProvenanceManager()
prov_mgr.track_entity("entity_1", "doc_1")
prov_mgr.track_chunk("chunk_1", "doc_1")
stats = prov_mgr.get_statistics()
assert stats["total_entries"] == 2
assert "entity_types" in stats
def test_clear(self):
"""Test clearing provenance data."""
prov_mgr = ProvenanceManager()
prov_mgr.track_entity("entity_1", "doc_1")
count = prov_mgr.clear()
assert count == 1
lineage = prov_mgr.get_lineage("entity_1")
assert lineage == {}
def test_retrack_with_explicit_parent_overrides_history_link(self):
"""#742 — re-tracking an entity with an explicit parent_entity_id must
honor the new value, not silently replace it with an auto-generated
history pointer."""
prov_mgr = ProvenanceManager()
e1 = prov_mgr.track_entity("X", source="doc_1", parent_entity_id="parent_v1")
e2 = prov_mgr.track_entity("X", source="doc_1", parent_entity_id="parent_v2")
assert e1.parent_entity_id == "parent_v1"
assert e2.parent_entity_id == "parent_v2"
def test_retrack_without_explicit_parent_still_uses_history_link(self):
"""#742 — when NO explicit parent is given on a re-track call, the
auto-generated history link (Y:v:<timestamp>) should still be used,
preserving pre-existing behavior for callers that don't supply a parent."""
prov_mgr = ProvenanceManager()
y1 = prov_mgr.track_entity("Y", source="doc_1")
y2 = prov_mgr.track_entity("Y", source="doc_1")
assert y1.parent_entity_id is None
assert y2.parent_entity_id is not None
assert y2.parent_entity_id.startswith("Y:v:")
def test_retrack_with_derived_from_overrides_history_link(self):
"""#742 — re-tracking with metadata['derived_from'] (no parent_entity_id
kwarg) should also override the auto-generated history link, not just
the parent_entity_id kwarg case."""
prov_mgr = ProvenanceManager()
prov_mgr.track_entity("parent_A", source="doc_1")
prov_mgr.track_entity("parent_B", source="doc_1")
e1 = prov_mgr.track_entity("Z", source="doc_1", metadata={"derived_from": "parent_A"})
e2 = prov_mgr.track_entity("Z", source="doc_1", metadata={"derived_from": "parent_B"})
assert e1.parent_entity_id == "parent_A"
assert e2.parent_entity_id == "parent_B"
def test_retrack_history_reachable_via_used_entities(self):
"""#742 — when re-tracking with an explicit parent, the archived history
entry for the previous version must still be reachable in the lineage
chain via used_entities (prov:used), even though it's no longer the
direct parent_entity_id."""
prov_mgr = ProvenanceManager()
prov_mgr.track_entity("explicit_parent", source="doc_1")
prov_mgr.track_entity("X", source="doc_1") # first track, no parent
# Re-track with an explicit parent — should NOT lose the history entry
e2 = prov_mgr.track_entity("X", source="doc_1", parent_entity_id="explicit_parent")
assert e2.parent_entity_id == "explicit_parent"
assert len(e2.used_entities) == 1
assert e2.used_entities[0].startswith("X:v:")
# trace_lineage should reach: X, explicit_parent (via parent_entity_id),
# AND the archived history snapshot (via used_entities)
lineage = prov_mgr.get_lineage("X")
entity_ids = {e["entity_id"] for e in lineage["lineage_chain"]}
assert "X" in entity_ids
assert "explicit_parent" in entity_ids
assert e2.used_entities[0] in entity_ids, (
"Archived history entry should be reachable via used_entities in lineage"
)
def test_cli_lineage(self):
"""Test CLI lineage wrapper method on ProvenanceManager."""
prov_mgr = ProvenanceManager()
prov_mgr.track_entity("e_cli", source="doc_cli")
res = prov_mgr.lineage("e_cli", depth=2)
assert res["entity_id"] == "e_cli"
assert res["depth"] == 2
assert isinstance(res["lineage"], list)
assert len(res["lineage"]) > 0
assert len(res["entries"]) > 0
assert res["lineage"][0]["entity_id"] == "e_cli"
assert isinstance(res["sources"], list)
def test_cli_audit_log(self):
"""Test CLI audit_log wrapper method on ProvenanceManager."""
prov_mgr = ProvenanceManager()
prov_mgr.track_entity("e_audit", source="doc_audit")
res_table = prov_mgr.audit_log(format="table")
assert isinstance(res_table, str)
assert "ENTITY_ID" in res_table
res_csv = prov_mgr.audit_log(format="csv")
assert "entity_id,entity_type,activity_id,agent_id,timestamp" in res_csv
res_json = prov_mgr.audit_log(format="json")
assert isinstance(res_json, list)
def test_cli_export_prov(self):
"""Test CLI export_prov method on ProvenanceManager."""
prov_mgr = ProvenanceManager()
prov_mgr.track_entity("e_parent", source="doc_rdf")
prov_mgr.track_entity(
"e_child",
source="doc_rdf",
parent_entity_id="e_parent",
used_entities=["e_parent"],
activity_id="act_transform",
)
ttl = prov_mgr.export_prov(format="turtle")
assert "prov:Entity" in ttl or "http://www.w3.org/ns/prov#Entity" in ttl
assert "wasDerivedFrom" in ttl
assert "used" in ttl
def test_cli_check(self):
"""Test CLI check integrity method on ProvenanceManager."""
prov_mgr = ProvenanceManager()
prov_mgr.track_entity("e_valid_parent", source="doc_1")
prov_mgr.track_entity(
"e_valid_child",
source="doc_1",
parent_entity_id="e_valid_parent",
used_entities=["e_valid_parent"],
)
check_res = prov_mgr.check(strict=True)
assert check_res["valid"] is True
assert check_res["total_entries"] >= 2
assert check_res["errors"] == 0
# Confirm non-strict mode still reports valid=False and errors > 0 when references are missing
prov_mgr.track_entity(
"e_broken_child",
source="doc_1",
parent_entity_id="non_existent_parent",
)
check_broken = prov_mgr.check(strict=False)
assert check_broken["valid"] is False
assert check_broken["errors"] >= 1
assert "e_broken_child -> non_existent_parent" in check_broken["missing_references"]
def test_track_entity_storage_error_swallowed(self):
"""Test that track_entity returns None when storage.store() fails on a brand-new entity,
since nothing was persisted (#782)."""
prov_mgr = ProvenanceManager()
with patch.object(prov_mgr.storage, "store", side_effect=RuntimeError("storage error")):
entry = prov_mgr.track_entity("e_test", source="doc_1")
# Returning None is correct because nothing was actually persisted,
# and the old assertion was encoding the bug this issue was filed to fix.
assert entry is None
def test_track_relationship_storage_error_swallowed(self):
"""Test that track_relationship returns None when storage.store() fails,
since nothing was persisted (#783)."""
prov_mgr = ProvenanceManager()
with patch.object(prov_mgr.storage, "store", side_effect=RuntimeError("storage error")):
entry = prov_mgr.track_relationship("r_test", source="doc_1")
# Returning None is correct because nothing was actually persisted,
# and the old assertion was encoding the bug this issue was filed to fix.
assert entry is None
def test_track_chunk_storage_error_swallowed(self):
"""Test that track_chunk returns None when storage.store() fails,
since nothing was persisted (#783)."""
prov_mgr = ProvenanceManager()
with patch.object(prov_mgr.storage, "store", side_effect=RuntimeError("storage error")):
entry = prov_mgr.track_chunk(
chunk_id="c_test",
source_document="doc_1",
source_path="/path/to/doc.pdf",
start_index=0,
end_index=100,
)
# Returning None is correct because nothing was actually persisted,
# and the old assertion was encoding the bug this issue was filed to fix.
assert entry is None
def test_track_property_source_storage_error_swallowed(self):
"""Test that track_property_source returns None when storage.store() fails,
since nothing was persisted (#783)."""
prov_mgr = ProvenanceManager()
source = SourceReference(document="doc_1", page=1, confidence=0.9)
with patch.object(prov_mgr.storage, "store", side_effect=RuntimeError("storage error")):
entry = prov_mgr.track_property_source(
entity_id="e_test",
property_name="prop_test",
value="val",
source=source,
)
# Returning None is correct because nothing was actually persisted,
# and the old assertion was encoding the bug this issue was filed to fix.
assert entry is None
def test_save_entry_logs_on_every_failure_path(self):
"""Test that _save_entry logs on storage failures for both raising and swallowing paths (#783)."""
prov_mgr = ProvenanceManager()
entry = ProvenanceEntry(
entity_id="log_test_id",
entity_type="entity",
activity_id="test",
source_document="doc_1",
first_seen=datetime.utcnow().isoformat(),
last_updated=datetime.utcnow().isoformat(),
)
with patch.object(prov_mgr.storage, "store", side_effect=RuntimeError("storage error")), \
patch.object(prov_mgr.logger, "error") as mock_log_error:
# Swallowing branch (_raise_on_error=False)
res = prov_mgr._save_entry(entry, _raise_on_error=False)
assert res is None
mock_log_error.assert_called_once()
assert "log_test_id" in mock_log_error.call_args[0][1]
mock_log_error.reset_mock()
# Raising branch (_raise_on_error=True)
with pytest.raises(RuntimeError, match="storage error"):
prov_mgr._save_entry(entry, _raise_on_error=True)
mock_log_error.assert_called_once()
assert "log_test_id" in mock_log_error.call_args[0][1]
def test_track_entities_batch_logs_per_item_failure(self):
"""Test that track_entities_batch emits item-level logs via _save_entry when items fail (#783)."""
prov_mgr = ProvenanceManager()
entities = [{"id": "e_fail_1"}, {"id": "e_fail_2"}]
with patch.object(prov_mgr.storage, "_store_with_conn", side_effect=RuntimeError("batch store error")), \
patch.object(prov_mgr.logger, "error") as mock_log_error:
count = prov_mgr.track_entities_batch(entities, "doc_1")
assert count == 0
assert mock_log_error.call_count == 2
def test_track_entity_pre_build_failure_fallback_skips_store(self):
"""Test that when track_entity fails before the entry is built (e.g. a
retrieve error inside the atomic transaction) on a brand-new entity,
it returns None (#782/#784)."""
prov_mgr = ProvenanceManager()
with patch.object(
prov_mgr.storage, "_retrieve_with_conn", side_effect=RuntimeError("retrieve error")
), patch.object(prov_mgr.storage, "store") as mock_store:
entry = prov_mgr.track_entity("e_test", source="doc_1")
# Returning None is correct because nothing was actually persisted,
# and the old assertion was encoding the bug this issue was filed to fix.
assert entry is None
mock_store.assert_not_called()
@pytest.mark.parametrize("backend_type", ["memory", "sqlite"])
def test_track_entity_atomic_rollback_on_history_write_failure(self, backend_type, tmp_path):
"""Test that history write failure rolls back transaction cleanly on both InMemory and SQLite storage."""
if backend_type == "memory":
class FlakyInMemory(InMemoryStorage):
def __init__(self):
super().__init__()
self.store_calls = 0
self.fail_on_call = None
def _store_with_conn(self, conn, entry):
self.store_calls += 1
if self.store_calls == self.fail_on_call:
raise RuntimeError(f"Simulated error on call {self.store_calls}")
super()._store_with_conn(conn, entry)
storage = FlakyInMemory()
else:
db_path = str(tmp_path / "test_hist.db")
class FlakySQLite(SQLiteStorage):
def __init__(self, path):
super().__init__(path)
self.store_calls = 0
self.fail_on_call = None
def _store_with_conn(self, conn, entry):
self.store_calls += 1
if self.store_calls == self.fail_on_call:
raise RuntimeError(f"Simulated error on call {self.store_calls}")
super()._store_with_conn(conn, entry)
storage = FlakySQLite(db_path)
mgr = ProvenanceManager(storage=storage)
v1 = mgr.track_entity("e1", source="doc1")
assert v1.source_document == "doc1"
storage.fail_on_call = 2
# Standalone call should not raise, should return pre-failure state (v1)
res = mgr.track_entity("e1", source="doc2")
in_storage = storage.retrieve("e1")
assert res.source_document == "doc1"
assert in_storage.source_document == "doc1"
assert [e.entity_id for e in storage.retrieve_all()] == ["e1"]
# Batch call (_conn supplied) should raise
storage.store_calls = 1
with pytest.raises(RuntimeError):
with mgr._get_or_create_transaction() as conn:
mgr.track_entity("e1", source="doc2", _conn=conn)
@pytest.mark.parametrize("backend_type", ["memory", "sqlite"])
def test_track_entity_atomic_rollback_on_primary_write_failure(self, backend_type, tmp_path):
"""Test that primary write failure after history write rolls back the entire transaction."""
if backend_type == "memory":
class FlakyInMemory(InMemoryStorage):
def __init__(self):
super().__init__()
self.store_calls = 0
self.fail_on_call = None
def _store_with_conn(self, conn, entry):
self.store_calls += 1
if self.store_calls == self.fail_on_call:
raise RuntimeError(f"Simulated error on call {self.store_calls}")
super()._store_with_conn(conn, entry)
storage = FlakyInMemory()
else:
db_path = str(tmp_path / "test_prim.db")
class FlakySQLite(SQLiteStorage):
def __init__(self, path):
super().__init__(path)
self.store_calls = 0
self.fail_on_call = None
def _store_with_conn(self, conn, entry):
self.store_calls += 1
if self.store_calls == self.fail_on_call:
raise RuntimeError(f"Simulated error on call {self.store_calls}")
super()._store_with_conn(conn, entry)
storage = FlakySQLite(db_path)
mgr = ProvenanceManager(storage=storage)
v1 = mgr.track_entity("e1", source="doc1")
assert v1.source_document == "doc1"
storage.fail_on_call = 3
# Standalone call should not raise, should return pre-failure state (v1)
res = mgr.track_entity("e1", source="doc2")
in_storage = storage.retrieve("e1")
assert res.source_document == "doc1"
assert in_storage.source_document == "doc1"
assert [e.entity_id for e in storage.retrieve_all()] == ["e1"]
# Batch call (_conn supplied) should raise
storage.store_calls = 1
with pytest.raises(RuntimeError):
with mgr._get_or_create_transaction() as conn:
mgr.track_entity("e1", source="doc2", _conn=conn)
@pytest.mark.parametrize("backend_type", ["memory", "sqlite"])
def test_track_entity_rollback_returns_safe_copy(self, backend_type, tmp_path):
"""Test that after a rollback returning the existing entry, mutating the returned object does not affect storage."""
if backend_type == "memory":
class FlakyInMemory(InMemoryStorage):
def __init__(self):
super().__init__()
self.store_calls = 0
self.fail_on_call = None
def _store_with_conn(self, conn, entry):
self.store_calls += 1
if self.store_calls == self.fail_on_call:
raise RuntimeError(f"Simulated error on call {self.store_calls}")
super()._store_with_conn(conn, entry)
storage = FlakyInMemory()
else:
db_path = str(tmp_path / "test_safe_copy.db")
class FlakySQLite(SQLiteStorage):
def __init__(self, path):
super().__init__(path)
self.store_calls = 0
self.fail_on_call = None
def _store_with_conn(self, conn, entry):
self.store_calls += 1
if self.store_calls == self.fail_on_call:
raise RuntimeError(f"Simulated error on call {self.store_calls}")
super()._store_with_conn(conn, entry)
storage = FlakySQLite(db_path)
mgr = ProvenanceManager(storage=storage)
v1 = mgr.track_entity("e1", source="doc1", metadata={"key": "val"})
assert v1.source_document == "doc1"
# Force failure on update
storage.fail_on_call = 2
res = mgr.track_entity("e1", source="doc2")
assert res.source_document == "doc1"
# Mutate the returned pre-failure object
res.source_document = "mutated"
res.metadata["tampered"] = True
# Assert stored copy in storage is completely unchanged
in_storage = storage.retrieve("e1")
assert in_storage.source_document == "doc1"
assert in_storage.metadata == {"key": "val"}
assert "tampered" not in in_storage.metadata
@pytest.mark.parametrize("backend_type", ["memory", "sqlite"])
def test_track_entities_batch_per_item_savepoint_rollback(self, backend_type, tmp_path):
"""Test that in batch mode, an exception during primary write rolls back any staged history archive for that item."""
if backend_type == "memory":
class FlakyInMemory(InMemoryStorage):
def __init__(self):
super().__init__()
self.store_calls = 0
self.fail_on_call = None
def _store_with_conn(self, conn, entry):
self.store_calls += 1
if self.store_calls == self.fail_on_call:
raise RuntimeError(f"Simulated error on call {self.store_calls}")
super()._store_with_conn(conn, entry)
storage = FlakyInMemory()
else:
db_path = str(tmp_path / "test_batch_sp.db")
class FlakySQLite(SQLiteStorage):
def __init__(self, path):
super().__init__(path)
self.store_calls = 0
self.fail_on_call = None
def _store_with_conn(self, conn, entry):
self.store_calls += 1
if self.store_calls == self.fail_on_call:
raise RuntimeError(f"Simulated error on call {self.store_calls}")
super()._store_with_conn(conn, entry)
storage = FlakySQLite(db_path)
mgr = ProvenanceManager(storage=storage)
v1 = mgr.track_entity("e1", source="doc1")
assert v1.source_document == "doc1"
# Fail on call 3 (the primary update for e1, after its history entry was stored on call 2)
storage.fail_on_call = 3
count = mgr.track_entities_batch(
[{"id": "e1"}, {"id": "e2"}],
source="doc_batch"
)
assert count == 1 # Only e2 should succeed
all_entries = {e.entity_id: e for e in storage.retrieve_all()}
# Assert e2 is present
assert "e2" in all_entries
assert all_entries["e2"].source_document == "doc_batch"
# Assert e1 is untouched and NO history archive was left behind
assert all_entries["e1"].source_document == "doc1"
assert not any(":v:" in eid for eid in all_entries.keys()), f"Found leaked history entry: {list(all_entries.keys())}"
def test_track_entity_logs_exception_with_exc_info(self):
"""Test that track_entity logs errors with exc_info=True when a storage write fails."""
class FlakyInMemory(InMemoryStorage):
def _store_with_conn(self, conn, entry):
raise RuntimeError("Simulated write failure")
storage = FlakyInMemory()
mgr = ProvenanceManager(storage=storage)
with patch.object(mgr.logger, "error") as mock_error:
res = mgr.track_entity("e1", source="doc1")
assert res is None
assert mock_error.called
_, kwargs = mock_error.call_args
assert kwargs.get("exc_info") is True
class TestAgentTyping:
"""Issue #825, Part A item 3 — agent_id/agent_type/is_automated actually
populate on tracked entries (previously a dead field: no track_* method
read agent_id from kwargs at all)."""
def test_track_entity_scalar_agent_kwargs(self):
prov_mgr = ProvenanceManager()
entry = prov_mgr.track_entity(
"e1", source="doc1",
agent_id="alice", agent_type="person", is_automated=False, role="approver",
)
assert entry.agent_id == "alice"
assert entry.agent_type == "person"
assert entry.is_automated is False
assert entry.role == "approver"
def test_track_entity_agent_record_kwarg(self):
from semantica.provenance import AgentRecord
prov_mgr = ProvenanceManager()
agent = AgentRecord(id="reviewer_bob", agent_type="person", is_automated=False)
entry = prov_mgr.track_entity("e1", source="doc1", agent=agent, role="approver")
assert entry.agent_id == "reviewer_bob"
assert entry.agent_type == "person"
assert entry.is_automated is False
assert entry.role == "approver"
def test_track_entity_default_agent_unchanged(self):
"""No agent kwargs supplied should still default to 'semantica' (back-compat)."""
prov_mgr = ProvenanceManager()
entry = prov_mgr.track_entity("e1", source="doc1")
assert entry.agent_id == "semantica"
assert entry.agent_type == "software_agent"
assert entry.is_automated is True
def test_track_relationship_agent_kwargs(self):
prov_mgr = ProvenanceManager()
entry = prov_mgr.track_relationship("r1", source="doc1", agent_id="bot1")
assert entry.agent_id == "bot1"
def test_track_chunk_agent_kwargs_not_leaked_into_metadata(self):
"""agent_id/agent_type/is_automated passed to track_chunk must populate
the real fields, not leak into the opaque metadata blob."""
prov_mgr = ProvenanceManager()
entry = prov_mgr.track_chunk(
"c1", source_document="doc1", agent_id="chunker_v2", note="hello"
)
assert entry.agent_id == "chunker_v2"
assert "agent_id" not in entry.metadata
assert entry.metadata.get("note") == "hello"
def test_track_property_source_agent_kwargs(self):
prov_mgr = ProvenanceManager()
source = SourceReference(document="doc1")
entry = prov_mgr.track_property_source(
"e1", "prop", "value", source, agent_id="prop_tracker"
)
assert entry.agent_id == "prop_tracker"
assert "agent_id" not in entry.metadata
def test_track_entities_batch_agent_id_not_swallowed_into_metadata(self):
"""Regression test for the documented bug: track_entities_batch used to
merge agent_id/entity_type/activity_id into the metadata dict instead
of forwarding them as real track_entity kwargs."""
prov_mgr = ProvenanceManager()
count = prov_mgr.track_entities_batch(
[{"id": "b1"}, {"id": "b2"}],
source="doc_batch",
agent_id="batch_service_v2",
entity_type="credit_feature",
activity_id="bureau_parsing",
extra_note="kept as free-form metadata",
)
assert count == 2
for eid in ("b1", "b2"):
entry = prov_mgr.storage.retrieve(eid)
assert entry.agent_id == "batch_service_v2"
assert entry.entity_type == "credit_feature"
assert entry.activity_id == "bureau_parsing"
assert "agent_id" not in entry.metadata
assert entry.metadata.get("extra_note") == "kept as free-form metadata"
class TestVersioningVsDerivation:
"""Issue #825, Part A item 4 — previous_version_id (correction) is
additive alongside derived_from_id (cross-source derivation); both are
populated without disturbing the legacy parent_entity_id field."""
def test_retrack_without_parent_sets_previous_version_id_only(self):
prov_mgr = ProvenanceManager()
prov_mgr.track_entity("X", source="doc_1")
v2 = prov_mgr.track_entity("X", source="doc_1")
assert v2.previous_version_id is not None
assert v2.previous_version_id.startswith("X:v:")
assert v2.derived_from_id is None
# Legacy field behavior is unchanged
assert v2.parent_entity_id == v2.previous_version_id
def test_retrack_with_explicit_parent_sets_both_fields(self):
prov_mgr = ProvenanceManager()
prov_mgr.track_entity("X", source="doc_1")
v2 = prov_mgr.track_entity("X", source="doc_1", parent_entity_id="explicit_parent")
assert v2.derived_from_id == "explicit_parent"
assert v2.previous_version_id is not None
assert v2.previous_version_id.startswith("X:v:")
# Legacy field keeps explicit-wins semantics
assert v2.parent_entity_id == "explicit_parent"
def test_track_chunk_split_sets_derived_from_id(self):
prov_mgr = ProvenanceManager()
entry = prov_mgr.track_chunk(
"c2", source_document="doc1", parent_chunk_id="c1"
)
assert entry.derived_from_id == "c1"
assert entry.previous_version_id is None
assert entry.parent_entity_id == "c1"
class TestInvalidation:
"""Issue #825, Part A item 1 — tombstone instead of hard delete."""
def test_invalidate_marks_entry_without_deleting(self):
prov_mgr = ProvenanceManager()
prov_mgr.track_entity("e1", source="doc1")
result = prov_mgr.invalidate("e1", agent_id="reviewer_jane", reason="retracted")
assert result.invalidated is True
assert result.invalidated_by == "reviewer_jane"
assert result.invalidation_reason == "retracted"
assert result.invalidated_at_time is not None
# Entry remains visible via retrieve (tombstone, not delete)
stored = prov_mgr.storage.retrieve("e1")
assert stored is not None
assert stored.invalidated is True
def test_invalidate_unknown_entity_raises(self):
prov_mgr = ProvenanceManager()
with pytest.raises(ValueError):
prov_mgr.invalidate("never_tracked", agent_id="reviewer_jane")
def test_check_reports_invalidated_count(self):
prov_mgr = ProvenanceManager()
prov_mgr.track_entity("e1", source="doc1")
prov_mgr.track_entity("e2", source="doc1")
prov_mgr.invalidate("e1", agent_id="reviewer_jane")
result = prov_mgr.check()
assert result["invalidated_count"] == 1
class TestHashChain:
"""Issue #825, Part A item 2 — hash-chained integrity: chained checksums
detect wholesale row deletion, which per-row checksums alone cannot."""
def test_verify_chain_valid_on_clean_history(self):
prov_mgr = ProvenanceManager()
prov_mgr.track_entity("e1", source="doc1")
prov_mgr.track_entity("e2", source="doc1")
prov_mgr.track_entity("X", source="doc1")
prov_mgr.track_entity("X", source="doc1") # triggers archival
prov_mgr.track_entity("X", source="doc1", parent_entity_id="explicit_p")
result = prov_mgr.verify_chain()
assert result["valid"] is True
assert result["broken_links"] == []
assert result["total_entries"] == 5
def test_verify_chain_detects_deleted_row(self, tmp_path):
"""A hard delete of a row must break the chain for whatever followed it."""
import sqlite3
db_path = str(tmp_path / "chain.db")
prov_mgr = ProvenanceManager(storage_path=db_path)
prov_mgr.track_entity("e1", source="doc1")
prov_mgr.track_entity("e2", source="doc1")
prov_mgr.track_entity("e3", source="doc1")
conn = sqlite3.connect(db_path)
conn.execute("DELETE FROM provenance WHERE entity_id = 'e2'")
conn.commit()
conn.close()
prov_mgr2 = ProvenanceManager(storage_path=db_path)
result = prov_mgr2.verify_chain()
assert result["valid"] is False
assert any(link["reason"] == "chain_break" for link in result["broken_links"])
def test_sequence_ids_are_assigned_and_monotonic(self):
prov_mgr = ProvenanceManager()
e1 = prov_mgr.track_entity("e1", source="doc1")
e2 = prov_mgr.track_entity("e2", source="doc1")
assert e1.sequence_id is not None
assert e2.sequence_id is not None
assert e2.sequence_id > e1.sequence_id
assert e2.previous_checksum == e1.checksum
def test_chain_survives_interleaved_retrack_and_invalidate(self, tmp_path):
"""Regression test: an entry that already chained its previous_checksum
from another entity's checksum (Y -> X) must stay valid even after X
is later retracked (archived/relabeled) and then invalidated — both
of which write NEW entries under X's canonical key. Mutating X's
existing row in place (rather than archiving-then-appending) used to
silently orphan Y's chain link, producing a false-positive break."""
db_path = str(tmp_path / "interleaved.db")
prov_mgr = ProvenanceManager(storage_path=db_path)
prov_mgr.track_entity("e1", source="doc1")
prov_mgr.track_entity("X", source="doc1")
prov_mgr.track_entity("Y", source="doc1", parent_entity_id="X")
prov_mgr.track_entity("X", source="doc1") # retrack: archives old X
prov_mgr.track_entity("Z", source="doc1", parent_entity_id="X")
prov_mgr.invalidate("X", agent_id="reviewer") # archives again
prov_mgr.track_entity("W", source="doc1", parent_entity_id="X")
result = prov_mgr.verify_chain()
assert result["valid"] is True
assert result["broken_links"] == []
def test_invalidate_does_not_break_chain(self):
prov_mgr = ProvenanceManager()
prov_mgr.track_entity("e1", source="doc1")
prov_mgr.track_entity("e2", source="doc1", parent_entity_id="e1")
prov_mgr.invalidate("e1", agent_id="reviewer_jane")
result = prov_mgr.verify_chain()
assert result["valid"] is True
class TestDownstreamLineage:
"""Issue #825, Part A item 5 — downstream/descendant traversal (reverse
BFS), complementing the existing upstream-only trace_lineage/get_lineage."""
def test_get_descendants_finds_direct_child(self):
prov_mgr = ProvenanceManager()
prov_mgr.track_entity("parent1", source="doc1")
prov_mgr.track_entity(
"child1", source="doc1", parent_entity_id="parent1", used_entities=["parent1"]
)
result = prov_mgr.get_descendants("parent1")
entity_ids = {e["entity_id"] for e in result["entries"]}
assert "child1" in entity_ids
def test_get_descendants_transitive(self):
prov_mgr = ProvenanceManager()
prov_mgr.track_entity("a", source="doc1")
prov_mgr.track_entity("b", source="doc1", parent_entity_id="a")
prov_mgr.track_entity("c", source="doc1", parent_entity_id="b")
result = prov_mgr.get_descendants("a")
entity_ids = {e["entity_id"] for e in result["entries"]}
assert entity_ids == {"b", "c"}
def test_get_descendants_empty_for_leaf(self):
prov_mgr = ProvenanceManager()
prov_mgr.track_entity("leaf", source="doc1")
assert prov_mgr.get_descendants("leaf") == {}
def test_descendants_work_on_sqlite_backend(self, tmp_path):
db_path = str(tmp_path / "desc.db")
prov_mgr = ProvenanceManager(storage_path=db_path)
prov_mgr.track_entity("parent1", source="doc1")
prov_mgr.track_entity("child1", source="doc1", parent_entity_id="parent1")
result = prov_mgr.get_descendants("parent1")
entity_ids = {e["entity_id"] for e in result["entries"]}
assert "child1" in entity_ids
class TestQualifiedExport:
"""Issue #825, Part A item 6 — qualified Association with hadRole, plus
qualified Invalidation, in the RDF export."""
def test_export_prov_includes_qualified_association_and_role(self):
prov_mgr = ProvenanceManager()
prov_mgr.track_entity(
"e1", source="doc1", agent_id="alice", agent_type="person", role="approver"
)
ttl = prov_mgr.export_prov(format="turtle")
assert "qualifiedAssociation" in ttl
assert "hadRole" in ttl
assert "role_approver" in ttl
assert "Person" in ttl
def test_export_prov_includes_qualified_invalidation(self):
prov_mgr = ProvenanceManager()
prov_mgr.track_entity("e1", source="doc1")
prov_mgr.invalidate("e1", agent_id="reviewer_jane", reason="retracted")
ttl = prov_mgr.export_prov(format="turtle")
assert "qualifiedInvalidation" in ttl
assert "Invalidation" in ttl
class TestTypedActivity:
"""Issue #825, Part B Tier 1 — typed Activity via ActivityRecord."""
def test_track_entity_activity_record_kwarg(self):
from semantica.provenance import ActivityRecord
prov_mgr = ProvenanceManager()
activity = ActivityRecord(
id="bureau_parsing_run_42",
started_at_time="2026-01-01T00:00:00",
ended_at_time="2026-01-01T00:00:03",
)
entry = prov_mgr.track_entity("e1", source="doc1", activity=activity)
assert entry.activity_id == "bureau_parsing_run_42"
assert entry.activity_started_at_time == "2026-01-01T00:00:00"
assert entry.activity_ended_at_time == "2026-01-01T00:00:03"
def test_track_entity_activity_scalar_kwargs(self):
prov_mgr = ProvenanceManager()
entry = prov_mgr.track_entity(
"e1", source="doc1",
activity_id="parse_step", activity_started_at_time="t0", activity_ended_at_time="t1",
)
assert entry.activity_id == "parse_step"
assert entry.activity_started_at_time == "t0"
assert entry.activity_ended_at_time == "t1"
def test_export_prov_qualified_generation_usage_derivation(self):
prov_mgr = ProvenanceManager()
prov_mgr.track_entity("parent1", source="doc1")
prov_mgr.track_entity(
"child1", source="doc1",
parent_entity_id="parent1", used_entities=["parent1"],
activity_id="transform",
)
ttl = prov_mgr.export_prov(format="turtle")
assert "qualifiedGeneration" in ttl
assert "Generation" in ttl
assert "qualifiedUsage" in ttl
assert "Usage" in ttl
assert "qualifiedDerivation" in ttl
assert "Derivation" in ttl
def test_export_prov_activity_timing(self):
from semantica.provenance import ActivityRecord
prov_mgr = ProvenanceManager()
prov_mgr.track_entity(
"e1", source="doc1",
activity=ActivityRecord(id="act1", started_at_time="2026-01-01T00:00:00",
ended_at_time="2026-01-01T00:00:05"),
)
ttl = prov_mgr.export_prov(format="turtle")
assert "startedAtTime" in ttl
assert "endedAtTime" in ttl
class TestAssociationDelegationChaining:
"""Issue #825, Part B Tier 2 — wasAssociatedWith, actedOnBehalfOf, wasInformedBy."""
def test_acted_on_behalf_of(self):
prov_mgr = ProvenanceManager()
entry = prov_mgr.track_entity(
"e1", source="doc1", agent_id="bot1", acted_on_behalf_of="org1"
)
assert entry.acted_on_behalf_of == "org1"
ttl = prov_mgr.export_prov(format="turtle")
assert "actedOnBehalfOf" in ttl
def test_informed_by_activities(self):
prov_mgr = ProvenanceManager()
entry = prov_mgr.track_entity(
"e1", source="doc1", activity_id="parse", informed_by=["ingest_activity"]
)
assert entry.informed_by_activities == ["ingest_activity"]
ttl = prov_mgr.export_prov(format="turtle")
assert "wasInformedBy" in ttl
def test_was_associated_with_in_export(self):
prov_mgr = ProvenanceManager()
prov_mgr.track_entity("e1", source="doc1", agent_id="alice", activity_id="act1")
ttl = prov_mgr.export_prov(format="turtle")
assert "wasAssociatedWith" in ttl
def test_track_entities_batch_forwards_tier2_kwargs(self):
"""Regression test for the batch-kwargs-vs-metadata bug (issue #825,
Part A) extended to the new Tier 2/3 keys."""
prov_mgr = ProvenanceManager()
count = prov_mgr.track_entities_batch(
[{"id": "b1"}],
source="doc_batch",
activity_id="bureau_parsing",
acted_on_behalf_of="org1",
informed_by=["ingest_activity"],
bundle_id="run_1",
)
assert count == 1
entry = prov_mgr.storage.retrieve("b1")
assert entry.activity_id == "bureau_parsing"
assert entry.acted_on_behalf_of == "org1"
assert entry.informed_by_activities == ["ingest_activity"]
assert entry.bundle_id == "run_1"
assert "acted_on_behalf_of" not in entry.metadata
class TestBitemporalMerge:
"""Issue #825, Part B Tier 3 — revision_history()/query_recorded_between()
close kg.ProvenanceTracker's documented 'no direct equivalent yet' gaps."""
def test_revision_history_ascending_with_valid_until(self):
prov_mgr = ProvenanceManager()
prov_mgr.track_entity("X", source="doc1", agent_id="alice")
prov_mgr.track_entity("X", source="doc1", agent_id="bob", revision_type="correction")
prov_mgr.track_entity("X", source="doc1", agent_id="carol", supersedes="X_old_claim")
history = prov_mgr.revision_history("X")
assert len(history) == 3
assert [h["version"] for h in history] == [1, 2, 3]
assert history[0]["author"] == "alice"
assert history[1]["author"] == "bob"
assert history[1]["revision_type"] == "correction"
assert history[2]["author"] == "carol"
assert history[2]["supersedes"] == "X_old_claim"
# Every version except the last has a valid_until set to the next version's timestamp
assert history[0]["valid_until"] == history[1]["valid_from"]
assert history[1]["valid_until"] == history[2]["valid_from"]
assert history[2]["valid_until"] is None
def test_revision_history_empty_for_untracked_entity(self):
prov_mgr = ProvenanceManager()
assert prov_mgr.revision_history("never_tracked") == []
def test_revision_history_single_version(self):
prov_mgr = ProvenanceManager()
prov_mgr.track_entity("Y", source="doc1")
history = prov_mgr.revision_history("Y")
assert len(history) == 1
assert history[0]["version"] == 1
assert history[0]["valid_until"] is None
def test_query_recorded_between(self):
prov_mgr = ProvenanceManager()
prov_mgr.track_entity("e1", source="doc1")
prov_mgr.track_entity("e2", source="doc1")
results = prov_mgr.query_recorded_between("2000-01-01T00:00:00", "2100-01-01T00:00:00")
entity_ids = {r["entity_id"] for r in results}
assert {"e1", "e2"}.issubset(entity_ids)
no_results = prov_mgr.query_recorded_between("1990-01-01T00:00:00", "1990-01-02T00:00:00")
assert no_results == []
def test_check_flags_missing_informed_by_activity(self):
prov_mgr = ProvenanceManager()
prov_mgr.track_entity("e1", source="doc1", informed_by=["never_tracked_activity"])
result = prov_mgr.check()
assert result["valid"] is False
assert any("never_tracked_activity" in ref for ref in result["missing_references"])
class TestBundleAndBaseUri:
"""Issue #825, Part B Tier 3 — prov:Bundle membership and configurable base_uri."""
def test_bundle_id_produces_bundle_triples(self):
prov_mgr = ProvenanceManager()
prov_mgr.track_entity("e1", source="doc1", bundle_id="ingestion_run_1")
ttl = prov_mgr.export_prov(format="turtle")
assert "Bundle" in ttl
assert "hadMember" in ttl
def test_default_base_uri_matches_rdf_exporter_namespace(self):
from semantica.provenance.manager import DEFAULT_BASE_URI
from semantica.export.rdf_exporter import NamespaceManager
assert NamespaceManager().namespaces["semantica"] == DEFAULT_BASE_URI
def test_export_prov_base_uri_override(self):
prov_mgr = ProvenanceManager()
prov_mgr.track_entity("e1", source="doc1")
default_ttl = prov_mgr.export_prov(format="turtle")
assert "https://semantica.dev/ns#" in default_ttl
custom_ttl = prov_mgr.export_prov(format="turtle", base_uri="https://example.org/kg#")
assert "https://example.org/kg#" in custom_ttl
assert "https://semantica.dev/ns#" not in custom_ttl
def test_owl_exporter_default_ontology_uri_matches_shared_namespace(self):
"""Issue #825 follow-up — OWLExporter was the one exporter left out of
the Part B Tier 3 namespace interlinking; its default ontology_uri
must match the same shared DEFAULT_BASE_URI as RDFExporter and
export_prov()."""
from semantica.provenance.manager import DEFAULT_BASE_URI
from semantica.export.owl_exporter import OWLExporter
assert OWLExporter().ontology_uri == DEFAULT_BASE_URI
class TestExplicitBitemporalFields:
"""Issue #825 follow-up — valid_from/valid_until as explicit,
caller-supplied ProvenanceEntry fields (matching the deprecated
kg.ProvenanceTracker's actual contract: these were always caller-supplied
metadata keys, never auto-computed)."""
def test_valid_from_valid_until_are_plain_passthrough_fields(self):
prov_mgr = ProvenanceManager()
entry = prov_mgr.track_entity(
"price1", source="doc1", valid_from="2026-01-01", valid_until="2026-06-01"
)
assert entry.valid_from == "2026-01-01"
assert entry.valid_until == "2026-06-01"
def test_revision_history_prefers_explicit_valid_from_until(self):
prov_mgr = ProvenanceManager()
prov_mgr.track_entity(
"price1", source="doc1", valid_from="2026-01-01", valid_until="2026-06-01"
)
history = prov_mgr.revision_history("price1")
assert history[0]["valid_from"] == "2026-01-01"
assert history[0]["valid_until"] == "2026-06-01"
def test_revision_history_falls_back_to_timestamp_when_unset(self):
"""Backward-compat: entries that don't set valid_from/valid_until
explicitly still get the dynamic timestamp-based derivation."""
prov_mgr = ProvenanceManager()
prov_mgr.track_entity("X", source="doc1")
prov_mgr.track_entity("X", source="doc1")
history = prov_mgr.revision_history("X")
assert history[0]["valid_until"] == history[1]["valid_from"]
assert history[1]["valid_until"] is None
def test_valid_from_until_survive_sqlite_round_trip(self, tmp_path):
db_path = str(tmp_path / "bitemporal.db")
prov_mgr = ProvenanceManager(storage_path=db_path)
prov_mgr.track_entity(
"price1", source="doc1", valid_from="2026-01-01", valid_until="2026-06-01"
)
prov_mgr2 = ProvenanceManager(storage_path=db_path)
entry = prov_mgr2.storage.retrieve("price1")
assert entry.valid_from == "2026-01-01"
assert entry.valid_until == "2026-06-01"
class TestActivityTimingAcrossWrappers:
"""Issue #825 follow-up — activity_started_at_time/ended_at_time wired
into all *_provenance.py wrapper modules that measure real work, not
just the 2 wrappers from the original Part B pass."""
def test_embedding_wrapper_records_activity_timing(self):
from semantica.embeddings.embeddings_provenance import EmbeddingGeneratorWithProvenance
wrapper = EmbeddingGeneratorWithProvenance(provenance=True, agent_id="embed_svc")
wrapper._generator.embed = lambda texts, **kw: [[0.1, 0.2] for _ in texts]
wrapper.embed(["hello", "world"])
entries = wrapper._prov_manager.storage.retrieve_all()
assert len(entries) == 1
assert entries[0].activity_started_at_time is not None
assert entries[0].activity_ended_at_time is not None
def test_kg_algorithm_tracker_accepts_caller_supplied_activity_timing(self):
from semantica.kg.kg_provenance import AlgorithmTrackerWithProvenance
tracker = AlgorithmTrackerWithProvenance(provenance=True, agent_id="algo_svc")
eid = tracker.track_embedding_computation(
graph=object(), algorithm="node2vec", embeddings={"n1": [0.1, 0.2]},
parameters={"d": 2},
activity_started_at_time="t0", activity_ended_at_time="t1",
)
entry = tracker._prov_manager.get_provenance(eid)
assert entry["activity_started_at_time"] == "t0"
assert entry["activity_ended_at_time"] == "t1"
def test_graph_builder_build_operation_has_no_end_time_yet(self):
"""The build-operation marker is recorded before the build runs, so
it legitimately has a start but no end time."""
from semantica.kg.kg_provenance import GraphBuilderWithProvenance
builder = GraphBuilderWithProvenance(provenance=True, agent_id="builder_svc")
builder._builder.build_single_source = lambda kg_data, **kw: {"entities": [], "relationships": []}
builder.build_single_source({"foo": "bar"})
entries = builder._prov_manager.storage.retrieve_all()
# NOTE: entity_type isn't asserted here — kg_provenance.py has a
# pre-existing, out-of-scope bug where entity_type is nested inside
# the metadata dict instead of passed as a track_entity kwarg, so it
# never actually populates the real field for this call site.
build_entries = [e for e in entries if e.entity_id.startswith("graph_build_single_")]
assert len(build_entries) == 1
assert build_entries[0].activity_started_at_time is not None
assert build_entries[0].activity_ended_at_time is None
def test_semantic_extract_wrapper_records_activity_timing(self):
from semantica.semantic_extract.semantic_extract_provenance import ProvenanceMixin
class FakeExtractor(ProvenanceMixin):
pass
wrapper = FakeExtractor(provenance=True, agent_id="extract_svc")
wrapper._track_extraction(
entity_id="e1", source="doc1", entity_type="named_entity",
activity_started_at_time="t0", activity_ended_at_time="t1",
)
entry = wrapper._prov_manager.get_provenance("e1")
assert entry["activity_started_at_time"] == "t0"
assert entry["activity_ended_at_time"] == "t1"
assert "activity_started_at_time" not in entry["metadata"]
def test_conflicts_wrapper_records_activity_timing(self):
from semantica.conflicts.conflicts_provenance import SourceTrackerWithUnifiedBackend
class FakeSource:
document = "doc1"
page = 1
section = None
confidence = 0.9
tracker = SourceTrackerWithUnifiedBackend(agent_id="conflicts_svc")
tracker.track_property_source("e1", "prop", "val", FakeSource())
entry = tracker._unified_manager.get_provenance("e1_prop")
assert entry["activity_started_at_time"] is not None
assert entry["activity_ended_at_time"] is not None