[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 of node.parent. Fixed degree only. Pure composition of siblings_of() — no new SQL.
  • niblings_of(node) / node.niblings() — children of node'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 exactly degree levels above node, at the same depth as node. degree=2 corresponds to genealogical "1st cousin"; degree=3 to "2nd cousin"; degree=1 is degenerate and returns the same set as siblings_of().

Algorithm — cousins_of(node, degree)

Given node.path split into segments (one per ancestor PK, self included):

  1. ancestor_path = segments minus the last degree → the common ancestor. If len(segments) <= degree, no such ancestor exists → return none().
  2. closer_ancestor_path = segments minus the last degree - 1 (or node.path itself when degree == 1) — the branch to exclude, since anything in that subtree is a closer relation (sibling, pibling, nibling, or a lower-degree cousin).
  3. Candidates = descendants of ancestor_path, excluding the closer_ancestor_path subtree, filtered to the same depth as node.

Backend dispatch — single query, either backend

  • PostgreSQL: path__descendant_of=ancestor_path, .exclude(path__descendant_of=closer_ancestor_path), depth filter via a new NLevel Func (nlevel(path)) registered in clade/fields.py alongside AncestorOf/DescendantOf (DD-015 pattern). Internal use only — not added to clade/__init__.py's __all__.
  • Fallback (SQLite, …): path__startswith=ancestor_path + ".", .exclude(path__startswith=closer_ancestor_path + ".").exclude(path=closer_ancestor_path), depth filter via Length(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_of ship with zero new SQL surface.
  • cousins_of introduces one new internal component (NLevel Func) in clade/fields.py, not part of the public API.
  • Deferred to post-v1.0.0: genealogical removed parameter for cousins_of; configurable-degree variant of pibling/nibling (grand-pibling, grand-nibling). The degree primitive 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 methods
  • tests — unit (SQLite), dispatch (PostgreSQL, mocked), integration (real PostgreSQL)

References