From 0c94a6ecd38d8da84674d84f8802d5cb55f6a73e Mon Sep 17 00:00:00 2001 From: Gulum Date: Sat, 6 Jun 2026 00:10:19 +0200 Subject: [PATCH] FEAT-1b: Inzuchtkoeffizient endpoints via Wright path method - PedigreeCalculator: pure, EF-free Wright path-method engine (recursive F_A, per-common-ancestor contributions, generationsAvailable, cycle guards) - InbreedingService: loads pedigree from ApplicationContext shadow FKs (Gerbils.LitterId, Litters.FatherId/MotherId) into in-memory maps - InbreedingController: GET /gerbils/{id}/inbreeding-coefficient, POST /genetics/test-inbreeding (hypothetical pairing for Probeverpaarung) - Injects ApplicationContext directly; no Program.cs change (Dwight owns it) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Controllers/InbreedingController.cs | 39 ++++ GerbilManagerWebAPI/Dtos/TestInbreedingDto.cs | 9 + .../Genetics/InbreedingResult.cs | 25 ++ .../Genetics/InbreedingService.cs | 74 ++++++ .../Genetics/PedigreeCalculator.cs | 219 ++++++++++++++++++ 5 files changed, 366 insertions(+) create mode 100644 GerbilManagerWebAPI/Controllers/InbreedingController.cs create mode 100644 GerbilManagerWebAPI/Dtos/TestInbreedingDto.cs create mode 100644 GerbilManagerWebAPI/Genetics/InbreedingResult.cs create mode 100644 GerbilManagerWebAPI/Genetics/InbreedingService.cs create mode 100644 GerbilManagerWebAPI/Genetics/PedigreeCalculator.cs diff --git a/GerbilManagerWebAPI/Controllers/InbreedingController.cs b/GerbilManagerWebAPI/Controllers/InbreedingController.cs new file mode 100644 index 0000000..45a7a76 --- /dev/null +++ b/GerbilManagerWebAPI/Controllers/InbreedingController.cs @@ -0,0 +1,39 @@ +using GerbilManagerWebAPI.DAL; +using GerbilManagerWebAPI.Dtos; +using GerbilManagerWebAPI.Genetics; +using Microsoft.AspNetCore.Mvc; + +namespace GerbilManagerWebAPI.Controllers +{ + /// + /// Inbreeding-coefficient (Inzuchtkoeffizient) endpoints — both for an existing + /// gerbil and for a hypothetical pairing (Probeverpaarung). + /// + [ApiController] + public class InbreedingController : ControllerBase + { + private readonly InbreedingService service; + + // ApplicationContext is already registered in DI; the service is a thin, + // dependency-free wrapper so it needs no separate registration in Program.cs. + public InbreedingController(ApplicationContext db) + { + service = new InbreedingService(db); + } + + // GET /gerbils/{id}/inbreeding-coefficient + [HttpGet("gerbils/{id}/inbreeding-coefficient")] + public ActionResult GetForGerbil(Guid id) + { + var result = service.ForGerbil(id); + return result is null ? NotFound() : result; + } + + // POST /genetics/test-inbreeding + [HttpPost("genetics/test-inbreeding")] + public ActionResult TestPairing(TestInbreedingDto dto) + { + return service.ForPairing(dto.FatherId, dto.MotherId); + } + } +} diff --git a/GerbilManagerWebAPI/Dtos/TestInbreedingDto.cs b/GerbilManagerWebAPI/Dtos/TestInbreedingDto.cs new file mode 100644 index 0000000..dacdf96 --- /dev/null +++ b/GerbilManagerWebAPI/Dtos/TestInbreedingDto.cs @@ -0,0 +1,9 @@ +namespace GerbilManagerWebAPI.Dtos +{ + /// + /// Request body for a hypothetical pairing's inbreeding coefficient + /// (Inzuchtkoeffizient der geplanten Verpaarung). Either parent may be omitted, + /// in which case the coefficient is 0. + /// + public record TestInbreedingDto(Guid? FatherId, Guid? MotherId); +} diff --git a/GerbilManagerWebAPI/Genetics/InbreedingResult.cs b/GerbilManagerWebAPI/Genetics/InbreedingResult.cs new file mode 100644 index 0000000..d2d5b6e --- /dev/null +++ b/GerbilManagerWebAPI/Genetics/InbreedingResult.cs @@ -0,0 +1,25 @@ +namespace GerbilManagerWebAPI.Genetics +{ + /// + /// Result of an inbreeding-coefficient (Inzuchtkoeffizient) calculation for an + /// individual or a hypothetical pairing. + /// + /// Wright's inbreeding coefficient F, in the range 0..1. + /// The coefficient expressed as a percentage (F * 100). + /// + /// The deepest generation of known ancestry above the individual (parents = 1, + /// grandparents = 2, …). 0 when no parents are recorded. + /// + /// + /// The ancestors common to both parents, each with its additive contribution to F, + /// ordered by contribution descending. Empty when there is no inbreeding. + /// + public record InbreedingResult( + double Coefficient, + double Percent, + int GenerationsAvailable, + IReadOnlyList CommonAncestors); + + /// A single common ancestor and how much it contributes to F. + public record CommonAncestorContribution(Guid Id, string Name, double Contribution); +} diff --git a/GerbilManagerWebAPI/Genetics/InbreedingService.cs b/GerbilManagerWebAPI/Genetics/InbreedingService.cs new file mode 100644 index 0000000..7ba7c19 --- /dev/null +++ b/GerbilManagerWebAPI/Genetics/InbreedingService.cs @@ -0,0 +1,74 @@ +using GerbilManagerWebAPI.Models; +using Microsoft.EntityFrameworkCore; + +namespace GerbilManagerWebAPI.Genetics +{ + /// + /// Loads the pedigree out of the database into in-memory lookups (the home dataset + /// is small) and delegates the actual maths to . + /// + /// Parent links live in EF shadow foreign keys: a gerbil's birth litter is + /// Gerbils.LitterId; that litter's parents are Litters.FatherId and + /// Litters.MotherId. + /// + public sealed class InbreedingService + { + private readonly ApplicationContext _db; + + public InbreedingService(ApplicationContext db) => _db = db; + + /// F for an existing gerbil, or null if no such gerbil exists. + public InbreedingResult? ForGerbil(Guid id) + { + var calculator = BuildCalculator(); + return calculator.Contains(id) ? calculator.ForIndividual(id) : null; + } + + /// F for the hypothetical offspring of the given sire and dam. + public InbreedingResult ForPairing(Guid? fatherId, Guid? motherId) + { + return BuildCalculator().ForOffspringOf(fatherId, motherId); + } + + private PedigreeCalculator BuildCalculator() + { + var litters = _db.Set() + .Select(l => new + { + l.Id, + FatherId = EF.Property(l, "FatherId"), + MotherId = EF.Property(l, "MotherId"), + }) + .ToDictionary(l => l.Id, l => (l.FatherId, l.MotherId)); + + var gerbils = _db.Set() + .Select(g => new + { + g.Id, + g.Name, + LitterId = EF.Property(g, "LitterId"), + }) + .ToList(); + + var parents = new Dictionary(gerbils.Count); + var names = new Dictionary(gerbils.Count); + + foreach (var g in gerbils) + { + names[g.Id] = g.Name; + + Guid? father = null; + Guid? mother = null; + if (g.LitterId is Guid litterId && litters.TryGetValue(litterId, out var litterParents)) + { + father = litterParents.FatherId; + mother = litterParents.MotherId; + } + + parents[g.Id] = (father, mother); + } + + return new PedigreeCalculator(parents, names); + } + } +} diff --git a/GerbilManagerWebAPI/Genetics/PedigreeCalculator.cs b/GerbilManagerWebAPI/Genetics/PedigreeCalculator.cs new file mode 100644 index 0000000..90dd751 --- /dev/null +++ b/GerbilManagerWebAPI/Genetics/PedigreeCalculator.cs @@ -0,0 +1,219 @@ +namespace GerbilManagerWebAPI.Genetics +{ + /// + /// Computes the inbreeding coefficient (Inzuchtkoeffizient) using Wright's path + /// method: + /// + /// F = Σ over common ancestors, over every valid path, of + /// (1/2)^(n1 + n2 + 1) * (1 + F_A) + /// + /// where, for a given common ancestor A, n1 is the number of links from the sire + /// up to A and n2 the number of links from the dam up to A, and F_A is A's own + /// inbreeding coefficient (computed recursively at full available pedigree depth). + /// A path counts only if the sire-side and dam-side chains share no individual + /// other than A — this is what prevents double counting. + /// + /// This type is deliberately free of any database / EF dependency: it works purely + /// from a parent lookup, so it can be unit-tested with hand-built pedigrees. + /// + public sealed class PedigreeCalculator + { + private readonly IReadOnlyDictionary _parents; + private readonly IReadOnlyDictionary _names; + + // F_A memo + re-entrancy guard (the latter only matters if the data somehow + // contains a cycle, which is biologically impossible but cheap to defend). + private readonly Dictionary _fCache = new(); + private readonly HashSet _fInProgress = new(); + private readonly Dictionary _depthCache = new(); + + public PedigreeCalculator( + IReadOnlyDictionary parents, + IReadOnlyDictionary names) + { + _parents = parents; + _names = names; + } + + /// True if the given individual exists in the pedigree. + public bool Contains(Guid id) => _parents.ContainsKey(id); + + /// Inbreeding coefficient of an existing individual. + public InbreedingResult ForIndividual(Guid id) + { + var (father, mother) = ParentsOf(id); + return ForOffspringOf(father, mother); + } + + /// + /// Inbreeding coefficient of the (possibly hypothetical) offspring of the given + /// sire and dam — equivalently, the kinship between the two parents. + /// + public InbreedingResult ForOffspringOf(Guid? sire, Guid? dam) + { + int generations = GenerationsAvailable(sire, dam); + + if (sire is not Guid s || dam is not Guid d) + { + // A parent is unknown → no inbreeding can be established. + return new InbreedingResult(0.0, 0.0, generations, Array.Empty()); + } + + var contributions = Coefficient(s, d); + double f = contributions.Values.Sum(); + + var commonAncestors = contributions + .Select(kv => new CommonAncestorContribution(kv.Key, NameOf(kv.Key), kv.Value)) + .OrderByDescending(c => c.Contribution) + .ThenBy(c => c.Name, StringComparer.OrdinalIgnoreCase) + .ToList(); + + return new InbreedingResult(f, f * 100.0, generations, commonAncestors); + } + + /// + /// Per-common-ancestor contributions to the kinship of + /// and . The sum of the values is the coefficient. + /// + private Dictionary Coefficient(Guid sire, Guid dam) + { + var sirePaths = EnumerateAncestorPaths(sire); + var damPaths = EnumerateAncestorPaths(dam); + + // Group the dam's paths by the ancestor they terminate at for quick lookup. + var damPathsByAncestor = damPaths + .GroupBy(p => p[^1]) + .ToDictionary(g => g.Key, g => g.ToList()); + + var contributions = new Dictionary(); + + foreach (var sirePath in sirePaths) + { + var ancestor = sirePath[^1]; + if (!damPathsByAncestor.TryGetValue(ancestor, out var matchingDamPaths)) + { + continue; + } + + var sireNodes = new HashSet(sirePath); + + foreach (var damPath in matchingDamPaths) + { + // The two chains may meet only at the common ancestor itself. + if (damPath.Any(node => node != ancestor && sireNodes.Contains(node))) + { + continue; + } + + int n1 = sirePath.Count - 1; + int n2 = damPath.Count - 1; + double term = Math.Pow(0.5, n1 + n2 + 1) * (1.0 + F(ancestor)); + + contributions[ancestor] = contributions.GetValueOrDefault(ancestor) + term; + } + } + + return contributions; + } + + /// Inbreeding coefficient F of a single individual (memoized). + private double F(Guid id) + { + if (_fCache.TryGetValue(id, out var cached)) + { + return cached; + } + + // Defend against accidental cycles in the data. + if (!_fInProgress.Add(id)) + { + return 0.0; + } + + double f = 0.0; + var (father, mother) = ParentsOf(id); + if (father is Guid s && mother is Guid d) + { + f = Coefficient(s, d).Values.Sum(); + } + + _fInProgress.Remove(id); + _fCache[id] = f; + return f; + } + + /// + /// Every chain of parent links starting at , including + /// the trivial chain [start] itself. Each returned list runs from the start + /// individual down to one of its ancestors (inclusive). A visited set keeps the + /// walk finite even if the data contains a cycle. + /// + private List> EnumerateAncestorPaths(Guid start) + { + var all = new List>(); + var path = new List(); + + void Walk(Guid node) + { + path.Add(node); + all.Add(new List(path)); + + var (father, mother) = ParentsOf(node); + foreach (var parent in new[] { father, mother }) + { + if (parent is Guid p && !path.Contains(p)) + { + Walk(p); + } + } + + path.RemoveAt(path.Count - 1); + } + + Walk(start); + return all; + } + + /// + /// Deepest generation of known ancestry for the offspring of the two parents + /// (parents = 1, grandparents = 2, …). 0 when neither parent is known. + /// + private int GenerationsAvailable(Guid? sire, Guid? dam) + { + int FromParent(Guid? parent) => parent is Guid p ? 1 + AncestorDepth(p) : 0; + return Math.Max(FromParent(sire), FromParent(dam)); + } + + /// Number of ancestral generations above (0 for a founder). + private int AncestorDepth(Guid id) + { + if (_depthCache.TryGetValue(id, out var cached)) + { + return cached; + } + + // Temporary 0 breaks any cycle while the real value is computed. + _depthCache[id] = 0; + + var (father, mother) = ParentsOf(id); + int depth = 0; + if (father is Guid f) + { + depth = Math.Max(depth, 1 + AncestorDepth(f)); + } + if (mother is Guid m) + { + depth = Math.Max(depth, 1 + AncestorDepth(m)); + } + + _depthCache[id] = depth; + return depth; + } + + private (Guid? Father, Guid? Mother) ParentsOf(Guid id) => + _parents.TryGetValue(id, out var p) ? p : (null, null); + + private string NameOf(Guid id) => + _names.TryGetValue(id, out var name) ? name : id.ToString(); + } +}