docs(plan,adr): Lineage ≠ Faction; family_tree.yaml splits into bloodline + factions

ADR 0003: a Lineage is a bloodline (descent from one founding
ancestor); a Faction is an association (House, Order, Guild).
A person can be in at most one Lineage but in many Factions
over their lifetime.

Slice 1 schema update:
  - family_tree.yaml: header is now lineage: <id>, no House
    name. Produces Lineage node + PARENT_OF/SPOUSE_OF/MEMBER_OF
    edges. ADR 0002 dispute machinery catches conflicting
    PARENT_OF claims from two YAMLs.
  - factions.yaml: NEW. Produces Faction node + MEMBER_OF(Faction)
    edges with reason: birth|marriage|adoption|swearing|other.

Cross-lineage marriages: a child is MEMBER_OF the lineage named
in the YAML header. The other parent's lineage is reachable
via PARENT_OF; no duplicate membership edge.

Added criteria 1.12-1.15 to slice 1.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-06-17 12:55:21 -04:00
parent 22f25ca043
commit 8cce0204a2
2 changed files with 122 additions and 15 deletions

View File

@@ -0,0 +1,37 @@
# Lineage is blood; Faction is association
**Status:** accepted.
`Lineage` and `Faction` are two different graph node labels with
different semantics, not one concept with two names:
- **`Lineage`** is a *bloodline* — a descent chain from a single
`founding_ancestor`. Membership is determined by walking the
`PARENT_OF` graph back to that ancestor. A person is in at most
one `Lineage` (their biological birth lineage; adopted lineages
are a future extension). Edges: `MEMBER_OF(Lineage)`,
`PARENT_OF`, `SPOUSE_OF`.
- **`Faction`** is an *association* — a House, Order, Guild,
Company, Cult. Membership is `MEMBER_OF(Faction)` with an
optional `reason: birth | marriage | adoption | swearing |
other` and a time-bounded `valid_from`/`valid_until`. A person
can be in zero, one, or many `Faction`s over their lifetime.
The `family_tree.yaml` schema in slice 1 names the **Lineage**,
not the House. "House Raventhorne" is a `Faction`; the
bloodline inside it is a separate `Lineage` node (e.g.
`ashveil_bloodline`). A child born to two House Raventhorne
parents is in the `ashveil_bloodline` by `PARENT_OF` and in
`House Raventhorne` by `MEMBER_OF(Faction, reason=birth)`. A
character who marries into the house gets a `MEMBER_OF(Faction,
reason=marriage)` edge with `valid_from = wedding.year`.
Why this matters: conflating blood and association at the YAML
layer (one `family_tree.yaml` per house) makes it impossible to
model a person who is born into one house and joins another by
marriage, which is the most common political fact in a
high-fantasy world. The blood/association split is what makes
`who are my relatives?` (a `Lineage` query) and `who do I owe
fealty to?` (a `Faction` query) two distinct graph traversals
instead of one ambiguous one.

View File

@@ -26,24 +26,90 @@ fuzziness. Every edge traces to a YAML line.
## What's in the slice
1. `lore_engine_poc/parsers/family_tree.py` — emits `PARENT_OF`
with `valid_from = child.born`, `valid_until = parent.died`.
`SPOUSE_OF` with `valid_from = max(spouse1.born, spouse2.born)`
and `valid_until = min(spouse1.died, spouse2.died)`. Runs
anachronism check on every member.
2. `lore_engine_poc/parsers/timeline.py` — emits `Era` nodes with
1. `lore_engine_poc/parsers/family_tree.py` — emits a `Lineage`
node (not a `Faction`), with `MEMBER_OF(Lineage)` edges and
`PARENT_OF` / `SPOUSE_OF` edges inside the lineage. Each
member's `parents:` field is the source of truth for descent;
a child is in the lineage iff they have a `PARENT_OF` chain
back to the `founding_ancestor`. **The House name does not
appear in this YAML.** Houses are Factions, modelled
separately (per ADR 0003).
```yaml
lineage: "ashveil_bloodline" # required, the Lineage node id
founding_ancestor: "theron_ashveil" # optional, lineage anchor
description: "The bloodline of Theron Ashveil's descendants."
members: # required, list
- id: "aldric_raventhorne" # required, slug
name: "Aldric Raventhorne" # required, human-readable
born: "3rd_age.year_300" # optional, null = unknown
died: null # optional, null = still living
parents: ["cael_vyr", "yssa_raventhorne"] # optional, [] = unknown
spouse_of: ["elara_raventhorne"] # optional, symmetric
reliability: "canonical" # optional, defaults to canonical
```
Edge generation:
- `MEMBER_OF(Lineage=ashveil_bloodline)` per member with
`valid_from = member.born`
- `PARENT_OF` for each parent→child pair with
`valid_from = child.born`, `valid_until = parent.died`
(or `null` if parent still living)
- `SPOUSE_OF` for each spouse pair, symmetric, with
`valid_from = max(a.born, b.born)`,
`valid_until = min(a.died, b.died)` (or `null` if either
still living)
Cross-lineage marriages: a child whose `parents:` span two
lineages is `MEMBER_OF` the lineage named in the YAML header.
The other lineage is reachable via the `PARENT_OF` chain to
the other parent. Slice 1 does not yet model "belongs to two
lineages by blood" — that's a future extension (adopted
lineages).
2. `lore_engine_poc/parsers/factions.py` — emits `Faction` nodes
and `MEMBER_OF(Faction)` edges. **This is where Houses,
Orders, Companies, Guilds live** — separate from the blood
`Lineage` graph. A person can be `MEMBER_OF` multiple
Factions over their lifetime.
```yaml
faction: "house_raventhorne" # required, the Faction node id
name: "House Raventhorne" # required, human-readable
kind: "noble_house" # required: noble_house | order | guild | company | cult | other
founded: "2nd_age.year_87" # optional, null = unknown
dissolved: null # optional, null = still extant
reliability: "canonical" # optional, defaults to canonical
members: # required, list
- id: "aldric_raventhorne" # required, slug of an existing Person
joined: "3rd_age.year_300" # optional, null = unknown
left: null # optional, null = still a member
reason: "birth" # required enum: birth | marriage | adoption | swearing | other
```
Edge generation:
- One `Faction` node
- `MEMBER_OF(Faction=house_raventhorne)` per member with
`valid_from = member.joined`, `valid_until = member.left`
- The `reason` field is stored as a property on the edge for
audit, but does not gate any query in slice 1 — that's
slice 4 territory.
3. `lore_engine_poc/parsers/timeline.py` — emits `Era` nodes with
`CONTAINS` parent-child edges, `Event` nodes with `OCCURRED_AT`,
`OCCURRED_DURING`, `PARTICIPATED_IN`.
3. `lore_engine_poc/parsers/gazetteer.py``Location` and `Region`
4. `lore_engine_poc/parsers/gazetteer.py` — `Location` and `Region`
with `PART_OF` edges, `CULTURE_OF` edges, named events as
`OCCURRED_AT` edges.
4. `lore_engine_poc/parsers/bestiary.py``Creature` with
5. `lore_engine_poc/parsers/bestiary.py` — `Creature` with
`DEFEATED` edges and `first_appeared` time.
5. `lore_engine_poc/parsers/magic_system.py``MagicSystem`,
6. `lore_engine_poc/parsers/magic_system.py` — `MagicSystem`,
`Spell` with `PRACTICES` edges.
6. `lore_engine_poc/parsers/culture.py``Culture`, `Language`,
7. `lore_engine_poc/parsers/culture.py` — `Culture`, `Language`,
`Deity` with `WORSHIPS` and `SPEAKS` edges.
7. **`LoreSource` as a first-class node** — every YAML file
8. **`LoreSource` as a first-class node** — every YAML file
becomes a `LoreSource` node with a `reliability` field
(`canonical | factional | rumor | dialogue | fanon`). Each
edge points to one or more `LoreSource` nodes via a
@@ -53,12 +119,12 @@ fuzziness. Every edge traces to a YAML line.
overridable per-file via YAML frontmatter; the default is
`canonical` for `*.yaml` files and is path-inferred for
prose files (Quests/Random/ → `rumor`).
8. Schema validation: strict, fails loudly with line numbers
9. Schema validation: strict, fails loudly with line numbers
(YAML "gotchas" — `NO: false` parsing as `True`,
tab/space sensitivity).
9. `time_model.py` test suite grows: era-tree membership,
month/day precision, `current` token resolution against
`:Now` config node, null bounds semantics.
10. `time_model.py` test suite grows: era-tree membership,
month/day precision, `current` token resolution against
`:Now` config node, null bounds semantics.
## Acceptance criteria
@@ -75,6 +141,10 @@ fuzziness. Every edge traces to a YAML line.
| 1.9 | `LoreSource` is a first-class node with `reliability` field, `SOURCED_FROM` edges from every typed edge |
| 1.10 | YAML files default to `reliability: canonical`; frontmatter can override |
| 1.11 | Time-bounded edges (from `family_tree.yaml` PARENT_OF) carry `valid_from` and `valid_until`; the demo's `was_true_at` queries actually exercise `time_in_window` |
| 1.12 | `Lineage` and `Faction` are separate node labels (per ADR 0003); `family_tree.yaml` produces `Lineage` only, `factions.yaml` produces `Faction` only |
| 1.13 | `factions.yaml` `MEMBER_OF(Faction)` edges carry a `reason: birth \| marriage \| adoption \| swearing \| other` property |
| 1.14 | A person can have multiple `MEMBER_OF(Faction)` edges with non-overlapping time bounds; the consistency engine (slice 2) flags impossible overlaps as `Contradiction` nodes |
| 1.15 | Cross-lineage marriages model the other parent's lineage via the `PARENT_OF` chain; the child is `MEMBER_OF` the lineage named in the YAML header |
## Test plan