diff --git a/src/vre/__init__.py b/src/vre/__init__.py index 35ca720..6b42d03 100644 --- a/src/vre/__init__.py +++ b/src/vre/__init__.py @@ -19,7 +19,7 @@ from vre.core.graph import PrimitiveRepository from vre.core.grounding import ConceptResolver, GroundingEngine, GroundingResult -from vre.core.models import DepthLevel, Provenance, ProvenanceSource +from vre.core.models import CyclicRelationshipError, DepthLevel, Provenance, ProvenanceSource from vre.core.policy import Cardinality, PolicyAction, PolicyCallbackResult, PolicyResult, PolicyViolation from vre.core.policy.callback import PolicyCallContext from vre.core.policy.gate import PolicyGate @@ -33,6 +33,7 @@ __all__ = [ "VRE", + "CyclicRelationshipError", "PrimitiveRepository", "ConceptResolver", "GroundingEngine", @@ -92,6 +93,8 @@ def check( Parameters ---------- + concepts: + List of free-form concept names to ground. min_depth: Optional integrator override — enforces a minimum depth floor on all root primitives. Can only raise the floor, never lower it. diff --git a/src/vre/core/__init__.py b/src/vre/core/__init__.py index c23b6b4..3d4acc8 100644 --- a/src/vre/core/__init__.py +++ b/src/vre/core/__init__.py @@ -1,6 +1,16 @@ # Copyright 2026 Andrew Greene # Licensed under the Apache License, Version 2.0 -from vre.core.models import Provenance, ProvenanceSource +from vre.core.models import ( + CyclicRelationshipError, + Provenance, + ProvenanceSource, + TRANSITIVE_RELATION_TYPES, +) -__all__ = ["Provenance", "ProvenanceSource"] +__all__ = [ + "CyclicRelationshipError", + "Provenance", + "ProvenanceSource", + "TRANSITIVE_RELATION_TYPES", +] diff --git a/src/vre/core/graph.py b/src/vre/core/graph.py index 1887c72..be0440c 100644 --- a/src/vre/core/graph.py +++ b/src/vre/core/graph.py @@ -16,6 +16,7 @@ from neo4j import GraphDatabase from vre.core.models import ( + CyclicRelationshipError, Depth, DepthLevel, EpistemicStep, @@ -24,11 +25,12 @@ Relatum, RelationType, ResolvedSubgraph, + TRANSITIVE_RELATION_TYPES, ) from vre.core.policy.models import parse_policy -_TRANSITIVE_RELS = ["REQUIRES", "DEPENDS_ON", "CONSTRAINED_BY"] +_TRANSITIVE_RELS = [rt.value for rt in TRANSITIVE_RELATION_TYPES] class PrimitiveRepository: @@ -162,6 +164,51 @@ def _hydrate_primitive( provenance=node_prov, ) + @staticmethod + def _check_transitive_cycles( + tx: Any, + source_id: str, + relata_params: list[dict[str, Any]], + transitive_types: list[str], + ) -> None: + """ + Verify that no new transitive edge would create a cycle. + + Called inside the write transaction after old edges have been deleted. + For each new transitive relatum, checks whether the target can already + reach the source via transitive edges. Raises CyclicRelationshipError + on the first cycle found. + """ + type_union = "|".join(transitive_types) + + for rp in relata_params: + if rp["relation_type"] not in transitive_types: + continue + + if rp["target_id"] == source_id: + raise CyclicRelationshipError( + f"Self-referential {rp['relation_type']} on {source_id}" + ) + + record = tx.run( + cast( + LiteralString, + "MATCH (t:Primitive {id: $target_id}) " + "MATCH (s:Primitive {id: $source_id}) " + f"OPTIONAL MATCH path = (t)-[:{type_union}*1..]->(s) " + "RETURN path IS NOT NULL AS would_cycle " + "LIMIT 1", + ), + source_id=source_id, + target_id=rp["target_id"], + ).single() + + if record and record["would_cycle"]: + raise CyclicRelationshipError( + f"{rp['relation_type']} from {source_id} to " + f"{rp['target_id']} would create a cycle" + ) + def save_primitive(self, primitive: Primitive) -> None: """ Persist a Primitive — full replace of depths and relata. @@ -210,6 +257,10 @@ def _tx(tx: Any) -> None: id=str(primitive.id), ) + PrimitiveRepository._check_transitive_cycles( + tx, str(primitive.id), relata_params, _TRANSITIVE_RELS, + ) + for rp in relata_params: tx.run( cast( diff --git a/src/vre/core/models.py b/src/vre/core/models.py index 5bb2d11..7254b4b 100644 --- a/src/vre/core/models.py +++ b/src/vre/core/models.py @@ -39,6 +39,21 @@ class RelationType(str, Enum): INCLUDES = "INCLUDES" +TRANSITIVE_RELATION_TYPES: frozenset[RelationType] = frozenset( + { + RelationType.REQUIRES, + RelationType.CONSTRAINED_BY, + RelationType.DEPENDS_ON, + } +) + + +class CyclicRelationshipError(ValueError): + """ + Raised when an edge would create a cycle on transitive relationship types. + """ + + class ProvenanceSource(str, Enum): """ Origin category for knowledge in the epistemic graph. diff --git a/src/vre/learning/engine.py b/src/vre/learning/engine.py index 7185fae..3cde741 100644 --- a/src/vre/learning/engine.py +++ b/src/vre/learning/engine.py @@ -14,6 +14,7 @@ from vre.core.graph import PrimitiveRepository from vre.core.grounding.models import GroundingResult from vre.core.models import ( + CyclicRelationshipError, Depth, DepthGap, DepthLevel, @@ -261,8 +262,6 @@ def _persist_reachability( result = self._learn_missing_depths(target, candidate.target_depth_level, grounding, callback) if result in (CandidateDecision.REJECTED, CandidateDecision.SKIPPED): return result - if result is not None: - target = self._repo.find_by_id(target.id) # Phase 2: place the edge depth_obj = next(d for d in source.depths if d.level == candidate.source_depth_level) @@ -276,7 +275,13 @@ def _persist_reachability( depth_obj.relata.append(new_relatum) source.depths.sort(key=lambda d: int(d.level)) - self._repo.save_primitive(source) + + try: + self._repo.save_primitive(source) + except CyclicRelationshipError: + depth_obj.relata.remove(new_relatum) + return CandidateDecision.SKIPPED + return CandidateDecision.ACCEPTED def _persist( diff --git a/tests/vre/test_learning.py b/tests/vre/test_learning.py index 91f30b0..1a2665d 100644 --- a/tests/vre/test_learning.py +++ b/tests/vre/test_learning.py @@ -5,18 +5,21 @@ learning loop. """ +from collections import deque from uuid import UUID, uuid4 import pytest from vre.core.grounding.models import GroundingResult from vre.core.models import ( + CyclicRelationshipError, Depth, DepthGap, DepthLevel, EpistemicQuery, EpistemicResponse, EpistemicResult, + EpistemicStep, ExistenceGap, Primitive, Provenance, @@ -25,6 +28,7 @@ Relatum, RelationalGap, RelationType, + TRANSITIVE_RELATION_TYPES, ) from vre.learning.callback import LearningCallback from vre.learning.engine import LearningEngine, _make_provenance @@ -104,6 +108,34 @@ def find_by_id(self, id: UUID) -> Primitive | None: return self._by_id.get(id) def save_primitive(self, primitive: Primitive) -> None: + for depth in primitive.depths: + for relatum in depth.relata: + if relatum.relation_type not in TRANSITIVE_RELATION_TYPES: + continue + if relatum.target_id == primitive.id: + raise CyclicRelationshipError( + f"Self-referential {relatum.relation_type.value} " + f"on {primitive.name}" + ) + visited: set[UUID] = {relatum.target_id} + queue: deque[UUID] = deque([relatum.target_id]) + while queue: + current = queue.popleft() + p = self._by_id.get(current) + if p is None: + continue + for d in p.depths: + for r in d.relata: + if r.relation_type not in TRANSITIVE_RELATION_TYPES: + continue + if r.target_id == primitive.id: + raise CyclicRelationshipError( + f"{relatum.relation_type.value} from " + f"{primitive.name} would create a cycle" + ) + if r.target_id not in visited: + visited.add(r.target_id) + queue.append(r.target_id) self._by_id[primitive.id] = primitive self.saved.append(primitive) @@ -1026,3 +1058,345 @@ def callback(candidate, gr, gap): prim, DepthLevel.CAPABILITIES, grounding, callback, ) assert result == CandidateDecision.REJECTED + + +# --------------------------------------------------------------------------- +# Cycle detection +# --------------------------------------------------------------------------- + +def _reachability_grounding( + source: Primitive, + target: Primitive, + all_primitives: list[Primitive], + pathway: list[EpistemicStep] | None = None, +) -> tuple[ReachabilityGap, GroundingResult]: + """Build a ReachabilityGap + GroundingResult with a trace for cycle tests.""" + gap = ReachabilityGap(primitive=source) + trace = EpistemicResponse( + query=EpistemicQuery(concept_ids=[]), + result=EpistemicResult( + primitives=all_primitives, + gaps=[gap], + pathway=pathway or [], + ), + ) + grounding = GroundingResult( + grounded=False, resolved=[], gaps=[gap], trace=trace, + ) + return gap, grounding + + +class TestCycleDetection: + """Cycle detection via save_primitive → CyclicRelationshipError → SKIPPED.""" + + def test_self_referential_transitive_skipped(self): + """A→A via REQUIRES is a trivial cycle → SKIPPED.""" + a = _primitive("A", depths=[ + _depth(DepthLevel.EXISTENCE), + _depth(DepthLevel.IDENTITY), + _depth(DepthLevel.CAPABILITIES), + ]) + repo = StubRepository([a]) + engine = LearningEngine(repo) + gap, grounding = _reachability_grounding(a, a, [a]) + + filled = ReachabilityCandidate( + target_name="A", + relation_type=RelationType.REQUIRES, + source_depth_level=DepthLevel.CAPABILITIES, + target_depth_level=DepthLevel.CAPABILITIES, + ) + + def callback(candidate, gr, gap): + return filled, CandidateDecision.ACCEPTED + + result = engine.learn_at(grounding, 0, callback) + assert result.decision == CandidateDecision.SKIPPED + + def test_direct_cycle_skipped(self): + """A→B via REQUIRES exists; B→A via REQUIRES would cycle → SKIPPED.""" + b = _primitive("B", depths=[ + _depth(DepthLevel.EXISTENCE), + _depth(DepthLevel.IDENTITY), + _depth(DepthLevel.CAPABILITIES), + ]) + a = _primitive("A", depths=[ + _depth(DepthLevel.EXISTENCE), + _depth(DepthLevel.IDENTITY), + Depth( + level=DepthLevel.CAPABILITIES, + properties={}, + relata=[Relatum( + relation_type=RelationType.REQUIRES, + target_id=b.id, + target_depth=DepthLevel.CAPABILITIES, + provenance=_prov(), + )], + provenance=_prov(), + ), + ]) + repo = StubRepository([a, b]) + engine = LearningEngine(repo) + gap, grounding = _reachability_grounding(b, a, [a, b]) + + filled = ReachabilityCandidate( + target_name="A", + relation_type=RelationType.REQUIRES, + source_depth_level=DepthLevel.CAPABILITIES, + target_depth_level=DepthLevel.CAPABILITIES, + ) + + def callback(candidate, gr, gap): + return filled, CandidateDecision.ACCEPTED + + result = engine.learn_at(grounding, 0, callback) + assert result.decision == CandidateDecision.SKIPPED + + def test_indirect_cycle_skipped(self): + """A→B→C via DEPENDS_ON exists; C→A would cycle → SKIPPED.""" + c = _primitive("C", depths=[ + _depth(DepthLevel.EXISTENCE), + _depth(DepthLevel.IDENTITY), + _depth(DepthLevel.CAPABILITIES), + ]) + b = _primitive("B", depths=[ + _depth(DepthLevel.EXISTENCE), + _depth(DepthLevel.IDENTITY), + Depth( + level=DepthLevel.CAPABILITIES, + properties={}, + relata=[Relatum( + relation_type=RelationType.DEPENDS_ON, + target_id=c.id, + target_depth=DepthLevel.CAPABILITIES, + provenance=_prov(), + )], + provenance=_prov(), + ), + ]) + a = _primitive("A", depths=[ + _depth(DepthLevel.EXISTENCE), + _depth(DepthLevel.IDENTITY), + Depth( + level=DepthLevel.CAPABILITIES, + properties={}, + relata=[Relatum( + relation_type=RelationType.DEPENDS_ON, + target_id=b.id, + target_depth=DepthLevel.CAPABILITIES, + provenance=_prov(), + )], + provenance=_prov(), + ), + ]) + repo = StubRepository([a, b, c]) + engine = LearningEngine(repo) + gap, grounding = _reachability_grounding(c, a, [a, b, c]) + + filled = ReachabilityCandidate( + target_name="A", + relation_type=RelationType.DEPENDS_ON, + source_depth_level=DepthLevel.CAPABILITIES, + target_depth_level=DepthLevel.CAPABILITIES, + ) + + def callback(candidate, gr, gap): + return filled, CandidateDecision.ACCEPTED + + result = engine.learn_at(grounding, 0, callback) + assert result.decision == CandidateDecision.SKIPPED + + def test_mixed_transitive_types_cycle_skipped(self): + """A→B via REQUIRES exists; B→A via CONSTRAINED_BY would cycle → SKIPPED.""" + b = _primitive("B", depths=[ + _depth(DepthLevel.EXISTENCE), + _depth(DepthLevel.IDENTITY), + _depth(DepthLevel.CAPABILITIES), + ]) + a = _primitive("A", depths=[ + _depth(DepthLevel.EXISTENCE), + _depth(DepthLevel.IDENTITY), + Depth( + level=DepthLevel.CAPABILITIES, + properties={}, + relata=[Relatum( + relation_type=RelationType.REQUIRES, + target_id=b.id, + target_depth=DepthLevel.CAPABILITIES, + provenance=_prov(), + )], + provenance=_prov(), + ), + ]) + repo = StubRepository([a, b]) + engine = LearningEngine(repo) + gap, grounding = _reachability_grounding(b, a, [a, b]) + + filled = ReachabilityCandidate( + target_name="A", + relation_type=RelationType.CONSTRAINED_BY, + source_depth_level=DepthLevel.CAPABILITIES, + target_depth_level=DepthLevel.CAPABILITIES, + ) + + def callback(candidate, gr, gap): + return filled, CandidateDecision.ACCEPTED + + result = engine.learn_at(grounding, 0, callback) + assert result.decision == CandidateDecision.SKIPPED + + def test_non_transitive_cycle_allowed(self): + """A→B via APPLIES_TO exists; B→A via APPLIES_TO is fine → ACCEPTED.""" + b = _primitive("B", depths=[ + _depth(DepthLevel.EXISTENCE), + _depth(DepthLevel.IDENTITY), + _depth(DepthLevel.CAPABILITIES), + ]) + a = _primitive("A", depths=[ + _depth(DepthLevel.EXISTENCE), + _depth(DepthLevel.IDENTITY), + Depth( + level=DepthLevel.CAPABILITIES, + properties={}, + relata=[Relatum( + relation_type=RelationType.APPLIES_TO, + target_id=b.id, + target_depth=DepthLevel.CAPABILITIES, + provenance=_prov(), + )], + provenance=_prov(), + ), + ]) + repo = StubRepository([a, b]) + engine = LearningEngine(repo) + gap, grounding = _reachability_grounding(b, a, [a, b]) + + filled = ReachabilityCandidate( + target_name="A", + relation_type=RelationType.APPLIES_TO, + source_depth_level=DepthLevel.CAPABILITIES, + target_depth_level=DepthLevel.CAPABILITIES, + ) + + def callback(candidate, gr, gap): + return filled, CandidateDecision.ACCEPTED + + result = engine.learn_at(grounding, 0, callback) + assert result.decision == CandidateDecision.ACCEPTED + + def test_non_transitive_self_ref_allowed(self): + """A→A via INCLUDES is fine → ACCEPTED.""" + a = _primitive("A", depths=[ + _depth(DepthLevel.EXISTENCE), + _depth(DepthLevel.IDENTITY), + _depth(DepthLevel.CAPABILITIES), + ]) + repo = StubRepository([a]) + engine = LearningEngine(repo) + gap, grounding = _reachability_grounding(a, a, [a]) + + filled = ReachabilityCandidate( + target_name="A", + relation_type=RelationType.INCLUDES, + source_depth_level=DepthLevel.CAPABILITIES, + target_depth_level=DepthLevel.CAPABILITIES, + ) + + def callback(candidate, gr, gap): + return filled, CandidateDecision.ACCEPTED + + result = engine.learn_at(grounding, 0, callback) + assert result.decision == CandidateDecision.ACCEPTED + + def test_valid_transitive_edge_accepted(self): + """A→B via REQUIRES with no path B→A → ACCEPTED.""" + b = _primitive("B", depths=[ + _depth(DepthLevel.EXISTENCE), + _depth(DepthLevel.IDENTITY), + _depth(DepthLevel.CAPABILITIES), + ]) + a = _primitive("A", depths=[ + _depth(DepthLevel.EXISTENCE), + _depth(DepthLevel.IDENTITY), + _depth(DepthLevel.CAPABILITIES), + ]) + repo = StubRepository([a, b]) + engine = LearningEngine(repo) + gap, grounding = _reachability_grounding(a, b, [a, b]) + + filled = ReachabilityCandidate( + target_name="B", + relation_type=RelationType.REQUIRES, + source_depth_level=DepthLevel.CAPABILITIES, + target_depth_level=DepthLevel.CAPABILITIES, + ) + + def callback(candidate, gr, gap): + return filled, CandidateDecision.ACCEPTED + + result = engine.learn_at(grounding, 0, callback) + assert result.decision == CandidateDecision.ACCEPTED + saved = repo.saved[0] + d2 = next(d for d in saved.depths if d.level == DepthLevel.CAPABILITIES) + assert len(d2.relata) == 1 + assert d2.relata[0].target_id == b.id + assert d2.relata[0].relation_type == RelationType.REQUIRES + + +class TestStubRepositoryCycleDetection: + """Defense-in-depth: save_primitive raises on transitive cycles.""" + + def test_save_raises_on_self_referential_transitive(self): + a = _primitive("A", depths=[ + Depth( + level=DepthLevel.CAPABILITIES, + properties={}, + provenance=_prov(), + relata=[Relatum( + relation_type=RelationType.REQUIRES, + target_id=uuid4(), # placeholder, overwritten below + target_depth=DepthLevel.CAPABILITIES, + provenance=_prov(), + )], + ), + ]) + a.depths[0].relata[0].target_id = a.id + repo = StubRepository() + with pytest.raises(CyclicRelationshipError): + repo.save_primitive(a) + + def test_save_raises_on_two_node_cycle(self): + b = _primitive("B", depths=[ + _depth(DepthLevel.EXISTENCE), + _depth(DepthLevel.CAPABILITIES), + ]) + a = _primitive("A", depths=[ + Depth( + level=DepthLevel.CAPABILITIES, + properties={}, + provenance=_prov(), + relata=[Relatum( + relation_type=RelationType.REQUIRES, + target_id=b.id, + target_depth=DepthLevel.CAPABILITIES, + provenance=_prov(), + )], + ), + ]) + repo = StubRepository([a]) # A→B already in repo + # Now try to save B with B→A + b_with_edge = _primitive("B", depths=[ + Depth( + level=DepthLevel.CAPABILITIES, + properties={}, + provenance=_prov(), + relata=[Relatum( + relation_type=RelationType.REQUIRES, + target_id=a.id, + target_depth=DepthLevel.CAPABILITIES, + provenance=_prov(), + )], + ), + ], id=b.id) + with pytest.raises(CyclicRelationshipError): + repo.save_primitive(b_with_edge)