From 0bdb64937a306623d8cef5966ff252556691b1c5 Mon Sep 17 00:00:00 2001 From: aaadrian Date: Sun, 22 Mar 2026 15:55:32 -0700 Subject: [PATCH] test: replace UniformTree with declarative MiniMaxTree test infrastructure - TreeNode: fluent DSL with Leaf(value), Goal(), Inner(children...) - TreePosition: self-loop on non-goal leaves so solver evaluates them as horizon nodes (uses position.Value) instead of terminal (always 0) - TreeRules: dummy legal step for non-goal leaves, empty for goals - 9 solver tests covering: basic minimax, symmetric values, equal branches, uneven branching, 4-level deep search, goal seeking/avoidance, negative values, forced single move - Removed UniformTreePosition and UniformTreeRules --- Alligator.Test/MiniMaxTree/TreeNode.cs | 29 +++ Alligator.Test/MiniMaxTree/TreePosition.cs | 36 +++ Alligator.Test/MiniMaxTree/TreeRules.cs | 38 ++++ Alligator.Test/SolverTests.cs | 246 ++++++++++++++++++--- Alligator.Test/UniformTreePosition.cs | 30 --- Alligator.Test/UniformTreeRules.cs | 44 ---- 6 files changed, 314 insertions(+), 109 deletions(-) create mode 100644 Alligator.Test/MiniMaxTree/TreeNode.cs create mode 100644 Alligator.Test/MiniMaxTree/TreePosition.cs create mode 100644 Alligator.Test/MiniMaxTree/TreeRules.cs delete mode 100644 Alligator.Test/UniformTreePosition.cs delete mode 100644 Alligator.Test/UniformTreeRules.cs diff --git a/Alligator.Test/MiniMaxTree/TreeNode.cs b/Alligator.Test/MiniMaxTree/TreeNode.cs new file mode 100644 index 0000000..90785cd --- /dev/null +++ b/Alligator.Test/MiniMaxTree/TreeNode.cs @@ -0,0 +1,29 @@ +namespace Alligator.Test.MiniMaxTree +{ + internal class TreeNode + { + public sbyte? LeafValue { get; } + public bool IsGoal { get; } + public List Children { get; } = []; + + private TreeNode(sbyte? leafValue, bool isGoal, IEnumerable children) + { + LeafValue = leafValue; + IsGoal = isGoal; + Children.AddRange(children); + } + + public bool IsLeaf => Children.Count == 0; + + public static TreeNode Leaf(sbyte value) => new(value, isGoal: false, []); + + public static TreeNode Goal() => new(leafValue: null, isGoal: true, []); + + public static TreeNode Inner(params TreeNode[] children) + { + if (children.Length == 0) + throw new ArgumentException("Inner node must have at least one child."); + return new(leafValue: null, isGoal: false, children); + } + } +} diff --git a/Alligator.Test/MiniMaxTree/TreePosition.cs b/Alligator.Test/MiniMaxTree/TreePosition.cs new file mode 100644 index 0000000..0c137b2 --- /dev/null +++ b/Alligator.Test/MiniMaxTree/TreePosition.cs @@ -0,0 +1,36 @@ +using Alligator.Solver; + +namespace Alligator.Test.MiniMaxTree +{ + internal class TreePosition : IPosition + { + private readonly Stack<(TreeNode Node, int Step, ulong PrevIdentifier)> path = new(); + private TreeNode current; + + public TreePosition(TreeNode root) + { + current = root ?? throw new ArgumentNullException(nameof(root)); + } + + public TreeNode Current => current; + + public ulong Identifier { get; private set; } + + public sbyte Value => current.LeafValue ?? 0; + + public void Take(int step) + { + path.Push((current, step, Identifier)); + if (!current.IsLeaf) + current = current.Children[step]; + Identifier = unchecked(Identifier * 7919 + (ulong)step + 1); + } + + public void TakeBack() + { + var (parent, _, prevId) = path.Pop(); + current = parent; + Identifier = prevId; + } + } +} diff --git a/Alligator.Test/MiniMaxTree/TreeRules.cs b/Alligator.Test/MiniMaxTree/TreeRules.cs new file mode 100644 index 0000000..84bb322 --- /dev/null +++ b/Alligator.Test/MiniMaxTree/TreeRules.cs @@ -0,0 +1,38 @@ +using Alligator.Solver; + +namespace Alligator.Test.MiniMaxTree +{ + internal class TreeRules : IRules + { + private readonly TreeNode root; + + public TreeRules(TreeNode root) + { + this.root = root ?? throw new ArgumentNullException(nameof(root)); + } + + public TreePosition InitialPosition() + { + return new TreePosition(root); + } + + public IEnumerable LegalStepsAt(TreePosition position) + { + if (position.Current.IsLeaf) + { + if (!position.Current.IsGoal) + yield return 0; + yield break; + } + for (int i = 0; i < position.Current.Children.Count; i++) + { + yield return i; + } + } + + public bool IsGoal(TreePosition position) + { + return position.Current.IsGoal; + } + } +} diff --git a/Alligator.Test/SolverTests.cs b/Alligator.Test/SolverTests.cs index b4648f4..6d7a35c 100644 --- a/Alligator.Test/SolverTests.cs +++ b/Alligator.Test/SolverTests.cs @@ -1,48 +1,224 @@ using Alligator.Solver.Algorithms; +using Alligator.Test.MiniMaxTree; +using static Alligator.Test.MiniMaxTree.TreeNode; namespace Alligator.Test { [TestClass] public class SolverTests { - [TestMethod] - public void FirstSolverTest() - { - // Arrange - var valuesById = new Dictionary - { - { 0, 0 }, - { 1, 0 }, - { 2, 0 }, - { 3, 3 }, - { 4, 1 }, - { 5, 2 }, - { 6, 0 }, - { 7, 0 }, - { 8, 0 }, - { 9, 0 }, - { 10, 2 }, - { 11, 1 }, - { 12, 3 }, - { 13, 0 }, - { 14, 3 }, - { 15, 0 } - }; - uint degree = 2; - int depth = 2; - - var rules = new UniformTreeRules(valuesById, degree); - var cacheTables = new CacheTables(); + /// + /// Runs the full solver (iterative deepening + MTD(f)) on the given tree + /// and returns the index of the root's chosen child. + /// + private static int Solve(TreeNode root, int maxDepth = 7) + { + var rules = new TreeRules(root); + var cacheTables = new CacheTables(); var heuristicTables = new HeuristicTables(); - var searchManager = new SearchManager(depth - 1); - var alphabeta = new AlphaBetaPruning(rules, cacheTables, heuristicTables, searchManager); - var solver = new AlphaBetaSolver(alphabeta, rules, searchManager, (logMsg) => { }); + var searchManager = new SearchManager(maxDepth - 1); + var alphaBeta = new AlphaBetaPruning( + rules, cacheTables, heuristicTables, searchManager); + var solver = new AlphaBetaSolver( + alphaBeta, rules, searchManager, _ => { }); + + return solver.OptimizeNextStep([]); + } + + // + // root (max) + // / \ + // A(min) B(min) + // / \ / \ + // [3] [-1] [2] [5] + // + // A = min(3, -1) = -1 + // B = min(2, 5) = 2 + // root = max(-1, 2) = 2 → step 1 (B) + // + [TestMethod] + public void Simple_two_level_tree_picks_optimal_branch() + { + var tree = Inner( + Inner(Leaf(3), Leaf(-1)), + Inner(Leaf(2), Leaf(5))); + + Assert.AreEqual(1, Solve(tree)); + } + + // + // root (max) + // / \ + // A(min) B(min) + // / \ / \ + // [5] [3] [1] [3] + // + // A = min(5, 3) = 3 + // B = min(1, 3) = 1 + // root = max(3, 1) = 3 → step 0 (A) + // + [TestMethod] + public void Symmetric_minimax_values_picks_left() + { + var tree = Inner( + Inner(Leaf(5), Leaf(3)), + Inner(Leaf(1), Leaf(3))); + + Assert.AreEqual(0, Solve(tree)); + } + + // + // root (max) + // / \ + // A(min) B(min) + // / \ / \ + // [4] [4] [4] [4] + // + // Both branches equal → either is acceptable. + // + [TestMethod] + public void Equal_branches_does_not_crash() + { + var tree = Inner( + Inner(Leaf(4), Leaf(4)), + Inner(Leaf(4), Leaf(4))); + + int step = Solve(tree); + Assert.IsTrue(step == 0 || step == 1); + } + + // + // root (max) + // / | \ + // A B C + // / \ / \ /|\ + // [1] [9] [5][3] [2][7][4] + // + // A = min(1, 9) = 1 + // B = min(5, 3) = 3 + // C = min(2, 7, 4) = 2 + // root = max(1, 3, 2) = 3 → step 1 (B) + // + [TestMethod] + public void Uneven_branching_factor() + { + var tree = Inner( + Inner(Leaf(1), Leaf(9)), + Inner(Leaf(5), Leaf(3)), + Inner(Leaf(2), Leaf(7), Leaf(4))); + + Assert.AreEqual(1, Solve(tree)); + } + + // + // root (max) + // / \ + // A (min) B (min) + // / \ / \ + // A1 A2 B1 B2 + // / \ / \ / \ / \ + // [1] [8][5][2] [7][3] [6][4] + // + // A1 = min(1,8)=1, A2 = min(5,2)=2 → A = max(1,2) = 2 + // B1 = min(7,3)=3, B2 = min(6,4)=4 → B = max(3,4) = 4 + // root = max(2, 4) = 4 → step 1 (B) + // + // Depth-2 iteration would see different values, depth-4 corrects. + // + [TestMethod] + public void Four_level_tree_requires_deep_search() + { + var tree = Inner( + Inner( + Inner(Leaf(1), Leaf(8)), + Inner(Leaf(5), Leaf(2))), + Inner( + Inner(Leaf(7), Leaf(3)), + Inner(Leaf(6), Leaf(4)))); + + Assert.AreEqual(1, Solve(tree)); + } - // Act - var bestStep = solver.OptimizeNextStep(new List()); + // + // root (max) + // / \ + // A(min) B(min) + // / \ / \ + // [3] [1] [GOAL] [2] + // + // A = min(3, 1) = 1 + // B: step 0 is a goal (opponent won) → value = -(sbyte.MaxValue + depth) + // B = min(very_negative, 2) = very_negative + // root = max(1, very_negative) = 1 → step 0 (A avoids defeat) + // + [TestMethod] + public void Solver_avoids_opponents_goal() + { + var tree = Inner( + Inner(Leaf(3), Leaf(1)), + Inner(Goal(), Leaf(2))); + + Assert.AreEqual(0, Solve(tree)); + } + + // + // root (max) + // / \ + // [GOAL] A(min) + // / \ + // [1] [-5] + // + // step 0 leads to goal (root won) → very high value + // A = min(1, -5) = -5 + // root = max(very_high, -5) → step 0 + // + [TestMethod] + public void Solver_finds_immediate_goal() + { + var tree = Inner( + Goal(), + Inner(Leaf(1), Leaf(-5))); + + Assert.AreEqual(0, Solve(tree)); + } + + // + // root (max) + // / \ + // A(min) B(min) + // / \ / \ + // [-2] [10] [7] [-3] + // + // A = min(-2, 10) = -2 + // B = min(7, -3) = -3 + // root = max(-2, -3) = -2 → step 0 (A), best of two bad options + // + [TestMethod] + public void All_negative_minimax_picks_least_bad() + { + var tree = Inner( + Inner(Leaf(-2), Leaf(10)), + Inner(Leaf(7), Leaf(-3))); + + Assert.AreEqual(0, Solve(tree)); + } + + // + // root (max) + // | + // A (min) + // / \ + // [3] [7] + // + // Only one move from root → must pick step 0. + // + [TestMethod] + public void Single_child_forced_move() + { + var tree = Inner( + Inner(Leaf(3), Leaf(7))); - // Assert - Assert.AreEqual(0, bestStep); + Assert.AreEqual(0, Solve(tree)); } } } \ No newline at end of file diff --git a/Alligator.Test/UniformTreePosition.cs b/Alligator.Test/UniformTreePosition.cs deleted file mode 100644 index e1792a0..0000000 --- a/Alligator.Test/UniformTreePosition.cs +++ /dev/null @@ -1,30 +0,0 @@ -using Alligator.Solver; - -namespace Alligator.Test -{ - internal class UniformTreePosition : IPosition - { - private readonly IDictionary valuesById; - private readonly uint degree; - - public ulong Identifier { get; private set; } - public sbyte Value => valuesById[Identifier]; - - public UniformTreePosition(IDictionary valuesById, uint degree) - { - this.valuesById = valuesById ?? throw new ArgumentNullException(nameof(valuesById)); - this.degree = degree; - Identifier = 0L; - } - - public void Take(int step) - { - Identifier = degree * Identifier + (ulong)step + 1; - } - - public void TakeBack() - { - Identifier = (Identifier - 1) / degree; - } - } -} \ No newline at end of file diff --git a/Alligator.Test/UniformTreeRules.cs b/Alligator.Test/UniformTreeRules.cs deleted file mode 100644 index c617ac8..0000000 --- a/Alligator.Test/UniformTreeRules.cs +++ /dev/null @@ -1,44 +0,0 @@ -using Alligator.Solver; - -namespace Alligator.Test -{ - internal class UniformTreeRules : IRules - { - private readonly IDictionary valuesById; - private readonly uint degree; - - public UniformTreeRules(IDictionary valuesById, uint degree) - { - this.valuesById = valuesById ?? throw new ArgumentNullException(nameof(valuesById)); - this.degree = degree; - } - - public UniformTreePosition InitialPosition() - { - return new UniformTreePosition(valuesById, degree); - } - - public bool IsGoal(UniformTreePosition position) - { - return position.Value == sbyte.MinValue || position.Value == sbyte.MaxValue; - } - - public IEnumerable LegalStepsAt(UniformTreePosition position) - { - for (int i = 0; i < degree; i++) - { - position.Take(i); - if (valuesById.ContainsKey(position.Identifier)) - { - position.TakeBack(); - yield return i; - - } - else - { - position.TakeBack(); - } - } - } - } -} \ No newline at end of file