OWNERSHIP: residency classification (Bestand vs external ancestors)

Per hive/agents/god/OWNERSHIP-residency.md (Julian): "Clan kleine Chaoten"
is the wife's own Zucht. Classify each animal:
 (a) RESIDENT if zuchtCanon contains 'kleinechaote' (separator/declension-
     insensitive: "v.d. Kleinen Chaoten", "Clan-Kleine-Chaoten", …); OR
 (b) PARENT EXCEPTION — a parent of a Clan offspring is resident even if its
     own Zuchtname is foreign (you can only breed a Clan litter if the parents
     were in your care). Runs AFTER the parentRefs→litter linking.
Otherwise EXTERNAL (pedigree ancestor, not living stock — kept, not deleted).

Stored as a dedicated Gerbil.IsResident bool (distinct from Status/Abgabe —
origin/identity, not current location), defaulting true (own stock unless
marked external; DB HasDefaultValue(true)). Additive migration
AddGerbilResidency; has-pending-model-changes clean. Round-trips in
GerbilDto/GerbilInput and is Gridify-filterable (isResident==true) so Kevin
can build a "nur Bestand" filter. ImportService computes it at (re-)import
and refreshes existing rows on the idempotent sweep. ResidencySummary added
to the import report.

Projected on real data: 306 loadable → 227 resident (188 rule a, +39 via
parent exception), 79 external ancestors.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-06 10:58:29 +02:00
parent ea79703dbf
commit 6775707387
10 changed files with 1504 additions and 11 deletions

View File

@@ -143,11 +143,13 @@ namespace GerbilManager.Tests
File.WriteAllText(Path.Combine(dir, "litters.json"), "[]");
File.WriteAllText(Path.Combine(dir, "animals.json"), """
[
{"id":"papa","name":"Papa v.d. Test","dob":"01.01.2022","death":"","farbschlag":"","gender":"male",
{"id":"papa","name":"Papa v.d. Test","dob":"01.01.2022","death":"","farbschlag":"","gender":"male","zuchtCanon":"test",
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false},
{"id":"mama","name":"Mama v.d. Test","dob":"02.02.2022","death":"","farbschlag":"","gender":"female",
{"id":"mama","name":"Mama v.d. Test","dob":"02.02.2022","death":"","farbschlag":"","gender":"female","zuchtCanon":"test",
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false},
{"id":"c","name":"C","dob":"29.04.2024","death":"","farbschlag":"",
{"id":"ext","name":"Fremd of Foreign","dob":"03.03.2022","death":"","farbschlag":"","zuchtCanon":"foreign",
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false},
{"id":"c","name":"C","dob":"29.04.2024","death":"","farbschlag":"","zuchtCanon":"kleinechaote",
"genotype":{"mapped8locus":{},"rawGenotype":"","unmappedTokens":[]},"conflict":false,
"parentRefs":[
{"name":"Papa v.d. Test","dob":"01.01.2022","roleGuess":"father","method":"chart-position","confidence":"medium"},
@@ -174,6 +176,17 @@ namespace GerbilManager.Tests
Assert.Equal(papa.Id, litter.FatherId);
Assert.Equal(mama.Id, litter.MotherId);
Assert.Contains("Diagramm", litter.Notes!); // transparent + reversible
// OWNERSHIP/RESIDENCY: C is Clan (rule a); its foreign-Zucht parents flip to
// resident (rule b); the unrelated foreign animal stays external.
Assert.True(c.IsResident); // rule (a)
Assert.True(papa.IsResident); // rule (b) parent exception
Assert.True(mama.IsResident); // rule (b)
Assert.False((await db.Gerbils.SingleAsync(g => g.ExternalRef == "ext")).IsResident);
Assert.NotNull(report.Residency);
Assert.Equal(3, report.Residency!.Resident);
Assert.Equal(1, report.Residency.External);
Assert.Equal(2, report.Residency.FlippedByParentRule);
}
finally { try { Directory.Delete(dir, recursive: true); } catch { } }
}

View File

@@ -55,6 +55,9 @@ public class ApplicationContext : DbContext
e.Property(g => g.Gender).HasConversion<string>();
e.Property(g => g.Status).HasConversion<string>();
// Residency defaults to true (own stock unless explicitly marked external).
e.Property(g => g.IsResident).HasDefaultValue(true);
// FEAT-14: character traits stored as a JSON text column (works on both
// Npgsql and the SQLite test host; opaque labels, no backend vocabulary).
var traitsConverter = new Microsoft.EntityFrameworkCore.Storage.ValueConversion.ValueConverter<List<string>, string>(

View File

@@ -28,7 +28,8 @@ namespace GerbilManagerWebAPI.Dtos
string? OriginBreeder,
List<string> CharacterTraits,
string? CharacterNote,
bool? IsDeaf);
bool? IsDeaf,
bool IsResident);
public record LitterDto(
Guid Id,
@@ -77,7 +78,8 @@ namespace GerbilManagerWebAPI.Dtos
string? OriginBreeder,
List<string>? CharacterTraits,
string? CharacterNote,
bool? IsDeaf);
bool? IsDeaf,
bool? IsResident);
public record LitterInput(
string Name,

View File

@@ -98,12 +98,13 @@ namespace GerbilManagerWebAPI.Endpoints
g.CharacterTraits = i.CharacterTraits ?? new List<string>();
g.CharacterNote = i.CharacterNote;
g.IsDeaf = i.IsDeaf;
g.IsResident = i.IsResident ?? (isCreate ? true : g.IsResident);
}
internal static GerbilDto ToDto(Gerbil g) => new(
g.Id, g.Name, g.Gender, g.Status, g.LitterId, g.OriginContactId, g.ReceiverContactId,
g.EnclosureId, g.ColorVarietyId, g.DateOfBirth, g.DateOfDeath, g.CauseOfDeath,
g.GoHomeDate, g.Genotype, g.Notes, g.ImportSource, g.ExternalRef, g.OriginBreeder,
g.CharacterTraits, g.CharacterNote, g.IsDeaf);
g.CharacterTraits, g.CharacterNote, g.IsDeaf, g.IsResident);
}
}

View File

@@ -16,6 +16,7 @@ namespace GerbilManagerWebAPI.Import
public List<string> FarbschlagVariants { get; set; } = new();
public SourceGenotype Genotype { get; set; } = new();
public string Zucht { get; set; } = "";
public string ZuchtCanon { get; set; } = ""; // declension-folded Zucht key (residency rule a)
public List<SourceParentRef> ParentRefs { get; set; } = new();
public List<string> Photos { get; set; } = new();
public List<string> SourceFiles { get; set; } = new();
@@ -73,7 +74,12 @@ namespace GerbilManagerWebAPI.Import
AnimalSummary Animals,
PhotoSummary Photos,
IReadOnlyList<string> Samples,
IReadOnlyList<string> Notes);
IReadOnlyList<string> Notes,
ResidencySummary? Residency = null);
/// <summary>Bestand (resident) vs external pedigree ancestors; FlippedByParentRule = foreign-
/// Zuchtname animals made resident because they parented a Clan offspring (rule b).</summary>
public sealed record ResidencySummary(int Resident, int External, int FlippedByParentRule);
public sealed record LitterSummary(int InSource, int Created, int AlreadyImported, int DerivedFromChart = 0);

View File

@@ -192,6 +192,17 @@ namespace GerbilManagerWebAPI.Import
parentLinksAdded++;
}
// litter id -> (father, mother) gids, across synthesized + Wurfchronik (by name) litters.
// Used by the residency rule (b) below; augmented with existing DB litters under execute.
var litterParents = new Dictionary<Guid, (Guid? F, Guid? M)>();
foreach (var sl in synthLitters.Values)
litterParents[sl.Id] = (sl.Father, sl.Mother);
foreach (var sl in litters)
if (litterIdMap.TryGetValue(sl.Id, out var lid))
litterParents[lid] = (
createdAnimalByName.TryGetValue(Normalize(StripZucht(sl.SireName)), out var fid) ? fid : (Guid?)null,
createdAnimalByName.TryGetValue(Normalize(StripZucht(sl.DamName)), out var mid) ? mid : (Guid?)null);
// PASS 2: write (litters synthesized first so offspring FK resolves), then animals + photos.
if (execute)
{
@@ -200,7 +211,10 @@ namespace GerbilManagerWebAPI.Import
.Select(l => new { l.Id, l.FatherId, l.MotherId, l.Date }).ToListAsync();
var litterByParentsDate = new Dictionary<string, Guid>();
foreach (var l in existingLitterRows)
{
litterByParentsDate[$"{l.FatherId}|{l.MotherId}|{l.Date:yyyy-MM-dd}"] = l.Id;
litterParents[l.Id] = (l.FatherId, l.MotherId);
}
foreach (var sl in synthLitters.Values.ToList())
{
@@ -226,11 +240,31 @@ namespace GerbilManagerWebAPI.Import
await _db.SaveChangesAsync();
}
// OWNERSHIP/RESIDENCY (runs AFTER litter links exist): (a) Zuchtname matches the Clan
// kennel (zuchtCanon contains 'kleinechaote'); (b) parent of a Clan offspring, even if
// the parent's own Zuchtname is foreign. See hive/agents/god/OWNERSHIP-residency.md.
static bool ClanCanon(string? zc) =>
(zc ?? "").Contains("kleinechaote", StringComparison.OrdinalIgnoreCase);
var resident = new HashSet<Guid>();
foreach (var p in plan) if (ClanCanon(p.A.ZuchtCanon)) resident.Add(p.Gid); // (a)
int residentByA = resident.Count, flippedByParentRule = 0;
foreach (var p in plan)
{
if (!ClanCanon(p.A.ZuchtCanon)) continue;
var litId = p.WurfLitterId ?? (synthLitterForGid.TryGetValue(p.Gid, out var s) ? s : (Guid?)null);
if (litId is null || !litterParents.TryGetValue(litId.Value, out var par)) continue;
if (par.F is Guid gf && resident.Add(gf)) flippedByParentRule++; // (b)
if (par.M is Guid gm && resident.Add(gm)) flippedByParentRule++;
}
int residentTotal = plan.Count(p => resident.Contains(p.Gid));
int externalTotal = plan.Count - residentTotal;
foreach (var p in plan)
{
Guid? litterId = p.WurfLitterId
?? (synthLitterForGid.TryGetValue(p.Gid, out var slid) ? slid : (Guid?)null);
if (litterId is not null) linked++;
bool isResident = resident.Contains(p.Gid);
if (p.ColorVarietyId is null) fbUnmatched++; else fbMatched++;
@@ -242,11 +276,15 @@ namespace GerbilManagerWebAPI.Import
if (p.Exists)
{
animalsExisting++;
// re-link an existing animal that just became linkable (sweep idempotency).
if (execute && p.CurrentLitterId is null && litterId is not null)
// sweep idempotency: re-link a now-linkable animal + refresh its residency.
if (execute)
{
var row = await _db.Gerbils.FirstOrDefaultAsync(g => g.Id == p.Gid);
if (row is not null) row.LitterId = litterId;
if (row is not null)
{
if (p.CurrentLitterId is null && litterId is not null) row.LitterId = litterId;
row.IsResident = isResident;
}
}
continue;
}
@@ -266,6 +304,7 @@ namespace GerbilManagerWebAPI.Import
ColorVarietyId = p.ColorVarietyId,
Genotype = ComposeGenotype(p.A.Genotype),
IsDeaf = p.A.Deaf,
IsResident = isResident,
ImportSource = ImportSourceTag,
ExternalRef = p.A.Id,
OriginBreeder = string.IsNullOrWhiteSpace(p.A.Zucht) ? null : p.A.Zucht.Trim(),
@@ -328,6 +367,7 @@ namespace GerbilManagerWebAPI.Import
notes.Add("Quarantäne (kein Import): Konflikte + Stubs ohne Geburtsdatum + unsichere Wurf-Zuordnungen — warten auf die Prüfung durch die Züchterin.");
if (parentLinksAdded > 0)
notes.Add($"Stammbaum-Diagramm: {parentLinksAdded} Tiere über Eltern-Verknüpfung einem (abgeleiteten) Wurf zugeordnet ({derivedLitters} abgeleitete Würfe).");
notes.Add($"Bestand/Herkunft: {residentTotal} im Bestand (Clan Kleine Chaoten), {externalTotal} externe Ahnen ({flippedByParentRule} davon über die Eltern-Regel als Bestand erkannt).");
if (!execute) notes.Add("DRY-RUN: nichts gespeichert. /import/execute lädt die konfliktfreien Daten.");
return new ImportReport(
@@ -339,7 +379,8 @@ namespace GerbilManagerWebAPI.Import
parentLinksAdded),
Photos: new PhotoSummary(photosAttached, photosMissing),
Samples: samples,
Notes: notes);
Notes: notes,
Residency: new ResidencySummary(residentTotal, externalTotal, flippedByParentRule));
}
private T? Load<T>(string file)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace GerbilManagerWebAPI.Migrations
{
/// <inheritdoc />
public partial class AddGerbilResidency : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "IsResident",
table: "Gerbils",
type: "boolean",
nullable: false,
defaultValue: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "IsResident",
table: "Gerbils");
}
}
}

View File

@@ -784,6 +784,11 @@ namespace GerbilManagerWebAPI.Migrations
b.Property<bool?>("IsDeaf")
.HasColumnType("boolean");
b.Property<bool>("IsResident")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(true);
b.Property<Guid?>("LitterId")
.HasColumnType("uuid");

View File

@@ -73,6 +73,13 @@ namespace GerbilManagerWebAPI.Models
/// (dea/taub), false = hearing (Dea/hörend). Set by the FEAT-8 import from the
/// after-spsp deafness annotation; see hive/agents/god/GENETIK-notation.md.</summary>
public bool? IsDeaf { get; set; }
/// <summary>Residency/ownership (ORIGIN, distinct from Abgabe location): true = part of
/// the Clan-kleine-Chaoten Bestand, false = external pedigree ancestor (bred elsewhere).
/// Rule (a) Zuchtname matches the Clan kennel, OR (b) it's a parent of a Clan offspring.
/// Defaults true (manually-added animals are own stock); the FEAT-8 import classifies
/// imported animals. See hive/agents/god/OWNERSHIP-residency.md. Gridify-filterable.</summary>
public bool IsResident { get; set; } = true;
}
/// <summary>Shared normalisation for the separator-insensitive name search.</summary>