mirror of
https://github.com/semantica-agi/semantica.git
synced 2026-08-29 04:26:20 +00:00
Fix ProvenanceManager.get_lineage not linking entities via derived_from
track_entity() only auto-linked a parent by looking up `source` as an existing entity_id, so two entities sharing a real source URL (e.g. a document and a decision derived from it) never got connected, and metadata["derived_from"] was stored but never consulted by any linking or traversal code. track_entity() now treats metadata["derived_from"] as an explicit parent link (unless parent_entity_id was already passed directly), so the existing BFS in trace_lineage() picks it up for free. Closes #735
This commit is contained in:
@@ -22,6 +22,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Fixed
|
||||
|
||||
- **`ProvenanceManager.get_lineage` does not link entities that share a source URL** (#735) by @KaifAhmad1
|
||||
- `track_entity()`'s only auto-linking logic looked up `source` as if it were an existing entity's `entity_id`, so passing the same real URL/DOI as `source` for two conceptually linked entities (e.g. a document and a decision derived from it) never produced a parent link, leaving `get_lineage()` returning a chain of length 1
|
||||
- `metadata["derived_from"]` was preserved and echoed back in the output JSON but was never consulted by any linking or traversal code, so the caller's explicit relationship was silently inert
|
||||
- `track_entity()` now treats `metadata["derived_from"]` as an explicit parent link (unless `parent_entity_id` was already passed directly), so `InMemoryStorage.trace_lineage()`'s existing BFS over `parent_entity_id` picks it up for free
|
||||
- Added 7 regression/edge-case tests in `tests/provenance/test_manager.py` covering the happy path, explicit `parent_entity_id` precedence over `derived_from`, precedence over the `source`-as-known-entity-id fallback, a `derived_from` pointing at a never-tracked entity, non-string/empty-string `derived_from` values being ignored, a self-referencing `derived_from` not hanging traversal, and multi-hop `derived_from` chains, closing #735
|
||||
|
||||
- **`InferenceResult.premises` always empty from `forward_chain`/`backward_chain`** (#739) by @Sameer6305
|
||||
- `_match_rule()` discarded matched facts and returned only instantiated conclusions, so `ExplanationGenerator` always produced empty premises lists regardless of which facts actually satisfied a rule, closing #733
|
||||
- `_match_rule()` now returns `(conclusion, matched_facts)` tuples; `forward_chain()` threads those facts into `InferenceResult(premises=...)`, merging premises when the same conclusion is derived more than once within a pass
|
||||
|
||||
@@ -115,7 +115,14 @@ class ProvenanceManager:
|
||||
# Check if entity already exists
|
||||
existing = self.storage.retrieve(entity_id)
|
||||
parent_id = kwargs.get("parent_entity_id")
|
||||
|
||||
|
||||
# If caller declared an explicit parent via metadata, honor it
|
||||
# (unless parent_entity_id was already passed directly)
|
||||
if not parent_id and metadata and isinstance(metadata, dict):
|
||||
derived_from = metadata.get("derived_from")
|
||||
if derived_from and isinstance(derived_from, str):
|
||||
parent_id = derived_from
|
||||
|
||||
# If source is a known entity, link it as parent (unless parent already set)
|
||||
if not parent_id and source and isinstance(source, str):
|
||||
try:
|
||||
|
||||
@@ -104,7 +104,150 @@ class TestProvenanceManager:
|
||||
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_batch_entity_tracking(self):
|
||||
"""Test batch entity tracking."""
|
||||
prov_mgr = ProvenanceManager()
|
||||
|
||||
Reference in New Issue
Block a user