diff --git a/CHANGELOG.md b/CHANGELOG.md index 9336703..03aa133 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/). ### Added - Optional **TypeSafe / Jev System One** pass (`tools/typesafe-pareto`) for Score + Choice judgments over a small Pareto candidate sample. Additive semantic layer — does not replace NSGA-III / U-NSGA-III objectives. Live calls only when `TYPESAFE_API_KEY` is set; CI uses mocks. Metrics append to `metrics/typesafe-runs.jsonl`. +- Opt-in `MatingPoolMode.TwoShuffledPasses`: two shuffled passes of consecutive tournaments (Seada & Deb Algorithm 1 and pymoo 0.6.2 `TournamentSelection`, pressure 2). When N parents are drawn from N members, each member is a contestant twice. The default pool stays N independent tournaments with replacement, which is the published ZDT1 Wilcoxon protocol. +- Opt-in `TournamentMode.Algorithm2`: a same-niche perpendicular-distance tie keeps the second parent. `PymooCompatible` still coin-flips. `RankNicheDistance` is unchanged. + +### Fixed + +- Odd population sizes no longer skip `parents[N-1]` in SBX. The leftover parent is paired with parent 0. Even-N pair order is unchanged (ZDT1 pop 52, DTLZ2 pop 92). ### Changed diff --git a/README.md b/README.md index 50a38c5..440ca57 100644 --- a/README.md +++ b/README.md @@ -76,7 +76,8 @@ var algo = new Unsga3Algorithm(dirs, populationSize: 92, seed: 1, | `ReferenceDirections.DasDennis` | Structured reference points | | `SimulatedBinaryCrossover` / `PolynomialMutation` | Variation operators | | `PerformanceIndicators` | IGD, GD, 2-D hypervolume | -| `TournamentMode` | Default rank→niche vs `PymooCompatible` | +| `TournamentMode` | Default rank→niche, `PymooCompatible` (distance-tie coin flip), or opt-in `Algorithm2` (keep the second parent) | +| `MatingPoolMode` | Default independent tournaments; opt-in `TwoShuffledPasses` (paper / pymoo 0.6.2) | Built-in problems: ZDT1–4/6, DTLZ1–4/7, Sphere, Ackley, Rosenbrock. diff --git a/docs/EQUIVALENCE.md b/docs/EQUIVALENCE.md index f4c9b52..3d8febd 100644 --- a/docs/EQUIVALENCE.md +++ b/docs/EQUIVALENCE.md @@ -64,9 +64,11 @@ ZDT2 **gens=100** is an early-stress snapshot (collapse on Bend, C#, and pymoo), | Item | This library | pymoo | |------|--------------|-------| | Tournament (default `RankNicheDistance`) | rank → niche count → dist, including across niches | not Algorithm 2 | -| Tournament (`PymooCompatible`) | same niche → rank then dist; else random; distance tie is a coin flip | `comp_by_rank_and_ref_line_dist` (paper keeps the second parent on a distance tie) | +| Tournament (`PymooCompatible`) | same niche → rank then dist; else random; distance tie is a coin flip | `comp_by_rank_and_ref_line_dist` | +| Tournament (`Algorithm2`, opt-in) | same rules as `PymooCompatible`, except a same-niche distance tie keeps the second parent | paper Algorithm 2. Not pymoo's coin flip, and not the default | | SBX p_c | **1.0** (pymoo `SBX(prob=1.0)`) | paper section 4 uses **0.9** | -| Mating pool | N independent tournaments with replacement | two shuffled consecutive-pair passes | +| Mating pool (default) | N independent tournaments with replacement. This is the published ZDT1 Wilcoxon protocol | not the pymoo pool | +| Mating pool (`TwoShuffledPasses`, opt-in) | two shuffled passes of consecutive pairs. When N parents are drawn from N members, each index is a contestant twice | pymoo 0.6.2 `TournamentSelection` (pressure 2) **is** this pool: `n_perms = ceil(2N / N)` permutations, then consecutive pairs | | Niche ids at mating | `PrepareForSelection` re-normalizes survivors and re-associates | ids written during survival are kept | | `WithDasDennis(1, 1)` | throws. One objective has a single direction, and N must be ≥ 2 | pass `populationSize` ≥ 2 for the single-objective degeneration | | Reference layers | single-layer Das–Dennis only. Two-layer directions are absent | many-objective NSGA-III adds an inside layer for larger M | @@ -78,6 +80,16 @@ ZDT2 **gens=100** is an early-stress snapshot (collapse on Bend, C#, and pymoo), | Collapsed nadir | if the span is still ≤ 1e-6, nadir = ideal + 1 | stop at worst-of-population | | Infeasible points in the hyperplane | ideal and worst from the whole pool, including infeasible points. ASF extremes use the ND index set when supplied | pymoo niching can restrict the normalized set to feasible members | +## Mating pool + +The default pool is **N independent tournaments with replacement**, together with `RankNicheDistance`. That combination is the published ZDT1 Wilcoxon protocol. A two-pass run is not that table. + +pymoo 0.6.2 `TournamentSelection` (`pressure=2`) **is** the two-shuffled-pass pool, not a third scheme. Selecting N parents from N members uses `n_perms = ceil(2N / N) = 2` permutations and consecutive pairs, so each index is a contestant twice. `MatingPoolMode.TwoShuffledPasses` is that pool, opt-in. It does not change the default comparator. An odd population still uses two passes here. pymoo's mating loop instead asks for `ceil(N/2)` SBX pairs, which can draw a third permutation; that extra pass is not copied. + +`TournamentMode.Algorithm2` is the other opt-in: on a same-niche perpendicular-distance tie it keeps the second parent (Seada & Deb Algorithm 2) and does not draw. `PymooCompatible` still coin-flips that tie. Equal constraint violation stays a coin flip in both modes. Either comparator can be used with either pool. + +Odd N (Das–Dennis M=2, p=12 → 13; M=3, p=12 → 91): `CreateOffspring` pairs the leftover parent with parent 0, so `parents[N-1]` enters SBX, then the cursor restarts at 0. Even N stays consecutive pairs `(0,1), (2,3), …` and wraps to `(0,1)` with no extra random draw. Published ZDT1 (52) and DTLZ2 (92) are even, so their pair sequence is unchanged. + ## DTLZ2 gap history | Stage | C# pymoo-mode IGD | vs pymoo 0.0035 | diff --git a/docs/RESEARCH-STANDARDS.md b/docs/RESEARCH-STANDARDS.md index cd59a68..a1445d3 100644 --- a/docs/RESEARCH-STANDARDS.md +++ b/docs/RESEARCH-STANDARDS.md @@ -45,9 +45,11 @@ Default dimensions (Deb / pymoo convention): **pymoo** `comp_by_rank_and_ref_line_dist` follows that same-niche / different-niche split and coin-flips a distance tie. pymoo `NSGA3` builds `SBX(eta=30, prob=1.0)`. -**`TournamentMode.PymooCompatible`** matches that pymoo comparator, including the coin flip. It does not use the paper's second-parent tie break, p_c = 0.9, or the consecutive-pair mating pool. +**`TournamentMode.PymooCompatible`** matches that pymoo comparator, including the coin flip. It does not use the paper's second-parent tie break or p_c = 0.9. The consecutive-pair mating pool is a separate opt-in (`MatingPoolMode.TwoShuffledPasses`), not this mode. -**`TournamentMode.RankNicheDistance`** is the constructor default: rank, then niche count, then perpendicular distance, including when the niches differ. That is a local expansion, not Algorithm 2. The default stays `RankNicheDistance`. ZDT1's published Wilcoxon table uses it. +**`TournamentMode.RankNicheDistance`** is the constructor default: rank, then niche count, then perpendicular distance, including when the niches differ. That is a local expansion, not Algorithm 2. The default stays `RankNicheDistance`. ZDT1's published Wilcoxon table uses it, with the default independent-with-replacement pool. + +**`TournamentMode.Algorithm2`** (opt-in) keeps the second parent on a same-niche distance tie. **`MatingPoolMode.TwoShuffledPasses`** (opt-in) is pymoo 0.6.2's permutation tournament: two shuffled passes, consecutive pairs, each member a contestant twice when selecting N parents. Neither replaces the published ZDT1 defaults. ## 3. Performance indicators (what to report) diff --git a/src/Unsga3/Algorithm/Unsga3Algorithm.cs b/src/Unsga3/Algorithm/Unsga3Algorithm.cs index 9d1b627..db1fe8f 100644 --- a/src/Unsga3/Algorithm/Unsga3Algorithm.cs +++ b/src/Unsga3/Algorithm/Unsga3Algorithm.cs @@ -21,6 +21,7 @@ public sealed class Unsga3Algorithm private readonly double? _mutationProbability; private readonly int? _seed; private readonly TournamentMode _tournamentMode; + private readonly MatingPoolMode _matingPool; private readonly bool _eliminateDuplicates; /// Das–Dennis (or custom) directions; one weight vector per niche. @@ -33,7 +34,11 @@ public sealed class Unsga3Algorithm /// /// Per-variable mutation probability; default 1/nVars at run time. /// Optional RNG seed for reproducibility. - /// Mating tournament policy; use for oracle runs. + /// + /// Mating tournament policy. Default . + /// Use for oracle runs. + /// keeps the second parent on a same-niche distance tie. + /// /// /// Drop offspring whose decision vector matches an existing parent or earlier offspring /// (pymoo eliminate_duplicates=True). Default true. The key is G12 @@ -41,6 +46,11 @@ public sealed class Unsga3Algorithm /// produce a new key, attempts are capped and the remaining slots may be duplicates /// so the loop cannot hang. /// + /// + /// How parents are drawn. Default + /// (published ZDT1 Wilcoxon). is the + /// Seada & Deb Algorithm 1 / pymoo 0.6.2 permutation tournament and is opt-in. + /// public Unsga3Algorithm( double[][] referenceDirections, int? populationSize = null, @@ -50,7 +60,8 @@ public Unsga3Algorithm( double? mutationProbability = null, int? seed = null, TournamentMode tournamentMode = TournamentMode.RankNicheDistance, - bool eliminateDuplicates = true) + bool eliminateDuplicates = true, + MatingPoolMode matingPool = MatingPoolMode.IndependentWithReplacement) { ArgumentNullException.ThrowIfNull(referenceDirections); if (referenceDirections.Length < 1) @@ -69,6 +80,7 @@ public Unsga3Algorithm( _mutationProbability = mutationProbability; _seed = seed; _tournamentMode = tournamentMode; + _matingPool = matingPool; _eliminateDuplicates = eliminateDuplicates; } @@ -84,10 +96,16 @@ public static Unsga3Algorithm WithDasDennis( int partitions, int? populationSize = null, int? seed = null, - TournamentMode tournamentMode = TournamentMode.RankNicheDistance) + TournamentMode tournamentMode = TournamentMode.RankNicheDistance, + MatingPoolMode matingPool = MatingPoolMode.IndependentWithReplacement) { var dirs = ReferenceDirections.DasDennis(numberOfObjectives, partitions); - return new Unsga3Algorithm(dirs, populationSize, seed: seed, tournamentMode: tournamentMode); + return new Unsga3Algorithm( + dirs, + populationSize, + seed: seed, + tournamentMode: tournamentMode, + matingPool: matingPool); } public OptimizationResult Run(IProblem problem, int maxGenerations) => @@ -126,7 +144,7 @@ public OptimizationResult Run( // across generations (pymoo HyperplaneNormalization). Survival reuses the same // instance so tournament prep and environmental selection stay consistent. var normalization = new Normalization(problem.NumberOfObjectives); - var tournament = new TournamentSelection(_tournamentMode); + var tournament = new TournamentSelection(_tournamentMode, _matingPool); var survival = new NondominatedSortingSurvival(refs, normalization); double mutProb = _mutationProbability ?? (1.0 / problem.NumberOfVariables); @@ -164,7 +182,7 @@ public OptimizationResult Run( return new OptimizationResult(population.Members.ToList(), generation, evaluations); } - private List CreateOffspring( + internal List CreateOffspring( IProblem problem, IReadOnlyList currentPop, IReadOnlyList parents, @@ -187,10 +205,12 @@ private List CreateOffspring( while (offspring.Count < _populationSize && safety < maxAttempts) { safety++; - if (pair + 1 >= parents.Count) - pair = 0; - var (c1, c2) = _crossover.Crossover(parents[pair], parents[pair + 1], problem, rng); - pair += 2; + // Even N: (0,1), (2,3), … then wrap to (0,1). That sequence is what the + // published even populations (ZDT1 52, DTLZ2 92) already used. Odd N used + // to wrap before reading the last index, so parents[N-1] never entered SBX. + (int left, int right, int next) = AdvanceMatingPair(pair, parents.Count); + pair = next; + var (c1, c2) = _crossover.Crossover(parents[left], parents[right], problem, rng); _mutation.Mutate(c1, problem, rng, mutProb); _mutation.Mutate(c2, problem, rng, mutProb); @@ -223,6 +243,26 @@ private List CreateOffspring( return offspring; } + /// + /// Next SBX pair and the cursor after it. + /// Even yields consecutive pairs and wraps from + /// parentCount back to (0, 1) with no extra draw. + /// Odd counts pair the leftover last parent with parent 0, then restart at 0. + /// + internal static (int Left, int Right, int Next) AdvanceMatingPair(int pair, int parentCount) + { + if (parentCount < 2) + throw new ArgumentOutOfRangeException(nameof(parentCount), "Need at least two parents to cross."); + + if (pair >= parentCount) + pair = 0; + + if (pair + 1 < parentCount) + return (pair, pair + 1, pair + 2); + + return (pair, 0, 0); + } + private static void TryAddOffspring(List offspring, Individual child, HashSet? seen) { if (seen is null) diff --git a/src/Unsga3/Operators/Selection/MatingPoolMode.cs b/src/Unsga3/Operators/Selection/MatingPoolMode.cs new file mode 100644 index 0000000..1bfe792 --- /dev/null +++ b/src/Unsga3/Operators/Selection/MatingPoolMode.cs @@ -0,0 +1,24 @@ +namespace Unsga3.Operators.Selection; + +/// +/// How the mating pool is built before SBX. The comparator is +/// and is chosen separately. +/// +public enum MatingPoolMode +{ + /// + /// N independent binary tournaments with replacement (constructor default). + /// Contestants inside one tournament are distinct. Across tournaments the + /// same member can win many times and others can miss the generation. + /// This is the pool behind the published ZDT1 Wilcoxon table. + /// + IndependentWithReplacement = 0, + + /// + /// Two shuffled passes of consecutive pairs (Seada & Deb Algorithm 1 and + /// pymoo 0.6.2 TournamentSelection with pressure 2). Opt-in. + /// When the number of parents equals the population size, each member is a + /// contestant twice. This is not the published ZDT1 protocol. + /// + TwoShuffledPasses = 1, +} diff --git a/src/Unsga3/Operators/Selection/TournamentMode.cs b/src/Unsga3/Operators/Selection/TournamentMode.cs index ff3522b..2447f70 100644 --- a/src/Unsga3/Operators/Selection/TournamentMode.cs +++ b/src/Unsga3/Operators/Selection/TournamentMode.cs @@ -15,7 +15,18 @@ public enum TournamentMode /// Same-niche / different-niche split from Seada & Deb Algorithm 2 and from /// pymoo comp_by_rank_and_ref_line_dist: constraint violation first; if the /// parents share a niche then rank, then distance-to-niche; otherwise random. - /// A distance tie is a coin flip (pymoo). Algorithm 2 keeps the second parent on that tie. + /// A distance tie is a coin flip (pymoo). Algorithm 2 keeps the second parent on + /// that tie; use for the paper rule. This mode's coin flip + /// is unchanged. /// PymooCompatible = 1, + + /// + /// Seada & Deb Algorithm 2 comparator. Same rules as + /// , except a same-niche perpendicular-distance tie + /// keeps the second parent instead of a coin flip. Opt-in. It does not change + /// or . Usable with + /// either . + /// + Algorithm2 = 2, } diff --git a/src/Unsga3/Operators/Selection/TournamentSelection.cs b/src/Unsga3/Operators/Selection/TournamentSelection.cs index 7c5ad4a..168012d 100644 --- a/src/Unsga3/Operators/Selection/TournamentSelection.cs +++ b/src/Unsga3/Operators/Selection/TournamentSelection.cs @@ -7,22 +7,34 @@ namespace Unsga3.Operators.Selection; /// /// Binary mating tournament. follows the /// Seada & Deb Algorithm 2 split (same niche: rank then distance; different niches: random) -/// and pymoo's coin flip on equal distance. +/// and pymoo's coin flip on equal distance. keeps the +/// second parent on that distance tie. /// is the constructor default and also prefers the smaller niche count across niches. +/// The pool is independent of the comparator: the default is N draws with replacement, +/// and is the paper / pymoo 0.6.2 pool. /// public sealed class TournamentSelection { - public TournamentSelection(TournamentMode mode = TournamentMode.RankNicheDistance) + public TournamentSelection( + TournamentMode mode = TournamentMode.RankNicheDistance, + MatingPoolMode matingPool = MatingPoolMode.IndependentWithReplacement) { Mode = mode; + MatingPool = matingPool; } public TournamentMode Mode { get; } + /// How parents are drawn. Default is independent tournaments with replacement. + public MatingPoolMode MatingPool { get; } + /// - /// Select parents by independent tournaments with replacement. - /// This is not the paper's two shuffled passes of consecutive pairs. - /// Population must already have Rank / niche association set via . + /// Select parents. + /// draws each tournament + /// independently (with replacement across tournaments). + /// shuffles the population twice + /// and competes consecutive pairs. Population must already have Rank / niche + /// association set via . /// public List SelectParents( IReadOnlyList population, @@ -35,10 +47,20 @@ public List SelectParents( throw new ArgumentException("Population is empty.", nameof(population)); if (count < 0) throw new ArgumentOutOfRangeException(nameof(count)); - var parents = new List(count); - for (int i = 0; i < count; i++) - parents.Add(Tournament(population, rng).Clone()); - return parents; + switch (MatingPool) + { + case MatingPoolMode.IndependentWithReplacement: + { + var parents = new List(count); + for (int i = 0; i < count; i++) + parents.Add(Tournament(population, rng).Clone()); + return parents; + } + case MatingPoolMode.TwoShuffledPasses: + return SelectTwoShuffledPasses(population, count, rng); + default: + return UnexpectedMatingPool(MatingPool); + } } public Individual Tournament(IReadOnlyList population, RandomProvider rng) @@ -55,11 +77,17 @@ internal static Individual Winner( RandomProvider rng, TournamentMode mode = TournamentMode.RankNicheDistance) { - return mode switch + switch (mode) { - TournamentMode.PymooCompatible => WinnerPymoo(a, b, rng), - _ => WinnerRankNicheDistance(a, b, rng), - }; + case TournamentMode.RankNicheDistance: + return WinnerRankNicheDistance(a, b, rng); + case TournamentMode.PymooCompatible: + return WinnerPymoo(a, b, rng, keepSecondParentOnDistanceTie: false); + case TournamentMode.Algorithm2: + return WinnerPymoo(a, b, rng, keepSecondParentOnDistanceTie: true); + default: + return UnexpectedMode(mode); + } } /// Default: rank → niche count → perpendicular distance. @@ -91,8 +119,14 @@ internal static Individual WinnerRankNicheDistance(Individual a, Individual b, R /// /// pymoo comp_by_rank_and_ref_line_dist: /// CV → if same associated reference (niche) then rank → dist-to-niche; else random. + /// When is set, a same-niche distance + /// tie returns (Seada & Deb Algorithm 2) and does not draw. /// - internal static Individual WinnerPymoo(Individual a, Individual b, RandomProvider rng) + internal static Individual WinnerPymoo( + Individual a, + Individual b, + RandomProvider rng, + bool keepSecondParentOnDistanceTie = false) { bool aInfeas = !a.IsFeasible; bool bInfeas = !b.IsFeasible; @@ -110,12 +144,69 @@ internal static Individual WinnerPymoo(Individual a, Individual b, RandomProvide return a.Rank < b.Rank ? a : b; if (a.PerpendicularDistance < b.PerpendicularDistance) return a; if (b.PerpendicularDistance < a.PerpendicularDistance) return b; + if (keepSecondParentOnDistanceTie) + return b; } - // Different niches (or no association) → random (pymoo). + // Different niches (or no association, or a pymoo distance tie) → random. return rng.NextDouble() < 0.5 ? a : b; } + /// + /// Contestant indices for . + /// Length is 2 * parentCount, grouped as consecutive pairs + /// (0,1), (2,3), …. When equals + /// , this is exactly two permutations and + /// each index appears twice. + /// + internal static int[] BuildTwoShuffleContestants(int populationSize, int parentCount, RandomProvider rng) + { + ArgumentNullException.ThrowIfNull(rng); + if (populationSize < 2) + throw new ArgumentOutOfRangeException(nameof(populationSize), "Need at least two members to form a tournament."); + if (parentCount < 1) + throw new ArgumentOutOfRangeException(nameof(parentCount)); + + int slots = checked(parentCount * 2); + int nPerms = (slots + populationSize - 1) / populationSize; + var shuffled = new int[nPerms * populationSize]; + for (int pass = 0; pass < nPerms; pass++) + { + int start = pass * populationSize; + for (int i = 0; i < populationSize; i++) + shuffled[start + i] = i; + ShuffleRange(shuffled, start, populationSize, rng); + } + + var contests = new int[slots]; + Array.Copy(shuffled, contests, slots); + // Even N: permutation boundaries fall between pairs, so every contest is + // two distinct members of one pass. Odd N: one pair crosses a boundary + // and can repeat an index; separate that pair without dropping a member. + if ((populationSize & 1) == 1) + SeparateOddBoundaryPairs(contests, populationSize); + return contests; + } + + private List SelectTwoShuffledPasses( + IReadOnlyList population, + int count, + RandomProvider rng) + { + if (count == 0) + return new List(); + + int[] contests = BuildTwoShuffleContestants(population.Count, count, rng); + var parents = new List(count); + for (int i = 0; i < count; i++) + { + int a = contests[2 * i]; + int b = contests[2 * i + 1]; + parents.Add(Winner(population[a], population[b], rng, Mode).Clone()); + } + return parents; + } + /// /// Recompute ranks and niche association for mating. This is a second normalization /// of the survivors. pymoo keeps the niche ids from environmental selection. @@ -135,4 +226,48 @@ public static void PrepareForSelection( references.ResetNicheCounts(); references.Associate(population, normalized); } + + private static void ShuffleRange(int[] values, int start, int length, RandomProvider rng) + { + for (int i = length - 1; i > 0; i--) + { + int j = rng.Next(i + 1); + (values[start + i], values[start + j]) = (values[start + j], values[start + i]); + } + } + + /// + /// An odd population puts a permutation boundary inside one contest + /// (last of pass k, first of pass k+1). Those two entries can be + /// the same index. Swap with the next slot when that slot is still in the + /// contest list: the pass is a permutation, so the next slot is a different + /// member and the following pair stays two distinct members. If the boundary + /// is the last slot (a short parent list), replace the repeated index. + /// + private static void SeparateOddBoundaryPairs(int[] contests, int populationSize) + { + // Step across passes. Only an odd boundary index sits inside a pair + // (the right-hand contestant). An even boundary falls between pairs. + for (int junction = populationSize; junction < contests.Length; junction += populationSize * 2) + { + int left = junction - 1; + if (contests[left] != contests[junction]) + continue; + + if (junction + 1 < contests.Length) + { + // Both slots are inside the later pass, which is a permutation. + (contests[junction], contests[junction + 1]) = (contests[junction + 1], contests[junction]); + continue; + } + + contests[junction] = (contests[left] + 1) % populationSize; + } + } + + private static List UnexpectedMatingPool(MatingPoolMode matingPool) => + throw new ArgumentOutOfRangeException(nameof(matingPool), matingPool, "Unhandled mating pool."); + + private static Individual UnexpectedMode(TournamentMode mode) => + throw new ArgumentOutOfRangeException(nameof(mode), mode, "Unhandled tournament mode."); } diff --git a/tests/Unsga3.Tests/Unit/MatingPairTests.cs b/tests/Unsga3.Tests/Unit/MatingPairTests.cs new file mode 100644 index 0000000..9319661 --- /dev/null +++ b/tests/Unsga3.Tests/Unit/MatingPairTests.cs @@ -0,0 +1,145 @@ +using Unsga3.Algorithm; +using Unsga3.Core; +using Unsga3.Operators.Crossover; +using Unsga3.Operators.Mutation; +using Unsga3.Operators.Selection; +using Unsga3.Problems; +using Unsga3.Utilities; + +namespace Unsga3.Tests.Unit; + +public class MatingPairTests +{ + [Theory] + [InlineData(4)] + [InlineData(52)] + [InlineData(92)] + public void Even_population_keeps_consecutive_pairs_and_wraps_to_zero(int n) + { + int pair = 0; + for (int cycle = 0; cycle < 2; cycle++) + { + for (int i = 0; i < n; i += 2) + { + (int left, int right, int next) = Unsga3Algorithm.AdvanceMatingPair(pair, n); + Assert.Equal(i, left); + Assert.Equal(i + 1, right); + pair = next; + } + } + + Assert.Equal(n, pair); + (int wrappedLeft, int wrappedRight, _) = Unsga3Algorithm.AdvanceMatingPair(pair, n); + Assert.Equal(0, wrappedLeft); + Assert.Equal(1, wrappedRight); + } + + [Theory] + [InlineData(3)] + [InlineData(13)] + [InlineData(91)] + public void Odd_population_pairs_the_last_parent_with_parent_zero(int n) + { + int pair = 0; + var pairs = new List<(int Left, int Right)>(); + for (int step = 0; step < n; step++) + { + (int left, int right, int next) = Unsga3Algorithm.AdvanceMatingPair(pair, n); + pairs.Add((left, right)); + pair = next; + } + + Assert.Contains(pairs, p => p.Left == n - 1 && p.Right == 0); + Assert.Equal((0, 1), pairs[0]); + } + + [Fact] + public void Odd_n_last_parent_is_used_as_a_crossover_parent() + { + const int n = 13; + var parents = IndexedParents(n); + var seen = new List<(int Left, int Right)>(); + var algo = new Unsga3Algorithm( + new[] { new[] { 1.0 } }, + populationSize: n, + crossover: new RecordingCrossover(seen), + mutation: new NoopMutation(), + eliminateDuplicates: false); + + algo.CreateOffspring(new BoxProblem(), parents, parents, new RandomProvider(1), mutProb: 1.0); + + Assert.Contains(seen, p => p.Left == n - 1 || p.Right == n - 1); + Assert.Equal((n - 1, 0), seen[n / 2]); + } + + [Fact] + public void Even_n_crossover_sequence_does_not_use_the_odd_wrap() + { + const int parentCount = 4; + var parents = IndexedParents(parentCount); + var seen = new List<(int Left, int Right)>(); + var algo = new Unsga3Algorithm( + new[] { new[] { 1.0 } }, + populationSize: 8, + crossover: new RecordingCrossover(seen), + mutation: new NoopMutation(), + eliminateDuplicates: false); + + algo.CreateOffspring(new BoxProblem(), parents, parents, new RandomProvider(1), mutProb: 1.0); + + Assert.Equal(new[] { (0, 1), (2, 3), (0, 1), (2, 3) }, seen); + } + + [Fact] + public void Two_shuffle_odd_population_run_returns_full_population() + { + // Das–Dennis (2, 2) has 3 directions, so default N is odd. + var algo = Unsga3Algorithm.WithDasDennis( + 2, + 2, + seed: 1, + tournamentMode: TournamentMode.Algorithm2, + matingPool: MatingPoolMode.TwoShuffledPasses); + var result = algo.Run(new Zdt1Problem(nVariables: 4), maxGenerations: 1); + Assert.Equal(3, result.FinalPopulation.Count); + } + + private static List IndexedParents(int n) + { + var parents = new List(n); + for (int i = 0; i < n; i++) + parents.Add(new Individual(new[] { (double)i }, 1)); + return parents; + } + + private sealed class BoxProblem : ProblemBase + { + public BoxProblem() + : base(1, 1, 0, new[] { (0.0, 1000.0) }) + { + } + + protected override void EvaluateCore(double[] x, double[] f, double[] g) => f[0] = x[0]; + } + + private sealed class RecordingCrossover : ICrossover + { + private readonly List<(int Left, int Right)> _pairs; + + public RecordingCrossover(List<(int Left, int Right)> pairs) => _pairs = pairs; + + public (Individual Child1, Individual Child2) Crossover( + Individual parent1, Individual parent2, IProblem problem, RandomProvider rng) + { + _pairs.Add(((int)parent1.Variables[0], (int)parent2.Variables[0])); + return (parent1.Clone(), parent2.Clone()); + } + } + + private sealed class NoopMutation : IMutation + { + public void Mutate(Individual individual, IProblem problem, RandomProvider rng, double probabilityPerVariable) + { + } + } +} diff --git a/tests/Unsga3.Tests/Unit/TournamentSelectionTests.cs b/tests/Unsga3.Tests/Unit/TournamentSelectionTests.cs index 6ac62f0..e046baa 100644 --- a/tests/Unsga3.Tests/Unit/TournamentSelectionTests.cs +++ b/tests/Unsga3.Tests/Unit/TournamentSelectionTests.cs @@ -151,4 +151,174 @@ public void Default_sbx_probability_is_one() // pymoo NSGA3/UNSGA3 uses SBX(prob=1.0). Seada & Deb section 4 uses pc = 0.9. Assert.Equal(1.0, new SimulatedBinaryCrossover().Probability); } + + [Fact] + public void Defaults_stay_rank_niche_and_independent_pool() + { + var selection = new TournamentSelection(); + Assert.Equal(TournamentMode.RankNicheDistance, selection.Mode); + Assert.Equal(MatingPoolMode.IndependentWithReplacement, selection.MatingPool); + } + + [Fact] + public void Algorithm2_same_niche_distance_tie_keeps_the_second_parent() + { + var first = TiedNicheParents(); + var second = TiedNicheParents(); + for (int seed = 0; seed < 20; seed++) + { + var winner = TournamentSelection.Winner( + first, second, new RandomProvider(seed), TournamentMode.Algorithm2); + Assert.Same(second, winner); + } + } + + [Fact] + public void Algorithm2_same_niche_still_prefers_the_shorter_distance() + { + var closer = TiedNicheParents(); + var farther = TiedNicheParents(); + farther.PerpendicularDistance = 0.8; + Assert.Same(closer, TournamentSelection.Winner( + closer, farther, new RandomProvider(0), TournamentMode.Algorithm2)); + Assert.Same(closer, TournamentSelection.Winner( + farther, closer, new RandomProvider(0), TournamentMode.Algorithm2)); + } + + [Fact] + public void Algorithm2_equal_constraint_violation_stays_a_coin_flip() + { + var a = new Individual(1, 1, 1); + var b = new Individual(1, 1, 1); + a.Constraints[0] = 1.5; + b.Constraints[0] = 1.5; + a.RefreshConstraintViolation(); + b.RefreshConstraintViolation(); + a.Rank = b.Rank = 0; + a.AssociatedReference = b.AssociatedReference = 1; + a.PerpendicularDistance = b.PerpendicularDistance = 0.2; + + int aWins = 0; + for (int seed = 0; seed < 40; seed++) + { + var winner = TournamentSelection.Winner(a, b, new RandomProvider(seed), TournamentMode.Algorithm2); + if (ReferenceEquals(winner, a)) aWins++; + } + Assert.InRange(aWins, 5, 35); + } + + [Theory] + [InlineData(4)] + [InlineData(13)] + [InlineData(52)] + [InlineData(91)] + public void Two_shuffle_each_index_is_a_contestant_twice(int n) + { + for (int seed = 0; seed < 40; seed++) + { + int[] contests = TournamentSelection.BuildTwoShuffleContestants(n, n, new RandomProvider(seed)); + Assert.Equal(n * 2, contests.Length); + + var times = new int[n]; + for (int i = 0; i < n; i++) + { + int a = contests[2 * i]; + int b = contests[2 * i + 1]; + Assert.NotEqual(a, b); + times[a]++; + times[b]++; + } + Assert.All(times, t => Assert.Equal(2, t)); + } + } + + [Fact] + public void Two_shuffle_select_parents_follows_the_contest_order() + { + const int n = 13; + const int seed = 7; + var population = RankedPopulation(n); + int[] contests = TournamentSelection.BuildTwoShuffleContestants(n, n, new RandomProvider(seed)); + var selection = new TournamentSelection( + TournamentMode.RankNicheDistance, + MatingPoolMode.TwoShuffledPasses); + var parents = selection.SelectParents(population, n, new RandomProvider(seed)); + + Assert.Equal(n, parents.Count); + for (int i = 0; i < n; i++) + { + int expected = Math.Min(contests[2 * i], contests[2 * i + 1]); + Assert.Equal(expected, parents[i].Variables[0]); + } + } + + [Fact] + public void Default_pool_stays_independent_draws_with_replacement() + { + const int n = 13; + const int seed = 4; + var population = RankedPopulation(n); + var rng = new RandomProvider(seed); + var expected = new double[n]; + for (int i = 0; i < n; i++) + { + int a = rng.Next(n); + int b = rng.NextExcept(n, a); + expected[i] = Math.Min(a, b); + } + + var parents = new TournamentSelection().SelectParents(population, n, new RandomProvider(seed)); + for (int i = 0; i < n; i++) + Assert.Equal(expected[i], parents[i].Variables[0]); + } + + [Fact] + public void Two_shuffle_algorithm2_distance_tie_keeps_the_second_contestant() + { + const int n = 8; + const int seed = 3; + var population = new List(n); + for (int i = 0; i < n; i++) + { + var ind = TiedNicheParents(); + ind.Variables[0] = i; + population.Add(ind); + } + + int[] contests = TournamentSelection.BuildTwoShuffleContestants(n, n, new RandomProvider(seed)); + var selection = new TournamentSelection(TournamentMode.Algorithm2, MatingPoolMode.TwoShuffledPasses); + var parents = selection.SelectParents(population, n, new RandomProvider(seed)); + for (int i = 0; i < n; i++) + Assert.Equal(contests[2 * i + 1], parents[i].Variables[0]); + } + + [Fact] + public void Short_odd_pool_still_pairs_distinct_contestants() + { + for (int seed = 0; seed < 30; seed++) + { + int[] contests = TournamentSelection.BuildTwoShuffleContestants(5, 3, new RandomProvider(seed)); + Assert.Equal(6, contests.Length); + for (int i = 0; i < 3; i++) + Assert.NotEqual(contests[2 * i], contests[2 * i + 1]); + } + } + + private static Individual TiedNicheParents() => + new(1, 1) + { + Rank = 0, + AssociatedReference = 2, + PerpendicularDistance = 0.2, + }; + + private static List RankedPopulation(int n) + { + var population = new List(n); + for (int i = 0; i < n; i++) + { + population.Add(new Individual(new[] { (double)i }, 1) { Rank = i }); + } + return population; + } }