Genetics: GenomeTemplate (per-organism gene allotment) + breed override
CI / build-test (push) Successful in 1m17s

Adds the species/organism layer on top of the gene foundation.
GenomeTemplate carries which GeneDefs an individual has plus the
per-organism base value and spread its alleles are drawn around (and an
optional discrete-variant override), so the same shared GeneDef expresses
different centres for different species — Generate() draws an individual,
Registry() feeds breeding and trait computation.

Genome.Breed gains an optional mutationChance that overrides every gene's
fixed MutationChance, so a caller can drive mutation from an evolvable
trait. Allele sampling (numeric spread+clamp, weighted discrete pick) is
factored into a shared GeneSampling used by both Generate paths.

Covered by GenomeTemplateTests (per-species centres, registry-driven
breeding, mutation override on/off).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Leonid Pershin
2026-06-12 23:51:02 +03:00
co-authored by Claude Opus 4.8
parent bc18db5df6
commit ead22517ee
4 changed files with 240 additions and 51 deletions
@@ -0,0 +1,56 @@
namespace MrGameEng.Genetics;
/// <summary>
/// Shared, deterministic allele sampling used by both <see cref="Genome.Generate"/> (gene defaults)
/// and <see cref="GenomeTemplate"/> (per-individual base overrides). Centralizes the numeric
/// spread+clamp and the weighted discrete pick so the two paths stay consistent.
/// </summary>
internal static class GeneSampling
{
/// <summary>A numeric allele drawn as <c>baseValue ± spread·|baseValue|</c>, clamped to the gene's range.</summary>
public static float Numeric(GeneDef gene, float baseValue, float spread, Random random)
{
var value = baseValue + (random.NextSingle() * 2f - 1f) * spread * MathF.Abs(baseValue);
return Math.Clamp(value, gene.Min, gene.Max);
}
/// <summary>
/// A discrete variant index in <c>[0, Variants)</c>, picked from <paramref name="weights"/> when
/// they match the variant count, otherwise the gene's own weights, otherwise uniformly.
/// </summary>
public static float Variant(GeneDef gene, float[]? weights, Random random)
{
var variants = Math.Max(1, gene.Variants);
var w =
weights is { Length: > 0 } && weights.Length == variants
? weights
: gene.VariantWeights;
if (w.Length != variants)
{
return random.Next(variants);
}
var total = 0f;
foreach (var value in w)
{
total += MathF.Max(0f, value);
}
if (total <= 0f)
{
return random.Next(variants);
}
var roll = random.NextSingle() * total;
for (var i = 0; i < variants; i++)
{
roll -= MathF.Max(0f, w[i]);
if (roll < 0f)
{
return i;
}
}
return variants - 1;
}
}
+17 -51
View File
@@ -77,12 +77,16 @@ public sealed class Genome
/// carries, it is inherited (from that parent, on both sides) with 50% probability. Every /// carries, it is inherited (from that parent, on both sides) with 50% probability. Every
/// inherited allele may then mutate per its <see cref="GeneDef"/>. <paramref name="registry"/> /// inherited allele may then mutate per its <see cref="GeneDef"/>. <paramref name="registry"/>
/// supplies the def for each gene id; genes absent from it are skipped. /// supplies the def for each gene id; genes absent from it are skipped.
/// <paramref name="mutationChance"/>, when given, overrides every gene's
/// <see cref="GeneDef.MutationChance"/> — letting the caller drive mutation from an evolvable
/// trait rather than a fixed per-gene constant.
/// </summary> /// </summary>
public static Genome Breed( public static Genome Breed(
Genome a, Genome a,
Genome b, Genome b,
IReadOnlyDictionary<string, GeneDef> registry, IReadOnlyDictionary<string, GeneDef> registry,
Random random Random random,
float? mutationChance = null
) )
{ {
var child = new Genome(); var child = new Genome();
@@ -93,21 +97,22 @@ public sealed class Genome
continue; continue;
} }
var chance = mutationChance ?? gene.MutationChance;
var inA = a.Has(geneId); var inA = a.Has(geneId);
var inB = b.Has(geneId); var inB = b.Has(geneId);
if (inA && inB) if (inA && inB)
{ {
child._alleles[geneId] = new Allele( child._alleles[geneId] = new Allele(
Meiosis(gene, a[geneId], random), Meiosis(gene, a[geneId], chance, random),
Meiosis(gene, b[geneId], random) Meiosis(gene, b[geneId], chance, random)
); );
} }
else if (random.NextSingle() < 0.5f) else if (random.NextSingle() < 0.5f)
{ {
var parent = inA ? a : b; var parent = inA ? a : b;
child._alleles[geneId] = new Allele( child._alleles[geneId] = new Allele(
Meiosis(gene, parent[geneId], random), Meiosis(gene, parent[geneId], chance, random),
Meiosis(gene, parent[geneId], random) Meiosis(gene, parent[geneId], chance, random)
); );
} }
} }
@@ -116,17 +121,17 @@ public sealed class Genome
} }
// One inherited allele: pick one of the parent slot's two alleles, then maybe mutate it. // One inherited allele: pick one of the parent slot's two alleles, then maybe mutate it.
private static float Meiosis(GeneDef gene, Allele parent, Random random) private static float Meiosis(GeneDef gene, Allele parent, float mutationChance, Random random)
{ {
var inherited = random.NextSingle() < 0.5f ? parent.A : parent.B; var inherited = random.NextSingle() < 0.5f ? parent.A : parent.B;
if (random.NextSingle() >= gene.MutationChance) if (random.NextSingle() >= mutationChance)
{ {
return inherited; return inherited;
} }
if (gene.Kind == GeneKind.Discrete) if (gene.Kind == GeneKind.Discrete)
{ {
return PickVariant(gene, random); return GeneSampling.Variant(gene, null, random);
} }
var shifted = var shifted =
@@ -135,49 +140,10 @@ public sealed class Genome
return Math.Clamp(shifted, gene.Min, gene.Max); return Math.Clamp(shifted, gene.Min, gene.Max);
} }
private static float GenerateAllele(GeneDef gene, Random random) private static float GenerateAllele(GeneDef gene, Random random) =>
{ gene.Kind == GeneKind.Discrete
if (gene.Kind == GeneKind.Discrete) ? GeneSampling.Variant(gene, null, random)
{ : GeneSampling.Numeric(gene, gene.Default, gene.Spread, random);
return PickVariant(gene, random);
}
var value =
gene.Default + (random.NextSingle() * 2f - 1f) * gene.Spread * MathF.Abs(gene.Default);
return Math.Clamp(value, gene.Min, gene.Max);
}
private static float PickVariant(GeneDef gene, Random random)
{
var variants = Math.Max(1, gene.Variants);
if (gene.VariantWeights.Length != variants)
{
return random.Next(variants);
}
var total = 0f;
foreach (var w in gene.VariantWeights)
{
total += MathF.Max(0f, w);
}
if (total <= 0f)
{
return random.Next(variants);
}
var roll = random.NextSingle() * total;
for (var i = 0; i < variants; i++)
{
roll -= MathF.Max(0f, gene.VariantWeights[i]);
if (roll < 0f)
{
return i;
}
}
return variants - 1;
}
// Deterministic union of both parents' gene ids (ordered) so breeding is reproducible. // Deterministic union of both parents' gene ids (ordered) so breeding is reproducible.
private static IEnumerable<string> UnionKeys(Genome a, Genome b) private static IEnumerable<string> UnionKeys(Genome a, Genome b)
@@ -0,0 +1,61 @@
namespace MrGameEng.Genetics;
/// <summary>
/// A species' (or any organism kind's) gene allotment: which <see cref="GeneDef"/>s an individual
/// carries and the per-organism base values its alleles are generated around. The same
/// <see cref="GeneDef"/> (e.g. "optimal light") is shared by every species, while the template
/// supplies the species-specific centre and spread — so an oak and grass differ in values, not in
/// machinery. <see cref="Generate"/> draws a fresh individual; <see cref="Registry"/> feeds breeding
/// and trait computation.
/// </summary>
public sealed class GenomeTemplate
{
/// <summary>
/// One gene in the allotment. <paramref name="Base"/>/<paramref name="Spread"/> centre a numeric
/// gene's alleles; <paramref name="VariantWeights"/> (optional) override a discrete gene's
/// variant distribution for this organism.
/// </summary>
public readonly record struct Entry(
GeneDef Gene,
float Base,
float Spread,
float[]? VariantWeights = null
);
private readonly List<Entry> _entries;
private readonly Dictionary<string, GeneDef> _registry;
/// <summary>Builds a template from its gene entries.</summary>
public GenomeTemplate(IEnumerable<Entry> entries)
{
_entries = entries.ToList();
_registry = new Dictionary<string, GeneDef>(StringComparer.Ordinal);
foreach (var entry in _entries)
{
_registry[entry.Gene.DefName] = entry.Gene;
}
}
/// <summary>The gene entries that make up the allotment.</summary>
public IReadOnlyList<Entry> Entries => _entries;
/// <summary>Gene id → def for every carried gene; pass to <see cref="Genome.Breed"/> and <see cref="Phenotype.Compute"/>.</summary>
public IReadOnlyDictionary<string, GeneDef> Registry => _registry;
/// <summary>Generates a fresh individual: two alleles per gene drawn around each entry's base/variant.</summary>
public Genome Generate(Random random)
{
var genome = new Genome();
foreach (var entry in _entries)
{
genome[entry.Gene.DefName] = new Allele(Draw(entry, random), Draw(entry, random));
}
return genome;
}
private static float Draw(Entry entry, Random random) =>
entry.Gene.Kind == GeneKind.Discrete
? GeneSampling.Variant(entry.Gene, entry.VariantWeights, random)
: GeneSampling.Numeric(entry.Gene, entry.Base, entry.Spread, random);
}
@@ -0,0 +1,106 @@
using MrGameEng.Genetics;
using Xunit;
namespace MrGameEng.Genetics.Tests;
public class GenomeTemplateTests
{
private static GeneDef Numeric(string name, float min, float max) =>
new()
{
DefName = name,
Kind = GeneKind.Numeric,
Min = min,
Max = max,
};
private static GeneDef Discrete(string name, int variants = 2) =>
new()
{
DefName = name,
Kind = GeneKind.Discrete,
Variants = variants,
};
[Fact]
public void Generate_UsesPerEntryBaseAndSpread_NotGeneDefault()
{
var gene = Numeric("opt", min: 0f, max: 100f); // GeneDef.Default is 0
var template = new GenomeTemplate([
new GenomeTemplate.Entry(gene, Base: 50f, Spread: 0.1f),
]);
var random = new Random(3);
for (var i = 0; i < 200; i++)
{
var allele = template.Generate(random)["opt"];
Assert.InRange(allele.A, 45f, 55f); // around the template base, not 0
Assert.InRange(allele.B, 45f, 55f);
}
}
[Fact]
public void Generate_TwoSpecies_DifferInCentre()
{
var gene = Numeric("opt", 0f, 100f);
var oak = new GenomeTemplate([new GenomeTemplate.Entry(gene, 20f, 0f)]);
var grass = new GenomeTemplate([new GenomeTemplate.Entry(gene, 80f, 0f)]);
Assert.Equal(20f, oak.Generate(new Random(1)).Express(gene), 3);
Assert.Equal(80f, grass.Generate(new Random(1)).Express(gene), 3);
}
[Fact]
public void Registry_CoversEntries_AndDrivesBreeding()
{
var template = new GenomeTemplate([
new GenomeTemplate.Entry(Numeric("a", 0f, 10f), 5f, 0.1f),
new GenomeTemplate.Entry(Discrete("morph"), 0f, 0f),
]);
var random = new Random(9);
var a = template.Generate(random);
var b = template.Generate(random);
var child = Genome.Breed(a, b, template.Registry, random);
Assert.True(child.Has("a"));
Assert.True(child.Has("morph"));
}
[Fact]
public void Breed_MutationChanceOverride_ForcesMutation()
{
var gene = Numeric("a", min: -100f, max: 100f);
var registry = new Dictionary<string, GeneDef> { ["a"] = gene };
var parent = new Genome { ["a"] = new Allele(10f, 10f) };
// Override chance to 1 → every inherited allele mutates away from 10.
var moved = false;
for (var seed = 0; seed < 30 && !moved; seed++)
{
var child = Genome.Breed(
parent,
parent,
registry,
new Random(seed),
mutationChance: 1f
);
moved = child["a"].A != 10f || child["a"].B != 10f;
}
Assert.True(moved, "with overridden mutationChance=1 the allele should mutate");
}
[Fact]
public void Breed_MutationChanceZero_KeepsAlleles()
{
var gene = Numeric("a", -100f, 100f);
var registry = new Dictionary<string, GeneDef> { ["a"] = gene };
var parent = new Genome { ["a"] = new Allele(10f, 10f) };
var child = Genome.Breed(parent, parent, registry, new Random(5), mutationChance: 0f);
Assert.Equal(10f, child["a"].A);
Assert.Equal(10f, child["a"].B);
}
}