Files
lore-engine/docs/02-time-model.md
Hermes Agent 7d81a761f9 docs(v1.2): planes as first-class graph nodes (Setting, Plane, EXISTS_IN, REFLECTS, LAYER_OF, ADJACENT_TO, ACCESSIBLE_VIA)
Replaces the v1.1 'world_id is a string' model with a graph-of-planes. Driven by Kay's Q2 ('worlds are planes') and the v1.2 design review.

- 17-planes.md (NEW): the plane taxonomy, the four relation types, Cypher patterns, migration from world_id, open questions
- 01-ontology.md: Plane and Setting as first-class nodes; the 6 plane-relation edge types
- 02-time-model.md: plane-aware time (entity_planes_at_time as the 6th time-aware primitive)
- 08-architecture.md: data flow for plane questions (the 'can I get from X to Y' pattern)
- 11-extensibility.md: how to add custom planes and plane-relations without code
- 12-storage-strategy.md: planes are pure graph (no Postgres/Redis/Qdrant/S3 changes)
- 14-examples.md: example 5 — full Setting + planes + Roland + Asmodeus + LLM tool calls
- README.md: v1.2 entry + doc 17 in the table of contents

The POC rebuild (T10) is the next step: migrate the existing 4 world_id values to Setting/Plane nodes and update the plugin queries to use EXISTS_IN/LOCATED_IN.
2026-06-17 03:17:15 +00:00

12 KiB

02 — Time Model

If the ontology is the floor of the engine, time is the load-bearing wall. Most of the historical-accuracy failures we want to prevent are time failures: a character appears in an event before they were born, a house rules a kingdom after its fall, a fact is stated as "currently true" when it was true only in a specific century.

This document is the contract for how time is represented, indexed, and queried.

Goals

  1. Every time-bound edge is queryable as "was X true at time T?" in a single round trip.
  2. A single canonical time format that the LLM, the extraction workers, and the Cypher queries all use. No "340 TA", "340 Third Age", "Year 340", "340 of the Third", "3.340" all meaning the same thing.
  3. Eras are hierarchical. A fact that happens "in the Third Age" is also "in the Age of Iron" if the Age of Iron is a sub-era. The engine answers the more specific question correctly.
  4. Open-ended validity is first-class. "Aldric ruled from 340 TA until his death" is valid_until: null, not valid_until: "present".
  5. Time references are a node, not a string. Era, Calendar, and the in-fiction Date are typed. The LLM reasons over them.

The canonical time format

All time references in the engine, on every edge, in every property, in every tool call, are encoded as:

{era}.{unit}

Where:

  • era is the slug of an Era node (e.g. 3rd_age, age_of_iron, long_winter).
  • unit is the most specific time unit the source supports. Per Kay's answer (Q1), the engine supports year-level precision by default, with optional month/day/event ID when the source supports it. Examples: year_340, month_3, day_17, event_42 (referencing a Date node by ID).

A bare {era} is valid when no finer granularity is known — it means "at some point during this era."

Examples:

Source phrase Canonical form
"in 340 TA" 3rd_age.year_340
"during the Age of Iron" 3rd_age.age_of_iron
"at the Battle of Black Spire" 3rd_age.event_battle-of-black-spire (where event_battle-of-black-spire is a Date node)
"around the time of the Long Winter" 3rd_age.long_winter (a sub-era)
"in the present" current (a reserved token — see below)
"unknown" null

The format is deliberately non-human-readable. The LLM sees a stable identifier; the engine looks up the human name when returning results. This is the same pattern as content-addressable storage: opaque to humans, deterministic for the system.

Reserved tokens

  • current — the present moment of the in-fiction world. The engine resolves this against a single configurable node (:Now of type Date) that the storyteller updates as the campaign progresses.
  • null — explicitly unknown. Not the same as "current"; means "we have no data."
  • epoch — the moment of calendar epoch. Most worlds have one; this is the absolute zero.

Era hierarchy

Eras form a tree. A Date node can be nested inside an Era, and an Era can be nested inside a parent Era:

(third_age:Era {name: "Third Age", slug: "3rd_age"})
  -[:CONTAINS]->
(age_of_iron:Era {name: "Age of Iron", slug: "3rd_age.age_of_iron"})
  -[:CONTAINS]->
(long_winter:Era {name: "The Long Winter", slug: "3rd_age.age_of_iron.long_winter"})

A query like "what happened during the Long Winter?" is:

MATCH (e:Era {slug: "3rd_age.age_of_iron.long_winter"})<-[:OCCURRED_DURING]-(event:Event)
RETURN event

A query like "what happened during the Third Age?" must traverse the hierarchy:

MATCH (root:Era {slug: "3rd_age"})
OPTIONAL MATCH path = (root)-[:CONTAINS*0..5]->(sub:Era)
WITH collect(root) + collect(sub) AS eras
MATCH (event:Event)-[:OCCURRED_DURING]->(e:Era)
WHERE e IN eras
RETURN DISTINCT event

The MCP tool query_era exposes both. See 05-mcp-tools.md.

The valid_from / valid_until pattern

Every time-bound edge carries both:

(:Person {name: "Aldric"})-[:RULED {
  valid_from: "3rd_age.year_340",
  valid_until: "3rd_age.year_352"
}]->(:Location {name: "Valdorn"})

The engine treats null on either side as "we don't know / open-ended." So:

  • valid_from: null, valid_until: null → we don't know when this was true.
  • valid_from: "3rd_age.year_340", valid_until: null → true from 340 TA onward, still true (or unknown when it ended).
  • valid_from: null, valid_until: "3rd_age.year_352" → true at some point, but no longer after 352 TA.
  • valid_from: "3rd_age.year_340", valid_until: "3rd_age.year_352" → exact window known.

The Cypher for "was this edge true at time T" is a single WHERE clause that calls a time_in_window function (implemented as a user-defined function in Neo4j — see 08-architecture.md#udfs).

The time_in_window UDF (the heart of the engine)

// Returns true if `t` falls within [valid_from, valid_until] inclusive
// of open-ended bounds. `t` is the canonical time string being queried.
RETURN time_in_window(
  '3rd_age.year_345',
  '3rd_age.year_340',
  '3rd_age.year_352'
)  // → true

RETURN time_in_window(
  '3rd_age.year_360',
  '3rd_age.year_340',
  '3rd_age.year_352'
)  // → false

This function handles:

  • null bounds (open-ended)
  • Era-tree membership: 3rd_age.age_of_iron falls inside 3rd_age
  • current resolution against the :Now config node
  • Comparison against Date nodes (events, named days)

It's the single primitive the rest of the engine is built on. If this is wrong, every time-aware query is wrong. See 10-critique.md for the known edge cases.

Date nodes — when an event has a specific day

For events that have a specific in-fiction date (a battle, a coronation, a death), we model the date as a node:

(:Date {
  id: "3rd_age.event_battle-of-black-spire",
  slug: "3rd_age.event_battle-of-black-spire",
  in_fiction_label: "17th Hearthmoon, 340 TA",
  era: "3rd_age",
  calendar: "imperial_reckoning",
  year: 340,
  month: 3,
  day: 17,
  canonical_event: "Battle of Black Spire"
})
  -[:PART_OF]->(:Era {slug: "3rd_age"})
  -[:USES]->(:Calendar {name: "Imperial Reckoning"})

The slug format lets edges reference dates by ID, which means:

  • The Battle's OCCURRED_AT edge can use the date as its temporal marker.
  • A character can be BORN_ON a specific date.
  • A lineage event (MARRIED_ON) can have the same precision.

Not every event needs a Date node. Most historical facts have era-level or year-level precision. The Date node is for the moments that matter.

Lifespan — a Person's existence window

A Person node carries a lifespan as either a property range or as EXISTED_DURING connections:

(:Person {name: "Aldric Raventhorne"})
  -[:EXISTED_DURING {from: "3rd_age.year_300", until: "3rd_age.year_360"}]->
(:Era {slug: "3rd_age"})

This makes anachronism detection a single Cypher query: "Was Aldric at the Battle of Black Spire?" → check if battle.date is within Aldric.lifespan. If not, flag it. See 04-consistency.md#anachronism-detection.

The same pattern applies to Faction (founding/dissolving), Location (existence), and Item (creation/destruction).

Time-aware queries: the 5 primitives

The MCP tool layer is built on five time-aware query primitives. Every other query is a composition of these.

1. was_true_at(relation_type, subject, object, at_time)

Returns the matching edge if it was true at at_time, else not_found.

2. true_during(relation_type, subject, object, era)

Returns all time-bounded edges of that type between subject and object, with the intervals in which each was true.

3. state_at(entity, at_time)

Returns a snapshot of the entity's relations at at_time: who they were allied with, where they were located, what they held, who ruled them. The single most important query — answers "who is this person at time T?"

4. entities_present(location, at_time)

Returns all entities (Person, Faction, Creature) recorded as located at the location during the time window.

5. timeline(entity, relation_type, from, to)

Returns a chronologically ordered list of edges of that type involving the entity, optionally filtered to a time window.

All five are documented in detail with signatures in 05-mcp-tools.md.

Plane-aware time (v1.2)

A LOCATED_IN edge from a Person (or any entity) to a Plane carries the same valid_from / valid_until properties as any other time-bounded edge. The pattern is identical:

(:Person {name: "Roland Raventhorne"})
  -[:LOCATED_IN {
    valid_from: "3rd_age.year_428",
    valid_until: "3rd_age.year_430"
  }]->
(:Plane {id: "mardonari.voldramir", kind: "demiplane"})

The same Roland can be on the Mardonari Material Plane (the whole 425-445 span) AND visiting Voldramir (428-430). The two LOCATED_IN edges have overlapping but distinct windows.

A sixth time-aware query primitive is added in v1.2:

6. entity_planes_at_time(entity, at_time)

Returns every plane the entity was on at at_time (via LOCATED_IN), plus the planes the entity can exist in (via EXISTS_IN, the timeless type-assertion). The "where was Roland in 430 TA" question is a single tool call.

The two planes are returned separately:

  • located_in — the time-bounded set: where the entity was at T.
  • exists_in — the timeless set: what kinds of planes the entity is a citizen of.

A mortal in a single setting typically has one EXISTS_IN edge and one or two LOCATED_IN edges. A god has many EXISTS_IN edges and may or may not have LOCATED_IN edges (gods are not usually located in a place; they exist in planes).

Plane-relative vs. setting-relative time

A valid_from of "3rd_age.year_428" is setting-relative time — it is measured in the setting's primary calendar. The Mardonari setting's calendar, Eberron's, the Forgotten Realms' — each setting has its own Calendar and Era tree. The canonical time string is unique within the setting.

Cross-setting time conversion is out of scope. If two settings are connected (a portal, a planar crossing), the world-builder writes a WHEN_CROSSED relation that carries the local-time of each side. The engine does not infer it.

What we deliberately do not do

  • We do not model timezones or relativity. High-fantasy worlds are not physics engines. If two events are "on the same day" we treat them as concurrent within rounding.
  • We do not do fuzzy time matching. "Around 340 TA" must be resolved at ingest time to a specific canonical string. The engine does not guess.
  • We do not store wall-clock time in the ontology. The wall-clock created_at is metadata only, used for staleness and source recency, never for in-fiction reasoning.

Known limitations (and why we accept them)

A fact that was true for centuries cannot be precisely queried by century — only at the era granularity we have. This is fine: most lore is at era granularity, and over-precision usually means fabrication. Year-level precision is supported for the cases where the source supports it (coronations, battles, deaths, births), and the engine prefers the most-specific-time-the-source-supports rather than guessing.

  • Two sources giving the same era but different years produce a contradiction, not a synthesis. The engine flags it; it does not pick a winner. See 04-consistency.md.
  • "Before the world began" or "after the end of time" are not representable. A null valid_from with no parent Era is the closest, and we mark those as mythic: true to be clear they are cosmological claims, not historical ones.

The time model is the part of the engine that is most likely to need a v2. The 14 docs in this design describe v1. If you find yourself wanting year-level precision and we only have era-level, that's a source problem (write a better source document), not a time-model problem.