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>
243 lines
12 KiB
Markdown
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.
|