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:
37
docs/adr/0003-lineage-vs-faction.md
Normal file
37
docs/adr/0003-lineage-vs-faction.md
Normal 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.
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user