[DD-016] Extended kinship: pibling, nibling, cousin queries
DD Identifier
DD-016
Target version
v0.4.0
Context
DD-002 defines the full kinship vocabulary (pibling, nibling, cousin)
but only ancestor/descendant/sibling/root/leaf were implemented
at v0.2.0/v0.3.0. v0.4.0 closes the gap for the three remaining terms.
pibling and nibling have a single, unambiguous fixed-degree
definition. cousin does not: genealogical cousinage uses two
parameters (degree, removed) and an asymmetric path comparison,
which does not reduce to a single-query pattern reusing the existing
ancestors_of/descendants_of primitives.
Options considered
| Option | Advantage | Risk |
|---|---|---|
A — Genealogical (degree, removed) |
Exact human vocabulary (1st cousin, 1st cousin once removed…) | Two parameters, asymmetric algorithm, no single-query pattern reusing existing primitives |
| B — Symmetric degree (chosen) | Reuses the distance-on-path primitive already used by ancestors_of/descendants_of; single query per backend |
Does not cover "removed" cases (different-depth cousins) — documented limitation |
| C — Distance from pibling | Appears simple | Ambiguous: the same value conflates "2nd cousin" and "1st cousin once removed" depending on node's own depth — rejected |
Decision
Option B — symmetric degree.
piblings_of(node)/node.piblings()— siblings ofnode.parent. Fixed degree only. Pure composition ofsiblings_of()— no new SQL.niblings_of(node)/node.niblings()— children ofnode's siblings. Fixed degree only.filter(parent__in=siblings_of(node))— no new SQL.cousins_of(node, degree=2)/node.cousins(degree=2)— nodes sharing a common ancestor exactlydegreelevels abovenode, at the same depth asnode.degree=2corresponds to genealogical "1st cousin";degree=3to "2nd cousin";degree=1is degenerate and returns the same set assiblings_of().
Algorithm — cousins_of(node, degree)
Given node.path split into segments (one per ancestor PK, self
included):
ancestor_path= segments minus the lastdegree→ the common ancestor. Iflen(segments) <= degree, no such ancestor exists → returnnone().closer_ancestor_path= segments minus the lastdegree - 1(ornode.pathitself whendegree == 1) — the branch to exclude, since anything in that subtree is a closer relation (sibling, pibling, nibling, or a lower-degree cousin).- Candidates = descendants of
ancestor_path, excluding thecloser_ancestor_pathsubtree, filtered to the same depth asnode.
Backend dispatch — single query, either backend
- PostgreSQL:
path__descendant_of=ancestor_path,.exclude(path__descendant_of=closer_ancestor_path), depth filter via a newNLevelFunc(nlevel(path)) registered inclade/fields.pyalongsideAncestorOf/DescendantOf(DD-015 pattern). Internal use only — not added toclade/__init__.py's__all__. - Fallback (SQLite, …):
path__startswith=ancestor_path + ".",.exclude(path__startswith=closer_ancestor_path + ".").exclude(path=closer_ancestor_path), depth filter viaLength(F("path")) - Length(Replace(F("path"), Value("."), Value("")))(dot count) — standard Django ORM functions, no new field code needed.
The ancestor's path string is already known in Python (sliced from
node.path, no extra query) and drives the filter directly.
Consequences
piblings_of/niblings_ofship with zero new SQL surface.cousins_ofintroduces one new internal component (NLevelFunc) inclade/fields.py, not part of the public API.- Deferred to post-v1.0.0: genealogical
removedparameter forcousins_of; configurable-degree variant ofpibling/nibling(grand-pibling, grand-nibling). Thedegreeprimitive introduced here is the foundation for that later work. - Related: DD-002 (#2 (closed)), DD-012 (#39 (closed)), DD-013 (#40 (closed)), DD-015 (#51 (closed)).
Affected scopes
api— three new public query methodstests— unit (SQLite), dispatch (PostgreSQL, mocked), integration (real PostgreSQL)
References
- DD-002 kinship glossary (this repository,
DESIGN_DECISIONS.md) - PostgreSQL ltree
nlevel(): https://www.postgresql.org/docs/current/ltree.html