slice 6.3: plane-relation edge type characterisation tests
Pins the contract for REFLECTS, LAYER_OF, ADJACENT_TO, ACCESSIBLE_VIA: each is a typed, timeless Edge (Layer 1), NOT a reified :Relation node (Layer 2). Per docs/17-planes.md, plane relations are structural — they don't carry per-entity time bounds; reified :Relation is reserved for time-bounded facts (slice 6.5). Tests cover round-trip through InMemoryGraph's subject / object / id indexes, timelessness (valid_from/valid_until both None), and the non-reified property (no :Relation node created on add). The 4 edge names are pinned in EDGE_TYPES per slice 6.1. +6 tests (726 → 732). All green. No regressions.
This commit is contained in:
260
tests/test_planes/test_plane_relation_edges.py
Normal file
260
tests/test_planes/test_plane_relation_edges.py
Normal file
@@ -0,0 +1,260 @@
|
||||
"""Slice 6.3 — Plane-relation edge types: typed, non-reified, timeless.
|
||||
|
||||
Per ``docs/plan/exec/06-planes.md`` sub-slice 6.3:
|
||||
|
||||
- ``REFLECTS``, ``LAYER_OF``, ``ADJACENT_TO``, ``ACCESSIBLE_VIA``
|
||||
are **typed edges** (Edge dataclass, ``relation`` field),
|
||||
**not** reified ``:Relation`` nodes.
|
||||
- They are **timeless** (no ``valid_from`` / ``valid_until``).
|
||||
- One round-trip test per edge type, plus cross-cutting
|
||||
tests asserting the "non-reified" property (no relation
|
||||
node is created) and the "timeless" property (no time
|
||||
bounds are stamped).
|
||||
|
||||
Why "not reified":
|
||||
|
||||
Per ``docs/17-planes.md``, the plane relations
|
||||
(``REFLECTS``, ``LAYER_OF``, ``ADJACENT_TO``,
|
||||
``ACCESSIBLE_VIA``) are *structural* facts about the world's
|
||||
geometry — they don't change over the timeline the
|
||||
``EXISTS_IN`` / time-bounded :Relation layer tracks. The
|
||||
reified ``:Relation`` (ADR 0009) exists for time-bounded
|
||||
facts (e.g. "Roland Raventhorne was MEMBER_OF House
|
||||
Raventhorne from year 200 to year 300"). A plane reflection
|
||||
is a property of the planes themselves, not a per-entity
|
||||
time-bounded fact.
|
||||
|
||||
Why "timeless":
|
||||
|
||||
These edges are added to ``EDGE_TYPES`` (slice 6.1) without
|
||||
any ``valid_from`` / ``valid_until``. A retcon that changes
|
||||
a plane relation is a structural change (the world-builder
|
||||
rewrites the design doc); it doesn't have a "valid during"
|
||||
window. If a future slice needs a time-bounded reflection
|
||||
(e.g. "The Feywild reflected the Material Plane only during
|
||||
the 2nd Age"), that's a separate, time-bounded :Relation
|
||||
that the slice 6.5 work enables.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from lore_engine_poc import ontology
|
||||
from lore_engine_poc.graph_backend import InMemoryGraph
|
||||
from lore_engine_poc.tools import Edge
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _plane_edge(relation: str, subject: str, object_: str) -> Edge:
|
||||
"""Build a plane-relation Edge with explicit no-time-bounds.
|
||||
|
||||
The ``valid_from=None`` / ``valid_until=None`` is the
|
||||
"timeless" property the design doc pins — these edges do
|
||||
not carry time windows. The Edge dataclass's default
|
||||
factory leaves both as ``None``, so the explicit kwarg
|
||||
is documentary, not behavioural; the test still asserts
|
||||
on the values to catch accidental default-factory
|
||||
changes.
|
||||
"""
|
||||
return Edge(
|
||||
subject=subject,
|
||||
relation=relation,
|
||||
object=object_,
|
||||
valid_from=None,
|
||||
valid_until=None,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Per-edge round-trip tests — one for each typed relation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_6_3_plane_relation_round_trip_reflects() -> None:
|
||||
"""AC 6.2 — ``REFLECTS`` is a typed, timeless Edge.
|
||||
|
||||
Example: the Feywild REFLECTS the Material Plane. Both
|
||||
endpoints are Plane nodes; the relation is structural,
|
||||
not per-entity.
|
||||
"""
|
||||
g = InMemoryGraph()
|
||||
edge = _plane_edge("REFLECTS", "mardonari.feywild", "mardonari.material")
|
||||
g.add(edge)
|
||||
|
||||
# Subject-side lookup.
|
||||
subject_edges = g.edges_for_subject("mardonari.feywild", relation="REFLECTS")
|
||||
assert len(subject_edges) == 1
|
||||
assert subject_edges[0].object == "mardonari.material"
|
||||
|
||||
# Object-side lookup.
|
||||
object_edges = g.edges_for_object("mardonari.material", relation="REFLECTS")
|
||||
assert len(object_edges) == 1
|
||||
assert object_edges[0].subject == "mardonari.feywild"
|
||||
|
||||
# Id-indexed lookup — same Edge object.
|
||||
found = g.find_edge_by_id(edge.edge_id)
|
||||
assert found is edge
|
||||
# Timeless — no time bounds stamped.
|
||||
assert found.valid_from is None
|
||||
assert found.valid_until is None
|
||||
# Non-reified — no :Relation node created.
|
||||
assert g.find_relation_by_id(edge.edge_id) is None
|
||||
|
||||
|
||||
def test_6_3_plane_relation_round_trip_layer_of() -> None:
|
||||
"""AC 6.2 — ``LAYER_OF`` connects a Region (or subsidiary
|
||||
plane) to its parent Plane.
|
||||
|
||||
Example: the Underdark is a ``LAYER_OF`` Voldramir (the
|
||||
shadow plane of Mardonari). This is the canonical
|
||||
Region→Plane migration fact per
|
||||
``docs/plan/06-slice-planes.md`` AC 6.8.
|
||||
"""
|
||||
g = InMemoryGraph()
|
||||
edge = _plane_edge("LAYER_OF", "mardonari.underdark", "mardonari.voldramir")
|
||||
g.add(edge)
|
||||
|
||||
subject_edges = g.edges_for_subject("mardonari.underdark", relation="LAYER_OF")
|
||||
assert len(subject_edges) == 1
|
||||
assert subject_edges[0].object == "mardonari.voldramir"
|
||||
|
||||
object_edges = g.edges_for_object("mardonari.voldramir", relation="LAYER_OF")
|
||||
assert len(object_edges) == 1
|
||||
assert object_edges[0].subject == "mardonari.underdark"
|
||||
|
||||
found = g.find_edge_by_id(edge.edge_id)
|
||||
assert found is edge
|
||||
assert found.valid_from is None
|
||||
assert found.valid_until is None
|
||||
assert g.find_relation_by_id(edge.edge_id) is None
|
||||
|
||||
|
||||
def test_6_3_plane_relation_round_trip_adjacent_to() -> None:
|
||||
"""AC 6.2 — ``ADJACENT_TO`` is a symmetric typed edge.
|
||||
|
||||
Example: the Material Plane is ADJACENT_TO the Astral
|
||||
Plane. Symmetric in the sense that the relation is
|
||||
unordered (the design doesn't model directional planar
|
||||
adjacency); a follow-up might add ``ADJACENT_FROM`` /
|
||||
``ADJACENT_TO`` asymmetry if needed, but for v1.2
|
||||
adjacency is undirected.
|
||||
"""
|
||||
g = InMemoryGraph()
|
||||
edge = _plane_edge("ADJACENT_TO", "mardonari.material", "mardonari.astral")
|
||||
g.add(edge)
|
||||
|
||||
# Both directions resolve.
|
||||
assert any(
|
||||
e.object == "mardonari.astral"
|
||||
for e in g.edges_for_subject("mardonari.material", relation="ADJACENT_TO")
|
||||
)
|
||||
assert any(
|
||||
e.subject == "mardonari.material"
|
||||
for e in g.edges_for_object("mardonari.astral", relation="ADJACENT_TO")
|
||||
)
|
||||
|
||||
found = g.find_edge_by_id(edge.edge_id)
|
||||
assert found is not None
|
||||
assert found.relation == "ADJACENT_TO"
|
||||
assert found.valid_from is None
|
||||
assert found.valid_until is None
|
||||
assert g.find_relation_by_id(edge.edge_id) is None
|
||||
|
||||
|
||||
def test_6_3_plane_relation_round_trip_accessible_via() -> None:
|
||||
"""AC 6.2 — ``ACCESSIBLE_VIA`` is a portal-gated typed edge.
|
||||
|
||||
Example: the Shadowfell is ACCESSIBLE_VIA the Underdark
|
||||
(a portal network that connects the two). The relation
|
||||
is directional in the design — the *target* plane is
|
||||
reachable *via* the *source* plane's portal.
|
||||
"""
|
||||
g = InMemoryGraph()
|
||||
edge = _plane_edge(
|
||||
"ACCESSIBLE_VIA",
|
||||
"mardonari.shadowfell", # the plane being reached
|
||||
"mardonari.underdark", # the portal source
|
||||
)
|
||||
g.add(edge)
|
||||
|
||||
subject_edges = g.edges_for_subject(
|
||||
"mardonari.shadowfell", relation="ACCESSIBLE_VIA"
|
||||
)
|
||||
assert len(subject_edges) == 1
|
||||
assert subject_edges[0].object == "mardonari.underdark"
|
||||
|
||||
# The portal side resolves the relation from the object
|
||||
# index — useful for "what can I reach from this plane?"
|
||||
# queries that walk the object index in reverse.
|
||||
object_edges = g.edges_for_object(
|
||||
"mardonari.underdark", relation="ACCESSIBLE_VIA"
|
||||
)
|
||||
assert any(e.subject == "mardonari.shadowfell" for e in object_edges)
|
||||
|
||||
found = g.find_edge_by_id(edge.edge_id)
|
||||
assert found is not None
|
||||
assert found.valid_from is None
|
||||
assert found.valid_until is None
|
||||
assert g.find_relation_by_id(edge.edge_id) is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cross-cutting properties: non-reified + timeless
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_6_3_all_plane_edges_are_in_edge_types() -> None:
|
||||
"""AC 6.2 — the 4 plane-relation strings are in
|
||||
``EDGE_TYPES``.
|
||||
|
||||
Per slice 6.1, this is the closed vocabulary the
|
||||
consistency engine validates against. The test pins
|
||||
the names so a typo in a future slice is caught at
|
||||
the import level, not at the runtime read-tool
|
||||
dispatch.
|
||||
"""
|
||||
expected = {"REFLECTS", "LAYER_OF", "ADJACENT_TO", "ACCESSIBLE_VIA"}
|
||||
assert expected <= set(ontology.EDGE_TYPES), (
|
||||
f"EDGE_TYPES missing plane relations: "
|
||||
f"{expected - set(ontology.EDGE_TYPES)}"
|
||||
)
|
||||
|
||||
|
||||
def test_6_3_plane_edges_do_not_create_relation_nodes() -> None:
|
||||
"""AC 6.2 — plane edges are Layer 1 ``Edge`` records, NOT
|
||||
Layer 2 reified ``:Relation`` nodes.
|
||||
|
||||
Per ``docs/11-extensibility.md``, Layer 2 (:Relation) is
|
||||
for time-bounded facts about specific entity pairs (e.g.
|
||||
"Roland was MEMBER_OF House Raventhorne from year 200 to
|
||||
year 300"). Plane relations are structural — they don't
|
||||
carry per-entity time bounds. Mixing them in Layer 2
|
||||
would force the world-builder to reify every plane edge
|
||||
as a time-bounded membership, which the design explicitly
|
||||
rejects.
|
||||
"""
|
||||
g = InMemoryGraph()
|
||||
edges = [
|
||||
_plane_edge("REFLECTS", "p1", "p2"),
|
||||
_plane_edge("LAYER_OF", "p3", "p1"),
|
||||
_plane_edge("ADJACENT_TO", "p1", "p4"),
|
||||
_plane_edge("ACCESSIBLE_VIA", "p4", "p3"),
|
||||
]
|
||||
for e in edges:
|
||||
g.add(e)
|
||||
|
||||
# No Layer 2 :Relation nodes — the polymorphic relation
|
||||
# storage is empty.
|
||||
assert g.relation_nodes == {}
|
||||
assert g.relation_nodes_by_endpoint == {}
|
||||
# The reverse-lookup helpers all return empty.
|
||||
assert g.relations_for("p1") == []
|
||||
assert g.relations_for("p1", direction="in") == []
|
||||
assert g.relations_for("p1", direction="both") == []
|
||||
|
||||
# But the Layer 1 Edge indexes are populated.
|
||||
assert g.find_edge_by_id(edges[0].edge_id) is edges[0]
|
||||
assert g.find_edge_by_id(edges[3].edge_id) is edges[3]
|
||||
Reference in New Issue
Block a user