Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion src/vre/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -33,6 +33,7 @@

__all__ = [
"VRE",
"CyclicRelationshipError",
"PrimitiveRepository",
"ConceptResolver",
"GroundingEngine",
Expand Down Expand Up @@ -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.
Expand Down
14 changes: 12 additions & 2 deletions src/vre/core/__init__.py
Original file line number Diff line number Diff line change
@@ -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",
]
53 changes: 52 additions & 1 deletion src/vre/core/graph.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
from neo4j import GraphDatabase

from vre.core.models import (
CyclicRelationshipError,
Depth,
DepthLevel,
EpistemicStep,
Expand All @@ -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:
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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(
Expand Down
15 changes: 15 additions & 0 deletions src/vre/core/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
11 changes: 8 additions & 3 deletions src/vre/learning/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand All @@ -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(
Expand Down
Loading
Loading