1 Commits

Author SHA1 Message Date
hermes
20cfd946c3 T10.1: plane schema migration (data layer)
Adds the v1.2 Setting/Plane graph on top of the v1.1 world_id
namespace. From docs/17-planes.md: a 'world' is now the primary
Material Plane of a Setting, and reflection / sub-layer relations
(REFLECTS, LAYER_OF) are first-class edges.

Schema (neo4j/init.cypher):
  - :Setting {id} uniqueness
  - :Plane {id} uniqueness
  - indexes on :Plane.kind, :Plane.parent_plane, :Plane.setting_id

Seed (seed.py:seed_planes):
  - 2 Settings (default, mardonari) + 4 Planes
    (default.material, default.arda_greyscale,
     mardonari.material, mardonari.voldramir)
  - 4 HAS_PLANE, 1 REFLECTS (greyscale -> material),
    1 LAYER_OF (voldramir -> material) edges
  - 37 EXISTS_IN edges from every Person/Faction/Location/Item/Event
    to its primary plane (idempotent MERGE on (n)-[:EXISTS_IN]->(p))
  - legacy world_id property is preserved for read-compat during
    the migration window (T10.2 rewrites the read tools, v2.0
    drops the property)

Wired into main() after the v1.1 seeds (so EXISTS_IN can attach
to existing Person/Faction/Location/Item/Event nodes).

Tests (tests/test_planes.py, 13 cases, all green):
  - 3 schema tests (Setting/Plane id constraints, Plane.kind index)
  - 4 entity tests (Settings, Planes, REFLECTS, LAYER_OF structure)
  - 4 EXISTS_IN tests (one per world_id: default, arda_greyscale,
    mardonar, voldramir)
  - 1 read-compat test (world_id property preserved)
  - 1 idempotency test (running seed_planes twice = same counts)

v2 surface regression check: bash test.sh exits 0, all 12 sections
green. Pre-existing test failures in test_multi_world.py (image
data gap) and test_register_image_hook.py (test-only kwarg issue)
are unrelated to this change.

T10.2 (Update plugins to use plane model) is the next slice and
will rewrite entity_context / was_true_at / state_at / list_worlds
to traverse EXISTS_IN first and fall back to world_id.
2026-06-17 03:26:28 +00:00
3 changed files with 457 additions and 0 deletions

View File

@@ -26,5 +26,23 @@ CREATE INDEX violation_status_orph IF NOT EXISTS FOR (n:Orphan)
CREATE INDEX era_parent IF NOT EXISTS FOR (e:Era) ON (e.parent_slug);
CREATE INDEX person_tier IF NOT EXISTS FOR (p:Person) ON (p.tier);
// ─── Plane model (v3.T10.1) ──────────────────────────────────────────────────
// v1.1 used a flat `world_id` string property on every node. The v1.2
// amendment (lore-engine/docs/17-planes.md) promotes "world" to a first-
// class `Setting` + `Plane` graph: every entity has an `EXISTS_IN` edge
// to a `Plane`, every `Plane` belongs to a `Setting` via `HAS_PLANE`,
// and reflection / sub-layer relations (`REFLECTS`, `LAYER_OF`) are
// first-class edges. The legacy `world_id` property is kept for read-
// compat during the migration window and is removed in v2.0.
CREATE CONSTRAINT setting_id IF NOT EXISTS FOR (s:Setting) REQUIRE s.id IS UNIQUE;
CREATE CONSTRAINT plane_id IF NOT EXISTS FOR (p:Plane) REQUIRE p.id IS UNIQUE;
// Indexes on the most-queried Plane properties: "what planes of kind X
// does this setting have?" and "what is the parent of this sub-layer?".
CREATE INDEX plane_kind IF NOT EXISTS FOR (p:Plane) ON (p.kind);
CREATE INDEX plane_parent_plane IF NOT EXISTS FOR (p:Plane) ON (p.parent_plane);
CREATE INDEX plane_setting IF NOT EXISTS FOR (p:Plane) ON (p.setting_id);
// Era tree: every Era has CONTAINS sub-eras or PART_OF parents
// (:Era {slug, name, start, end}) -[:PART_OF]-> (:Era)

121
seed.py
View File

@@ -546,6 +546,123 @@ def seed_greyscale_world(driver):
f"{len(GS_FACTIONS)} faction, {len(GS_LOCATIONS)} location")
# ─── v3.T10.1: Plane schema migration (data layer) ───────────────────────────
#
# v1.1 used a flat `world_id: string` property on every node as the world
# namespace. The v1.2 amendment (lore-engine/docs/17-planes.md) replaces
# that with a first-class `Setting` + `Plane` graph: every entity has an
# `EXISTS_IN` edge to a `Plane`, every `Plane` belongs to a `Setting` via
# `HAS_PLANE`, and reflection / sub-layer relations (`REFLECTS`, `LAYER_OF`)
# are first-class edges.
#
# This function is the DATA-LAYER side of the migration. It is additive:
# the legacy `world_id` property is NOT removed. Plugins keep reading by
# `world_id` for now; T10.2 will rewrite the read tools to traverse
# `EXISTS_IN` first and fall back to `world_id`. `world_id` is dropped in
# v2.0.
#
# The function is idempotent (every node/edge uses MERGE with a unique
# key) and is safe to re-run after the v1.1 seed.
# Maps the v1.1 world_id string to the (setting_id, plane_id, kind, parent_plane)
# tuple the new model uses. world_id was a flat string; setting_id and
# plane_id are slugs in the design's `{setting}.{plane}` convention
# (with the historical `mardonar` -> `mardonari` typo corrected).
_WORLD_TO_PLANE = [
# world_id, setting_id, plane_id, kind, parent_plane (or None)
("default", "default", "default.material", "material", None),
("arda_greyscale","default", "default.arda_greyscale", "demiplane", "default.material"), # REFLECTS
("mardonar", "mardonari", "mardonari.material", "material", None),
("voldramir", "mardonari", "mardonari.voldramir", "demiplane", "mardonari.material"), # LAYER_OF
]
def seed_planes(driver):
"""Materialize the v1.2 Setting/Plane graph on top of the v1.1 world.
For every distinct world_id value in the graph, this:
1. Creates the parent :Setting node (idempotent MERGE on id).
2. Creates the primary :Plane node for that world (idempotent MERGE).
3. Creates the :HAS_PLANE edge from the Setting to the Plane.
4. For reflection / sub-layer planes, creates the :REFLECTS or
:LAYER_OF edge to the parent plane.
5. For every Person/Faction/Location/Item/Event node carrying that
world_id, creates an :EXISTS_IN edge to the plane (idempotent).
The legacy `world_id` property is NOT touched. It stays for read-
compat during the migration window.
"""
with driver.session() as s:
# 1-4. Settings, Planes, HAS_PLANE, REFLECTS / LAYER_OF.
for world_id, setting_id, plane_id, kind, parent in _WORLD_TO_PLANE:
s.run("""
MERGE (st:Setting {id: $setting_id})
ON CREATE SET st.name = $setting_name,
st.summary = $setting_summary
MERGE (p:Plane {id: $plane_id})
ON CREATE SET p.kind = $kind,
p.name = $plane_name,
p.setting_id = $setting_id,
p.world_id = $world_id,
p.accessible = true
MERGE (st)-[:HAS_PLANE]->(p)
""", setting_id=setting_id,
setting_name=setting_id.capitalize(),
setting_summary=f"The {setting_id} setting (migrated from v1.1 world_id={world_id}).",
plane_id=plane_id,
plane_name=plane_id.split(".", 1)[-1].replace("_", " ").title(),
kind=kind,
world_id=world_id)
# 4. Reflection / sub-layer edge to parent plane.
# Per docs/17-planes.md, a demiplane can be modeled two ways:
# - REFLECTS the material plane (a parallel universe that
# mirrors its source with twisted qualities — the
# Shadowfell / Feywild / Arda-greyscale pattern)
# - LAYER_OF the parent plane (a sub-plane, a contained
# pocket dimension — the Nine Hells layers, Voldramir)
# The right rule is per-plane-intent, not per-kind. The POC
# uses REFLECTS for `default.arda_greyscale` (it's explicitly
# a dark-mirror of the default Material per the seed design)
# and LAYER_OF for `mardonari.voldramir` (a personal demiplane
# contained in the Mardonari Material).
if parent is None:
continue
if plane_id == "default.arda_greyscale":
s.run("""
MATCH (p:Plane {id: $plane_id})
MATCH (m:Plane {id: $parent_id})
MERGE (p)-[:REFLECTS]->(m)
""", plane_id=plane_id, parent_id=parent)
else:
s.run("""
MATCH (p:Plane {id: $plane_id})
MATCH (m:Plane {id: $parent_id})
MERGE (p)-[:LAYER_OF]->(m)
""", plane_id=plane_id, parent_id=parent)
# 5. EXISTS_IN edges from every world-scoped entity to its plane.
for world_id, _setting_id, plane_id, _kind, _parent in _WORLD_TO_PLANE:
s.run("""
MATCH (n)
WHERE n.world_id = $world_id
AND (n:Person OR n:Faction OR n:Location OR n:Item OR n:Event)
MATCH (p:Plane {id: $plane_id})
MERGE (n)-[:EXISTS_IN]->(p)
""", world_id=world_id, plane_id=plane_id)
# Snapshot for the log line.
n_settings = s.run("MATCH (n:Setting) RETURN count(n) AS n").single()["n"]
n_planes = s.run("MATCH (n:Plane) RETURN count(n) AS n").single()["n"]
n_has_plane = s.run("MATCH ()-[r:HAS_PLANE]->() RETURN count(r) AS n").single()["n"]
n_exists_in = s.run("MATCH ()-[r:EXISTS_IN]->() RETURN count(r) AS n").single()["n"]
n_reflects = s.run("MATCH ()-[r:REFLECTS]->() RETURN count(r) AS n").single()["n"]
n_layer_of = s.run("MATCH ()-[r:LAYER_OF]->() RETURN count(r) AS n").single()["n"]
print(f"[neo4j] plane schema seeded: {n_settings} settings, {n_planes} planes, "
f"{n_has_plane} HAS_PLANE, {n_exists_in} EXISTS_IN, "
f"{n_reflects} REFLECTS, {n_layer_of} LAYER_OF edges")
def seed_violations(s):
"""Materialize the 5 hand-crafted consistency violations (v2.T5) and the
one OntologyRule that drives ontology detection. Idempotent: re-runs
@@ -749,6 +866,10 @@ def main():
seed_neo4j(driver)
seed_greyscale_world(driver)
# v3.T10.1: layer the v1.2 Setting/Plane graph on top of the v1.1
# world_id namespace. Must run after the v1.1 seeds (so EXISTS_IN
# can attach to existing Person/Faction/Location/Item/Event nodes).
seed_planes(driver)
seed_postgres(pg)
seed_minio(minio, pg)
seed_greyscale_images(minio, pg)

318
tests/test_planes.py Normal file
View File

@@ -0,0 +1,318 @@
"""
Tests for v3.T10.1 — plane schema migration (data layer).
The v1.1 lore-engine design (docs/01-ontology.md) used `world_id: string`
as a flat namespace on every node. The v1.2 amendment (docs/17-planes.md)
replaces that with a first-class `Setting` + `Plane` graph: every entity
has an `EXISTS_IN` edge to a `Plane`, every `Plane` belongs to a
`Setting` via `HAS_PLANE`, and reflection / sub-layer relations
(`REFLECTS`, `LAYER_OF`) are first-class edges.
This file is the DATA-LAYER test contract. T10.1 ships:
- `:Setting` and `:Plane` node labels with id uniqueness constraints
- Indexes on Plane.kind and Plane.parent_plane
- A new `seed_planes()` function in `seed.py` that creates the planes
and the EXISTS_IN / HAS_PLANE / REFLECTS / LAYER_OF edges
- The new model is layered ON TOP of the legacy `world_id` property
(which stays for read-compat during the migration window — see
docs/17-planes.md §"Migration from v1.1")
The tests are read-only against the live Neo4j that `seed.py` populated.
They cover the seed for all four world_ids in use today: `default`,
`arda_greyscale`, `mardonar`, `voldramir`.
"""
import os
import sys
import pytest
# Make gateway/ + plugins/ importable (same pattern as test_multi_world.py)
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
for p in (os.path.join(ROOT, "gateway"), os.path.join(ROOT, "plugins")):
if p not in sys.path:
sys.path.insert(0, p)
NEO4J_URL = os.environ.get("TEST_NEO4J_URL", "bolt://localhost:7687")
NEO4J_USER = os.environ.get("TEST_NEO4J_USER", "neo4j")
NEO4J_PASSWORD = os.environ.get("TEST_NEO4J_PASSWORD", "lore-dev-password")
def _ensure_neo4j_env():
os.environ.setdefault("NEO4J_URL", NEO4J_URL)
os.environ.setdefault("NEO4J_USER", NEO4J_USER)
os.environ.setdefault("NEO4J_PASSWORD", NEO4J_PASSWORD)
def _neo4j_session():
from neo4j import GraphDatabase
return GraphDatabase.driver(NEO4J_URL, auth=(NEO4J_USER, NEO4J_PASSWORD))
@pytest.fixture(scope="module")
def neo4j():
_ensure_neo4j_env()
drv = _neo4j_session()
yield drv
drv.close()
# ─── schema: constraints and indexes ─────────────────────────────────────────
def test_setting_id_uniqueness_constraint_exists(neo4j):
"""`Setting` must have a uniqueness constraint on `id` so the seed
can MERGE on it idempotently."""
with neo4j.session() as s:
rows = s.run("""
SHOW CONSTRAINTS YIELD name, entityType, labelsOrTypes, properties
WHERE 'Setting' IN labelsOrTypes AND 'id' IN properties
RETURN name
""").data()
assert rows, "no uniqueness constraint on (:Setting {id}) — T10.1 missing?"
assert any("id" in (r.get("name") or "").lower() for r in rows) or any(
r.get("name") for r in rows
), f"unexpected constraint rows: {rows}"
def test_plane_id_uniqueness_constraint_exists(neo4j):
"""`Plane` must have a uniqueness constraint on `id`."""
with neo4j.session() as s:
rows = s.run("""
SHOW CONSTRAINTS YIELD name, entityType, labelsOrTypes, properties
WHERE 'Plane' IN labelsOrTypes AND 'id' IN properties
RETURN name
""").data()
assert rows, "no uniqueness constraint on (:Plane {id}) — T10.1 missing?"
def test_plane_kind_index_exists(neo4j):
"""`Plane.kind` should be indexed — every 'what planes of kind X exist'
query (e.g. 'find all demiplanes in this setting') will hit it."""
with neo4j.session() as s:
rows = s.run("""
SHOW INDEXES YIELD name, entityType, labelsOrTypes, properties
WHERE 'Plane' IN labelsOrTypes AND 'kind' IN properties
RETURN name
""").data()
assert rows, "no index on (:Plane {kind}) — T10.1 missing?"
# ─── seed: Settings and Planes exist for the 4 world_ids in use ─────────────
EXPECTED_SETTINGS = {
# setting_id -> summary or None
"default": None,
"mardonari": None, # the v1.1 "mardonar" string maps to setting "mardonari"
}
EXPECTED_PLANES = {
# plane_id -> (setting_id, kind, reflects_to or None)
"default.material": ("default", "material", None),
"default.arda_greyscale": ("default", "demiplane", "default.material"),
"mardonari.material": ("mardonari", "material", None),
"mardonari.voldramir": ("mardonari", "demiplane", None), # LAYER_OF material
}
def test_settings_present(neo4j):
"""All 2 Settings must exist (default + mardonari)."""
with neo4j.session() as s:
rows = s.run("MATCH (s:Setting) RETURN s.id AS id ORDER BY id").data()
ids = {r["id"] for r in rows}
missing = set(EXPECTED_SETTINGS) - ids
assert not missing, f"missing Settings: {missing}; got: {ids}"
def test_planes_present(neo4j):
"""All 4 Planes must exist with the expected kind and parent setting."""
with neo4j.session() as s:
rows = s.run("""
MATCH (st:Setting)-[:HAS_PLANE]->(p:Plane)
RETURN p.id AS id, p.kind AS kind, st.id AS setting
ORDER BY p.id
""").data()
by_id = {r["id"]: (r["setting"], r["kind"]) for r in rows}
for pid, (want_set, want_kind, _) in EXPECTED_PLANES.items():
assert pid in by_id, f"missing Plane: {pid}; got: {list(by_id)}"
assert by_id[pid] == (want_set, want_kind), (
f"Plane {pid}: expected ({want_set}, {want_kind}), got {by_id[pid]}"
)
def test_arda_greyscale_plane_reflects_material(neo4j):
"""`default.arda_greyscale` is a REFLECTS edge to `default.material` —
that's the 'worlds are planes' insight from docs/17-planes.md."""
with neo4j.session() as s:
rows = s.run("""
MATCH (p:Plane {id: 'default.arda_greyscale'})-[:REFLECTS]->(target:Plane)
RETURN target.id AS id, target.kind AS kind
""").data()
assert len(rows) == 1, f"expected 1 REFLECTS edge, got {rows}"
assert rows[0]["id"] == "default.material"
assert rows[0]["kind"] == "material"
def test_voldramir_is_demiplane_under_material(neo4j):
"""`mardonari.voldramir` is a LAYER_OF `mardonari.material` — Voldramir
is the personal demiplane of the Mardonari setting's primary plane."""
with neo4j.session() as s:
rows = s.run("""
MATCH (d:Plane {id: 'mardonari.voldramir'})-[:LAYER_OF]->(parent:Plane)
RETURN parent.id AS id
""").data()
assert len(rows) == 1, f"expected 1 LAYER_OF edge, got {rows}"
assert rows[0]["id"] == "mardonari.material"
# ─── seed: every world-scoped entity has an EXISTS_IN edge to its plane ─────
ENTITY_LABELS = ("Person", "Faction", "Location", "Item", "Event")
def test_default_world_entities_have_exists_in_to_material(neo4j):
"""All Person/Faction/Location/Item/Event nodes with world_id='default'
must have at least one EXISTS_IN edge, and the target plane must be
'default.material'."""
with neo4j.session() as s:
rows = s.run(f"""
MATCH (n)
WHERE n.world_id = 'default' AND any(l IN labels(n) WHERE l IN $labels)
OPTIONAL MATCH (n)-[:EXISTS_IN]->(p:Plane)
RETURN n.id AS id, labels(n) AS lbls, collect(p.id) AS planes
ORDER BY id
""", labels=list(ENTITY_LABELS)).data()
# The seed is idempotent — every default-world node must have at least
# one plane in its planes list.
missing = [r for r in rows if not r["planes"] or r["planes"] == [None]]
assert not missing, (
f"{len(missing)} default-world entities have no EXISTS_IN edge; "
f"first few: {missing[:5]}"
)
bad_target = [r for r in rows if r["planes"] and r["planes"] != ["default.material"]]
assert not bad_target, (
f"{len(bad_target)} default-world entities point at a non-Material plane; "
f"first few: {bad_target[:5]}"
)
def test_arda_greyscale_entities_have_exists_in_to_demiplane(neo4j):
"""arda_greyscale entities must point at default.arda_greyscale (the
demiplane), not at default.material."""
with neo4j.session() as s:
rows = s.run(f"""
MATCH (n)
WHERE n.world_id = 'arda_greyscale'
AND any(l IN labels(n) WHERE l IN $labels)
OPTIONAL MATCH (n)-[:EXISTS_IN]->(p:Plane)
RETURN n.id AS id, collect(p.id) AS planes
ORDER BY id
""", labels=list(ENTITY_LABELS)).data()
missing = [r for r in rows if not r["planes"] or r["planes"] == [None]]
assert not missing, (
f"{len(missing)} greyscale entities have no EXISTS_IN edge; "
f"first few: {missing[:5]}"
)
bad_target = [r for r in rows if r["planes"] != ["default.arda_greyscale"]]
assert not bad_target, (
f"{len(bad_target)} greyscale entities point at the wrong plane; "
f"first few: {bad_target[:5]}"
)
def test_mardonar_entities_have_exists_in_to_mardonari_material(neo4j):
"""If any entity is namespaced under world_id='mardonar' (the v1.1 string),
it must point at mardonari.material."""
with neo4j.session() as s:
rows = s.run(f"""
MATCH (n)
WHERE n.world_id = 'mardonar'
AND any(l IN labels(n) WHERE l IN $labels)
OPTIONAL MATCH (n)-[:EXISTS_IN]->(p:Plane)
RETURN n.id AS id, collect(p.id) AS planes
ORDER BY id
""", labels=list(ENTITY_LABELS)).data()
# It's OK if no v1.1 mardonar entity exists (the seed only uses
# default + arda_greyscale in the POC). But IF they exist, they
# must point at mardonari.material.
bad_target = [r for r in rows if r["planes"] != ["mardonari.material"]]
assert not bad_target, (
f"{len(bad_target)} mardonar entities point at the wrong plane; "
f"first few: {bad_target[:5]}"
)
def test_voldramir_entities_have_exists_in_to_voldramir_demiplane(neo4j):
"""Same for world_id='voldramir' — must point at mardonari.voldramir."""
with neo4j.session() as s:
rows = s.run(f"""
MATCH (n)
WHERE n.world_id = 'voldramir'
AND any(l IN labels(n) WHERE l IN $labels)
OPTIONAL MATCH (n)-[:EXISTS_IN]->(p:Plane)
RETURN n.id AS id, collect(p.id) AS planes
ORDER BY id
""", labels=list(ENTITY_LABELS)).data()
bad_target = [r for r in rows if r["planes"] != ["mardonari.voldramir"]]
assert not bad_target, (
f"{len(violations := bad_target)} voldramir entities point at the wrong plane; "
f"first few: {bad_target[:5]}"
)
# ─── seed: legacy world_id property is preserved (read-compat) ──────────────
def test_legacy_world_id_still_present(neo4j):
"""T10.1 is additive — the legacy `world_id` string property must
remain on nodes so the v2 plugins keep working unchanged. T10.2 will
migrate the read tools; only v2.0 will drop the property."""
with neo4j.session() as s:
rows = s.run("""
MATCH (n:Person)
WHERE n.world_id IS NULL
RETURN count(n) AS n
""").data()
assert rows[0]["n"] == 0, (
f"some Person nodes lost their world_id property; "
f"got {rows[0]['n']} world_id-less nodes"
)
# ─── seed idempotency ────────────────────────────────────────────────────────
def test_seed_planes_is_idempotent(neo4j):
"""Running seed_planes() twice must produce the same node + edge counts.
Tested by capturing the counts, invoking seed_planes() directly, and
re-reading."""
# Snapshot before
with neo4j.session() as s:
before = {
"settings": s.run("MATCH (n:Setting) RETURN count(n) AS n").single()["n"],
"planes": s.run("MATCH (n:Plane) RETURN count(n) AS n").single()["n"],
"has_plane": s.run("MATCH ()-[r:HAS_PLANE]->() RETURN count(r) AS n").single()["n"],
"reflects": s.run("MATCH ()-[r:REFLECTS]->() RETURN count(r) AS n").single()["n"],
"layer_of": s.run("MATCH ()-[r:LAYER_OF]->() RETURN count(r) AS n").single()["n"],
"exists_in": s.run("MATCH ()-[r:EXISTS_IN]->() RETURN count(r) AS n").single()["n"],
}
# Re-run the seeder
from seed import seed_planes, load_neo4j
drv = load_neo4j()
try:
seed_planes(drv)
finally:
drv.close()
# Snapshot after
with neo4j.session() as s:
after = {
"settings": s.run("MATCH (n:Setting) RETURN count(n) AS n").single()["n"],
"planes": s.run("MATCH (n:Plane) RETURN count(n) AS n").single()["n"],
"has_plane": s.run("MATCH ()-[r:HAS_PLANE]->() RETURN count(r) AS n").single()["n"],
"reflects": s.run("MATCH ()-[r:REFLECTS]->() RETURN count(r) AS n").single()["n"],
"layer_of": s.run("MATCH ()-[r:LAYER_OF]->() RETURN count(r) AS n").single()["n"],
"exists_in": s.run("MATCH ()-[r:EXISTS_IN]->() RETURN count(r) AS n").single()["n"],
}
for k in before:
assert before[k] == after[k], (
f"idempotency violated on {k}: before={before[k]} after={after[k]}"
)