Files
lore-engine/docs/02-time-model.md
Kaysser Kayyali 7d2ab8699f docs(v1.2): switch substrate to Cognee + fix counts + world_id deprecation
The /docs review surfaced two categories of work: real bugs and a
substrate decision. This commit lands both.

Pure fixes:
- Fix markdown table corruption in 01-ontology.md (3 `||` lines +
  duplicate `### Magic, culture, language` header)
- Replace wrong tool/label/doc count claims with the real numbers:
  30 -> 45 MCP tools, 14 -> 36 node labels, 14 -> 18 docs
- Resolve Phase 11 collision in 09-roadmap.md (v1.1 phases renumbered
  to 4-7 in the unified plan; v1 Polish keeps its Phase 11)
- Propagate the v1.2 world_id -> Setting/Plane deprecation to the
  v1.1 docs (11, 12, 14, 17): YAML world_id -> setting_id, Postgres
  world table -> setting table, Cypher index renamed, migration
  steps 6 + 7 added in 17-planes.md

Cognee substrate switch (the strategy change):
- 00-overview.md: substrate line now Cognee, not GraphMCP-Example
- 08-architecture.md: full system diagram, services layout, hosting,
  and resource budget rewritten for Cognee as the substrate
- 11-extensibility.md: 4-layer diagram reframed (Cognee data-model
  extension; template-watcher is a Cognee data-pipeline)
- 12-storage-strategy.md: 5 stores collapse to 3 layers; saga pattern
  removed (Cognee handles cross-store transactions)
- 13-microservice-decomposition.md: full rewrite (Cognee is the
  gateway; no monolith problem; 5+ Go services collapse to one
  in-process Python extension)
- 14-examples.md: Example 6 added (full 8-step Cognee integration
  walkthrough from "stand up Cognee" to "template-driven tool call")
- 15-related-work.md: Cognee reframed as substrate, not option
- 16-comparison.md: strategic section reframed (decision made, not
  debate); the build order is now in 09-roadmap.md (Cognee-spike-first)
- 10-critique.md: S1.4, S1.5, S2.5, S2.6 status updated for Cognee
- 07-reasoning-harness.md: system prompt mentions Cognee + cognee.recall
  fallback
- 06-ingestion.md: Path 1 (prose) via cognee.add/cognify; Path 2
  (YAML) via Lore Engine parser; Go-worker section replaced

Build plan (09-roadmap.md): the v1+v1.1 work collapses from 43 days
on GraphMCP-Example to 33 days on Cognee. The MVP is end of Phase 3
(16 days): Cognee spike, typed ontology, time model, 45 MCP tools.
Phase 4-6 add the consistency engine, TypeTemplate polymorphic
extension, and reasoning-harness validation.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-17 00:27:50 -04:00

243 lines
12 KiB
Markdown

# 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.