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;
+ }
}