mirror of
https://github.com/semantica-agi/semantica.git
synced 2026-08-29 04:26:20 +00:00
* fix(provenance): use timezone-aware UTC and assert stored records (#946) Replace datetime.utcnow() in ProvenanceManager, ProvenanceEntry, BridgeAxiom, and GraphBuilderWithProvenance with datetime.now(timezone.utc), matching PipelineWithProvenance. KG workflow and integration tests now read provenance back through get_provenance() and assert algorithm metadata instead of generated IDs, and call tracker methods that actually persist records. * fix(provenance): compare provenance timestamps as instants, not strings query_recorded_between() and audit_log() filtered and sorted on raw ISO strings. With the timezone-aware change, a store can hold both pre-existing naive stamps and offset-bearing ones, and the two are not string-comparable: "...500000+00:00" sorts above "...500000", so a record at the identical instant as a naive bound falls outside the range that should contain it. Both now parse through _parse_timestamp() before comparing, reading naive values as UTC. This mirrors ProvenanceTracker._parse_dt() in kg/, the class ProvenanceManager replaces, so both sides of the migration answer a range query the same way. Unparseable stored timestamps are skipped and logged rather than silently dropped; unparseable bounds raise ValueError. --------- Co-authored-by: Pravit Ampapathini <pravit.amp@gmail.com>
1868 lines
81 KiB
Python
1868 lines
81 KiB
Python
"""
|
|
Test Unified Provenance Manager
|
|
|
|
Tests for ProvenanceManager functionality including entity tracking,
|
|
chunk tracking, source tracking, and lineage tracing.
|
|
"""
|
|
|
|
import pytest
|
|
import warnings
|
|
from unittest.mock import patch
|
|
from datetime import datetime, timedelta, timezone
|
|
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_clear_resets_chain_state(self):
|
|
"""Regression: clear() must fully reset chain state so the first write
|
|
after clear starts a fresh chain and verify_chain() passes."""
|
|
prov_mgr = ProvenanceManager()
|
|
|
|
prov_mgr.track_entity("e1", source="doc1")
|
|
prov_mgr.track_entity("e2", source="doc1")
|
|
prov_mgr.clear()
|
|
|
|
# Chain head must be empty immediately after clear
|
|
assert prov_mgr.storage.get_chain_head() is None
|
|
|
|
# First write after clear starts a fresh chain (sequence_id=1, previous_checksum=None)
|
|
entry = prov_mgr.track_entity("e3", source="doc2")
|
|
assert entry.sequence_id == 1
|
|
assert entry.previous_checksum is None
|
|
|
|
# verify_chain() must pass on the fresh chain
|
|
prov_mgr.track_entity("e4", source="doc2")
|
|
result = prov_mgr.verify_chain()
|
|
assert result["valid"] is True
|
|
assert result["total_entries"] == 2
|
|
assert result["broken_links"] == []
|
|
|
|
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_relationship_storage_error_swallowed_sqlite(self, tmp_path):
|
|
"""Test that track_relationship returns None when storage.store() fails
|
|
on the SQLite backend, mirroring the InMemory contract verified by
|
|
test_track_relationship_storage_error_swallowed (#783/#785)."""
|
|
db_path = str(tmp_path / "test_track_relationship_sqlite.db")
|
|
prov_mgr = ProvenanceManager(storage_path=db_path)
|
|
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_sqlite(self, tmp_path):
|
|
"""Test that track_chunk returns None when storage.store() fails
|
|
on the SQLite backend, mirroring the InMemory contract verified by
|
|
test_track_chunk_storage_error_swallowed (#783/#785)."""
|
|
db_path = str(tmp_path / "test_track_chunk_sqlite.db")
|
|
prov_mgr = ProvenanceManager(storage_path=db_path)
|
|
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_track_property_source_storage_error_swallowed_sqlite(self, tmp_path):
|
|
"""Test that track_property_source returns None when storage.store() fails
|
|
on the SQLite backend, mirroring the InMemory contract verified by
|
|
test_track_property_source_storage_error_swallowed (#783/#785)."""
|
|
db_path = str(tmp_path / "test_track_property_source_sqlite.db")
|
|
prov_mgr = ProvenanceManager(storage_path=db_path)
|
|
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.now(timezone.utc).isoformat(),
|
|
last_updated=datetime.now(timezone.utc).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_chunks_batch_logs_per_item_failure_memory(self):
|
|
"""Test that track_chunks_batch emits item-level logs via _save_entry when items fail,
|
|
mirroring test_track_entities_batch_logs_per_item_failure for the chunk path (#783/#785)."""
|
|
prov_mgr = ProvenanceManager()
|
|
chunks = [
|
|
{"id": "chk_fail_1", "start_index": 0, "end_index": 10},
|
|
{"id": "chk_fail_2", "start_index": 0, "end_index": 10},
|
|
]
|
|
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_chunks_batch(chunks, source_document="doc_1")
|
|
assert count == 0
|
|
assert mock_log_error.call_count == 2
|
|
|
|
def test_track_chunks_batch_block_level_transaction_failure_logs(self, tmp_path):
|
|
"""Test that track_chunks_batch logs the block-level failure message when the
|
|
shared transaction itself fails to commit, mirroring the entities-batch coverage
|
|
established by test_batch_rollback_does_not_count_unpersisted_entries (#807/#785)."""
|
|
from contextlib import contextmanager
|
|
import sqlite3
|
|
|
|
db_path = str(tmp_path / "test_chunks_block_level_failure.db")
|
|
prov_mgr = ProvenanceManager(storage_path=db_path)
|
|
chunks = [{"id": f"chk_{i}", "start_index": 0, "end_index": 10} for i in range(5)]
|
|
|
|
# Capture the unpatched bound method first: once `transaction` is patched below,
|
|
# `prov_mgr.storage.transaction` would resolve to the mock itself, and calling it
|
|
# from inside failing_tx() would recurse into the mock instead of the real
|
|
# implementation (RecursionError, not the intended sqlite3.OperationalError).
|
|
orig_transaction = prov_mgr.storage.transaction
|
|
|
|
@contextmanager
|
|
def failing_tx():
|
|
with orig_transaction() as conn:
|
|
yield conn
|
|
raise sqlite3.OperationalError("Commit failed")
|
|
|
|
with patch.object(prov_mgr.storage, "transaction", side_effect=failing_tx), \
|
|
patch.object(prov_mgr.logger, "error") as mock_log_error:
|
|
count = prov_mgr.track_chunks_batch(chunks, source_document="doc_1")
|
|
assert count == 0
|
|
mock_log_error.assert_called_once()
|
|
assert "Block-level storage transaction failed in track_chunks_batch" in mock_log_error.call_args[0][0]
|
|
# Confirm the logged exception is the injected commit failure, not some
|
|
# other failure mode (e.g. a mocking mistake re-entering the patched mock).
|
|
logged_exc = mock_log_error.call_args[0][1]
|
|
assert isinstance(logged_exc, sqlite3.OperationalError)
|
|
assert str(logged_exc) == "Commit failed"
|
|
|
|
assert len(prov_mgr.storage.retrieve_all()) == 0
|
|
|
|
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_verify_chain_detects_tampered_sequence_gap(self, tmp_path):
|
|
"""The sequence_id continuity check catches a gap introduced by
|
|
directly tampering with a row's sequence_id column, independent of
|
|
the previous_checksum comparison — hardening against the narrow case
|
|
where compute_checksum()'s deliberate exclusion of entity_id could
|
|
otherwise let two distinct rows coincidentally share a checksum."""
|
|
import sqlite3
|
|
db_path = str(tmp_path / "chain_seq_gap.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("UPDATE provenance SET sequence_id = 10 WHERE entity_id = 'e3'")
|
|
conn.commit()
|
|
conn.close()
|
|
|
|
prov_mgr2 = ProvenanceManager(storage_path=db_path)
|
|
result = prov_mgr2.verify_chain()
|
|
assert result["valid"] is False
|
|
broken = [link for link in result["broken_links"] if link["entity_id"] == "e3"]
|
|
assert broken
|
|
assert broken[0]["expected_sequence_id"] == 3
|
|
|
|
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
|
|
|
|
|
|
class TestTimezoneAwareUtcTimestamps:
|
|
"""Issue #946 — timezone-aware UTC stamps without datetime.utcnow()."""
|
|
|
|
def test_track_entity_stamps_timezone_aware_utc(self):
|
|
before = datetime.now(timezone.utc) - timedelta(seconds=1)
|
|
with warnings.catch_warnings(record=True) as caught:
|
|
warnings.simplefilter("always", DeprecationWarning)
|
|
prov_mgr = ProvenanceManager()
|
|
entry = prov_mgr.track_entity("utc_entity", source="doc_1")
|
|
after = datetime.now(timezone.utc) + timedelta(seconds=1)
|
|
|
|
utcnow_warnings = [
|
|
w
|
|
for w in caught
|
|
if issubclass(w.category, DeprecationWarning)
|
|
and "utcnow" in str(w.message).lower()
|
|
]
|
|
assert utcnow_warnings == []
|
|
|
|
for field in ("timestamp", "first_seen", "last_updated"):
|
|
value = getattr(entry, field)
|
|
parsed = datetime.fromisoformat(value)
|
|
assert parsed.tzinfo is not None, f"{field}={value!r} is naive"
|
|
assert parsed.utcoffset() == timedelta(0), f"{field}={value!r} is not UTC"
|
|
assert before <= parsed <= after
|
|
|
|
stored = prov_mgr.get_provenance("utc_entity")
|
|
assert stored is not None
|
|
assert stored["source_document"] == "doc_1"
|
|
assert stored["last_updated"] == entry.last_updated
|
|
|
|
def test_invalidate_stamps_timezone_aware_utc(self):
|
|
prov_mgr = ProvenanceManager()
|
|
prov_mgr.track_entity("utc_invalidate", source="doc_1")
|
|
result = prov_mgr.invalidate(
|
|
"utc_invalidate", agent_id="reviewer", reason="test"
|
|
)
|
|
parsed = datetime.fromisoformat(result.invalidated_at_time)
|
|
assert parsed.tzinfo is not None
|
|
assert parsed.utcoffset() == timedelta(0)
|
|
|
|
def test_graph_builder_activity_times_are_timezone_aware_utc(self):
|
|
from semantica.kg.kg_provenance import GraphBuilderWithProvenance
|
|
|
|
builder = GraphBuilderWithProvenance(provenance=True, agent_id="builder_svc")
|
|
result = builder.build_single_source({
|
|
"entities": [{"id": "person1", "type": "Person", "name": "Ada"}],
|
|
"relationships": [],
|
|
})
|
|
assert result["entities"][0]["id"] == "person1"
|
|
|
|
person = builder._prov_manager.get_provenance("person1")
|
|
assert person is not None
|
|
assert person["metadata"]["operation"] == "build_entity"
|
|
for field in ("activity_started_at_time", "activity_ended_at_time"):
|
|
parsed = datetime.fromisoformat(person[field])
|
|
assert parsed.tzinfo is not None, (
|
|
f"{field}={person[field]!r} is naive"
|
|
)
|
|
assert parsed.utcoffset() == timedelta(0)
|
|
started = datetime.fromisoformat(person["activity_started_at_time"])
|
|
ended = datetime.fromisoformat(person["activity_ended_at_time"])
|
|
assert started <= ended
|
|
|
|
class TestMixedFormatTimestampComparisons:
|
|
"""Issue #946 review — naive and offset-bearing stamps must compare as instants.
|
|
|
|
Records written before the timezone-aware change carry a naive UTC stamp
|
|
(``2026-08-19T22:35:38.501697``); records written after carry ``+00:00``.
|
|
Comparing the two as raw strings puts the offset-bearing form above a naive
|
|
bound at the identical instant, so the record falls outside a range that
|
|
should contain it. These tests pin the boundary in both directions.
|
|
"""
|
|
|
|
NAIVE = "2026-08-19T12:00:00.500000"
|
|
AWARE = "2026-08-19T12:00:00.500000+00:00"
|
|
|
|
def _manager_with(self, *timestamps):
|
|
prov_mgr = ProvenanceManager()
|
|
for index, stamp in enumerate(timestamps):
|
|
prov_mgr.storage.store(
|
|
ProvenanceEntry(
|
|
entity_id=f"entity_{index}",
|
|
entity_type="entity",
|
|
activity_id="test",
|
|
source_document="doc_1",
|
|
timestamp=stamp,
|
|
)
|
|
)
|
|
return prov_mgr
|
|
|
|
def test_raw_string_compare_would_exclude_the_boundary_record(self):
|
|
"""Guards the premise: the two forms are not string-comparable."""
|
|
assert not (self.AWARE <= self.NAIVE)
|
|
assert datetime.fromisoformat(self.AWARE) == datetime.fromisoformat(
|
|
self.NAIVE
|
|
).replace(tzinfo=timezone.utc)
|
|
|
|
def test_query_range_with_naive_bounds_includes_aware_record(self):
|
|
prov_mgr = self._manager_with(self.AWARE)
|
|
results = prov_mgr.query_recorded_between(
|
|
"2026-08-19T12:00:00.500000", "2026-08-19T12:00:00.500000"
|
|
)
|
|
assert [r["entity_id"] for r in results] == ["entity_0"]
|
|
|
|
def test_query_range_with_aware_bounds_includes_naive_record(self):
|
|
prov_mgr = self._manager_with(self.NAIVE)
|
|
results = prov_mgr.query_recorded_between(
|
|
"2026-08-19T12:00:00.500000+00:00", "2026-08-19T12:00:00.500000+00:00"
|
|
)
|
|
assert [r["entity_id"] for r in results] == ["entity_0"]
|
|
|
|
def test_query_range_returns_both_formats_from_a_mixed_store(self):
|
|
prov_mgr = self._manager_with(self.NAIVE, self.AWARE)
|
|
results = prov_mgr.query_recorded_between(
|
|
"2026-08-19T11:00:00", "2026-08-19T13:00:00+00:00"
|
|
)
|
|
assert {r["entity_id"] for r in results} == {"entity_0", "entity_1"}
|
|
|
|
def test_query_range_sorts_mixed_formats_chronologically(self):
|
|
prov_mgr = self._manager_with(
|
|
"2026-08-19T12:00:02+00:00", # entity_0, latest
|
|
"2026-08-19T12:00:00", # entity_1, earliest
|
|
"2026-08-19T12:00:01+00:00", # entity_2, middle
|
|
)
|
|
results = prov_mgr.query_recorded_between(
|
|
"2026-08-19T11:00:00", "2026-08-19T13:00:00"
|
|
)
|
|
assert [r["entity_id"] for r in results] == [
|
|
"entity_1",
|
|
"entity_2",
|
|
"entity_0",
|
|
]
|
|
|
|
def test_query_range_accepts_trailing_z(self):
|
|
prov_mgr = self._manager_with(self.NAIVE)
|
|
results = prov_mgr.query_recorded_between(
|
|
"2026-08-19T11:00:00Z", "2026-08-19T13:00:00Z"
|
|
)
|
|
assert len(results) == 1
|
|
|
|
def test_query_range_skips_unparseable_timestamp(self):
|
|
prov_mgr = self._manager_with("not-a-timestamp", self.AWARE)
|
|
results = prov_mgr.query_recorded_between(
|
|
"2026-08-19T11:00:00", "2026-08-19T13:00:00"
|
|
)
|
|
assert [r["entity_id"] for r in results] == ["entity_1"]
|
|
|
|
def test_audit_log_since_naive_bound_includes_aware_record(self):
|
|
prov_mgr = self._manager_with(self.AWARE)
|
|
entries = prov_mgr.audit_log(
|
|
since="2026-08-19T12:00:00.500000", format="json"
|
|
)
|
|
assert [e["entity_id"] for e in entries] == ["entity_0"]
|
|
|
|
def test_audit_log_since_aware_bound_includes_naive_record(self):
|
|
prov_mgr = self._manager_with(self.NAIVE)
|
|
entries = prov_mgr.audit_log(
|
|
since="2026-08-19T12:00:00.500000+00:00", format="json"
|
|
)
|
|
assert [e["entity_id"] for e in entries] == ["entity_0"]
|
|
|
|
def test_audit_log_excludes_records_before_since_across_formats(self):
|
|
prov_mgr = self._manager_with(
|
|
"2026-08-19T11:59:59", # entity_0, before
|
|
"2026-08-19T12:00:01+00:00", # entity_1, after
|
|
)
|
|
entries = prov_mgr.audit_log(since="2026-08-19T12:00:00+00:00", format="json")
|
|
assert [e["entity_id"] for e in entries] == ["entity_1"]
|
|
|
|
def test_audit_log_sorts_mixed_formats_chronologically(self):
|
|
prov_mgr = self._manager_with(
|
|
"2026-08-19T12:00:02+00:00",
|
|
"2026-08-19T12:00:00",
|
|
"2026-08-19T12:00:01+00:00",
|
|
)
|
|
entries = prov_mgr.audit_log(format="json")
|
|
assert [e["entity_id"] for e in entries] == [
|
|
"entity_1",
|
|
"entity_2",
|
|
"entity_0",
|
|
]
|
|
|
|
def test_audit_log_unparseable_timestamp_sorts_first(self):
|
|
prov_mgr = self._manager_with("not-a-timestamp", "2026-08-19T12:00:00+00:00")
|
|
entries = prov_mgr.audit_log(format="json")
|
|
assert [e["entity_id"] for e in entries] == ["entity_0", "entity_1"]
|
|
|
|
def test_audit_log_since_excludes_unparseable_timestamp(self):
|
|
prov_mgr = self._manager_with("not-a-timestamp", "2026-08-19T12:00:00+00:00")
|
|
entries = prov_mgr.audit_log(since="2026-08-19T11:00:00", format="json")
|
|
assert [e["entity_id"] for e in entries] == ["entity_1"]
|