Skip to content

GenealogyKG design plan

Author: Eric G. Suchanek, PhD Status: Phase 0 through 5 complete (Phase 5 on 2026-08-24) -- build/query/pack, lineage walks, ASCII family trees, KGRAG federation, the WITHIN place hierarchy, the living-person filter, Julian dates, the full analyze() report, snapshots, install-hooks, the 2-D pedigree/network views, and the organic-growth 3-D quilt/viz3d renderer all work end to end.

adapter.py's KGKind.GENEALOGY defect (kg-rag >=0.15.0 needed; GenealogyKGAdapter.display() is unreachable via make_adapter() in either direction) is resolved for the floor and documented as a standing limitation for display() -- see the Phase 4 notes below and kgrag_priv/FLEET_SWEEP_PLAN.md.

GenealogyKG turns a GEDCOM file into a KGModule: a SQLite graph of people, families, events, places and sources, a sqlite-vec index over prose summaries of each record, and the standard fleet surfaces on top (CLI, MCP server, KGRAG adapter, snapshots). Nothing in the storage, query or packing layers is new. The whole module is a KGExtractor that yields NodeSpec/EdgeSpec objects, a few lineage helpers over GraphStore, and one function that maps GEDCOM dates onto the fleet's temporal contract.

What the reader gets

  • genkg build --source family.ged produces .genealogykg/graph.sqlite and .genealogykg/vectors.sqlite.
  • genkg query "chemists born in Cincinnati" returns ranked person, event and place nodes, expanded one hop through family links.
  • genkg pack "Hartwell emigration" returns the raw GEDCOM records behind each hit, with line numbers, ready for an LLM context window.
  • genkg ancestors I7 --generations 3 and descendants I1 print an ASCII family tree -- no semantic query, no plotting library, works over SSH:

John Hartwell (1820-1891) m. Mary Ashcombe
+-- William Hartwell (1848-1922) m. Anna Kessler
|   `-- Robert Hartwell (1876-1949) m. Louise Brandt
`-- Eliza Hartwell (b. 1851) m. Samuel Pryce
- genkg mcp exposes the same operations to Claude Code and other MCP clients, including family_tree for the ASCII rendering. - A federated kgrag query --from 1840 --to 1900 includes genealogy hits, because every dated node carries occurred_start / occurred_end, and kgrag itself knows the kind (KGKind.GENEALOGY, registered 2026-08-23). - genkg viz I1 --output tree.html draws the descent chart; --view network draws the person/family topology around someone. - Later: genkg quilt I1 for a Looking Glass hologram (Phase 5).

Decisions

Parse with ged4py, do not write a parser

ged4py (MIT, 0.5.2, Python 3.11+) reads GEDCOM 5.5 and 5.5.1. It handles the parts that look small and are not: ANSEL and UTF-8 codecs with BOM detection, CONT/CONC continuation, @xref@ pointer resolution, and the full DATE grammar (ABT, CAL, EST, BEF, AFT, BET ... AND, FROM ... TO, Julian and Hebrew calendars, free-text phrases). Every Record carries its byte offset in the file, which is what lets pack() return the original record text.

Rejected: python-gedcom is GPLv2 and cannot be a dependency of an Elastic-2.0 package. gedcom7 reads only GEDCOM 7, has no license metadata, and no mainstream exporter emits GEDCOM 7 by default yet. A hand-rolled parser would be a second implementation of the date grammar for no gain.

GEDCOM 7 support is deferred. Revisit when Ancestry or FamilySearch exports change their default.

Stay on the shared pipeline

The module subclasses kg_utils.pipeline.KGModule and lets GraphStore, SemanticIndex and the default query()/pack() do the work. FTreeKG opted out of GraphStore for raw SQL and was the one fleet module where the temporal contract failed to reach callers (FLEET_STANDARDS.md, "Temporal data"). This module does not repeat that.

Two overrides are needed:

  • index_meta_columns() adds text to the vector metadata so query hits surface the prose summary as their docstring.
  • query() and pack() default rels to the genealogy edge set instead of DEFAULT_RELS (CONTAINS, CALLS, ...), which would expand nothing.

One KG kind, many people

The kind string is genealogy, the store directory is .genealogykg, the KGRAG adapter kind is KGKind.GENEALOGY. kg-rag already carries a KGKind.PERSON stub for a future single-person biographical KG. That is a different thing (one subject, many facts) from this one (many subjects, few facts each), and the two must not be conflated when the adapter is registered.

Source files are private by default

A GEDCOM file is personal data about living relatives. .gitignore excludes *.ged everywhere except tests/fixtures/, and the fixture is fictional. The build never copies the source into .genealogykg/; pack() reads it in place, so deleting the file removes the data from every path that could reveal it.

The living-person filter (Phase 3) is [tool.genealogykg] living_cutoff_years = N, or GenealogyKG(living_cutoff_years=N). On by default: unset means 100 years (config.DEFAULT_LIVING_CUTOFF_YEARS), and 0 turns it off. GedcomExtractor itself stays explicit, redacting nobody unless given a cutoff; the defaults live in config.py. A person with no DEAT/BURI record whose birth (fallback BAPM, CHR) falls within N years of today becomes a bare person node named Living: xref, sex and lineage edges kept (so trees still walk through them), name, dates, events, notes, citations and surname dropped, and their name withheld from every other node's prose -- family names, children lists, spouse phrases, parents. A family with a living spouse also drops its MARR date, place, event and temporal keys.

Unknown birth status is configurable with [tool.genealogykg] unknown_birth_policy = "keep" | "redact" or the matching GenealogyKG argument. "redact" is the default: when no usable birth year and no DEAT/BURI record exist, the person is treated as living. It closes the fail-open privacy gap at the cost of hiding some historical people with incomplete records; "keep" preserves them. A DEAT or BURI record is affirmative evidence even when its own date is missing. pack() reads the GEDCOM file in place, so a redacted store must travel without the file.

pack() also clips every SUBM record from its snippets, filter on or off: the submitter's name, address, phone and email sit just above the first INDI in most files, inside its context padding.

The filter covers what the tool emits, not the files in the repository: the curated corpus under corpora/entries/ is committed as published, minus its submitter records (scripts/scrub_submitters.py).

Graph model

Node kinds

kind one per name qualname docstring (embedded text)
person INDI record formatted name Surname, Given one prose paragraph: name, sex, birth and death with place, parents, spouses with marriage year, occupation, notes
family FAM record Hartwell-Ashcombe family same spouses, marriage date and place, children in order
event dated sub-record (BIRT, DEAT, BURI, BAPM, CHR, MARR, DIV, RESI, OCCU, IMMI, EMIG, CENS) Birth of John Hartwell I1.BIRT who, what, when, where
place distinct PLAC string the string as written same the place string plus the events that happened there
source SOUR record TITL same title, author, publication

node_id is deterministic and readable: person:I1, family:F1, event:I1:BIRT, event:I1:RESI:2 (ordinal for repeated tags), place:leeds-yorkshire-england, source:S1. IDs are stable across rebuilds of the same file, which is what snapshots and cross-KG links need.

source_path is the GEDCOM file's repo-relative path. lineno and end_lineno are the record's line span, computed once per build from ged4py's byte offsets. That is all pack() needs to return the original record.

Edge kinds

relation from to why it exists
CHILD_IN person family FAMC
SPOUSE_IN person family FAMS
PARENT_OF person person derived from FAM: each HUSB/WIFE to each CHIL. Makes an ancestor walk one hop per generation instead of two.
MARRIED_TO person person derived, HUSB to WIFE, one direction. GraphStore.expand is undirected so one edge serves both.
HAS_EVENT person or family event
OCCURRED_AT event place
CITES person, family or event source SOUR pointers at any level
WITHIN place place comma-split hierarchy: Cincinnati, Hamilton, Ohio, USA -> Hamilton, Ohio, USA -> Ohio, USA -> USA, each level its own place node, emitted the first time any string reaches it. Excluded from the default expansion rels, like CITES, so a hit on a country does not pull in every place inside it.

Ancestors walk PARENT_OF inbound (GraphStore.callers_of), descendants walk it outbound (GraphStore.edges_from). No second edge kind is needed.

Temporal contract

One function, genealogy_kg.temporal.temporal_keys(date_value), maps a ged4py DateValue onto kg_utils.temporal.temporal_metadata(...) and returns {} for anything it cannot place. It is the only writer of occurred_start, occurred_end and recorded_at, per the "derive, don't author twice" rule.

GEDCOM contract
12 MAR 1901 occurred_start=1901-03-12 (day precision)
MAR 1901, 1901 month, year precision
ABT 1850, CAL 1850, EST 1850 occurred_start=1850; qualifier kept in date_qualifier
BEF 1857 occurred_end=1857 only
AFT 1930 occurred_start=1930 only
BET 1899 AND 1901, FROM 1846 TO 1850 start and end
phrase ((before the war)) {}; raw text stays in date_raw
@#DJULIAN@ 1 MAR 1700 occurred_start=1700-03-12: converted with convertdate.julian at day precision; Julian year and year-month pass through unchanged (the calendars differ by less than that)
Hebrew, French Republican, BC years {}; still deferred

Person nodes get occurred_start from BIRT (fallback BAPM, CHR) and occurred_end from DEAT (fallback BURI). Family nodes get MARR. Event nodes get their own DATE. recorded_at is the HEAD.DATE of the file when present. The raw DATE string is always kept beside the derived keys.

Package layout

src/genealogy_kg/
  __init__.py       GenealogyKG, GedcomExtractor, __version__
  gedcom.py         thin reader over ged4py: records, line spans, name/place/date helpers
  temporal.py       temporal_keys()/person_temporal_keys(): ged4py DateValue -> kg_utils.temporal
  extractor.py      GedcomExtractor(KGExtractor): the graph model above
  module.py         GenealogyKG(KGModule): kind(), analysis()/analyze(), rels defaults, tree()
  lineage.py        ancestors(), descendants(), kinship_path(), tree_data(), ascii_tree()/FamilyTree
  analysis.py       analyze_graph()/render_report(): the analyze() data and its Markdown
  config.py         [tool.genealogykg] sources + living_cutoff_years, .genealogykg/config.json
  snapshots.py      GenealogySnapshotManager over kg_utils.snapshots
  mcp_server.py     kg_utils.mcp: stats, query, pack, get_node, neighbors, then
                    get_person, ancestors, descendants, family_tree, analyze_genealogy
  adapter.py        GenealogyKGAdapter for KGRAG (optional extra `adapter`);
                    display() unreachable via make_adapter(), kept as reference
  viz.py            2-D rendering: pedigree_figure() (plotly), network_html()
                    (the genealogy theme over kg_utils.viz.build_graph_html)
  theme.py          Colours + generation_depths(), shared by viz.py and scene.py,
                    dependency-free so either's extra installs alone
  scene.py          family_tree_positions() (pure NumPy) + build_family_tree_scene()
                    (grow_tree/tree_mesh/leaf_glyphs, or the schematic straight-line mode)
  viz3d.py          FamilyTreeWindow: QtInteractor + one Cast-to-Looking-Glass action
  cli/
    group.py        root click group (`genkg`)
    options.py      shared --repo/--db/--vectors/--model/-k options
    cmd_build.py    build
    cmd_query.py    query, pack
    cmd_lineage.py  ancestors, descendants (both print an ASCII tree)
    cmd_analyze.py  analyze
    cmd_status.py   status
    cmd_snapshot.py snapshot save|list|show|diff
    cmd_hooks.py    install-hooks
    cmd_viz.py      viz (--view pedigree|network); quilt, viz3d (--shape, --schematic)

Storage: .genealogykg/graph.sqlite, .genealogykg/vectors.sqlite, .genealogykg/snapshots/ (tracked), .genealogykg/config.json (the source path used by the last build).

Phases

Each phase ends with a green suite and a release. Version numbers follow the fleet's usual pace (a minor bump per phase).

Phase 0: skeleton (done)

Repo structure per fleet conventions, CI (lint, type, test, wheel smoke test), release workflow, fixture GEDCOM, this document. Source modules exist with their public signatures and raise NotImplementedError.

Phase 1: build, query, pack (0.1.0) -- done 2026-08-23

  1. gedcom.py: open a file, iterate INDI/FAM/SOUR, compute line spans from offsets, format names and places.
  2. temporal.py with a table-driven test covering every row above.
  3. extractor.py emitting the node and edge kinds above. Test: node and edge counts on the fixture, ID stability across two extractions, every dated node carries the contract.
  4. module.py: kind(), index_meta_columns(), rels defaults, a minimal analyze().
  5. cli/cmd_build.py, cmd_query.py, cmd_status.py. Test through click.testing.CliRunner on the fixture.
  6. pack() returns the GEDCOM record for person:I1 with the right line numbers. This is the acceptance test for the whole phase -- tests/test_build_query_pack.py::test_pack_returns_the_original_gedcom_record.
  7. Build corpora/torture/TGC551LF.ged (ANSEL, every 5.5 tag), corpora/gedcom-samples/royal/royal92.ged (no GEDC header) and corpora/gedcom-samples/pres/pres2020.ged (5.5.1, BOM) without error and with the node counts CORPORA.md lists. These run as integration tests, skipped when corpora/ is absent.

Phase 2: lineage and federation (0.2.0) -- done 2026-08-23

  1. lineage.py: ancestors/descendants (bounded by generations, nearest first), kinship_path (shortest PARENT_OF/MARRIED_TO chain, BFS over both directions since MARRIED_TO is stored husband -> wife only but is conceptually symmetric).
  2. ascii_tree() / FamilyTree, also in lineage.py. A recursive walk (children via edges_from, parents via callers_of, depth-bounded, cycle-safe on the current path) renders pure-ASCII connectors (+--/`--/|, never Unicode box-drawing) with spouses and life spans inline:
John Hartwell (1820-1891) m. Mary Ashcombe
+-- William Hartwell (1848-1922) m. Anna Kessler
|   +-- Robert Hartwell (1876-1949) m. Louise Brandt
|   |   `-- Margaret Hartwell (1903-1990)
|   `-- Clara Hartwell (b. 1879)
+-- Eliza Hartwell (b. 1851) m. Samuel Pryce
|   `-- Edith Pryce (b. 1874)
`-- Thomas Hartwell (1855-1857)

FamilyTree.__repr__/__str__ both return the rendered text, so kg.tree("I1") prints the tree directly at a REPL prompt or in a notebook -- no plotting library, no build step. This is the zero-setup tier every heavier visualization in Phase 4/5 sits on top of: it works the moment build has run, on a laptop or over SSH, and it is what genkg ancestors/descendants print and what the family_tree MCP tool returns. 3. GenealogyKG.tree(); cli/cmd_lineage.py's ancestors/descendants commands print it; MCP gained family_tree alongside the existing ancestors/descendants (which return the flat, generation-tagged JSON list lineage.ancestors/descendants produce, for callers that want data rather than art). 4. adapter.py in this repo, and in kg-rag: KGKind.GENEALOGY, genealogy_adapter.py, the .genealogykg directory mapping in cmd_registry.py, a colour and glyph in app.py, "genealogy" added to the three MCP tool kind-filter enums. Landed as kgrag commit 3dcb9ab, not a public TODO.

A wrong finding, corrected the same day. Writing genealogy_adapter.py required knowing the shape kg_utils.pipeline.KGModule.query()/.pack() return (node["id"], node["relevance"]["score"], node["snippet"]), and this document originally claimed kg_rag.adapters.ftree_adapter.FTreeKGAdapter was broken for still reading the older node_id/top-level-score/ .snippets shape -- on the assumption that every KGModule subclass returns the shared base shape. It does not: FileTreeKG overrides query()/pack() itself and deliberately keeps the older shape for backward compatibility (its own module.py docstrings say so). A real FileTreeKG build confirmed node_id and score were already correct there. The one genuine defect: FTreeKGAdapter.pack() built CrossSnippet without metadata=, even though FileTreeKG.pack() populates it on every snippet and query()'s CrossHit already read it -- so a time_range-scoped pack() call saw every FTreeKG snippet as undated. Fixed in kgrag commit 4ecd0cc (one line, two regression tests), CHANGELOG corrected there to retract the false claim. diary_adapter.py and agent_adapter.py were checked the same way (their backends' actual query()/pack() source, not assumed) and are correct: both genuinely return node_id/score at the top level by design, unrelated to kg_utils.pipeline's shape. 5. Deliberately not built this phase: a dedicated federated time-scoped-query test (ftree_kg's test_temporal_contract.py pattern). That needs kg-rag installed, which is the adapter extra, not the dev group -- ftree_kg/adapter.py has the identical gap for the identical reason (confirmed: no test file references it, and its own pyproject.toml comment says nothing in the fleet imports it directly). Matching that precedent rather than inventing a one-off kg group entry for a single test. The adapter was smoke-tested by hand instead (a scratch venv with both packages installed editable, a real build, and a real query/pack/stats/analyze/snapshot round trip) -- confirmed working, just not wired into CI.

Phase 3: hygiene and analysis (0.3.0) -- done 2026-08-23

  1. WITHIN place hierarchy, emitted by the extractor as described in the edge table. On the fixture, Ohio, USA and USA appear once each and both Cincinnati and Dayton sit WITHIN them.
  2. Living-person filter, as described under "Source files are private by default". Tested with a 200-year cutoff on the fixture: five people without death records born after 1826 are redacted, the two with AFT/plain death dates are not, and their names appear nowhere else in the graph.
  3. Julian conversion in temporal.iso_date() via convertdate.julian, day precision only. ged4py rejects dual years (1699/00) outside the Gregorian calendar, so there is no dual-year case to handle. Hebrew and French Republican stay unplaced; convertdate can do both, but the month-name mapping deserves its own verified table rather than a drive-by.
  4. analysis.py: analyze_graph() returns the data (counts, generation depth as the longest PARENT_OF chain with cycle cut-off, surname distribution from the new surname metadata key, date coverage per dated kind, people with no CHILD_IN/SPOUSE_IN edge, places with no WITHIN edge in either direction, redacted-person count) and render_report() the Markdown. GenealogyKG.analysis() exposes the dict; analyze() still never raises. Snapshots record the same numbers.
  5. snapshots.py: GenealogySnapshotManager.capture_genealogy(stats, analysis) over kg_utils.snapshots, with people/families/events/places deltas beside the shared node/edge ones and the diary_kg-style get_previous() fallback so an unsaved capture already carries vs_previous. genkg snapshot save|list|show|diff and genkg install-hooks (quality checks on every commit; the rebuild-and-snapshot step only under GENKG_SNAPSHOT=1, per kgrag_priv/docs/SNAPSHOT_STRATEGY.md).

25 new tests (83 total).

Phase 4: viz -- 2-D family-tree diagrams (0.4.0) -- done 2026-08-24

The fleet convention for a KG's optional 2-D visualization is a viz extra, never hand-rolled per repo. This phase is that extra plus the domain glue that turns a GenealogyKG into the shapes those libraries draw:

  1. viz.py: network_html() renders the person/family/CHILD_IN/SPOUSE_IN/MARRIED_TO graph -- the ontological view. People are coloured by sex or by generation depth from a chosen root; each relation gets its own edge colour, with PARENT_OF and MARRIED_TO loud and the membership edges they derive from muted.
  2. pedigree_figure() -- a plotly pedigree/descent chart, the semantic view and the direct visual upgrade path from ascii_tree(). Both now walk lineage.tree_data(), so they cannot drift into two independent layouts: the walk was extracted rather than duplicated, and the chart keeps the ASCII orientation (root on top, a row per generation).
  3. cli/cmd_viz.py: genkg viz <xref> --output tree.html, with --view pedigree|network, --direction, --generations, --color-by and --max-nodes.
  4. KGAdapter.display(): SEMANTIC draws the descent chart from a progenitor, ONTOLOGICAL the network. Non-Streamlit backends and a missing viz extra both fall back to the base placeholder card rather than failing the visualizer. Written and tested, but see below -- kg-rag cannot reach it yet.
  5. pip install "genealogy-kg[viz]"; nothing in viz.py is imported outside the cli/cmd_viz.py and adapter.display() entry points, so a bare install never pulls plotly/pyvis. A subprocess test asserts it, because this environment has the extra installed and would hide a regression otherwise.

Two things landed differently from the sketch above.

The renderer is not hand-rolled pyvis. kg_utils.viz.build_graph_html already draws any KG's graph; what belongs here is only the genealogy vocabulary -- kinds, colours, tooltip fields -- exactly as pycode_kg.graph_html does for code. The viz extra therefore asks for kgmodule-utils[viz] and does not hand-list pyvis. Hand-listing the render stack while never depending on the SDK extra where the code lives is what left diary_kg's viz3d extra dead; see FLEET_STANDARDS.md.

display() is unreachable today, in either copy, and that is not fixable here. make_adapter() maps KGKind.GENEALOGY to kg-rag's own genealogy_adapter through a hard-coded dict with no override hook, so this repo's copy is never the one federation instantiates. Porting the override into kg-rag does not help either: nothing calls display() on the Streamlit path. Its only live caller is kg-rag's Qt2D visualizer (viz_qt.py:266), Viewport.streamlit_*() has no call sites, and app.py uses adapters only for is_available()/stats(). Zero of kg-rag's 15 adapters have ever overridden display().

The override is kept here anyway: it costs nothing, it runs if the adapter is constructed directly, and it is the reference for whenever the forest is wired. A port to kg-rag was written, tested and reverted unmerged rather than ship dead code -- with a note that the per-adapter shape should not be the template, since only two of its ~75 lines are domain-specific. The design for doing it properly (a primitives-only render hook on KGAdapter, and an entry-point adapter registry) is in kgrag_priv/FLEET_SWEEP_PLAN.md.

25 new tests (108 total).

Phase 5: viz3d -- the family tree, literally (0.5.0) -- done 2026-08-24

Grows xref's descent line with kg_utils.viz3d.organic and renders a quilt with quiltwright: xref (plus spouse(s)) as trunk, each family a limb, each person a leaf clustered around their birth family's limb tip, ordered within the cluster by birth year, leaf colour by generation (default) or sex, cluster radius scaled by children per couple -- attractors are real genealogical data, so the canopy's shape is the family's shape, per the visualization stack's own organic-growth premise. scene.py is the only new module; layers 1-2 (kg_utils.viz3d.organic, quiltwright) are the shared fleet stack and are never reimplemented here -- kgrag_priv/docs/VISUALIZATION_STACK.md is the canonical reference, and gutenberg_kg's scene.py/cli/cmd_quilt.py and pycode_kg's module-to-limb mapping (scene3d.py) are the patterns scene.py follows.

  1. scene.py: family_tree_positions() -- pure NumPy, no PyVista import -- places the trunk, every limb, and every leaf; build_family_tree_scene() composes that layout through grow_tree -> tree_mesh -> leaf_glyphs (organic mode) or straight trunk/branch/twig lines plus sphere glyphs (schematic mode, organic=False) into a caller-supplied pv.Plotter.
  2. cli/cmd_viz.py gains quilt (genkg quilt <xref> --preset <name> [--schematic] -> a Looking Glass quilt via render_quilt) and viz3d (genkg viz3d <xref> [--schematic] -> an interactive PyQt5/pyvistaqt window with orbit/zoom/pan and a Cast-to-Looking-Glass action). viz3d.py is that window: deliberately much smaller than gutenberg_kg's/pycode_kg's (~1500 lines each) -- no custom picking, no info popups, no filter toggles, since QtInteractor supplies orbit/zoom/pan for free and kg_utils.viz3d.qt.cast_scene_to_looking_glass does the entire cast in one call.
  3. pip install "genealogy-kg[viz3d]" (PyVista/PyQt5-backed, heavy); quiltwright>=0.10.0.

Two things landed differently from the sketch above, both discovered by testing against a real corpus rather than the 12-person fixture -- exactly the payoff Phase 5 was sequenced after Phase 4 to get.

xref founds the tree; auto-detecting "the root couple" was dropped. A GEDCOM can hold several unrelated lines, and a person can have two ancestor chains (through each parent) with no single well-defined progenitor to find. xref plus every descendant plus every spouse who married in is unambiguous and matches every other genkg command's convention.

The population walk needed a dedicated forward-only closure, not reused generation_depths(). The first version filtered Phase 4's generation_depths() (bidirectional -- built for "generation distance from whoever I clicked on") to non-negative offsets, on the theory that this was exactly the descent line. Wrong on a real corpus: for a person with recorded parents, that walk climbs to them and back down through every sibling's own line. Confirmed against the Kennedy corpus (corpora/gedcom-samples) -- it pulled in 86 collateral relatives for a person with no descendants of their own, and the corrected walk changed a 183-person tree to the true 120. scene._descendant_population() is the fix: children and married-in spouses only, never a step to a parent. generation_depths() is still used, correctly, for leaf colouring on the already-correctly-scoped population.

The schematic mode (organic=False) was added after the organic renderer, once seeing it render made plain how expensive and slow organic growth is to iterate on for a large family, and how useful a cheap straight-line diagram is as a sanity check on the layout that organic mode then grows wood toward -- the same relationship gutenberg_kg's --schematic flag has to its own organic render. Both modes draw the same family_tree_positions() output; only the connecting geometry and person glyphs differ.

26 new tests (134 total).

The hourglass

The descent-only tree above has two problems, both visible on the Kennedy file. Ancestors are absent, though a GEDCOM is mostly ancestry. And the wood does not follow the kinship edge: colonization grows toward people from whatever wood is nearest, so the limb that reaches a child need not branch from the parent.

hourglass.py replaces it as the default shape. The focal person and spouse(s) are the trunk at ground level; ancestors are roots below, descendants canopy above. It is a layout, not a growth: the canopy is a radial tidy tree (a person's wedge split among families and children by descendant weight), the roots a fan chart (a person's wedge halved between their parents). The wood graph is the kinship graph, one node per person and one edge per parent-to-child link, with married-in spouses as short twigs off their partner's node. Organic mode curves each edge (curved_wood) and sweeps it with the shared pipe_radii/tree_mesh, so a limb is exactly as thick as the family it carries. No kg_utils change was needed: a Skeleton is points plus a parent index, and the hourglass builds one directly.

The ancestor walk is backward-only (CHILD_IN to the birth family, then its spouses), never generation_depths, which is bidirectional and would pull every aunt and great-uncle into the roots. Pedigree collapse (cousins marrying) places a shared ancestor once and counts the second edge in skipped_edges. Both halves are depth-capped (5 ancestor and 4 descendant generations by default) because royal92 runs 70 generations above Victoria, almost all single-file, and uncapped that chain is all the picture shows.

Exports for The Family

export.py and export_swift.py write the files kpress_family reads: export-web the orchard's JSON, export-swift the on-device search pack. The pack format lives in kg_utils.devicepack (kgmodule-utils 0.28.0), lifted out of gutenberg_kg's export-swift so both KGs write one format; search_pack there reproduced all 24 of gutenberg_kg's golden rankings.

The privacy gate is the design's center. Redaction happens at build time and the store does not record its cutoff, so an export re-reads the GEDCOM under its own cutoff and checks that everyone it treats as living is only a Living placeholder in the store; a store built looser fails. It then scans everything it wrote for those people's real names before moving the output into place. A name someone not living also bears is excluded from the scan: the Kennedy file has a living Patrick Joseph Kennedy whose grandfather's page must carry the same name. That is the gate's one blind spot, a note naming a living namesake of an ancestor, and it is documented rather than guessed at.

Lines (assign_lines) are surnames by birth joined by descent, so every person is in one line and the pack's single genre column can scope by line. Surnames with no letters ((-----), an unrecorded maiden name) group nobody. People with no surname anywhere above them are cut into houses, named for a head with at least three children (counted father first) and holding the head's descent down to the next head, so a surname-less file still plants. Families take the husband's line in the pack.

Not in scope

  • Writing or editing GEDCOM.
  • Merging duplicate persons across files. One file, one graph.
  • A web UI. The MCP server and KGRAG's app are the interfaces.
  • GEDCOM X or GEDCOM 7 input.

Decisions taken 2026-08-23

  • Event nodes exist from Phase 1. A semantic hit on "marriage in Dayton 1901" lands on the marriage event and expands to both spouses in one hop.
  • The kind is genealogy, with its own KGKind.GENEALOGY in kg-rag. The person stub there stays reserved for a single-subject biography KG.
  • No real GEDCOM export exists yet. Development runs against the fixture and the public corpora listed in CORPORA.md, fetched by scripts/fetch_corpora.sh into the gitignored corpora/ directory.

Fleet checklist for the first release

  • [ ] poetry lock committed; CI green on main
  • [ ] CHANGELOG ## [0.1.0] - YYYY-MM-DD (one ASCII hyphen)
  • [ ] release-notes.md generated from the changelog section
  • [ ] CITATION.cff version: and date-released:; DOI added after Zenodo mints it (use the zenodo skill, do not copy the DOI off the record page)
  • [ ] README badge, APA and BibTeX versions
  • [ ] Trusted publishing configured on PyPI for genealogy-kg before the tag
  • [ ] FLEET_REPOS in kgrag_priv/scripts/fleet_audit.py and the roster in ~/.claude/CLAUDE.md list genealogy_kg (done in Phase 0)