Genetics: GenomeTemplate (per-organism gene allotment) + breed override
CI / build-test (push) Successful in 1m17s
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:
co-authored by
Claude Opus 4.8
parent
bc18db5df6
commit
ead22517ee
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user