Fix kin-distance metric: shortest descent from direct lineage

Replaces the previous "depth in generations along the strict
lineal chain" definition (which excluded siblings, aunts, cousins
entirely) with the metric the user actually wants: the number of
descent-steps separating the target from the recipient's closest
direct ancestor or descendant.

Examples relative to the recipient:
- sibling:        1  (parent → sibling)
- great-aunt:     1  (great-grandparent → great-aunt)
- nephew:         2  (parent → sibling → nephew)
- first cousin:   2  (grandparent → aunt → cousin)
- second cousin:  3
- ego, parents, grandparents, ..., children, ..., great-greats: 0
- own spouse, step-parents, brothers-in-law: inherit partner's
  distance (so spouse-of-distance-1 is also distance 1)

Implementation:
- Anchor set seeded with R's direct ancestors + R + direct
  descendants (capped at 25 generations to bound runaway data).
- Multi-source BFS expanding by descent only.
- Spouse propagation at every level so a person and their
  spouse always share the same distance.
- Memoised per (recipient xref, max distance).

Tree preference key and range kept (NEWSLETTER_LINEAL_DEPTH,
0–10, default 3); only the semantics and the user-facing label
+ help text change, with concrete examples in both English and
German.
This commit is contained in:
2026-05-15 13:10:38 +02:00
parent ff743e484f
commit 105b09c4c5
4 changed files with 111 additions and 41 deletions
+3 -3
View File
@@ -311,13 +311,13 @@ final class NewsletterDispatchService
return array_fill_keys(array_keys($featured), true);
}
$depth = Configuration::linealDepth($tree);
$lineal_set = $this->relationship_finder->linealKin($self, $depth);
$max_distance = Configuration::linealDepth($tree);
$distances = $this->relationship_finder->kinDistances($self, $max_distance);
$detailed = [];
foreach ($featured as $xref => $_individual) {
if (isset($lineal_set[$xref])) {
if (isset($distances[$xref]) && $distances[$xref] <= $max_distance) {
$detailed[$xref] = true;
}
}
+103 -33
View File
@@ -33,8 +33,8 @@ final class RelationshipPathFinder
/** Default BFS depth — ~7 generations of ancestry / descent. */
public const int DEFAULT_MAX_DEPTH = 14;
/** @var array<string,array<string,true>> Cache: xref => {ancestor/descendant xref set} */
private array $lineal_cache = [];
/** @var array<string,array<string,int>> Cache: "rootXref|maxDistance" => {xref => distance} */
private array $distance_cache = [];
/** @var array<string,string|null> Cache: "fromXref|toXref" -> label */
private array $label_cache = [];
@@ -78,42 +78,112 @@ final class RelationshipPathFinder
}
/**
* Set of xrefs that are direct ancestors or direct descendants of
* `$root` within `$depth` generations.
*
* Only lineal hops are followed (parent ↔ child) — spouses,
* siblings, and step-relations are deliberately excluded. The
* recipient's own xref is included in the set.
*
* The result is memoised: callers may invoke this once per
* recipient and reuse it across all featured xrefs.
*
* @return array<string,true>
* Hard cap on how far up / down we'll walk when seeding the direct
* lineal anchor set. 25 generations is well beyond practical
* genealogy and prevents pathological data from running away.
*/
public function linealKin(Individual $root, int $depth): array
private const int LINEAL_SEED_DEPTH = 25;
/**
* Distance from `$root` to every reachable kin within `$max_distance`,
* measured by the "branch length from the closest direct ancestor
* or descendant of `$root`" metric:
*
* 1. Take all of `$root`'s direct ancestors and descendants (and
* `$root` itself). These are *anchors* at distance 0.
* 2. Walk downward (child links only) from every anchor. Each
* hop increases distance by one — so e.g. sibling = 1
* (anchor = parent, one hop down), great-aunt = 1 (anchor =
* great-grandparent, one hop down to great-aunt), nephew = 2
* (anchor = parent → sibling → nephew), first cousin = 2.
* 3. At every level, a person's spouses inherit that level too —
* so brothers-in-law share the sibling's distance, and step-
* grandparents share grandparents' distance.
*
* The search stops at `$max_distance` to keep the result bounded.
*
* @return array<string,int> xref => smallest distance found
*/
public function kinDistances(Individual $root, int $max_distance): array
{
if ($depth < 0) {
return [$root->xref() => true];
$cache_key = $root->xref() . '|' . $max_distance;
if (isset($this->distance_cache[$cache_key])) {
return $this->distance_cache[$cache_key];
}
$cache_key = $root->xref() . '|' . $depth;
// Anchors: every direct-line ancestor and descendant, plus root.
$anchors = [$root->xref() => $root];
$this->collectAncestors($root, self::LINEAL_SEED_DEPTH, $anchors);
$this->collectDescendants($root, self::LINEAL_SEED_DEPTH, $anchors);
if (isset($this->lineal_cache[$cache_key])) {
return $this->lineal_cache[$cache_key];
/** @var array<string,int> $distance */
$distance = [];
/** @var array<string,Individual> $frontier */
$frontier = [];
foreach ($anchors as $xref => $individual) {
$distance[$xref] = 0;
$frontier[$xref] = $individual;
}
$set = [$root->xref() => true];
// Spouses of anchors inherit distance 0 (so own spouse,
// step-parents, step-grandparents, ... all qualify).
$this->propagateSpouses($frontier, 0, $distance, $frontier);
$this->expandAncestors($root, $depth, $set);
$this->expandDescendants($root, $depth, $set);
for ($d = 1; $d <= $max_distance && $frontier !== []; $d++) {
$next = [];
return $this->lineal_cache[$cache_key] = $set;
foreach ($frontier as $individual) {
foreach ($individual->spouseFamilies(Auth::PRIV_HIDE) as $family) {
foreach ($family->children(Auth::PRIV_HIDE) as $child) {
$xref = $child->xref();
if (!isset($distance[$xref])) {
$distance[$xref] = $d;
$next[$xref] = $child;
}
}
}
}
// Spouses of new arrivals share their distance.
$this->propagateSpouses($next, $d, $distance, $next);
$frontier = $next;
}
return $this->distance_cache[$cache_key] = $distance;
}
/**
* @param array<string,true> $set
* For every individual in $sources, mark each of their spouses as
* being at $level (if not already labelled at a smaller distance)
* and add them to $into so subsequent iterations also descend from
* them.
*
* @param array<string,Individual> $sources
* @param array<string,int> $distance
* @param array<string,Individual> $into
*/
private function expandAncestors(Individual $indi, int $depth, array &$set): void
private function propagateSpouses(array $sources, int $level, array &$distance, array &$into): void
{
foreach ($sources as $individual) {
foreach ($individual->spouseFamilies(Auth::PRIV_HIDE) as $family) {
foreach ($family->spouses(Auth::PRIV_HIDE) as $spouse) {
$xref = $spouse->xref();
if (!isset($distance[$xref])) {
$distance[$xref] = $level;
$into[$xref] = $spouse;
}
}
}
}
}
/**
* @param array<string,Individual> $bag
*/
private function collectAncestors(Individual $indi, int $depth, array &$bag): void
{
if ($depth <= 0) {
return;
@@ -121,18 +191,18 @@ final class RelationshipPathFinder
foreach ($indi->childFamilies(Auth::PRIV_HIDE) as $family) {
foreach ($family->spouses(Auth::PRIV_HIDE) as $parent) {
if (!isset($set[$parent->xref()])) {
$set[$parent->xref()] = true;
$this->expandAncestors($parent, $depth - 1, $set);
if (!isset($bag[$parent->xref()])) {
$bag[$parent->xref()] = $parent;
$this->collectAncestors($parent, $depth - 1, $bag);
}
}
}
}
/**
* @param array<string,true> $set
* @param array<string,Individual> $bag
*/
private function expandDescendants(Individual $indi, int $depth, array &$set): void
private function collectDescendants(Individual $indi, int $depth, array &$bag): void
{
if ($depth <= 0) {
return;
@@ -140,9 +210,9 @@ final class RelationshipPathFinder
foreach ($indi->spouseFamilies(Auth::PRIV_HIDE) as $family) {
foreach ($family->children(Auth::PRIV_HIDE) as $child) {
if (!isset($set[$child->xref()])) {
$set[$child->xref()] = true;
$this->expandDescendants($child, $depth - 1, $set);
if (!isset($bag[$child->xref()])) {
$bag[$child->xref()] = $child;
$this->collectDescendants($child, $depth - 1, $bag);
}
}
}