From 105b09c4c5f88af4f3cadac6a235f01e4396147a Mon Sep 17 00:00:00 2001 From: Alexander Bocken Date: Fri, 15 May 2026 13:10:38 +0200 Subject: [PATCH] Fix kin-distance metric: shortest descent from direct lineage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- resources/views/admin.phtml | 4 +- src/Module.php | 6 +- src/Services/NewsletterDispatchService.php | 6 +- src/Services/RelationshipPathFinder.php | 136 ++++++++++++++++----- 4 files changed, 111 insertions(+), 41 deletions(-) diff --git a/resources/views/admin.phtml b/resources/views/admin.phtml index 44d6ea9..c1237ab 100644 --- a/resources/views/admin.phtml +++ b/resources/views/admin.phtml @@ -132,7 +132,7 @@ use Illuminate\Support\Collection;
- +
diff --git a/src/Module.php b/src/Module.php index f897c00..c08ea76 100644 --- a/src/Module.php +++ b/src/Module.php @@ -125,9 +125,9 @@ class Module extends AbstractModule implements ModuleCustomInterface, ModuleConf 'Other birthdays' => 'Weitere Geburtstage', 'Other anniversaries' => 'Weitere Hochzeitstage', 'Other historical events' => 'Weitere historische Ereignisse', - 'Detailed view depth (generations)' => 'Detailansicht-Tiefe (Generationen)', - 'Recipients see profile pictures, icons and the timeline only for their own direct ancestors and descendants within this many generations. Everyone else appears as a compact text list at the bottom of each section. Set to 0 to render the whole newsletter as text. Recipients with no linked tree record always see the full detailed view.' - => 'Empfänger sehen Profilbilder, Symbole und Zeitachse nur für ihre eigenen direkten Vorfahren und Nachkommen innerhalb dieser Generationenzahl. Alle anderen erscheinen als kompakte Textliste am Ende des jeweiligen Abschnitts. Auf 0 setzen, um den gesamten Newsletter als Text darzustellen. Empfänger ohne verknüpftes Baumprofil sehen stets die vollständige Detailansicht.', + 'Detailed view distance' => 'Detailansicht-Abstand', + 'A person is shown in detail (avatar, icon, timeline) when they sit within this many descent-steps of the recipient\'s direct lineage. Examples relative to the recipient: a sibling is distance 1 (one step down from the recipient\'s parent), a great-aunt is distance 1 (one step down from a great-grandparent), a nephew is distance 2, a first cousin is distance 2. Spouses share their partner\'s distance. Everyone outside this radius appears as a compact text bullet at the bottom of each section. Set to 0 to render the whole newsletter as text; recipients with no linked tree record always see the full detailed view.' + => 'Eine Person erscheint in der Detailansicht (Profilbild, Symbol, Zeitachse), wenn sie innerhalb dieser Anzahl Abstammungsschritte von der direkten Linie des Empfängers entfernt liegt. Beispiele bezogen auf den Empfänger: ein Geschwister hat Abstand 1 (ein Schritt abwärts vom Elternteil), eine Großtante hat Abstand 1 (ein Schritt abwärts vom Urgroßelternteil), ein Neffe hat Abstand 2, eine Cousine ersten Grades hat Abstand 2. Ehepartner teilen den Abstand ihres Partners. Alle außerhalb dieses Radius erscheinen als kompakte Textzeile am Ende des jeweiligen Abschnitts. Auf 0 setzen, um den gesamten Newsletter als Text darzustellen. Empfänger ohne verknüpftes Baumprofil sehen stets die vollständige Detailansicht.', '%s birthday' => '%s Geburtstag', '%s wedding anniversary' => '%s Hochzeitstag', 'Birthday' => 'Geburtstag', diff --git a/src/Services/NewsletterDispatchService.php b/src/Services/NewsletterDispatchService.php index e8b4618..0e4ca73 100644 --- a/src/Services/NewsletterDispatchService.php +++ b/src/Services/NewsletterDispatchService.php @@ -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; } } diff --git a/src/Services/RelationshipPathFinder.php b/src/Services/RelationshipPathFinder.php index 1549036..adc99e2 100644 --- a/src/Services/RelationshipPathFinder.php +++ b/src/Services/RelationshipPathFinder.php @@ -33,8 +33,8 @@ final class RelationshipPathFinder /** Default BFS depth — ~7 generations of ancestry / descent. */ public const int DEFAULT_MAX_DEPTH = 14; - /** @var array> Cache: xref => {ancestor/descendant xref set} */ - private array $lineal_cache = []; + /** @var array> Cache: "rootXref|maxDistance" => {xref => distance} */ + private array $distance_cache = []; /** @var array 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 + * 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 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 $distance */ + $distance = []; + /** @var array $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 $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 $sources + * @param array $distance + * @param array $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 $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 $set + * @param array $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); } } }