diff --git a/Directory.Packages.props b/Directory.Packages.props index ef4f28d..2f43290 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -11,6 +11,11 @@ + + + + + diff --git a/challenges/conway-life/Solution.md b/challenges/conway-life/Solution.md index cd3b8fd..bca73fb 100644 --- a/challenges/conway-life/Solution.md +++ b/challenges/conway-life/Solution.md @@ -6,7 +6,7 @@ The full signed 64-bit coordinate space is too large to store or process as a gr Any solution must identify the cells that need consideration while keeping each generation based on the completed state of the previous one. It must also handle coordinates at the signed 64-bit limits without allowing arithmetic to wrap across the board. How the population is represented and how repeated work is avoided determine much of the cost, with trade-offs between execution time, memory use, and implementation complexity. -### The initial approach +### The initial approach _(Active Region strategy)_ In the spirit of the exercise, this approach was conceptualized by exploring the problem space and approaching it intuitively as a candidate would do in a whiteboard setting. No research into prior art, common algorithms, nor the work of others was done. diff --git a/challenges/conway-life/src/csharp/ConwayLifeChallenge.Benchmark.slnx b/challenges/conway-life/src/csharp/ConwayLifeChallenge.Benchmark.slnx new file mode 100644 index 0000000..de05bf8 --- /dev/null +++ b/challenges/conway-life/src/csharp/ConwayLifeChallenge.Benchmark.slnx @@ -0,0 +1,4 @@ + + + + diff --git a/challenges/conway-life/src/csharp/benchmark/ConwayLifeChallenge.Benchmark.csproj b/challenges/conway-life/src/csharp/benchmark/ConwayLifeChallenge.Benchmark.csproj new file mode 100644 index 0000000..2d04b48 --- /dev/null +++ b/challenges/conway-life/src/csharp/benchmark/ConwayLifeChallenge.Benchmark.csproj @@ -0,0 +1,17 @@ + + + $(CommonTargetFramework) + Squire.ConwayLifeChallenge.Benchmark + Exe + false + true + + + + + + + + + + \ No newline at end of file diff --git a/challenges/conway-life/src/csharp/benchmark/DensityAnalysisBenchmarks.cs b/challenges/conway-life/src/csharp/benchmark/DensityAnalysisBenchmarks.cs new file mode 100644 index 0000000..ede2058 --- /dev/null +++ b/challenges/conway-life/src/csharp/benchmark/DensityAnalysisBenchmarks.cs @@ -0,0 +1,200 @@ +using BenchmarkDotNet.Attributes; +using Squire.ConwayLifeChallenge.Infrastructure; +using Squire.ConwayLifeChallenge.Strategies; + +namespace Squire.ConwayLifeChallenge.Benchmark; + +/// +/// Answers three questions about the per-generation visited set. +/// +/// +/// +/// The first group measures the cost of computing the density of a population and compares it +/// against the cost of a single generation, so the overhead of the density check can be seen +/// relative to the work it gates. The second group compares the always-use, never-use, and +/// density-gated approaches across sparse, dense, mixed, and R-pentomino workloads. The third +/// group narrows the crossover density where the visited set starts to pay off. +/// +/// +[MemoryDiagnoser] +public class DensityAnalysisBenchmarks +{ + /// The density threshold at or above which the gated approach uses the visited set. + private const double Threshold = 0.25; + + /// 100 cells in a 100 by 100 box. + private readonly HashSet Cells100 = CreateCells(100, 100); + + /// 1,000 cells in a 100 by 100 box. + private readonly HashSet Cells1000 = CreateCells(1_000, 100); + + /// 10,000 cells in a 200 by 200 box. + private readonly HashSet Cells10000 = CreateCells(10_000, 200); + + /// 1,000 cells in a 2,000 by 2,000 box, a density of 0.025 percent. + private readonly HashSet Sparse = CreateCells(1_000, 2_000); + + /// 1,000 cells in a 35 by 35 box, a density of 82 percent. + private readonly HashSet Dense = CreateCells(1_000, 35); + + /// 1,000 cells in a 100 by 100 box, a density of 10 percent. + private readonly HashSet Mixed = CreateCells(1_000, 100); + + /// The R-pentomino, a methuselah with five live cells. + private readonly HashSet RPentomino = + [ + new(1, 0), new(2, 0), new(0, 1), new(1, 1), new(1, 2) + ]; + + /// 1,000 cells at a density of about 15 percent. + private readonly HashSet Density15 = CreateCells(1_000, 82); + + /// 1,000 cells at a density of about 20 percent. + private readonly HashSet Density20 = CreateCells(1_000, 71); + + /// 1,000 cells at a density of about 25 percent. + private readonly HashSet Density25 = CreateCells(1_000, 63); + + /// 1,000 cells at a density of about 30 percent. + private readonly HashSet Density30 = CreateCells(1_000, 58); + + /// 1,000 cells at a density of about 35 percent. + private readonly HashSet Density35 = CreateCells(1_000, 53); + + /// Computes the density of 100 cells. + [Benchmark] + public double DensityCost100() => DensityCalculator.GetDensity(Cells100); + + /// Computes the density of 1,000 cells. + [Benchmark] + public double DensityCost1000() => DensityCalculator.GetDensity(Cells1000); + + /// Computes the density of 10,000 cells. + [Benchmark] + public double DensityCost10000() => DensityCalculator.GetDensity(Cells10000); + + /// Simulates 100 cells for one generation with the visited set. + [Benchmark] + public HashSet Generation100() => new ActiveRegionStrategy().Simulate(Cells100, 1); + + /// Simulates 1,000 cells for one generation with the visited set. + [Benchmark] + public HashSet Generation1000() => new ActiveRegionStrategy().Simulate(Cells1000, 1); + + /// Simulates 10,000 cells for one generation with the visited set. + [Benchmark] + public HashSet Generation10000() => new ActiveRegionStrategy().Simulate(Cells10000, 1); + + /// Simulates the sparse population for one generation, always using the visited set. + [Benchmark] + public HashSet AlwaysWithSparse() => new ActiveRegionStrategy().Simulate(Sparse, 1); + + /// Simulates the sparse population for one generation, never using the visited set. + [Benchmark] + public HashSet NeverWithSparse() => new NoVisitedSetStrategy().Simulate(Sparse, 1); + + /// Simulates the sparse population for one generation, using the density-gated approach. + [Benchmark] + public HashSet GatedSparse() => new DensityGatedStrategy(Threshold).Simulate(Sparse, 1); + + /// Simulates the dense population for one generation, always using the visited set. + [Benchmark] + public HashSet AlwaysWithDense() => new ActiveRegionStrategy().Simulate(Dense, 1); + + /// Simulates the dense population for one generation, never using the visited set. + [Benchmark] + public HashSet NeverWithDense() => new NoVisitedSetStrategy().Simulate(Dense, 1); + + /// Simulates the dense population for one generation, using the density-gated approach. + [Benchmark] + public HashSet GatedDense() => new DensityGatedStrategy(Threshold).Simulate(Dense, 1); + + /// Simulates the mixed population for one generation, always using the visited set. + [Benchmark] + public HashSet AlwaysWithMixed() => new ActiveRegionStrategy().Simulate(Mixed, 1); + + /// Simulates the mixed population for one generation, never using the visited set. + [Benchmark] + public HashSet NeverWithMixed() => new NoVisitedSetStrategy().Simulate(Mixed, 1); + + /// Simulates the mixed population for one generation, using the density-gated approach. + [Benchmark] + public HashSet GatedMixed() => new DensityGatedStrategy(Threshold).Simulate(Mixed, 1); + + /// Simulates the R-pentomino for thirty generations, always using the visited set. + [Benchmark] + public HashSet AlwaysWithRPentomino() => new ActiveRegionStrategy().Simulate(RPentomino, 30); + + /// Simulates the R-pentomino for thirty generations, never using the visited set. + [Benchmark] + public HashSet NeverWithRPentomino() => new NoVisitedSetStrategy().Simulate(RPentomino, 30); + + /// Simulates the R-pentomino for thirty generations, using the density-gated approach. + [Benchmark] + public HashSet GatedRPentomino() => new DensityGatedStrategy(Threshold).Simulate(RPentomino, 30); + + /// Simulates the 15 percent population for one generation with the visited set. + [Benchmark] + public HashSet With15() => new ActiveRegionStrategy().Simulate(Density15, 1); + + /// Simulates the 15 percent population for one generation without the visited set. + [Benchmark] + public HashSet Without15() => new NoVisitedSetStrategy().Simulate(Density15, 1); + + /// Simulates the 20 percent population for one generation with the visited set. + [Benchmark] + public HashSet With20() => new ActiveRegionStrategy().Simulate(Density20, 1); + + /// Simulates the 20 percent population for one generation without the visited set. + [Benchmark] + public HashSet Without20() => new NoVisitedSetStrategy().Simulate(Density20, 1); + + /// Simulates the 25 percent population for one generation with the visited set. + [Benchmark] + public HashSet With25() => new ActiveRegionStrategy().Simulate(Density25, 1); + + /// Simulates the 25 percent population for one generation without the visited set. + [Benchmark] + public HashSet Without25() => new NoVisitedSetStrategy().Simulate(Density25, 1); + + /// Simulates the 30 percent population for one generation with the visited set. + [Benchmark] + public HashSet With30() => new ActiveRegionStrategy().Simulate(Density30, 1); + + /// Simulates the 30 percent population for one generation without the visited set. + [Benchmark] + public HashSet Without30() => new NoVisitedSetStrategy().Simulate(Density30, 1); + + /// Simulates the 35 percent population for one generation with the visited set. + [Benchmark] + public HashSet With35() => new ActiveRegionStrategy().Simulate(Density35, 1); + + /// Simulates the 35 percent population for one generation without the visited set. + [Benchmark] + public HashSet Without35() => new NoVisitedSetStrategy().Simulate(Density35, 1); + + /// + /// Creates a deterministic random population of distinct live cells within a square box. + /// + /// + /// The number of distinct live cells to create. + /// The side length of the square box the cells are placed in. + /// + /// A set of distinct live cells. + /// + private static HashSet CreateCells(int count, int boxSize) + { + var random = new Random(12345); + var half = boxSize / 2; + var cells = new HashSet(count); + + while (cells.Count < count) + { + var x = random.Next(-half, half); + var y = random.Next(-half, half); + _ = cells.Add(new Coordinate(x, y)); + } + + return cells; + } +} diff --git a/challenges/conway-life/src/csharp/benchmark/DensityCalculator.cs b/challenges/conway-life/src/csharp/benchmark/DensityCalculator.cs new file mode 100644 index 0000000..88efca5 --- /dev/null +++ b/challenges/conway-life/src/csharp/benchmark/DensityCalculator.cs @@ -0,0 +1,67 @@ +using Squire.ConwayLifeChallenge.Infrastructure; + +namespace Squire.ConwayLifeChallenge.Benchmark; + +/// +/// Computes the density of a population as the ratio of live cells to bounding box area. +/// +/// +/// +/// The density is the number of live cells divided by the area of the bounding box that +/// contains them. It is a single pass over the live cells, so it is O(n) in the population +/// size. This is the value the uses to decide whether +/// the per-generation visited set pays off. +/// +/// +internal static class DensityCalculator +{ + /// + /// Computes the density of a population as the ratio of live cells to bounding box area. + /// + /// + /// The population to measure. + /// + /// The density as a value between 0 and 1, or 0 for an empty population. + /// + public static double GetDensity(HashSet cells) + { + if (cells.Count == 0) + { + return 0; + } + + var minX = long.MaxValue; + var maxX = long.MinValue; + var minY = long.MaxValue; + var maxY = long.MinValue; + + foreach (var cell in cells) + { + if (cell.X < minX) + { + minX = cell.X; + } + + if (cell.X > maxX) + { + maxX = cell.X; + } + + if (cell.Y < minY) + { + minY = cell.Y; + } + + if (cell.Y > maxY) + { + maxY = cell.Y; + } + } + + var width = maxX - minX + 1; + var height = maxY - minY + 1; + var area = width * height; + + return (double)cells.Count / area; + } +} diff --git a/challenges/conway-life/src/csharp/benchmark/DensityGatedStrategy.cs b/challenges/conway-life/src/csharp/benchmark/DensityGatedStrategy.cs new file mode 100644 index 0000000..5c61f81 --- /dev/null +++ b/challenges/conway-life/src/csharp/benchmark/DensityGatedStrategy.cs @@ -0,0 +1,210 @@ +using Squire.ConwayLifeChallenge.Infrastructure; + +namespace Squire.ConwayLifeChallenge.Benchmark; + +/// +/// A benchmark-only variant of +/// that computes the density at the start of each generation and chooses whether to use the +/// per-generation visited set based on that. +/// +/// +/// +/// This exists only to measure the cost and benefit of the density-gated approach. It is not +/// part of the application and is never selected by . +/// +/// The algorithm is identical to +/// except that the visited set is only used when the density is at or above a threshold. When the +/// density is below the threshold, the visited set is skipped, which saves the allocation and GC +/// cost of tracking dead cells. +/// +/// +internal sealed class DensityGatedStrategy : StrategyBase +{ + private readonly double Threshold; + + /// + /// Initializes a new instance of the class. + /// + /// + /// The density threshold at or above which the visited set is used. + /// + public DensityGatedStrategy(double threshold) + { + Threshold = threshold; + } + + /// + public override Strategy Strategy => Strategy.ActiveRegion; + + /// + public override HashSet Simulate(HashSet cells, + int generations) + { + ArgumentNullException.ThrowIfNull(cells, nameof(cells)); + + if (generations <= 0) + { + return [ ..cells ]; + } + + var startingAliveCells = generations switch + { + > 1 => new HashSet(cells, cells.Comparer), + _ => cells + }; + + var endingAliveCells = new HashSet(cells.Count); + var visitedCells = new HashSet(cells.Count); + + while (startingAliveCells.Count > 0) + { + if (DensityCalculator.GetDensity(startingAliveCells) >= Threshold) + { + SimulateGenerationWithVisitedSet(startingAliveCells, endingAliveCells, visitedCells); + } + else + { + SimulateGenerationWithoutVisitedSet(startingAliveCells, endingAliveCells); + } + + if (--generations <= 0) + { + return endingAliveCells; + } + + (startingAliveCells, endingAliveCells) = (endingAliveCells, startingAliveCells); + endingAliveCells.Clear(); + visitedCells.Clear(); + } + + return endingAliveCells; + } + + /// + /// Executes a run for a single generation of the simulation using the visited set. + /// + /// + /// The initial set of live cells for the generation. + /// The set of live cells after the generation has been simulated. + /// The set of cells that have been visited during the generation. + /// + private static void SimulateGenerationWithVisitedSet(HashSet startingAliveCells, + HashSet endingAliveCells, + HashSet generationVisitedCells) + { + foreach (var cell in startingAliveCells) + { + ProcessCellWithVisitedSet(cell, startingAliveCells, endingAliveCells, generationVisitedCells); + } + } + + /// + /// Executes a run for a single generation of the simulation without the visited set. + /// + /// + /// The initial set of live cells for the generation. + /// The set of live cells after the generation has been simulated. + /// + private static void SimulateGenerationWithoutVisitedSet(HashSet startingAliveCells, + HashSet endingAliveCells) + { + foreach (var cell in startingAliveCells) + { + ProcessCellWithoutVisitedSet(cell, startingAliveCells, endingAliveCells); + } + } + + /// + /// Processes a cell and, if needed, it's direct neighbors to determine if the cell(s) will be alive + /// in the next generation, using the visited set. + /// + /// + /// The cell to process. + /// The initial set of live cells for the generation. + /// The set of live cells after the generation has been simulated. + /// The set of cells that have been visited during the generation. + /// Optional. The current recursive depth of processing. + /// + private static void ProcessCellWithVisitedSet(Coordinate cell, + HashSet startingAliveCells, + HashSet endingAliveCells, + HashSet generationVisitedCells, + byte depth = 0) + { + if ((depth > 1) + || (generationVisitedCells.Contains(cell)) + || (endingAliveCells.Contains(cell))) + { + return; + } + + var liveNeighbors = 0; + + ++depth; + + foreach (var neighborCell in cell.GetNeighbors()) + { + if (startingAliveCells.Contains(neighborCell)) + { + ++liveNeighbors; + } + else + { + ProcessCellWithVisitedSet(neighborCell, startingAliveCells, endingAliveCells, generationVisitedCells, depth); + } + } + + _ = startingAliveCells.Contains(cell) switch + { + true when liveNeighbors is 2 or 3 => endingAliveCells.Add(cell), + false when liveNeighbors is 3 => endingAliveCells.Add(cell), + false => generationVisitedCells.Add(cell), + _ => false + }; + } + + /// + /// Processes a cell and, if needed, it's direct neighbors to determine if the cell(s) will be alive + /// in the next generation, without the visited set. + /// + /// + /// The cell to process. + /// The initial set of live cells for the generation. + /// The set of live cells after the generation has been simulated. + /// Optional. The current recursive depth of processing. + /// + private static void ProcessCellWithoutVisitedSet(Coordinate cell, + HashSet startingAliveCells, + HashSet endingAliveCells, + byte depth = 0) + { + if ((depth > 1) + || (endingAliveCells.Contains(cell))) + { + return; + } + + var liveNeighbors = 0; + + ++depth; + + foreach (var neighborCell in cell.GetNeighbors()) + { + if (startingAliveCells.Contains(neighborCell)) + { + ++liveNeighbors; + } + else + { + ProcessCellWithoutVisitedSet(neighborCell, startingAliveCells, endingAliveCells, depth); + } + } + + _ = startingAliveCells.Contains(cell) switch + { + true when liveNeighbors is 2 or 3 => endingAliveCells.Add(cell), + false when liveNeighbors is 3 => endingAliveCells.Add(cell), + _ => false + }; + } +} diff --git a/challenges/conway-life/src/csharp/benchmark/NoVisitedSetStrategy.cs b/challenges/conway-life/src/csharp/benchmark/NoVisitedSetStrategy.cs new file mode 100644 index 0000000..8be6efc --- /dev/null +++ b/challenges/conway-life/src/csharp/benchmark/NoVisitedSetStrategy.cs @@ -0,0 +1,121 @@ +using Squire.ConwayLifeChallenge.Infrastructure; + +namespace Squire.ConwayLifeChallenge.Benchmark; + +/// +/// A benchmark-only variant of +/// that removes the per-generation visited set. +/// +/// +/// +/// This exists only to measure the cost of the visited set. It is not part of the application and +/// is never selected by . +/// +/// The algorithm is identical to +/// except that dead cells are no longer tracked as visited. A dead cell that is a neighbor of more +/// than one live cell is therefore processed once per live neighbor instead of once per generation. +/// The result is unchanged because a cell's fate depends only on its live neighbors, which are fixed +/// for the generation. +/// +/// +internal sealed class NoVisitedSetStrategy : StrategyBase +{ + /// + public override Strategy Strategy => Strategy.ActiveRegion; + + /// + public override HashSet Simulate(HashSet cells, + int generations) + { + ArgumentNullException.ThrowIfNull(cells, nameof(cells)); + + if (generations <= 0) + { + return [ ..cells ]; + } + + var startingAliveCells = generations switch + { + > 1 => new HashSet(cells, cells.Comparer), + _ => cells + }; + + var endingAliveCells = new HashSet(cells.Count); + + while (startingAliveCells.Count > 0) + { + SimulateGeneration(startingAliveCells, endingAliveCells); + + if (--generations <= 0) + { + return endingAliveCells; + } + + (startingAliveCells, endingAliveCells) = (endingAliveCells, startingAliveCells); + endingAliveCells.Clear(); + } + + return endingAliveCells; + } + + /// + /// Executes a run for a single generation of the simulation. + /// + /// + /// The initial set of live cells for the generation. + /// The set of live cells after the generation has been simulated. + /// + private static void SimulateGeneration(HashSet startingAliveCells, + HashSet endingAliveCells) + { + foreach (var cell in startingAliveCells) + { + ProcessCell(cell, startingAliveCells, endingAliveCells); + } + } + + /// + /// Processes a cell and, if needed, it's direct neighbors to determine if the cell(s) will be alive + /// in the next generation. + /// + /// + /// The cell to process. + /// The initial set of live cells for the generation. + /// The set of live cells after the generation has been simulated. + /// Optional. The current recursive depth of processing. + /// + private static void ProcessCell(Coordinate cell, + HashSet startingAliveCells, + HashSet endingAliveCells, + byte depth = 0) + { + if ((depth > 1) + || (endingAliveCells.Contains(cell))) + { + return; + } + + var liveNeighbors = 0; + + ++depth; + + foreach (var neighborCell in cell.GetNeighbors()) + { + if (startingAliveCells.Contains(neighborCell)) + { + ++liveNeighbors; + } + else + { + ProcessCell(neighborCell, startingAliveCells, endingAliveCells, depth); + } + } + + _ = startingAliveCells.Contains(cell) switch + { + true when liveNeighbors is 2 or 3 => endingAliveCells.Add(cell), + false when liveNeighbors is 3 => endingAliveCells.Add(cell), + _ => false + }; + } +} diff --git a/challenges/conway-life/src/csharp/benchmark/PerGenerationBenchmarks.cs b/challenges/conway-life/src/csharp/benchmark/PerGenerationBenchmarks.cs new file mode 100644 index 0000000..fa90457 --- /dev/null +++ b/challenges/conway-life/src/csharp/benchmark/PerGenerationBenchmarks.cs @@ -0,0 +1,175 @@ +using BenchmarkDotNet.Attributes; +using Squire.ConwayLifeChallenge.Infrastructure; +using Squire.ConwayLifeChallenge.Strategies; + +namespace Squire.ConwayLifeChallenge.Benchmark; + +/// +/// Measures the per-generation cost and allocation of the +/// (with the per-generation visited set) against (without it). +/// +/// +/// +/// Two workloads are measured. The density sweep holds the population at 1,000 cells and varies +/// the bounding box to set the density, isolating the variable that determines whether the visited +/// set pays off. The evolution workload simulates the R-pentomino for an increasing number of +/// generations so the per-generation cost and allocation can be tracked as the pattern grows and +/// settles. Both report time and allocation so the two can be correlated. +/// +/// +[MemoryDiagnoser] +public class PerGenerationBenchmarks +{ + /// 1,000 cells in a 2,000 by 2,000 box, a density of 0.025 percent. + private readonly HashSet Sparse = CreateCells(1_000, 2_000); + + /// 1,000 cells in a 500 by 500 box, a density of 0.4 percent. + private readonly HashSet Low = CreateCells(1_000, 500); + + /// 1,000 cells in a 200 by 200 box, a density of 2.5 percent. + private readonly HashSet Medium = CreateCells(1_000, 200); + + /// 1,000 cells in a 100 by 100 box, a density of 10 percent. + private readonly HashSet High = CreateCells(1_000, 100); + + /// 1,000 cells in a 50 by 50 box, a density of 40 percent. + private readonly HashSet VeryHigh = CreateCells(1_000, 50); + + /// 1,000 cells in a 35 by 35 box, a density of 82 percent. + private readonly HashSet Dense = CreateCells(1_000, 35); + + /// The R-pentomino, a methuselah with five live cells. + private readonly HashSet RPentomino = + [ + new(1, 0), new(2, 0), new(0, 1), new(1, 1), new(1, 2) + ]; + + /// Simulates the sparse population for one generation with the visited set. + [Benchmark] + public HashSet WithSparse() => new ActiveRegionStrategy().Simulate(Sparse, 1); + + /// Simulates the sparse population for one generation without the visited set. + [Benchmark] + public HashSet WithoutSparse() => new NoVisitedSetStrategy().Simulate(Sparse, 1); + + /// Simulates the low density population for one generation with the visited set. + [Benchmark] + public HashSet WithLow() => new ActiveRegionStrategy().Simulate(Low, 1); + + /// Simulates the low density population for one generation without the visited set. + [Benchmark] + public HashSet WithoutLow() => new NoVisitedSetStrategy().Simulate(Low, 1); + + /// Simulates the medium density population for one generation with the visited set. + [Benchmark] + public HashSet WithMedium() => new ActiveRegionStrategy().Simulate(Medium, 1); + + /// Simulates the medium density population for one generation without the visited set. + [Benchmark] + public HashSet WithoutMedium() => new NoVisitedSetStrategy().Simulate(Medium, 1); + + /// Simulates the high density population for one generation with the visited set. + [Benchmark] + public HashSet WithHigh() => new ActiveRegionStrategy().Simulate(High, 1); + + /// Simulates the high density population for one generation without the visited set. + [Benchmark] + public HashSet WithoutHigh() => new NoVisitedSetStrategy().Simulate(High, 1); + + /// Simulates the very high density population for one generation with the visited set. + [Benchmark] + public HashSet WithVeryHigh() => new ActiveRegionStrategy().Simulate(VeryHigh, 1); + + /// Simulates the very high density population for one generation without the visited set. + [Benchmark] + public HashSet WithoutVeryHigh() => new NoVisitedSetStrategy().Simulate(VeryHigh, 1); + + /// Simulates the dense population for one generation with the visited set. + [Benchmark] + public HashSet WithDense() => new ActiveRegionStrategy().Simulate(Dense, 1); + + /// Simulates the dense population for one generation without the visited set. + [Benchmark] + public HashSet WithoutDense() => new NoVisitedSetStrategy().Simulate(Dense, 1); + + /// Simulates the R-pentomino for one generation with the visited set. + [Benchmark] + public HashSet WithRPentominoOne() => new ActiveRegionStrategy().Simulate(RPentomino, 1); + + /// Simulates the R-pentomino for one generation without the visited set. + [Benchmark] + public HashSet WithoutRPentominoOne() => new NoVisitedSetStrategy().Simulate(RPentomino, 1); + + /// Simulates the R-pentomino for two generations with the visited set. + [Benchmark] + public HashSet WithRPentominoTwo() => new ActiveRegionStrategy().Simulate(RPentomino, 2); + + /// Simulates the R-pentomino for two generations without the visited set. + [Benchmark] + public HashSet WithoutRPentominoTwo() => new NoVisitedSetStrategy().Simulate(RPentomino, 2); + + /// Simulates the R-pentomino for three generations with the visited set. + [Benchmark] + public HashSet WithRPentominoThree() => new ActiveRegionStrategy().Simulate(RPentomino, 3); + + /// Simulates the R-pentomino for three generations without the visited set. + [Benchmark] + public HashSet WithoutRPentominoThree() => new NoVisitedSetStrategy().Simulate(RPentomino, 3); + + /// Simulates the R-pentomino for five generations with the visited set. + [Benchmark] + public HashSet WithRPentominoFive() => new ActiveRegionStrategy().Simulate(RPentomino, 5); + + /// Simulates the R-pentomino for five generations without the visited set. + [Benchmark] + public HashSet WithoutRPentominoFive() => new NoVisitedSetStrategy().Simulate(RPentomino, 5); + + /// Simulates the R-pentomino for ten generations with the visited set. + [Benchmark] + public HashSet WithRPentominoTen() => new ActiveRegionStrategy().Simulate(RPentomino, 10); + + /// Simulates the R-pentomino for ten generations without the visited set. + [Benchmark] + public HashSet WithoutRPentominoTen() => new NoVisitedSetStrategy().Simulate(RPentomino, 10); + + /// Simulates the R-pentomino for twenty generations with the visited set. + [Benchmark] + public HashSet WithRPentominoTwenty() => new ActiveRegionStrategy().Simulate(RPentomino, 20); + + /// Simulates the R-pentomino for twenty generations without the visited set. + [Benchmark] + public HashSet WithoutRPentominoTwenty() => new NoVisitedSetStrategy().Simulate(RPentomino, 20); + + /// Simulates the R-pentomino for thirty generations with the visited set. + [Benchmark] + public HashSet WithRPentominoThirty() => new ActiveRegionStrategy().Simulate(RPentomino, 30); + + /// Simulates the R-pentomino for thirty generations without the visited set. + [Benchmark] + public HashSet WithoutRPentominoThirty() => new NoVisitedSetStrategy().Simulate(RPentomino, 30); + + /// + /// Creates a deterministic random population of distinct live cells within a square box. + /// + /// + /// The number of distinct live cells to create. + /// The side length of the square box the cells are placed in. + /// + /// A set of distinct live cells. + /// + private static HashSet CreateCells(int count, int boxSize) + { + var random = new Random(12345); + var half = boxSize / 2; + var cells = new HashSet(count); + + while (cells.Count < count) + { + var x = random.Next(-half, half); + var y = random.Next(-half, half); + _ = cells.Add(new Coordinate(x, y)); + } + + return cells; + } +} diff --git a/challenges/conway-life/src/csharp/benchmark/PopulationEvolution.cs b/challenges/conway-life/src/csharp/benchmark/PopulationEvolution.cs new file mode 100644 index 0000000..50a9a50 --- /dev/null +++ b/challenges/conway-life/src/csharp/benchmark/PopulationEvolution.cs @@ -0,0 +1,136 @@ +using Squire.ConwayLifeChallenge.Infrastructure; +using Squire.ConwayLifeChallenge.Strategies; + +namespace Squire.ConwayLifeChallenge.Benchmark; + +/// +/// Simulates sparse, dense, and mixed workloads for thirty generations and records the population +/// size and density at each generation. +/// +/// +/// +/// This is a measurement, not a benchmark. It exists to answer whether the initial seed predicts +/// the regime the simulation runs in. The visited set's benefit depends on the density at each +/// generation, so if the density is stable across generations the initial seed is a reliable +/// predictor, and if it swings it is not. +/// +/// +public static class PopulationEvolution +{ + /// + /// Runs the population evolution measurement and prints the results. + /// + /// + public static void Run() + { + var sparse = CreateCells(1_000, 2_000); + var dense = CreateCells(1_000, 35); + var mixed = CreateCells(1_000, 100); + + var strategy = new ActiveRegionStrategy(); + + Console.WriteLine("Generation | Sparse (count, density) | Dense (count, density) | Mixed (count, density)"); + Console.WriteLine("-----------|-------------------------|------------------------|-------------------------"); + + for (var gen = 0; gen <= 30; gen++) + { + Console.WriteLine( + $"{gen,10} | {Format(sparse)} | {Format(dense)} | {Format(mixed)}"); + + if (gen < 30) + { + sparse = strategy.Simulate(sparse, 1); + dense = strategy.Simulate(dense, 1); + mixed = strategy.Simulate(mixed, 1); + } + } + } + + /// + /// Formats a population as its count and density. + /// + /// + /// The population to format. + /// + /// A string with the count and density. + /// + private static string Format(HashSet cells) + { + if (cells.Count == 0) + { + return "0, 0%"; + } + + var (minX, maxX, minY, maxY) = GetBoundingBox(cells); + var area = (maxX - minX + 1) * (maxY - minY + 1); + var density = 100.0 * cells.Count / area; + + return $"{cells.Count}, {density:F1}%"; + } + + /// + /// Gets the bounding box of a population. + /// + /// + /// The population to measure. + /// + /// The minimum and maximum X and Y coordinates. + /// + private static (long MinX, long MaxX, long MinY, long MaxY) GetBoundingBox(HashSet cells) + { + var minX = long.MaxValue; + var maxX = long.MinValue; + var minY = long.MaxValue; + var maxY = long.MinValue; + + foreach (var cell in cells) + { + if (cell.X < minX) + { + minX = cell.X; + } + + if (cell.X > maxX) + { + maxX = cell.X; + } + + if (cell.Y < minY) + { + minY = cell.Y; + } + + if (cell.Y > maxY) + { + maxY = cell.Y; + } + } + + return (minX, maxX, minY, maxY); + } + + /// + /// Creates a deterministic random population of distinct live cells within a square box. + /// + /// + /// The number of distinct live cells to create. + /// The side length of the square box the cells are placed in. + /// + /// A set of distinct live cells. + /// + private static HashSet CreateCells(int count, int boxSize) + { + var random = new Random(12345); + var half = boxSize / 2; + var cells = new HashSet(count); + + while (cells.Count < count) + { + var x = random.Next(-half, half); + var y = random.Next(-half, half); + _ = cells.Add(new Coordinate(x, y)); + } + + return cells; + } +} diff --git a/challenges/conway-life/src/csharp/benchmark/Program.cs b/challenges/conway-life/src/csharp/benchmark/Program.cs new file mode 100644 index 0000000..6e80a40 --- /dev/null +++ b/challenges/conway-life/src/csharp/benchmark/Program.cs @@ -0,0 +1,25 @@ +using BenchmarkDotNet.Running; + +namespace Squire.ConwayLifeChallenge.Benchmark; + +/// +/// Hosts the entry point for the benchmark suite. +/// +/// +public static class Program +{ + /// + /// Runs the benchmark suite. + /// + /// + public static void Main(string[] args) + { + if (args.Length > 0 && args[0] == "--evolution") + { + PopulationEvolution.Run(); + return; + } + + _ = BenchmarkRunner.Run(); + } +} diff --git a/challenges/conway-life/src/csharp/benchmark/README.md b/challenges/conway-life/src/csharp/benchmark/README.md new file mode 100644 index 0000000..193ed88 --- /dev/null +++ b/challenges/conway-life/src/csharp/benchmark/README.md @@ -0,0 +1,91 @@ +# Conway's Game of Life Benchmark + +### Purpose + +This project holds the benchmark suite for the Conway's Game of Life challenge. Each entry in the suite is a measurement we made to answer a specific question with data, and the findings are recorded here as design input for how the solution evolves. + +The benchmarks support our iteration on efficiency. We pick a question, design benchmarks that answer it, run them, and write down what the data shows. Findings here are evidence for design decisions, not decoration. Strategies used only by the benchmarks, such as `NoVisitedSetStrategy` and `DensityGatedStrategy`, live in this project and are never selected by `StrategyBase.CreateStrategy`. + +### How an analysis is structured + +Each analysis starts with the question it answers. The benchmarks measure timing and, with the memory diagnoser enabled, allocations and GC cycles, because allocations and GC cycles are performance as well. Findings are stated only where the data supports them, and design issues the data surfaced are recorded alongside the results. + +### Analyses + +#### Density and the per-generation visited set + +**Question.** The `ActiveRegionStrategy` uses a per-generation visited set so a dead cell that is a neighbor of more than one live cell is evaluated once per generation instead of once per live neighbor. Three questions came out of our iteration on that. + +1. How much does computing the density of a population cost, and is that cost worth paying to decide whether the visited set helps? +2. Does a density-gated approach, which decides per generation whether to use the visited set, beat always using it or never using it? +3. At what density does the visited set start to pay off? + +**What was measured.** The visited set makes a difference in dense populations, where deduplication saves work, and not in sparse ones. The density of a population, live cells divided by bounding box area, is the candidate signal. `DensityAnalysisBenchmarks` measures three groups. + +- **Density cost.** The cost of computing the density of 100, 1,000, and 10,000 cells, compared against the cost of simulating one generation of the same population. This shows the overhead of the check relative to the work it gates. +- **Gated approach.** The sparse, dense, mixed, and R-pentomino workloads, each simulated with the visited set always on, always off, and density-gated. This shows whether the gated approach is a good one-size-fits-all. +- **Crossover.** Populations at 15, 20, 25, 30, and 35 percent density, each simulated with and without the visited set. This narrows the density where the visited set starts to pay off. + +**The data.** + +*Density cost.* The density check is a single pass over the live cells, so it is O(n) in the population size. + +| Population | Density check | One generation | Density as a share of a generation | +|---|---|---|---| +| 100 cells | 64.75 ns | 29,277.67 ns | 0.22% | +| 1,000 cells | 606.02 ns | 1,047,859.22 ns | 0.058% | +| 10,000 cells | 6,973.29 ns | 8,805,142.08 ns | 0.079% | + +The density check scales roughly linearly at about 0.6 to 0.7 ns per cell. The generation cost scales super-linearly, so the density check is a smaller share of the work as the population grows. + +*Gated approach.* Each workload is simulated with the visited set always on, always off, and density-gated at a threshold of 0.25. + +| Workload | Always | Never | Gated | Gated vs the best of the two | +|---|---|---|---|---| +| Sparse (0.025%) | 999,270.61 ns | 748,564.00 ns | 760,415.16 ns | 1.6% over | +| Dense (82%) | 53,696.06 ns | 138,056.70 ns | 53,980.23 ns | 0.5% over | +| Mixed (10%) | 1,052,983.66 ns | 941,533.19 ns | 941,753.93 ns | 0.02% over | +| R-pentomino (30 gen) | 262,237.29 ns | 291,987.99 ns | 271,097.37 ns | 3.4% over | + +The gated approach picks the right side in every case. It is within 3.4% of the best of always-use or never-use in every workload. + +*Crossover.* Each population is simulated with and without the visited set. + +| Density | With visited set | Without | With vs without | +|---|---|---|---| +| 15% | 907,514.02 ns | 903,598.99 ns | 0.4% slower | +| 20% | 760,132.15 ns | 771,145.89 ns | 1.4% faster | +| 25% | 606,335.95 ns | 680,948.15 ns | 11% faster | +| 30% | 555,791.92 ns | 639,003.40 ns | 13% faster | +| 35% | 454,103.35 ns | 586,295.90 ns | 22.5% faster | + +The crossover is between 15% and 20% density. Below it the visited set does not help, and at 15% it is actually 0.4% slower. Above it the benefit grows with density, from 11% at 25% to 22.5% at 35%. + +**Findings.** + +- The density check is negligible. It is under 0.1% of a generation's cost in every case, so it is safe to pay it to decide whether the visited set helps. +- The gated approach is a good one-size-fits-all. It is within 3.4% of the best of always-use or never-use in every workload, and a threshold of 0.25 sits above the crossover, so it uses the visited set when it helps and skips it when it does not. +- The crossover is around 15 to 20 percent density. Below it the visited set does not help. Above it the benefit grows with density. + +**Design issue the data surfaced.** The gated approach allocates the visited set eagerly, even when it is not used. For the sparse workload it allocates 62,024 B while the never-use approach allocates 31,024 B. The extra 31,000 B is the visited set, and the same gap appears in the mixed workload. The strategy allocates the set once at the start of the simulation and clears it at the end of each generation, so when the density is below the threshold the set is allocated but never used. This is a cost that lazy allocation, only when first needed, would avoid. It does not change the timing results, because the allocation is a one-time cost amortized across the generations, but it does change the allocation numbers. + +### Running the benchmarks + +From the `benchmark` directory: + +```shell +dotnet run -c Release +``` + +To run a single case, filter by name: + +```shell +dotnet run -c Release -- --filter "DensityAnalysisBenchmarks.GatedSparse" +``` + +### Notes + +- The benchmark has its own solution, `ConwayLifeChallenge.Benchmark.slnx`, and is not part of the main `ConwayLifeChallenge.slnx`. +- The benchmarks run in Release mode with the default BenchmarkDotNet job. +- Run the benchmark from the `benchmark` directory so the `BenchmarkDotNet.Artifacts` folder it creates stays under the benchmark area. +- The random populations use a fixed seed so the results are reproducible. diff --git a/challenges/conway-life/src/csharp/benchmark/VisitedSetBenchmarks.cs b/challenges/conway-life/src/csharp/benchmark/VisitedSetBenchmarks.cs new file mode 100644 index 0000000..a08db36 --- /dev/null +++ b/challenges/conway-life/src/csharp/benchmark/VisitedSetBenchmarks.cs @@ -0,0 +1,149 @@ +using BenchmarkDotNet.Attributes; +using Squire.ConwayLifeChallenge.Infrastructure; +using Squire.ConwayLifeChallenge.Strategies; + +namespace Squire.ConwayLifeChallenge.Benchmark; + +/// +/// Compares the (with the +/// per-generation visited set) against (without it) across a range +/// of populations and generation counts. +/// +/// +/// +/// Both strategies produce identical results. The only difference is that the visited set avoids +/// re-processing a dead cell that is a neighbor of more than one live cell. The benchmark measures +/// the real-world cost of that deduplication. +/// +/// +[MemoryDiagnoser] +public class VisitedSetBenchmarks +{ + /// The blinker, a period-2 oscillator with three live cells. + private readonly HashSet Blinker = + [ + new(1, 0), new(2, 0), new(3, 0) + ]; + + /// The block, a still life with four live cells. + private readonly HashSet Block = + [ + new(0, 0), new(1, 0), new(0, 1), new(1, 1) + ]; + + /// The glider, a period-4 spaceship with five live cells. + private readonly HashSet Glider = + [ + new(1, 0), new(2, 1), new(0, 2), new(1, 2), new(2, 2) + ]; + + /// The R-pentomino, a methuselah with five live cells. + private readonly HashSet RPentomino = + [ + new(1, 0), new(2, 0), new(0, 1), new(1, 1), new(1, 2) + ]; + + /// A random population of 100 live cells. + private readonly HashSet HundredCells = CreateRandomCells(100); + + /// A random population of 1,000 live cells. + private readonly HashSet ThousandCells = CreateRandomCells(1_000); + + /// A random population of 10,000 live cells. + private readonly HashSet TenThousandCells = CreateRandomCells(10_000); + + /// Simulates the blinker for one generation with the visited set. + [Benchmark] + public HashSet WithVisitedSetBlinker() => new ActiveRegionStrategy().Simulate(Blinker, 1); + + /// Simulates the blinker for one generation without the visited set. + [Benchmark] + public HashSet WithoutVisitedSetBlinker() => new NoVisitedSetStrategy().Simulate(Blinker, 1); + + /// Simulates the block for one generation with the visited set. + [Benchmark] + public HashSet WithVisitedSetBlock() => new ActiveRegionStrategy().Simulate(Block, 1); + + /// Simulates the block for one generation without the visited set. + [Benchmark] + public HashSet WithoutVisitedSetBlock() => new NoVisitedSetStrategy().Simulate(Block, 1); + + /// Simulates the glider for one generation with the visited set. + [Benchmark] + public HashSet WithVisitedSetGlider() => new ActiveRegionStrategy().Simulate(Glider, 1); + + /// Simulates the glider for one generation without the visited set. + [Benchmark] + public HashSet WithoutVisitedSetGlider() => new NoVisitedSetStrategy().Simulate(Glider, 1); + + /// Simulates the R-pentomino for one generation with the visited set. + [Benchmark] + public HashSet WithVisitedSetRPentomino() => new ActiveRegionStrategy().Simulate(RPentomino, 1); + + /// Simulates the R-pentomino for one generation without the visited set. + [Benchmark] + public HashSet WithoutVisitedSetRPentomino() => new NoVisitedSetStrategy().Simulate(RPentomino, 1); + + /// Simulates the R-pentomino for ten generations with the visited set. + [Benchmark] + public HashSet WithVisitedSetRPentominoTenGenerations() => new ActiveRegionStrategy().Simulate(RPentomino, 10); + + /// Simulates the R-pentomino for ten generations without the visited set. + [Benchmark] + public HashSet WithoutVisitedSetRPentominoTenGenerations() => new NoVisitedSetStrategy().Simulate(RPentomino, 10); + + /// Simulates the R-pentomino for thirty generations with the visited set. + [Benchmark] + public HashSet WithVisitedSetRPentominoThirtyGenerations() => new ActiveRegionStrategy().Simulate(RPentomino, 30); + + /// Simulates the R-pentomino for thirty generations without the visited set. + [Benchmark] + public HashSet WithoutVisitedSetRPentominoThirtyGenerations() => new NoVisitedSetStrategy().Simulate(RPentomino, 30); + + /// Simulates 100 random cells for one generation with the visited set. + [Benchmark] + public HashSet WithVisitedSetHundredCells() => new ActiveRegionStrategy().Simulate(HundredCells, 1); + + /// Simulates 100 random cells for one generation without the visited set. + [Benchmark] + public HashSet WithoutVisitedSetHundredCells() => new NoVisitedSetStrategy().Simulate(HundredCells, 1); + + /// Simulates 1,000 random cells for one generation with the visited set. + [Benchmark] + public HashSet WithVisitedSetThousandCells() => new ActiveRegionStrategy().Simulate(ThousandCells, 1); + + /// Simulates 1,000 random cells for one generation without the visited set. + [Benchmark] + public HashSet WithoutVisitedSetThousandCells() => new NoVisitedSetStrategy().Simulate(ThousandCells, 1); + + /// Simulates 10,000 random cells for one generation with the visited set. + [Benchmark] + public HashSet WithVisitedSetTenThousandCells() => new ActiveRegionStrategy().Simulate(TenThousandCells, 1); + + /// Simulates 10,000 random cells for one generation without the visited set. + [Benchmark] + public HashSet WithoutVisitedSetTenThousandCells() => new NoVisitedSetStrategy().Simulate(TenThousandCells, 1); + + /// + /// Creates a deterministic random population of distinct live cells. + /// + /// + /// The number of distinct live cells to create. + /// + /// A set of distinct live cells. + /// + private static HashSet CreateRandomCells(int count) + { + var random = new Random(12345); + var cells = new HashSet(count); + + while (cells.Count < count) + { + var x = random.Next(-1000, 1000); + var y = random.Next(-1000, 1000); + _ = cells.Add(new Coordinate(x, y)); + } + + return cells; + } +} diff --git a/challenges/conway-life/src/csharp/src/EntryPoint.cs b/challenges/conway-life/src/csharp/src/EntryPoint.cs index 1eaa058..da6f575 100644 --- a/challenges/conway-life/src/csharp/src/EntryPoint.cs +++ b/challenges/conway-life/src/csharp/src/EntryPoint.cs @@ -26,7 +26,7 @@ public static class EntryPoint /// /// /// - /// ConwayLifeChallenge --file glider.lif --generations 10 --strategy Naive + /// ConwayLifeChallenge --file glider.lif --generations 10 --strategy ActiveRegion /// /// /// @@ -66,7 +66,7 @@ public static void Main(string[] args) { Description = "The strategy to use for simulating the generations.", Required = true, - DefaultValueFactory = _ => Strategy.Naive + DefaultValueFactory = _ => Strategy.ActiveRegion }; rootCommand.Options.Add(fileOption); diff --git a/challenges/conway-life/src/csharp/src/Infrastructure/Strategy.cs b/challenges/conway-life/src/csharp/src/Infrastructure/Strategy.cs index 682e0c6..2407dea 100644 --- a/challenges/conway-life/src/csharp/src/Infrastructure/Strategy.cs +++ b/challenges/conway-life/src/csharp/src/Infrastructure/Strategy.cs @@ -10,5 +10,5 @@ public enum Strategy /// An intuitive approach should be applied, staying close to the original inspiration for solving the challenge. /// /// - Naive + ActiveRegion } diff --git a/challenges/conway-life/src/csharp/src/Infrastructure/StrategyBase.cs b/challenges/conway-life/src/csharp/src/Infrastructure/StrategyBase.cs index 6b0a08d..6d87e7c 100644 --- a/challenges/conway-life/src/csharp/src/Infrastructure/StrategyBase.cs +++ b/challenges/conway-life/src/csharp/src/Infrastructure/StrategyBase.cs @@ -41,7 +41,7 @@ public abstract HashSet Simulate(HashSet cells, /// internal static StrategyBase CreateStrategy(Strategy strategy) => strategy switch { - Strategy.Naive => new NaiveStrategy(), + Strategy.ActiveRegion => new ActiveRegionStrategy(), _ => throw new ArgumentException($"Unknown strategy: `{ strategy }`.", nameof(strategy)) }; } diff --git a/challenges/conway-life/src/csharp/src/Properties/AssemblyInfo.cs b/challenges/conway-life/src/csharp/src/Properties/AssemblyInfo.cs index ae86b00..cb19c7b 100644 --- a/challenges/conway-life/src/csharp/src/Properties/AssemblyInfo.cs +++ b/challenges/conway-life/src/csharp/src/Properties/AssemblyInfo.cs @@ -1,3 +1,4 @@ using System.Runtime.CompilerServices; [assembly: InternalsVisibleTo("ConwayLifeChallenge.Tests")] +[assembly: InternalsVisibleTo("ConwayLifeChallenge.Benchmark")] diff --git a/challenges/conway-life/src/csharp/src/Strategies/NaiveStrategy.cs b/challenges/conway-life/src/csharp/src/Strategies/ActiveRegionStrategy.cs similarity index 98% rename from challenges/conway-life/src/csharp/src/Strategies/NaiveStrategy.cs rename to challenges/conway-life/src/csharp/src/Strategies/ActiveRegionStrategy.cs index 06e0df0..4b92b62 100644 --- a/challenges/conway-life/src/csharp/src/Strategies/NaiveStrategy.cs +++ b/challenges/conway-life/src/csharp/src/Strategies/ActiveRegionStrategy.cs @@ -14,10 +14,10 @@ namespace Squire.ConwayLifeChallenge.Strategies; /// /// /// -internal class NaiveStrategy : StrategyBase +internal class ActiveRegionStrategy : StrategyBase { /// - public override Strategy Strategy => Strategy.Naive; + public override Strategy Strategy => Strategy.ActiveRegion; /// public override HashSet Simulate(HashSet cells, @@ -123,8 +123,6 @@ private static void SimulateGeneration(HashSet startingAliveCells, /// if determined to be alive. Otherwise, they will be added to . /// /// - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static void ProcessCell(Coordinate cell, HashSet startingAliveCells, HashSet endingAliveCells, diff --git a/challenges/conway-life/src/csharp/tests/EntryPointTests.cs b/challenges/conway-life/src/csharp/tests/EntryPointTests.cs index 8e6fb84..9cf2316 100644 --- a/challenges/conway-life/src/csharp/tests/EntryPointTests.cs +++ b/challenges/conway-life/src/csharp/tests/EntryPointTests.cs @@ -286,7 +286,7 @@ public void ExecuteRejectsOutOfRangeGenerationsBeforeReadingInput(int generation Console.SetIn(input); Console.SetOut(output); - Assert.That(() => EntryPoint.Execute(null, generations, Strategy.Naive), + Assert.That(() => EntryPoint.Execute(null, generations, Strategy.ActiveRegion), Throws.TypeOf().With.Property("ParamName").EqualTo("generations"), "Direct execution should reject generation counts outside the supported range."); Assert.That(output.ToString(), Is.Empty, "Validation should precede prompting."); @@ -342,7 +342,7 @@ public void MainAcceptsSupportedGenerationsAndReadsInput(string? generations) /// /// [Test] - public void MainAcceptsNaiveStrategyAndReadsInput() + public void MainAcceptsActiveRegionStrategyAndReadsInput() { var originalIn = Console.In; var originalOut = Console.Out; @@ -358,9 +358,9 @@ public void MainAcceptsNaiveStrategyAndReadsInput() Console.SetOut(output); Console.SetError(error); - EntryPoint.Main([ "--strategy", "Naive" ]); + EntryPoint.Main([ "--strategy", "ActiveRegion" ]); - Assert.That(output.ToString(), Does.Contain("Enter live cell coordinates"), "The naive strategy option should reach input processing."); + Assert.That(output.ToString(), Does.Contain("Enter live cell coordinates"), "The active region strategy option should reach input processing."); Assert.That(error.ToString(), Does.Contain("Invalid coordinate: invalid"), "Malformed input should fail after accepting the strategy."); } finally