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.gedproduces.genealogykg/graph.sqliteand.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 3anddescendants I1print 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()addstextto the vector metadata so query hits surface the prose summary as theirdocstring.query()andpack()defaultrelsto the genealogy edge set instead ofDEFAULT_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¶
gedcom.py: open a file, iterateINDI/FAM/SOUR, compute line spans from offsets, format names and places.temporal.pywith a table-driven test covering every row above.extractor.pyemitting 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.module.py:kind(),index_meta_columns(), rels defaults, a minimalanalyze().cli/cmd_build.py,cmd_query.py,cmd_status.py. Test throughclick.testing.CliRunneron the fixture.pack()returns the GEDCOM record forperson:I1with 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.- Build
corpora/torture/TGC551LF.ged(ANSEL, every 5.5 tag),corpora/gedcom-samples/royal/royal92.ged(noGEDCheader) andcorpora/gedcom-samples/pres/pres2020.ged(5.5.1, BOM) without error and with the node counts CORPORA.md lists. These run asintegrationtests, skipped whencorpora/is absent.
Phase 2: lineage and federation (0.2.0) -- done 2026-08-23¶
lineage.py:ancestors/descendants(bounded by generations, nearest first),kinship_path(shortestPARENT_OF/MARRIED_TOchain, BFS over both directions sinceMARRIED_TOis stored husband -> wife only but is conceptually symmetric).ascii_tree()/FamilyTree, also inlineage.py. A recursive walk (children viaedges_from, parents viacallers_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¶
WITHINplace hierarchy, emitted by the extractor as described in the edge table. On the fixture,Ohio, USAandUSAappear once each and both Cincinnati and Dayton sitWITHINthem.- 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. - Julian conversion in
temporal.iso_date()viaconvertdate.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;convertdatecan do both, but the month-name mapping deserves its own verified table rather than a drive-by. analysis.py:analyze_graph()returns the data (counts, generation depth as the longestPARENT_OFchain with cycle cut-off, surname distribution from the newsurnamemetadata key, date coverage per dated kind, people with noCHILD_IN/SPOUSE_INedge, places with noWITHINedge in either direction, redacted-person count) andrender_report()the Markdown.GenealogyKG.analysis()exposes the dict;analyze()still never raises. Snapshots record the same numbers.snapshots.py:GenealogySnapshotManager.capture_genealogy(stats, analysis)overkg_utils.snapshots, with people/families/events/places deltas beside the shared node/edge ones and thediary_kg-styleget_previous()fallback so an unsaved capture already carriesvs_previous.genkg snapshot save|list|show|diffandgenkg install-hooks(quality checks on every commit; the rebuild-and-snapshot step only underGENKG_SNAPSHOT=1, perkgrag_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:
viz.py:network_html()renders theperson/family/CHILD_IN/SPOUSE_IN/MARRIED_TOgraph -- the ontological view. People are coloured by sex or by generation depth from a chosen root; each relation gets its own edge colour, withPARENT_OFandMARRIED_TOloud and the membership edges they derive from muted.pedigree_figure()-- aplotlypedigree/descent chart, the semantic view and the direct visual upgrade path fromascii_tree(). Both now walklineage.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).cli/cmd_viz.py:genkg viz <xref> --output tree.html, with--view pedigree|network,--direction,--generations,--color-byand--max-nodes.KGAdapter.display():SEMANTICdraws the descent chart from a progenitor,ONTOLOGICALthe network. Non-Streamlit backends and a missingvizextra 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.pip install "genealogy-kg[viz]"; nothing inviz.pyis imported outside thecli/cmd_viz.pyandadapter.display()entry points, so a bare install never pullsplotly/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.
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 throughgrow_tree->tree_mesh->leaf_glyphs(organic mode) or straight trunk/branch/twig lines plus sphere glyphs (schematic mode,organic=False) into a caller-suppliedpv.Plotter.cli/cmd_viz.pygainsquilt(genkg quilt <xref> --preset <name> [--schematic]-> a Looking Glass quilt viarender_quilt) andviz3d(genkg viz3d <xref> [--schematic]-> an interactive PyQt5/pyvistaqt window with orbit/zoom/pan and a Cast-to-Looking-Glass action).viz3d.pyis that window: deliberately much smaller thangutenberg_kg's/pycode_kg's (~1500 lines each) -- no custom picking, no info popups, no filter toggles, sinceQtInteractorsupplies orbit/zoom/pan for free andkg_utils.viz3d.qt.cast_scene_to_looking_glassdoes the entire cast in one call.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 ownKGKind.GENEALOGYin kg-rag. Thepersonstub 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.shinto the gitignoredcorpora/directory.
Fleet checklist for the first release¶
- [ ]
poetry lockcommitted; CI green onmain - [ ] CHANGELOG
## [0.1.0] - YYYY-MM-DD(one ASCII hyphen) - [ ]
release-notes.mdgenerated from the changelog section - [ ] CITATION.cff
version:anddate-released:; DOI added after Zenodo mints it (use thezenodoskill, do not copy the DOI off the record page) - [ ] README badge, APA and BibTeX versions
- [ ] Trusted publishing configured on PyPI for
genealogy-kgbefore the tag - [ ]
FLEET_REPOSinkgrag_priv/scripts/fleet_audit.pyand the roster in~/.claude/CLAUDE.mdlistgenealogy_kg(done in Phase 0)