# 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`: ```cypher (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: ```cypher 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: ```cypher 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: ```cypher (: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) ```cypher // 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: ```cypher (: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: ```cypher (: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: ```cypher (: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. These 18 docs describe v1, v1.1, and v1.2. 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.