From 6a5ba8d156af5c26d2ff2523233d5fa1af7290e3 Mon Sep 17 00:00:00 2001 From: FlysonBot <116744100+FlysonBot@users.noreply.github.com> Date: Sun, 1 Mar 2026 21:54:03 -0800 Subject: [PATCH 01/23] feat: add BitSet version of AllValidCode and CanonicalCode along with ConvertCode utils for index and code conversion --- CLAUDE.md | 4 +- .../org/mastermind/codes/AllValidCode.java | 21 +++++++ .../org/mastermind/codes/CanonicalCode.java | 33 +++++++++++ .../org/mastermind/codes/ConvertCode.java | 57 +++++++++++++++++++ 4 files changed, 113 insertions(+), 2 deletions(-) create mode 100644 src/main/java/org/mastermind/codes/ConvertCode.java diff --git a/CLAUDE.md b/CLAUDE.md index 2d74a7db..17e4c0ba 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -22,8 +22,8 @@ Status: Rewriting codebase; currently focused on Java algorithm only. ### Next Move / Current Move -- Run a 9x9 demo and profile the code to determine where the bottleneck is. -- Continue micro-optimizing code to increase efficiency +- IMPORTANT: Currently trying to do large scale refactor class-by-class step-by-step to deprecate int[] array + for passing combinations around, and instead uses BitSet ### Preference diff --git a/src/main/java/org/mastermind/codes/AllValidCode.java b/src/main/java/org/mastermind/codes/AllValidCode.java index 37b11d52..243fbfd4 100644 --- a/src/main/java/org/mastermind/codes/AllValidCode.java +++ b/src/main/java/org/mastermind/codes/AllValidCode.java @@ -1,5 +1,7 @@ package org.mastermind.codes; +import java.util.BitSet; + /** * A game of Mastermind has 2 parameters, c (number of colors) * and d (number of digits). A code is a valid Mastermind code @@ -81,4 +83,23 @@ public static int[] generateAllCodes(int c, int d) { return codes; } + + /** + * Generate a BitSet representing the universe of all valid Mastermind codes. + * The BitSet has size c^d with every bit set, where bit index i corresponds + * to codes[i] from generateAllCodes(). All bits set means the full solution + * space is active (no codes have been eliminated yet). + * + * @param c number of colors (<= 9) + * @param d number of digits (<= 9) + * @return BitSet of size c^d with all bits set + */ + public static BitSet generateAllCodesBitSet(int c, int d) { + int total = (int) Math.pow(c, d); + + BitSet bitSet = new BitSet(total); + bitSet.set(0, total); + + return bitSet; + } } \ No newline at end of file diff --git a/src/main/java/org/mastermind/codes/CanonicalCode.java b/src/main/java/org/mastermind/codes/CanonicalCode.java index 93006326..08de9359 100644 --- a/src/main/java/org/mastermind/codes/CanonicalCode.java +++ b/src/main/java/org/mastermind/codes/CanonicalCode.java @@ -1,5 +1,7 @@ package org.mastermind.codes; +import java.util.BitSet; + /** * Canonical forms refer to a specific subset of all Mastermind code * that starts with 1, digit ordered from small to large starting @@ -68,6 +70,37 @@ public static int[] enumerateCanonicalForms(int c, int d) { return results; } + /** + * Enumerate all Canonical forms as a BitSet, where bit i is set if + * codes[i] (from AllValidCode.generateAllCodes) is a canonical form. + * + * @param c number of colors (<= 9) + * @param d number of digits (<= 9) + * @return BitSet with bits set only at canonical form indices + */ + public static BitSet enumerateCanonicalFormsBitSet(int c, int d) { + int total = (int) Math.pow(c, d); + BitSet bitSet = new BitSet(total); + backtrackBitSet(bitSet, c, d, 0, 0, 0); + return bitSet; + } + + private static void backtrackBitSet(BitSet bitSet, int c, int d, int currentNum, int pos, int maxColorUsed) { + if (pos == d) { + bitSet.set(ConvertCode.toIndex(c, d, currentNum)); + return; + } + + for (int color = 1; color <= maxColorUsed; color++) { + backtrackBitSet(bitSet, c, d, (currentNum * 10) + color, pos + 1, maxColorUsed); + } + + if (maxColorUsed < c) { + int nextColor = maxColorUsed + 1; + backtrackBitSet(bitSet, c, d, (currentNum * 10) + nextColor, pos + 1, nextColor); + } + } + private static void backtrack(int[] results, int[] index, int currentNum, int pos, int maxColorUsed, int c, int d) { // Base case: Code is complete if (pos == d) { diff --git a/src/main/java/org/mastermind/codes/ConvertCode.java b/src/main/java/org/mastermind/codes/ConvertCode.java new file mode 100644 index 00000000..136956af --- /dev/null +++ b/src/main/java/org/mastermind/codes/ConvertCode.java @@ -0,0 +1,57 @@ +package org.mastermind.codes; + +/** + * Converts between Mastermind codes (as ints, e.g. 1234) and their index + * in the array produced by AllValidCode.generateAllCodes(c, d). + *

+ * Encoding: index is a base-c number where each "digit" runs 0..c-1. + * Position 0 is the rightmost (least significant) digit of the code. + * Code digit value = (index / c^pos) % c + 1. + *

+ * Examples (c=6, d=4): + * index 0 → code 1111 + * index 1 → code 1112 + * index 1295 → code 6666 + */ +public class ConvertCode { + + /** + * Convert a code int to its index in the AllValidCode array. + * + * @param c number of colors + * @param d number of digits + * @param code the code as an int (e.g. 1234) + * @return the 0-based index of this code + */ + public static int toIndex(int c, int d, int code) { + int index = 0; + int place = 1; // c^pos + for (int pos = 0; pos < d; pos++) { + int digit = (code % 10) - 1; // extract rightmost decimal digit, map 1..c → 0..c-1 + index += digit * place; + code /= 10; + place *= c; + } + return index; + } + + /** + * Convert a 0-based index to the corresponding code int. + * + * @param c number of colors + * @param d number of digits + * @param index the 0-based index + * @return the code as an int (e.g. 1234) + */ + public static int toCode(int c, int d, int index) { + int code = 0; + int pow10 = 1; // 10^pos + for (int pos = 0; pos < d; pos++) { + int digit = (index % c) + 1; // map 0..c-1 → 1..c + code += digit * pow10; + index /= c; + pow10 *= 10; + } + return code; + } +} From 594f3d69afa2a33fdd4f5421c771aa8220f5668f Mon Sep 17 00:00:00 2001 From: FlysonBot <116744100+FlysonBot@users.noreply.github.com> Date: Sun, 1 Mar 2026 21:56:46 -0800 Subject: [PATCH 02/23] refactor: pass SolutionSpace directly from MastermindSession to GuessStrategy --- .../java/org/mastermind/GuessStrategy.java | 32 ++++++++++--------- .../org/mastermind/MastermindSession.java | 8 +++-- 2 files changed, 22 insertions(+), 18 deletions(-) diff --git a/src/main/java/org/mastermind/GuessStrategy.java b/src/main/java/org/mastermind/GuessStrategy.java index 958def17..807b497e 100644 --- a/src/main/java/org/mastermind/GuessStrategy.java +++ b/src/main/java/org/mastermind/GuessStrategy.java @@ -3,6 +3,7 @@ import org.mastermind.codes.CodeCache; import org.mastermind.codes.SampledCode; import org.mastermind.solver.Feedback; +import org.mastermind.solver.SolutionSpace; /** * Selects which arrays to pass as guesses and secrets to BestGuess for each turn. @@ -26,15 +27,16 @@ public class GuessStrategy { /** * Select the guesses and secrets arrays for the current turn. * - * @param c number of colors - * @param d number of digits - * @param turn 0-indexed turn number (0 = first guess) - * @param secrets current remaining valid secrets (from SolutionSpace) + * @param c number of colors + * @param d number of digits + * @param turn 0-indexed turn number (0 = first guess) + * @param solutionSpace current solution space * @return int[][] where [0]=guesses, [1]=secrets */ - public static int[][] select(int c, int d, int turn, int[] secrets) { - if (turn == 0) return firstTurn(c, d, secrets); - return laterTurns(c, d, secrets); + public static int[][] select(int c, int d, int turn, SolutionSpace solutionSpace) { + int secretsSize = solutionSpace.getSize(); + if (turn == 0) return firstTurn(c, d, secretsSize, solutionSpace); + return laterTurns(c, d, secretsSize, solutionSpace); } /** @@ -42,10 +44,10 @@ public static int[][] select(int c, int d, int turn, int[] secrets) { * Fall back to a Monte Carlo sample for secrets if the product exceeds the threshold. * Tries progressively looser tolerances: 0.001, 0.005, then 0.01. */ - private static int[][] firstTurn(int c, int d, int[] secrets) { + private static int[][] firstTurn(int c, int d, int secretsSize, SolutionSpace solutionSpace) { int[] canonical = CodeCache.getCanonical(c, d); - if (fits(canonical.length, secrets.length)) return pair(canonical, secrets); + if (fits(canonical.length, secretsSize)) return pair(canonical, solutionSpace.getSecrets()); for (double tolerance : new double[] { 0.001, 0.005 }) { if (fits(canonical.length, secretSampleSize(d, tolerance))) { @@ -60,21 +62,21 @@ private static int[][] firstTurn(int c, int d, int[] secrets) { * Later turns: cascade through several levels of size reduction until the * search space fits within the threshold. */ - private static int[][] laterTurns(int c, int d, int[] secrets) { + private static int[][] laterTurns(int c, int d, int secretsSize, SolutionSpace solutionSpace) { int[] allValid = CodeCache.getAllValid(c, d); - if (fits(allValid.length, secrets.length)) return pair(allValid, secrets); - if (fits(secrets.length, secrets.length)) return pair(secrets, secrets); + if (fits(allValid.length, secretsSize)) return pair(allValid, solutionSpace.getSecrets()); + if (fits(secretsSize, secretsSize)) return pair(solutionSpace.getSecrets(), solutionSpace.getSecrets()); for (double tolerance : new double[] { 0.001, 0.005, 0.01 }) { - if (fits(secrets.length, secretSampleSize(d, tolerance))) { - return pair(secrets, secretSample(c, d, tolerance)); + if (fits(secretsSize, secretSampleSize(d, tolerance))) { + return pair(solutionSpace.getSecrets(), secretSample(c, d, tolerance)); } } int[] sSample = secretSample(c, d, 0.01); for (double percentile : new double[] { 0.001, 0.005, 0.01, 0.05 }) { - if (fits(secrets.length, guessSampleSize(percentile))) { + if (fits(secretsSize, guessSampleSize(percentile))) { return pair(guessSample(c, d, percentile), sSample); } } diff --git a/src/main/java/org/mastermind/MastermindSession.java b/src/main/java/org/mastermind/MastermindSession.java index e432128a..0bd0a5f6 100644 --- a/src/main/java/org/mastermind/MastermindSession.java +++ b/src/main/java/org/mastermind/MastermindSession.java @@ -68,10 +68,12 @@ public int suggestGuess() { public long[] suggestGuessWithDetails() { if (solved) throw new IllegalStateException("Game is already solved."); - int[] secrets = solutionSpace.getSecrets(); - if (secrets.length == 1) return new long[] { secrets[0], 1L, 1L }; + if (solutionSpace.getSize() == 1) { + int[] only = solutionSpace.getSecrets(); + return new long[] { only[0], 1L, 1L }; + } - int[][] searchSpace = GuessStrategy.select(c, d, history.size(), secrets); // {guesses, secrets} + int[][] searchSpace = GuessStrategy.select(c, d, history.size(), solutionSpace); // {guesses, secrets} long[] result = BestGuess.findBestGuess(searchSpace[0], searchSpace[1], c, d); return new long[] { result[0], result[1], searchSpace[1].length }; // {guess, rank, secrets length} } From 3c47b01f30641c88a1c4fd78c5bd02da5d56f7c0 Mon Sep 17 00:00:00 2001 From: FlysonBot <116744100+FlysonBot@users.noreply.github.com> Date: Mon, 2 Mar 2026 15:35:23 -0800 Subject: [PATCH 03/23] refactor: avoid large int[] allocation by avoiding generating allValid - Use ConvertCode.toCode(i) to avoid allValid[i] query - Use Math.pow(c, d) instead of allValid.length - Reduce allValid usage unless necessary - Use straight call to CanonicalCode without going through CodeCache - Removed cache for CanonicalCode in CodeCache --- .../java/org/mastermind/GuessStrategy.java | 7 ++-- .../java/org/mastermind/codes/CodeCache.java | 7 +--- .../org/mastermind/solver/SolutionSpace.java | 38 +++++++++---------- .../org/mastermind/solver/FeedbackTest.java | 2 +- 4 files changed, 25 insertions(+), 29 deletions(-) diff --git a/src/main/java/org/mastermind/GuessStrategy.java b/src/main/java/org/mastermind/GuessStrategy.java index 807b497e..451919b5 100644 --- a/src/main/java/org/mastermind/GuessStrategy.java +++ b/src/main/java/org/mastermind/GuessStrategy.java @@ -1,5 +1,6 @@ package org.mastermind; +import org.mastermind.codes.CanonicalCode; import org.mastermind.codes.CodeCache; import org.mastermind.codes.SampledCode; import org.mastermind.solver.Feedback; @@ -45,7 +46,7 @@ public static int[][] select(int c, int d, int turn, SolutionSpace solutionSpace * Tries progressively looser tolerances: 0.001, 0.005, then 0.01. */ private static int[][] firstTurn(int c, int d, int secretsSize, SolutionSpace solutionSpace) { - int[] canonical = CodeCache.getCanonical(c, d); + int[] canonical = CanonicalCode.enumerateCanonicalForms(c, d); if (fits(canonical.length, secretsSize)) return pair(canonical, solutionSpace.getSecrets()); @@ -63,9 +64,9 @@ private static int[][] firstTurn(int c, int d, int secretsSize, SolutionSpace so * search space fits within the threshold. */ private static int[][] laterTurns(int c, int d, int secretsSize, SolutionSpace solutionSpace) { - int[] allValid = CodeCache.getAllValid(c, d); - if (fits(allValid.length, secretsSize)) return pair(allValid, solutionSpace.getSecrets()); + if (fits((int) Math.pow(c, d), secretsSize)) + return pair(CodeCache.getAllValid(c, d), solutionSpace.getSecrets()); if (fits(secretsSize, secretsSize)) return pair(solutionSpace.getSecrets(), solutionSpace.getSecrets()); for (double tolerance : new double[] { 0.001, 0.005, 0.01 }) { diff --git a/src/main/java/org/mastermind/codes/CodeCache.java b/src/main/java/org/mastermind/codes/CodeCache.java index 0d1f2de6..619afaa7 100644 --- a/src/main/java/org/mastermind/codes/CodeCache.java +++ b/src/main/java/org/mastermind/codes/CodeCache.java @@ -7,16 +7,11 @@ */ public class CodeCache { - private static final int[][][] allValidCache = new int[10][10][]; - private static final int[][][] canonicalCache = new int[10][10][]; + private static final int[][][] allValidCache = new int[10][10][]; public static int[] getAllValid(int c, int d) { if (allValidCache[c][d] == null) allValidCache[c][d] = AllValidCode.generateAllCodes(c, d); return allValidCache[c][d]; } - public static int[] getCanonical(int c, int d) { - if (canonicalCache[c][d] == null) canonicalCache[c][d] = CanonicalCode.enumerateCanonicalForms(c, d); - return canonicalCache[c][d]; - } } diff --git a/src/main/java/org/mastermind/solver/SolutionSpace.java b/src/main/java/org/mastermind/solver/SolutionSpace.java index 6db4641d..a2dc6e40 100644 --- a/src/main/java/org/mastermind/solver/SolutionSpace.java +++ b/src/main/java/org/mastermind/solver/SolutionSpace.java @@ -1,6 +1,6 @@ package org.mastermind.solver; -import org.mastermind.codes.CodeCache; +import org.mastermind.codes.ConvertCode; import java.util.BitSet; import java.util.concurrent.ForkJoinPool; @@ -12,8 +12,8 @@ * secret is still a valid solution to the puzzle, allowing * progress tracking and calculating the best next move. * - *

Internally, a BitSet indexed over {@code CodeCache.getAllValid(c, d)} - * is used so that {@link #filterSolution} clears bits in-place with + *

Internally, a BitSet of size c^d is used so that + * {@link #filterSolution} clears bits in-place with * zero allocation. BitSet's nextSetBit iteration also skips eliminated * secrets in bulk (64 per word), making repeated filtering fast even * when the initial space is large (e.g., 9×9 = 387 M codes). @@ -25,23 +25,23 @@ public class SolutionSpace { private final int c; private final int d; - private final int[] allValid; // CodeCache.getAllValid(c, d) — index → code value - private final BitSet remaining; // bit i set ⟺ allValid[i] is still a valid secret - private int size; // cached cardinality of remaining + private final int totalCodes; // c^d + private final BitSet remaining; // bit i set ⟺ ConvertCode.toCode(c, d, i) is still a valid secret + private int size; // cached cardinality of remaining public SolutionSpace(int c, int d) { this.c = c; this.d = d; - this.allValid = CodeCache.getAllValid(c, d); - this.remaining = new BitSet(allValid.length); - remaining.set(0, allValid.length); - this.size = allValid.length; + this.totalCodes = (int) Math.pow(c, d); + this.remaining = new BitSet(totalCodes); + remaining.set(0, totalCodes); + this.size = totalCodes; } /** Reset the solution space to all valid codes */ public void reset() { - remaining.set(0, allValid.length); - size = allValid.length; + remaining.set(0, totalCodes); + size = totalCodes; } /** @@ -60,13 +60,13 @@ public void reset() { */ public void filterSolution(int guess, int obtainedFeedback) { if (size < PARALLEL_THRESHOLD) { - size -= filterRange(guess, obtainedFeedback, 0, allValid.length); + size -= filterRange(guess, obtainedFeedback, 0, totalCodes); return; } // Split into word-aligned (multiple-of-64) chunks for safe concurrent access. int parallelism = POOL.getParallelism(); - int words = (allValid.length + 63) >>> 6; // number of 64-bit words + int words = (totalCodes + 63) >>> 6; // number of 64-bit words int wordsPerTask = Math.max(1, (words + parallelism - 1) / parallelism); // Submit all tasks except the last; run the last chunk on the calling thread. @@ -74,7 +74,7 @@ public void filterSolution(int guess, int obtainedFeedback) { Future[] futures = new Future[parallelism]; int fromIndex = 0; int taskCount = 0; - while (fromIndex + wordsPerTask * 64 < allValid.length) { + while (fromIndex + wordsPerTask * 64 < totalCodes) { final int from = fromIndex; final int to = fromIndex + wordsPerTask * 64; futures[taskCount++] = POOL.submit(() -> filterRange(guess, obtainedFeedback, from, to)); @@ -82,7 +82,7 @@ public void filterSolution(int guess, int obtainedFeedback) { } // Run the tail on the calling thread and sum removed counts. - int removed = filterRange(guess, obtainedFeedback, fromIndex, allValid.length); + int removed = filterRange(guess, obtainedFeedback, fromIndex, totalCodes); // Wait for all submitted tasks and accumulate removed counts. for (int i = 0; i < taskCount; i++) { @@ -92,7 +92,7 @@ public void filterSolution(int guess, int obtainedFeedback) { } /** - * Single-threaded filter over {@code allValid[from..to)}. + * Single-threaded filter over indices {@code [from, to)}. * Safe to call from multiple threads as long as the index ranges are word-aligned * (multiples of 64) and disjoint, since each BitSet word is only touched by one thread. * @@ -102,7 +102,7 @@ private int filterRange(int guess, int obtainedFeedback, int from, int to) { int[] colorFreqCounter = new int[10]; int removed = 0; for (int i = remaining.nextSetBit(from); i >= 0 && i < to; i = remaining.nextSetBit(i + 1)) { - if (Feedback.getFeedback(guess, allValid[i], c, d, colorFreqCounter) != obtainedFeedback) { + if (Feedback.getFeedback(guess, ConvertCode.toCode(c, d, i), c, d, colorFreqCounter) != obtainedFeedback) { remaining.clear(i); removed++; } @@ -120,7 +120,7 @@ public int[] getSecrets() { int[] secrets = new int[size]; int j = 0; for (int i = remaining.nextSetBit(0); i >= 0; i = remaining.nextSetBit(i + 1)) { - secrets[j++] = allValid[i]; + secrets[j++] = ConvertCode.toCode(c, d, i); } return secrets; } diff --git a/src/tests/java/org/mastermind/solver/FeedbackTest.java b/src/tests/java/org/mastermind/solver/FeedbackTest.java index 785dc990..04175e37 100644 --- a/src/tests/java/org/mastermind/solver/FeedbackTest.java +++ b/src/tests/java/org/mastermind/solver/FeedbackTest.java @@ -52,7 +52,7 @@ public void testIterationPerformance() { startTime = System.nanoTime(); // Run multiple times - for (int t = 0; t < 100; t++) { + for (int t = 0; t < 50; t++) { // Call single version 1,296 times, storing results in a 2D array for (int guessIdx = 0; guessIdx < TOTAL_COMBINATIONS; guessIdx++) { int guess = allCombinations[guessIdx]; From a1e764017f6786866c7b9cd9a5bcdb1cd0da2ee3 Mon Sep 17 00:00:00 2001 From: FlysonBot <116744100+FlysonBot@users.noreply.github.com> Date: Mon, 2 Mar 2026 23:44:18 -0800 Subject: [PATCH 04/23] refactor: move from code to index representation to avoid overhead - getFeedback now take index and extract digit directly, so its caller doesn't have to waste time generate code before getFeedback can be called - This change propagated through all methods --- CLAUDE.md | 3 +- .../org/mastermind/BestGuessBenchmark.java | 19 ++-- .../org/mastermind/ExpectedSizeBenchmark.java | 29 ++++-- .../org/mastermind/FeedbackBenchmark.java | 31 +++--- .../mastermind/SolutionSpaceBenchmark.java | 33 ++++--- src/main/java/org/mastermind/Demo.java | 30 +++--- .../java/org/mastermind/GuessStrategy.java | 4 +- .../org/mastermind/MastermindSession.java | 4 +- .../org/mastermind/codes/AllValidCode.java | 96 ++----------------- .../org/mastermind/codes/CanonicalCode.java | 62 ++++-------- .../java/org/mastermind/codes/CodeCache.java | 17 ---- .../org/mastermind/codes/SampledCode.java | 14 +-- .../java/org/mastermind/solver/BestGuess.java | 96 ++++++------------- .../org/mastermind/solver/ExpectedSize.java | 32 ++----- .../java/org/mastermind/solver/Feedback.java | 18 ++-- .../org/mastermind/solver/SolutionSpace.java | 36 ++++--- .../org/mastermind/MastermindSessionTest.java | 59 ++++++------ .../mastermind/codes/CanonicalCodeTest.java | 69 +++++++------ .../org/mastermind/codes/SampledCodeTest.java | 55 +++-------- .../org/mastermind/solver/BestGuessTest.java | 32 +++---- .../mastermind/solver/ExpectedSizeTest.java | 38 +++++--- .../org/mastermind/solver/FeedbackTest.java | 52 +++------- .../mastermind/solver/SolutionSpaceTest.java | 31 +++--- 23 files changed, 319 insertions(+), 541 deletions(-) delete mode 100644 src/main/java/org/mastermind/codes/CodeCache.java diff --git a/CLAUDE.md b/CLAUDE.md index 17e4c0ba..36b00e13 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -29,4 +29,5 @@ Status: Rewriting codebase; currently focused on Java algorithm only. - Stick to primitive type unless there is a reason not to. - Unless necessary, do not write extra class and objects. Be simple. -- Do not run any tests or benchmark for me unless specifically instructed. \ No newline at end of file +- Do not run any tests or benchmark for me unless specifically instructed. +- Do not remove the average performance footer in benchmarks when updating. \ No newline at end of file diff --git a/src/benchmarks/java/org/mastermind/BestGuessBenchmark.java b/src/benchmarks/java/org/mastermind/BestGuessBenchmark.java index b94d3ebc..8d36a6b1 100644 --- a/src/benchmarks/java/org/mastermind/BestGuessBenchmark.java +++ b/src/benchmarks/java/org/mastermind/BestGuessBenchmark.java @@ -1,6 +1,5 @@ package org.mastermind; -import org.mastermind.codes.AllValidCode; import org.mastermind.solver.BestGuess; import org.openjdk.jmh.annotations.*; import org.openjdk.jmh.infra.Blackhole; @@ -17,20 +16,28 @@ public class BestGuessBenchmark { // Ordinary (Sequential) Version @Benchmark public void benchmarkOrdinaryVersion(BenchmarkState state, Blackhole blackhole) { - long[] bestGuess = BestGuess.findBestGuess(state.allCodes, state.allCodes, 6, 4, false); + long[] bestGuess = BestGuess.findBestGuess(state.allInd, state.allInd, BenchmarkState.C, BenchmarkState.D, + false); blackhole.consume(bestGuess); } // Parallel Version @Benchmark public void benchmarkParallelVersion(BenchmarkState state, Blackhole blackhole) { - long[] bestGuess = BestGuess.findBestGuess(state.allCodes, state.allCodes, 6, 4, true); + long[] bestGuess = BestGuess.findBestGuess(state.allInd, state.allInd, BenchmarkState.C, BenchmarkState.D, + true); blackhole.consume(bestGuess); } @State(Scope.Thread) public static class BenchmarkState { - private final int[] allCodes = AllValidCode.generateAllCodes(6, 4); + static final int C = 6, D = 4; + private final int[] allInd; + + public BenchmarkState() { + allInd = new int[(int) Math.pow(C, D)]; + for (int i = 0; i < allInd.length; i++) allInd[i] = i; + } // TEARDOWN - Shutdown the thread pool after benchmarking @TearDown(Level.Trial) @@ -42,6 +49,6 @@ public void tearDown() { /* Average Performance: Benchmark Mode Cnt Score Error Units -BestGuessBenchmark.benchmarkOrdinaryVersion avgt 6 34.278 ± 0.994 ms/op -BestGuessBenchmark.benchmarkParallelVersion avgt 6 12.409 ± 4.445 ms/op +BestGuessBenchmark.benchmarkOrdinaryVersion avgt 6 35.945 ± 4.358 ms/op +BestGuessBenchmark.benchmarkParallelVersion avgt 6 21.431 ± 3.777 ms/op */ \ No newline at end of file diff --git a/src/benchmarks/java/org/mastermind/ExpectedSizeBenchmark.java b/src/benchmarks/java/org/mastermind/ExpectedSizeBenchmark.java index db4d8f9a..fbb2ce7a 100644 --- a/src/benchmarks/java/org/mastermind/ExpectedSizeBenchmark.java +++ b/src/benchmarks/java/org/mastermind/ExpectedSizeBenchmark.java @@ -1,6 +1,6 @@ package org.mastermind; -import org.mastermind.codes.AllValidCode; +import org.mastermind.codes.ConvertCode; import org.mastermind.solver.ExpectedSize; import org.openjdk.jmh.annotations.*; import org.openjdk.jmh.infra.Blackhole; @@ -16,33 +16,42 @@ public class ExpectedSizeBenchmark { @Benchmark @OutputTimeUnit(TimeUnit.MICROSECONDS) public void benchmarkTest(BenchmarkState state, Blackhole blackhole) { - long expectedSize = state.calcExpectedRank(1123); + long expectedSize = state.calcExpectedRank(BenchmarkState.ind(1123)); blackhole.consume(expectedSize); } @Benchmark @OutputTimeUnit(TimeUnit.MILLISECONDS) public void variedTest(BenchmarkState state, Blackhole blackhole) { - for (int guess : state.secrets) { - long expectedSize = state.calcExpectedRank(guess); + for (int guessInd = 0; guessInd < state.total; guessInd++) { + long expectedSize = state.calcExpectedRank(guessInd); blackhole.consume(expectedSize); } } @State(Scope.Thread) public static class BenchmarkState { - private final int[] secrets = AllValidCode.generateAllCodes(6, 4); + static final int C = 6, D = 4; + private final int total = (int) Math.pow(C, D); // 1296 + private final int[] secretsInd; private final int[] feedbackFreq = new int[100]; - private final ExpectedSize expectedSizeObj = new ExpectedSize(4); + private final ExpectedSize expectedSizeObj = new ExpectedSize(D); - public long calcExpectedRank(int guess) { - return expectedSizeObj.calcExpectedRank(guess, secrets, 6, 4, feedbackFreq); + public BenchmarkState() { + secretsInd = new int[total]; + for (int i = 0; i < total; i++) secretsInd[i] = i; + } + + static int ind(int code) { return ConvertCode.toIndex(C, D, code); } + + public long calcExpectedRank(int guessInd) { + return expectedSizeObj.calcExpectedRank(guessInd, secretsInd, C, D, feedbackFreq); } } } /* Benchmark Average: Benchmark Mode Cnt Score Error Units -ExpectedSizeBenchmark.benchmarkTest avgt 9 25.096 ± 1.178 us/op -ExpectedSizeBenchmark.variedTest avgt 9 34.095 ± 0.792 ms/op +ExpectedSizeBenchmark.benchmarkTest avgt 9 25.656 ± 1.551 us/op +ExpectedSizeBenchmark.variedTest avgt 9 32.383 ± 2.467 ms/op */ \ No newline at end of file diff --git a/src/benchmarks/java/org/mastermind/FeedbackBenchmark.java b/src/benchmarks/java/org/mastermind/FeedbackBenchmark.java index 047ac58a..fd1b5972 100644 --- a/src/benchmarks/java/org/mastermind/FeedbackBenchmark.java +++ b/src/benchmarks/java/org/mastermind/FeedbackBenchmark.java @@ -1,6 +1,6 @@ package org.mastermind; -import org.mastermind.codes.AllValidCode; +import org.mastermind.codes.ConvertCode; import org.mastermind.solver.Feedback; import org.openjdk.jmh.annotations.*; import org.openjdk.jmh.infra.Blackhole; @@ -16,15 +16,15 @@ public class FeedbackBenchmark { @OutputTimeUnit(TimeUnit.NANOSECONDS) @Benchmark public void fixInputBenchmark(BenchmarkState state, Blackhole blackhole) { - int feedback = state.getFeedbackQuick(1234, 4263); + int feedback = state.getFeedbackQuick(BenchmarkState.ind(1234), BenchmarkState.ind(4263)); blackhole.consume(feedback); } @OutputTimeUnit(TimeUnit.MICROSECONDS) @Benchmark public void oneVariedInputBenchmark(BenchmarkState state, Blackhole blackhole) { - for (int secret : state.secrets) { - int feedback = state.getFeedbackQuick(1234, secret); + for (int secretIdx = 0; secretIdx < state.total; secretIdx++) { + int feedback = state.getFeedbackQuick(BenchmarkState.ind(1234), secretIdx); blackhole.consume(feedback); } } @@ -32,9 +32,9 @@ public void oneVariedInputBenchmark(BenchmarkState state, Blackhole blackhole) { @OutputTimeUnit(TimeUnit.MILLISECONDS) @Benchmark public void doubleVariedInputBenchmark(BenchmarkState state, Blackhole blackhole) { - for (int guess : state.secrets) { - for (int secret : state.secrets) { - int feedback = state.getFeedbackQuick(guess, secret); + for (int guessIdx = 0; guessIdx < state.total; guessIdx++) { + for (int secretIdx = 0; secretIdx < state.total; secretIdx++) { + int feedback = state.getFeedbackQuick(guessIdx, secretIdx); blackhole.consume(feedback); } } @@ -42,18 +42,21 @@ public void doubleVariedInputBenchmark(BenchmarkState state, Blackhole blackhole @State(Scope.Thread) public static class BenchmarkState { - public int[] secrets = AllValidCode.generateAllCodes(6, 4); - public int[] freq = new int[10]; + static final int C = 6, D = 4; + public int total = (int) Math.pow(C, D); // 1296 + public int[] freq = new int[C]; - public int getFeedbackQuick(int guess, int secret) { - return Feedback.getFeedback(guess, secret, 6, 4, freq); + public static int ind(int code) { return ConvertCode.toIndex(C, D, code); } + + public int getFeedbackQuick(int guessIdx, int secretIdx) { + return Feedback.getFeedback(guessIdx, secretIdx, C, D, freq); } } } /* Benchmark average: Benchmark Mode Cnt Score Error Units -FeedbackBenchmark.doubleVariedInputBenchmark avgt 4 28.907 ± 1.257 ms/op -FeedbackBenchmark.fixInputBenchmark avgt 4 18.134 ± 0.729 ns/op -FeedbackBenchmark.oneVariedInputBenchmark avgt 4 23.219 ± 0.912 us/op +FeedbackBenchmark.doubleVariedInputBenchmark avgt 4 29.915 ± 3.150 ms/op +FeedbackBenchmark.fixInputBenchmark avgt 4 18.171 ± 1.001 ns/op +FeedbackBenchmark.oneVariedInputBenchmark avgt 4 25.012 ± 0.728 us/op */ \ No newline at end of file diff --git a/src/benchmarks/java/org/mastermind/SolutionSpaceBenchmark.java b/src/benchmarks/java/org/mastermind/SolutionSpaceBenchmark.java index eed63a8a..4fb6a4c9 100644 --- a/src/benchmarks/java/org/mastermind/SolutionSpaceBenchmark.java +++ b/src/benchmarks/java/org/mastermind/SolutionSpaceBenchmark.java @@ -1,5 +1,6 @@ package org.mastermind; +import org.mastermind.codes.ConvertCode; import org.mastermind.solver.Feedback; import org.mastermind.solver.SolutionSpace; import org.openjdk.jmh.annotations.*; @@ -20,7 +21,7 @@ public class SolutionSpaceBenchmark { @Benchmark @OutputTimeUnit(TimeUnit.MICROSECONDS) public void filterSerialFull(SmallState state) { - state.space.filterSolution(state.guess, state.feedback); + state.space.filterSolution(state.guessInd, state.feedback); state.space.reset(); } @@ -30,7 +31,7 @@ public void filterSerialFull(SmallState state) { @Benchmark @OutputTimeUnit(TimeUnit.MILLISECONDS) public void filterParallelFull(LargeState state) { - state.space.filterSolution(state.guess, state.feedback); + state.space.filterSolution(state.guessInd, state.feedback); state.space.reset(); } @@ -56,15 +57,17 @@ public void getSize(SmallState state, Blackhole blackhole) { public static class SmallState { static final int C = 6, D = 4; SolutionSpace space; - int guess; + int guessInd; int feedback; - int[] freq = new int[10]; + int[] freq = new int[C]; + + static int ind(int code) { return ConvertCode.toIndex(C, D, code); } @Setup(Level.Trial) public void setup() { space = new SolutionSpace(C, D); - guess = 1122; - feedback = Feedback.getFeedback(guess, 3456, C, D, freq); + guessInd = ind(1122); + feedback = Feedback.getFeedback(guessInd, ind(3456), C, D, freq); } @Setup(Level.Invocation) @@ -77,15 +80,17 @@ public void reset() { public static class LargeState { static final int C = 9, D = 5; SolutionSpace space; - int guess; + int guessInd; int feedback; - int[] freq = new int[10]; + int[] freq = new int[C]; + + static int ind(int code) { return ConvertCode.toIndex(C, D, code); } @Setup(Level.Trial) public void setup() { space = new SolutionSpace(C, D); - guess = 11223; - feedback = Feedback.getFeedback(guess, 34567, C, D, freq); + guessInd = ind(11223); + feedback = Feedback.getFeedback(guessInd, ind(34567), C, D, freq); } @Setup(Level.Invocation) @@ -97,8 +102,8 @@ public void reset() { /* Average Performance: Benchmark Mode Cnt Score Error Units -SolutionSpaceBenchmark.filterParallelFull avgt 4 0.632 ± 0.672 ms/op -SolutionSpaceBenchmark.filterSerialFull avgt 4 30.677 ± 2.271 us/op -SolutionSpaceBenchmark.getSecretsFull avgt 4 6.024 ± 0.218 us/op -SolutionSpaceBenchmark.getSize avgt 4 17.925 ± 0.885 ns/op +SolutionSpaceBenchmark.filterParallelFull avgt 4 0.608 ± 0.195 ms/op +SolutionSpaceBenchmark.filterSerialFull avgt 4 38.088 ± 2.371 us/op +SolutionSpaceBenchmark.getSecretsFull avgt 4 5.723 ± 0.188 us/op +SolutionSpaceBenchmark.getSize avgt 4 18.442 ± 0.872 ns/op */ \ No newline at end of file diff --git a/src/main/java/org/mastermind/Demo.java b/src/main/java/org/mastermind/Demo.java index ee92eb83..fb258684 100644 --- a/src/main/java/org/mastermind/Demo.java +++ b/src/main/java/org/mastermind/Demo.java @@ -16,41 +16,41 @@ */ public class Demo { - // ── Adjust these defaults as needed ────────────────────────────────────── - static int C = 9; // number of colors - static int D = 9; // number of digit positions - static int SECRET = 641899762; // the secret the solver tries to crack + // ── Adjust these settings as needed ────────────────────────────────────── + static int C = 9; // number of colors + static int D = 9; // number of digit positions + static int SECRET_IND = 641899762; // index of the secret code (0-based, base-c encoding) // ───────────────────────────────────────────────────────────────────────── public static void main(String[] args) { if (args.length >= 3) { C = Integer.parseInt(args[0]); D = Integer.parseInt(args[1]); - SECRET = Integer.parseInt(args[2]); + SECRET_IND = Integer.parseInt(args[2]); } - System.out.printf("Mastermind Demo [c=%d, d=%d, secret=%d]%n%n", C, D, SECRET); + System.out.printf("Mastermind Demo [c=%d, d=%d, secretInd=%d]%n%n", C, D, SECRET_IND); - long startTime = System.nanoTime(); - MastermindSession session = new MastermindSession(C, D); - ExpectedSize expectedSizeObj = new ExpectedSize(D); - int[] colorFreqCounter = new int[10]; + long startTime = System.nanoTime(); + MastermindSession session = new MastermindSession(C, D); + ExpectedSize expectedSizeObj = new ExpectedSize(D); + int[] colorFreq = new int[C]; while (!session.isSolved()) { int spaceBefore = session.getSolutionSpaceSize(); long[] details = session.suggestGuessWithDetails(); - int guess = (int) details[0]; + int guessInd = (int) details[0]; float expSize = expectedSizeObj.convertSampleRankToExpectedSize(details[1], (int) details[2], spaceBefore); - int feedback = Feedback.getFeedback(guess, SECRET, C, D, colorFreqCounter); + int feedback = Feedback.getFeedback(guessInd, SECRET_IND, C, D, colorFreq); - session.recordGuess(guess, feedback); + session.recordGuess(guessInd, feedback); int turn = session.getTurnCount(); int black = feedback / 10; int white = feedback % 10; - System.out.printf("Turn %d: guess=%-6d space=%-5d expected=%.2f feedback=%db%dw%n", - turn, guess, spaceBefore, expSize, black, white); + System.out.printf("Turn %d: guessInd=%-10d space=%-5d expected=%.2f feedback=%db%dw%n", + turn, guessInd, spaceBefore, expSize, black, white); } double elapsedSec = (System.nanoTime() - startTime) / 1_000_000_000.0; diff --git a/src/main/java/org/mastermind/GuessStrategy.java b/src/main/java/org/mastermind/GuessStrategy.java index 451919b5..4a801e97 100644 --- a/src/main/java/org/mastermind/GuessStrategy.java +++ b/src/main/java/org/mastermind/GuessStrategy.java @@ -1,7 +1,7 @@ package org.mastermind; +import org.mastermind.codes.AllValidCode; import org.mastermind.codes.CanonicalCode; -import org.mastermind.codes.CodeCache; import org.mastermind.codes.SampledCode; import org.mastermind.solver.Feedback; import org.mastermind.solver.SolutionSpace; @@ -66,7 +66,7 @@ private static int[][] firstTurn(int c, int d, int secretsSize, SolutionSpace so private static int[][] laterTurns(int c, int d, int secretsSize, SolutionSpace solutionSpace) { if (fits((int) Math.pow(c, d), secretsSize)) - return pair(CodeCache.getAllValid(c, d), solutionSpace.getSecrets()); + return pair(AllValidCode.generateAllCodes(c, d), solutionSpace.getSecrets()); if (fits(secretsSize, secretsSize)) return pair(solutionSpace.getSecrets(), solutionSpace.getSecrets()); for (double tolerance : new double[] { 0.001, 0.005, 0.01 }) { diff --git a/src/main/java/org/mastermind/MastermindSession.java b/src/main/java/org/mastermind/MastermindSession.java index 0bd0a5f6..975f563f 100644 --- a/src/main/java/org/mastermind/MastermindSession.java +++ b/src/main/java/org/mastermind/MastermindSession.java @@ -48,7 +48,7 @@ public MastermindSession(int c, int d) { * against) is handled by {@link GuessStrategy}. If only one secret remains, * it is returned immediately without invoking the BestGuess search. * - * @return the recommended guess as an integer code (digits 1..c, length d) + * @return the recommended guess as a code index (0-based, base-c encoding) * @throws IllegalStateException if the game is already solved */ public int suggestGuess() { @@ -81,7 +81,7 @@ public long[] suggestGuessWithDetails() { /** * Record a guess and its feedback, then update the solution space. * - * @param guess the guessed code (digits 1..c, length d) + * @param guess the guess as a code index (0-based, base-c encoding) * @param feedback feedback from the game master (black*10 + white) * @throws IllegalStateException if the game is already solved * @throws IllegalArgumentException if the feedback leaves no valid secrets diff --git a/src/main/java/org/mastermind/codes/AllValidCode.java b/src/main/java/org/mastermind/codes/AllValidCode.java index 243fbfd4..9b67bc68 100644 --- a/src/main/java/org/mastermind/codes/AllValidCode.java +++ b/src/main/java/org/mastermind/codes/AllValidCode.java @@ -1,7 +1,5 @@ package org.mastermind.codes; -import java.util.BitSet; - /** * A game of Mastermind has 2 parameters, c (number of colors) * and d (number of digits). A code is a valid Mastermind code @@ -10,96 +8,16 @@ */ public class AllValidCode { /** - * Generate all valid Mastermind code for a game. + * Generate all valid Mastermind code indices for a game. * * @param c number of colors (<= 9) * @param d number of digits (<= 9) - * @return Array of all valid Mastermind code + * @return Array of all valid code indices in [0, c^d) */ public static int[] generateAllCodes(int c, int d) { - // Total number of codes = c^d (e.g. 6 colors, 4 pegs = 1296 codes) - int total = 1; - for (int i = 0; i < d; i++) total *= c; - - int[] codes = new int[total]; - - // digits[] tracks the current code as individual digits (0-indexed internally). - // digits[0] is the RIGHTMOST (least significant) digit, - // digits[d-1] is the LEFTMOST (most significant) digit. - // Internally digits run 0..c-1; we add 1 when building the int so output is 1..c. - int[] digits = new int[d]; - - // Precompute positional powers of 10 matching the digits[] layout: - // pow10[0] = 1 (rightmost / least significant) - // pow10[1] = 10 - // pow10[2] = 100 - // pow10[d-1] (leftmost / most significant) - // e.g. d=4: pow10 = [1, 10, 100, 1000] - int[] pow10 = new int[d]; - pow10[0] = 1; - for (int i = 1; i < d; i++) { - pow10[i] = pow10[i - 1] * 10; - } - - // The starting code is all 1s (e.g. d=4 → 1111). - // Since digits[] is initialized to all 0s (representing digit value 1), - // the base code is just the sum of all positional powers of 10. - int code = 0; - for (int i = 0; i < d; i++) code += pow10[i]; - - for (int i = 0; i < total; i++) { - // Store the current code before incrementing - codes[i] = code; - - // --- Odometer-style increment --- - // We tick the rightmost digit (index 0) up by 1, exactly like an odometer. - // If a digit exceeds c, it wraps back to 1 and carries over to the next - // digit to the left (index + 1). - int pos = 0; // start at the least significant (rightmost) digit - while (pos < d) { - digits[pos]++; // increment this digit - code += pow10[pos]; // reflect the +1 in the integer representation - - if (digits[pos] < c) { - // No carry needed — the digit is still within range [1..c]. - break; - } else { - // This digit has exceeded c, so wrap it back to 1. - // In terms of the integer: we added 1 just above, but we need - // the digit to go from c back to 1, a net change of (1 - c). - // We already added pow10[pos], so subtract c * pow10[pos] - // to get the net effect of -(c-1) * pow10[pos]. - // e.g. c=6, pos=0 (units place, pow10=1): digit went 6→1, - // code already +1, now -6, net = -5 ✓ (6→1 is -5 in value) - code -= c * pow10[pos]; - digits[pos] = 0; // reset internal digit to 0 (represents value 1) - - pos++; // carry: move left to the next digit (higher index) - } - } - // When the while loop exits without breaking (pos >= d), all digits - // have wrapped around — we've generated every code and are done. - } - - return codes; - } - - /** - * Generate a BitSet representing the universe of all valid Mastermind codes. - * The BitSet has size c^d with every bit set, where bit index i corresponds - * to codes[i] from generateAllCodes(). All bits set means the full solution - * space is active (no codes have been eliminated yet). - * - * @param c number of colors (<= 9) - * @param d number of digits (<= 9) - * @return BitSet of size c^d with all bits set - */ - public static BitSet generateAllCodesBitSet(int c, int d) { - int total = (int) Math.pow(c, d); - - BitSet bitSet = new BitSet(total); - bitSet.set(0, total); - - return bitSet; + int total = (int) Math.pow(c, d); + int[] ind = new int[total]; + for (int i = 0; i < total; i++) ind[i] = i; + return ind; } -} \ No newline at end of file +} diff --git a/src/main/java/org/mastermind/codes/CanonicalCode.java b/src/main/java/org/mastermind/codes/CanonicalCode.java index 08de9359..d5175c54 100644 --- a/src/main/java/org/mastermind/codes/CanonicalCode.java +++ b/src/main/java/org/mastermind/codes/CanonicalCode.java @@ -1,7 +1,5 @@ package org.mastermind.codes; -import java.util.BitSet; - /** * Canonical forms refer to a specific subset of all Mastermind code * that starts with 1, digit ordered from small to large starting @@ -50,11 +48,11 @@ public static int countCanonicalForms(int c, int d) { } /** - * Enumerate all Canonical forms in a Mastermind game. + * Enumerate all Canonical forms in a Mastermind game as code indices. * * @param c number of colors (<= 9) * @param d number of digits (<= 9) - * @return Array of all Canonical forms in Mastermind + * @return Array of indices (0-based, base-c encoding) of all Canonical forms */ public static int[] enumerateCanonicalForms(int c, int d) { @@ -64,59 +62,33 @@ public static int[] enumerateCanonicalForms(int c, int d) { // 2. Use a tiny wrapper array for the index to pass by reference in recursion int[] index = { 0 }; - // 3. Start recursion - backtrack(results, index, 0, 0, 0, c, d); + // 3. Precompute positional powers: place[pos] = c^(d-1-pos) for left-to-right building + int[] place = new int[d]; + place[d - 1] = 1; + for (int i = d - 2; i >= 0; i--) place[i] = place[i + 1] * c; - return results; - } - - /** - * Enumerate all Canonical forms as a BitSet, where bit i is set if - * codes[i] (from AllValidCode.generateAllCodes) is a canonical form. - * - * @param c number of colors (<= 9) - * @param d number of digits (<= 9) - * @return BitSet with bits set only at canonical form indices - */ - public static BitSet enumerateCanonicalFormsBitSet(int c, int d) { - int total = (int) Math.pow(c, d); - BitSet bitSet = new BitSet(total); - backtrackBitSet(bitSet, c, d, 0, 0, 0); - return bitSet; - } + // 4. Start recursion + backtrack(results, index, 0, 0, 0, c, d, place); - private static void backtrackBitSet(BitSet bitSet, int c, int d, int currentNum, int pos, int maxColorUsed) { - if (pos == d) { - bitSet.set(ConvertCode.toIndex(c, d, currentNum)); - return; - } - - for (int color = 1; color <= maxColorUsed; color++) { - backtrackBitSet(bitSet, c, d, (currentNum * 10) + color, pos + 1, maxColorUsed); - } - - if (maxColorUsed < c) { - int nextColor = maxColorUsed + 1; - backtrackBitSet(bitSet, c, d, (currentNum * 10) + nextColor, pos + 1, nextColor); - } + return results; } - private static void backtrack(int[] results, int[] index, int currentNum, int pos, int maxColorUsed, int c, int d) { + private static void backtrack( + int[] results, int[] index, int currentInd, int pos, int maxColorUsed, int c, int d, int[] place) { // Base case: Code is complete if (pos == d) { - results[index[0]++] = currentNum; + results[index[0]++] = currentInd; return; } - // Rule 1 & 2: Try existing colors - for (int color = 1; color <= maxColorUsed; color++) { - backtrack(results, index, (currentNum * 10) + color, pos + 1, maxColorUsed, c, d); + // Rule 1 & 2: Try existing colors (digit values 0..maxColorUsed-1 in index encoding) + for (int digitVal = 0; digitVal < maxColorUsed; digitVal++) { + backtrack(results, index, currentInd + digitVal * place[pos], pos + 1, maxColorUsed, c, d, place); } - // Rule 3: Try exactly one "new" color if limit c isn't reached + // Rule 3: Try exactly one "new" color if limit c isn't reached (digit value maxColorUsed) if (maxColorUsed < c) { - int nextColor = maxColorUsed + 1; - backtrack(results, index, (currentNum * 10) + nextColor, pos + 1, nextColor, c, d); + backtrack(results, index, currentInd + maxColorUsed * place[pos], pos + 1, maxColorUsed + 1, c, d, place); } } } diff --git a/src/main/java/org/mastermind/codes/CodeCache.java b/src/main/java/org/mastermind/codes/CodeCache.java deleted file mode 100644 index 619afaa7..00000000 --- a/src/main/java/org/mastermind/codes/CodeCache.java +++ /dev/null @@ -1,17 +0,0 @@ -package org.mastermind.codes; - -/** - * Lazy cache for generated code arrays, shared across classes. - * - *

Keyed by [c][d]; each entry is populated on first access. - */ -public class CodeCache { - - private static final int[][][] allValidCache = new int[10][10][]; - - public static int[] getAllValid(int c, int d) { - if (allValidCache[c][d] == null) allValidCache[c][d] = AllValidCode.generateAllCodes(c, d); - return allValidCache[c][d]; - } - -} diff --git a/src/main/java/org/mastermind/codes/SampledCode.java b/src/main/java/org/mastermind/codes/SampledCode.java index a370e26d..805b9281 100644 --- a/src/main/java/org/mastermind/codes/SampledCode.java +++ b/src/main/java/org/mastermind/codes/SampledCode.java @@ -13,25 +13,21 @@ public class SampledCode { /** - * Generate a random Monte Carlo sample from all possible Mastermind code - * with the specified sample size. + * Generate a random Monte Carlo sample of code indices from all possible + * Mastermind codes with the specified sample size. * * @param c number of colors (<= 9) * @param d number of digits (<= 9) * @param sampleSize size of the sample - * @return A random sample of all possible Mastermind code + * @return A random sample of code indices in [0, c^d) */ public static int[] getSample(int c, int d, int sampleSize) { Random random = new Random(); + int total = (int) Math.pow(c, d); int[] sample = new int[sampleSize]; for (int i = 0; i < sampleSize; i++) { - int code = 0; - for (int digit = 0; digit < d; digit++) { - int color = random.nextInt(c) + 1; // colors 1..c - code = code * 10 + color; - } - sample[i] = code; + sample[i] = random.nextInt(total); } return sample; diff --git a/src/main/java/org/mastermind/solver/BestGuess.java b/src/main/java/org/mastermind/solver/BestGuess.java index 36d0597c..0730277c 100644 --- a/src/main/java/org/mastermind/solver/BestGuess.java +++ b/src/main/java/org/mastermind/solver/BestGuess.java @@ -1,7 +1,6 @@ package org.mastermind.solver; import java.util.ArrayList; -import java.util.Arrays; import java.util.List; import java.util.concurrent.ExecutionException; import java.util.concurrent.ExecutorService; @@ -29,33 +28,33 @@ public class BestGuess { * Find the guess that will minimize the expected size of the solution space * after guessing. * - * @param guesses all candidate guesses - * @param secrets remaining possible secrets - * @param c number of colors (<= 9) - * @param d number of digits - * @return long[] where [0]=best guess, [1]=its rank (sum of squared partition sizes) + * @param guessesInd all candidate guess indices (0-based, base-c encoding) + * @param secretsInd remaining possible secret indices (0-based, base-c encoding) + * @param c number of colors (<= 9) + * @param d number of digits + * @return long[] where [0]=best guess index, [1]=its rank (sum of squared partition sizes) */ - public static long[] findBestGuess(int[] guesses, int[] secrets, int c, int d) { + public static long[] findBestGuess(int[] guessesInd, int[] secretsInd, int c, int d) { // Determine whether multi-threading is needed - if ((long) guesses.length * secrets.length < PARALLEL_THRESHOLD) { - return findBestGuessAlgorithm(guesses, secrets, c, d, 0, guesses.length); + if ((long) guessesInd.length * secretsInd.length < PARALLEL_THRESHOLD) { + return findBestGuessAlgorithm(guessesInd, secretsInd, c, d, 0, guessesInd.length); } // Call the parallelized version of the algorithm - return findBestGuessParallel(guesses, secrets, c, d); + return findBestGuessParallel(guessesInd, secretsInd, c, d); } - public static long[] findBestGuess(int[] guesses, int[] secrets, int c, int d, boolean parallel) { - if (!parallel) return findBestGuessAlgorithm(guesses, secrets, c, d, 0, guesses.length); - return findBestGuessParallel(guesses, secrets, c, d); + public static long[] findBestGuess(int[] guessesInd, int[] secretsInd, int c, int d, boolean parallel) { + if (!parallel) return findBestGuessAlgorithm(guessesInd, secretsInd, c, d, 0, guessesInd.length); + return findBestGuessParallel(guessesInd, secretsInd, c, d); } - private static long[] findBestGuessParallel(int[] guesses, int[] secrets, int c, int d) { + private static long[] findBestGuessParallel(int[] guessesInd, int[] secretsInd, int c, int d) { - // Calculate the chunk size with ceil(guesses.length / THREAD_COUNT) - int chunkSize = (guesses.length + THREAD_COUNT - 1) / THREAD_COUNT; - int actualThreads = (guesses.length + chunkSize - 1) / chunkSize; + // Calculate the chunk size with ceil(guessesInd.length / THREAD_COUNT) + int chunkSize = (guessesInd.length + THREAD_COUNT - 1) / THREAD_COUNT; + int actualThreads = (guessesInd.length + chunkSize - 1) / chunkSize; // Holder for function's output result List> futures = new ArrayList<>(actualThreads); @@ -63,13 +62,13 @@ private static long[] findBestGuessParallel(int[] guesses, int[] secrets, int c, // Submit work to each threads for (int t = 0; t < actualThreads; t++) { final int from = t * chunkSize; - final int to = Math.min(from + chunkSize, guesses.length); - futures.add(t, POOL.submit(() -> findBestGuessAlgorithm(guesses, secrets, c, d, from, to))); + final int to = Math.min(from + chunkSize, guessesInd.length); + futures.add(t, POOL.submit(() -> findBestGuessAlgorithm(guessesInd, secretsInd, c, d, from, to))); } // Find best guess from returned result - int bestGuess = -1; - long bestScore = Long.MAX_VALUE; + int bestGuessInd = -1; + long bestScore = Long.MAX_VALUE; for (Future future : futures) { try { @@ -78,7 +77,7 @@ private static long[] findBestGuessParallel(int[] guesses, int[] secrets, int c, // Update best guess if found better score if (result[1] < bestScore) { - bestGuess = (int) result[0]; + bestGuessInd = (int) result[0]; bestScore = result[1]; } @@ -90,65 +89,28 @@ private static long[] findBestGuessParallel(int[] guesses, int[] secrets, int c, } } - return new long[] { bestGuess, bestScore }; + return new long[] { bestGuessInd, bestScore }; } - private static long[] findBestGuessAlgorithm(int[] guesses, int[] secrets, int c, int d, int start, int end) { + private static long[] findBestGuessAlgorithm(int[] guessesInd, int[] secretsInd, int c, int d, int start, int end) { ExpectedSize expectedSizeObj = new ExpectedSize(d); int[] feedbackFreq = new int[100]; - int bestGuess = -1; - long bestScore = Long.MAX_VALUE; + int bestGuessInd = -1; + long bestScore = Long.MAX_VALUE; for (int i = start; i < end; i++) { // Compute rank - int guess = guesses[i]; - long score = expectedSizeObj.calcExpectedRank(guess, secrets, c, d, feedbackFreq); + int guessInd = guessesInd[i]; + long score = expectedSizeObj.calcExpectedRank(guessInd, secretsInd, c, d, feedbackFreq); // Update result if found a smaller rank if (score < bestScore) { bestScore = score; - bestGuess = guess; + bestGuessInd = guessInd; } } - return new long[] { bestGuess, bestScore }; - } - - /** - * Rank all guesses by the expected size of the solution space after each guess. - * - * @param guesses all candidate guesses - * @param secrets remaining possible secrets - * @param c number of colors (<= 9) - * @param d number of digits - * @return 2D array where each row is {guess, score}, sorted best to worst - */ - public static float[][] rankGuessesByExpectedSize(int[] guesses, int[] secrets, int c, int d) { - int n = guesses.length; - float[] scores = new float[n]; - Integer[] indices = new Integer[n]; // need Integer for custom comparator - int[] feedbackFreq = new int[100]; - - ExpectedSize expectedSizeObj = new ExpectedSize(d); - - // 1. Compute scores - for (int i = 0; i < n; i++) { - scores[i] = expectedSizeObj.calcExpectedSize(guesses[i], secrets, c, d, feedbackFreq); - indices[i] = i; - } - - // 2. Sort indices by score (ascending) - Arrays.sort(indices, (a, b) -> Float.compare(scores[a], scores[b])); - - // 3. Build ranked {guess, score} array - float[][] ranked = new float[n][2]; - for (int i = 0; i < n; i++) { - int idx = indices[i]; - ranked[i][0] = guesses[idx]; - ranked[i][1] = scores[idx]; - } - - return ranked; + return new long[] { bestGuessInd, bestScore }; } } diff --git a/src/main/java/org/mastermind/solver/ExpectedSize.java b/src/main/java/org/mastermind/solver/ExpectedSize.java index db4ca13e..c38cfa07 100644 --- a/src/main/java/org/mastermind/solver/ExpectedSize.java +++ b/src/main/java/org/mastermind/solver/ExpectedSize.java @@ -30,19 +30,19 @@ public ExpectedSize(int d) { * and not necessary the exact expected value. *

* - * @param guess code, digits 1..c, length d - * @param secrets list of codes, digits 1..c, length d + * @param guessInd index of the guess code (0-based, base-c encoding) + * @param secretsInd list of secret indices (0-based, base-c encoding) * @param c number of colors (<= 9) * @param d number of digits (<= 9) * @param feedbackFreq int array of 0 with length 100 * @return Sum of number of remaining solution for each secret */ - public long calcExpectedRank(int guess, int[] secrets, int c, int d, int[] feedbackFreq) { + public long calcExpectedRank(int guessInd, int[] secretsInd, int c, int d, int[] feedbackFreq) { // Calculate feedback for each secret - int[] colorFreqCounter = new int[10]; - for (int secret : secrets) { - int feedback = Feedback.getFeedback(guess, secret, c, d, colorFreqCounter); + int[] colorFreqCounter = new int[c]; + for (int secretInd : secretsInd) { + int feedback = Feedback.getFeedback(guessInd, secretInd, c, d, colorFreqCounter); feedbackFreq[feedback]++; } @@ -62,27 +62,11 @@ public float convertRankToExpectedSize(long rank, int total) { return (float) rank / total; } - public float convertRankToExpectedProportion(long rank, int total) { - return rank / (float) Math.pow(total, 2); - } - public float convertSampleRankToExpectedSize(long rank, int sampleSize, int populationSize) { return rank * populationSize / (float) Math.pow(sampleSize, 2); } - public float calcExpectedSize(int guess, int[] secrets, int c, int d, int[] feedbackFreq) { - return convertRankToExpectedSize(calcExpectedRank(guess, secrets, c, d, feedbackFreq), secrets.length); - } - - public float calcExpectedProportion(int guess, int[] secrets, int c, int d, int[] feedbackFreq) { - return convertRankToExpectedProportion(calcExpectedRank(guess, secrets, c, d, feedbackFreq), secrets.length); - } - - public float calcExpectedProportionFromSample( - int guess, int[] secrets, int c, int d, int[] feedbackFreq, int populationSize - ) { - return convertSampleRankToExpectedSize(calcExpectedRank( - guess, secrets, c, d, feedbackFreq), secrets.length, populationSize - ); + public float calcExpectedSize(int guessInd, int[] secretsInd, int c, int d, int[] feedbackFreq) { + return convertRankToExpectedSize(calcExpectedRank(guessInd, secretsInd, c, d, feedbackFreq), secretsInd.length); } } diff --git a/src/main/java/org/mastermind/solver/Feedback.java b/src/main/java/org/mastermind/solver/Feedback.java index 62bfedf2..c7c0536e 100644 --- a/src/main/java/org/mastermind/solver/Feedback.java +++ b/src/main/java/org/mastermind/solver/Feedback.java @@ -16,22 +16,22 @@ public final class Feedback { /** * Calculate the Mastermind feedback for a guess and a secret. * - * @param guess code, digits 1..c, length d - * @param secret code, digits 1..c, length d + * @param guessInd index of the guess code (0-based, base-c encoding) + * @param secretInd index of the secret code (0-based, base-c encoding) * @param c number of colors (<= 9) * @param d number of digits (<= 9) * @param colorFreqCounter int array of 0 with length c * @return Feedback value (black * 10 + white) */ - public static int getFeedback(int guess, int secret, int c, int d, int[] colorFreqCounter) { + public static int getFeedback(int guessInd, int secretInd, int c, int d, int[] colorFreqCounter) { int black = 0; for (int i = 0; i < d; i++) { - // Extract digits - int currGuess = guess % 10; - int currSecret = secret % 10; - guess /= 10; - secret /= 10; + // Extract digits (0..c-1) + int currGuess = guessInd % c; + int currSecret = secretInd % c; + guessInd /= c; + secretInd /= c; // Increment counters if (currGuess == currSecret) { @@ -44,7 +44,7 @@ public static int getFeedback(int guess, int secret, int c, int d, int[] colorFr // Sum absolute values and reset in one pass int colorFreqTotal = 0; - for (int i = 1; i <= c; i++) { + for (int i = 0; i < c; i++) { int freq = colorFreqCounter[i]; colorFreqCounter[i] = 0; colorFreqTotal += (freq > 0) ? freq : -freq; diff --git a/src/main/java/org/mastermind/solver/SolutionSpace.java b/src/main/java/org/mastermind/solver/SolutionSpace.java index a2dc6e40..5319bebc 100644 --- a/src/main/java/org/mastermind/solver/SolutionSpace.java +++ b/src/main/java/org/mastermind/solver/SolutionSpace.java @@ -1,7 +1,5 @@ package org.mastermind.solver; -import org.mastermind.codes.ConvertCode; - import java.util.BitSet; import java.util.concurrent.ForkJoinPool; import java.util.concurrent.Future; @@ -26,7 +24,7 @@ public class SolutionSpace { private final int c; private final int d; private final int totalCodes; // c^d - private final BitSet remaining; // bit i set ⟺ ConvertCode.toCode(c, d, i) is still a valid secret + private final BitSet remaining; // bit i set ⟺ index i is still a valid secret private int size; // cached cardinality of remaining public SolutionSpace(int c, int d) { @@ -55,12 +53,12 @@ public void reset() { * BitSet, so concurrent {@code clear()} calls on non-overlapping words are safe. * For small spaces the single-threaded path is used to avoid FJP overhead. * - * @param guess code, digits 1..c, length d + * @param guessInd index of the guess code (0-based, base-c encoding) * @param obtainedFeedback feedback value (black * 9 + d - colorFreqTotal/2) */ - public void filterSolution(int guess, int obtainedFeedback) { + public void filterSolution(int guessInd, int obtainedFeedback) { if (size < PARALLEL_THRESHOLD) { - size -= filterRange(guess, obtainedFeedback, 0, totalCodes); + size -= filterRange(guessInd, obtainedFeedback, 0, totalCodes); return; } @@ -77,12 +75,12 @@ public void filterSolution(int guess, int obtainedFeedback) { while (fromIndex + wordsPerTask * 64 < totalCodes) { final int from = fromIndex; final int to = fromIndex + wordsPerTask * 64; - futures[taskCount++] = POOL.submit(() -> filterRange(guess, obtainedFeedback, from, to)); + futures[taskCount++] = POOL.submit(() -> filterRange(guessInd, obtainedFeedback, from, to)); fromIndex = to; } // Run the tail on the calling thread and sum removed counts. - int removed = filterRange(guess, obtainedFeedback, fromIndex, totalCodes); + int removed = filterRange(guessInd, obtainedFeedback, fromIndex, totalCodes); // Wait for all submitted tasks and accumulate removed counts. for (int i = 0; i < taskCount; i++) { @@ -98,11 +96,11 @@ public void filterSolution(int guess, int obtainedFeedback) { * * @return number of bits cleared */ - private int filterRange(int guess, int obtainedFeedback, int from, int to) { - int[] colorFreqCounter = new int[10]; + private int filterRange(int guessInd, int obtainedFeedback, int from, int to) { + int[] colorFreqCounter = new int[c]; int removed = 0; for (int i = remaining.nextSetBit(from); i >= 0 && i < to; i = remaining.nextSetBit(i + 1)) { - if (Feedback.getFeedback(guess, ConvertCode.toCode(c, d, i), c, d, colorFreqCounter) != obtainedFeedback) { + if (Feedback.getFeedback(guessInd, i, c, d, colorFreqCounter) != obtainedFeedback) { remaining.clear(i); removed++; } @@ -111,22 +109,20 @@ private int filterRange(int guess, int obtainedFeedback, int from, int to) { } /** - * Materialize the remaining valid secrets as an int array. + * Materialize the remaining valid secrets as an int array of indices. * Called once per turn suggestion, not per filter. * - * @return int array of currently valid secrets + * @return int array of indices of currently valid secrets */ public int[] getSecrets() { - int[] secrets = new int[size]; - int j = 0; + int[] secretsInd = new int[size]; + int j = 0; for (int i = remaining.nextSetBit(0); i >= 0; i = remaining.nextSetBit(i + 1)) { - secrets[j++] = ConvertCode.toCode(c, d, i); + secretsInd[j++] = i; } - return secrets; + return secretsInd; } - /** - * @return size of the current solution space (or valid secrets) - */ + /** @return size of the current solution space (or valid secrets) */ public int getSize() { return size; } } diff --git a/src/tests/java/org/mastermind/MastermindSessionTest.java b/src/tests/java/org/mastermind/MastermindSessionTest.java index a9b20f5c..60c72f76 100644 --- a/src/tests/java/org/mastermind/MastermindSessionTest.java +++ b/src/tests/java/org/mastermind/MastermindSessionTest.java @@ -1,6 +1,7 @@ package org.mastermind; import org.junit.jupiter.api.Test; +import org.mastermind.codes.ConvertCode; import org.mastermind.solver.ExpectedSize; import org.mastermind.solver.Feedback; @@ -12,43 +13,45 @@ public class MastermindSessionTest { private static final int D = 4; private static final int MAX_TURNS = 6; + private static int ind(int code) { return ConvertCode.toIndex(C, D, code); } + /** * Simulate a full game with secret 1234 (a typical canonical starting case). * The solver must finish within MAX_TURNS turns. */ @Test void testSolveSecret1234() { - runGame(1234); + runGame(ind(1234)); } /** Simulate a full game with secret 6666 (all same color, worst-case candidate). */ @Test void testSolveSecret6666() { - runGame(6666); + runGame(ind(6666)); } /** Simulate a full game with secret 1562 (arbitrary mid-range code). */ @Test void testSolveSecret1562() { - runGame(1562); + runGame(ind(1562)); } /** Record two guesses, undo both at once, and verify the session is fully reset. */ @Test void testUndoMultiple() { - int[] colorFreqCounter = new int[10]; + int[] colorFreqCounter = new int[C]; MastermindSession session = new MastermindSession(C, D); int spaceAtStart = session.getSolutionSpaceSize(); // Record two arbitrary guesses with their real feedbacks against secret 1234 - int guess1 = 1122; - int fb1 = Feedback.getFeedback(guess1, 1234, C, D, colorFreqCounter); + int guess1 = ind(1122); + int fb1 = Feedback.getFeedback(guess1, ind(1234), C, D, colorFreqCounter); session.recordGuess(guess1, fb1); int spaceAfter1 = session.getSolutionSpaceSize(); - int guess2 = 1344; - int fb2 = Feedback.getFeedback(guess2, 1234, C, D, colorFreqCounter); + int guess2 = ind(1344); + int fb2 = Feedback.getFeedback(guess2, ind(1234), C, D, colorFreqCounter); session.recordGuess(guess2, fb2); int spaceAfter2 = session.getSolutionSpaceSize(); @@ -71,20 +74,20 @@ void testUndoMultiple() { */ @Test void testUndoPartial() { - int[] colorFreqCounter = new int[10]; + int[] colorFreqCounter = new int[C]; MastermindSession session = new MastermindSession(C, D); - int guess1 = 1122; - int fb1 = Feedback.getFeedback(guess1, 1234, C, D, colorFreqCounter); + int guess1 = ind(1122); + int fb1 = Feedback.getFeedback(guess1, ind(1234), C, D, colorFreqCounter); session.recordGuess(guess1, fb1); int spaceAfter1 = session.getSolutionSpaceSize(); - int guess2 = 1344; - int fb2 = Feedback.getFeedback(guess2, 1234, C, D, colorFreqCounter); + int guess2 = ind(1344); + int fb2 = Feedback.getFeedback(guess2, ind(1234), C, D, colorFreqCounter); session.recordGuess(guess2, fb2); - int guess3 = 1234; - int fb3 = Feedback.getFeedback(guess3, 1234, C, D, colorFreqCounter); + int guess3 = ind(1234); + int fb3 = Feedback.getFeedback(guess3, ind(1234), C, D, colorFreqCounter); session.recordGuess(guess3, fb3); assertTrue(session.isSolved()); @@ -106,32 +109,32 @@ void testUndoInvalidN() { assertThrows(IllegalArgumentException.class, () -> session.undo(1)); } - private void runGame(int secret) { - MastermindSession session = new MastermindSession(C, D); - int[] colorFreqCounter = new int[10]; - ExpectedSize expectedSize = new ExpectedSize(D); - int[] feedbackFreq = new int[100]; + private void runGame(int secretInd) { + MastermindSession session = new MastermindSession(C, D); + int[] colorFreq = new int[C]; + ExpectedSize expectedSize = new ExpectedSize(D); + int[] feedbackFreq = new int[100]; - System.out.println("Secret: " + secret); + System.out.println("Secret index: " + secretInd); while (!session.isSolved()) { assertFalse(session.getTurnCount() >= MAX_TURNS, - "Solver exceeded " + MAX_TURNS + " turns for secret " + secret + "Solver exceeded " + MAX_TURNS + " turns for secret " + secretInd + " (still unsolved after turn " + session.getTurnCount() + ")"); int spaceBefore = session.getSolutionSpaceSize(); - int guess = session.suggestGuess(); - float expSize = expectedSize.calcExpectedSize(guess, session.getSolutionSpaceSecrets(), C, D, + int guessInd = session.suggestGuess(); + float expSize = expectedSize.calcExpectedSize(guessInd, session.getSolutionSpaceSecrets(), C, D, feedbackFreq); - int feedback = Feedback.getFeedback(guess, secret, C, D, colorFreqCounter); - session.recordGuess(guess, feedback); + int feedback = Feedback.getFeedback(guessInd, secretInd, C, D, colorFreq); + session.recordGuess(guessInd, feedback); int turn = session.getTurnCount(); int black = feedback / 10; int white = feedback % 10; - System.out.printf(" Turn %d: guess=%d space=%d expected=%.2f feedback=%db%dw%n", - turn, guess, spaceBefore, expSize, black, white); + System.out.printf(" Turn %d: guessInd=%d space=%d expected=%.2f feedback=%db%dw%n", + turn, guessInd, spaceBefore, expSize, black, white); } System.out.println(" Solved in " + session.getTurnCount() + " turns."); diff --git a/src/tests/java/org/mastermind/codes/CanonicalCodeTest.java b/src/tests/java/org/mastermind/codes/CanonicalCodeTest.java index 431d6405..fdaabc21 100644 --- a/src/tests/java/org/mastermind/codes/CanonicalCodeTest.java +++ b/src/tests/java/org/mastermind/codes/CanonicalCodeTest.java @@ -59,28 +59,26 @@ void testMaxIntSafety() { @Nested @DisplayName("Enumerate Canonical Forms") class CanonicalCodeForms { + /** - * Helper to verify if a number is mathematically canonical. - * New colors must be the smallest available integer. + * Helper to verify if an index is canonical. + * Extracts digits left-to-right and checks no color is skipped. */ - private boolean isCanonical(int code, int d) { - int maxSeen = 0; - int divisor = (int) Math.pow(10, d - 1); - - while (divisor > 0) { - int digit = code / divisor; // Get the leftmost digit - - if (digit > maxSeen + 1) return false; - if (digit > maxSeen) maxSeen = digit; - - code %= divisor; // Remove the leftmost digit - divisor /= 10; // Move to the next place value + private boolean isCanonicalInd(int ind, int c, int d) { + int maxSeen = -1; // highest digit value seen so far (0-based) + int place = (int) Math.pow(c, d - 1); + for (int pos = 0; pos < d; pos++) { + int digitVal = ind / place; // 0..c-1 + ind %= place; + place /= c; + if (digitVal > maxSeen + 1) return false; + if (digitVal > maxSeen) maxSeen = digitVal; } return true; } @Test - @DisplayName("Verify array size matches Stirling Sum for (6, 9)") + @DisplayName("Verify array size matches Stirling Sum for (9, 9)") void testArraySize() { int c = 9; int d = 9; @@ -92,54 +90,53 @@ void testArraySize() { } @Test - @DisplayName("Verify specific canonical forms for small c, d") + @DisplayName("Verify specific canonical form indices for small c, d") void testSmallEnumeration() { - // For c=2, d=3, canonical forms should be: - // 111 (uses 1 color) - // 112 (uses 2 colors) - // 121 (uses 2 colors) - // 122 (uses 2 colors) - int[] expected = { 111, 112, 121, 122 }; + // For c=2, d=3, canonical forms are: 111, 112, 121, 122 + // As indices (c=2): 0, 1, 2, 3 + int[] expected = { 0, 1, 2, 3 }; int[] actual = CanonicalCode.enumerateCanonicalForms(2, 3); - assertArrayEquals(expected, actual, "Should generate exactly 111, 112, 121, 122"); + assertArrayEquals(expected, actual); } @Test - @DisplayName("Verify Rule 1: All codes must start with 1") + @DisplayName("Verify Rule 1: All codes must start with color 1 (digitVal 0)") void testStartsWithOne() { - int[] results = CanonicalCode.enumerateCanonicalForms(6, 4); - for (int code : results) { - // A 4-digit number starting with 1 is between 1000 and 1999 - assertTrue(code >= 1000 && code <= 1999, "Code " + code + " must start with 1"); + int c = 6, d = 4; + int threshold = (int) Math.pow(c, d - 1); // indices with leading digitVal 0 are < c^(d-1) + int[] results = CanonicalCode.enumerateCanonicalForms(c, d); + for (int ind : results) { + assertTrue(ind < threshold, "Index " + ind + " does not start with digitVal 0 (color 1)"); } } @Test @DisplayName("Verify Rule 2: No skipping colors (Canonical check)") void testNoSkippedColors() { - int[] results = CanonicalCode.enumerateCanonicalForms(6, 5); - for (int code : results) { - assertTrue(isCanonical(code, 5), "Code " + code + " violates canonical rules"); + int c = 6, d = 5; + int[] results = CanonicalCode.enumerateCanonicalForms(c, d); + for (int ind : results) { + assertTrue(isCanonicalInd(ind, c, d), "Index " + ind + " violates canonical rules"); } } @Test @DisplayName("Edge Case: c=1") void testSingleColor() { - // If only 1 color is allowed, every digit must be 1 + // If only 1 color, every digit is 0 → index 0 int[] results = CanonicalCode.enumerateCanonicalForms(1, 4); assertEquals(1, results.length); - assertEquals(1111, results[0]); + assertEquals(0, results[0]); } @Test @DisplayName("Edge Case: d=1") void testSingleDigit() { - // If length is 1, only '1' is possible + // If length is 1, only color 1 (digitVal 0) is possible → index 0 int[] results = CanonicalCode.enumerateCanonicalForms(6, 1); assertEquals(1, results.length); - assertEquals(1, results[0]); + assertEquals(0, results[0]); } } -} \ No newline at end of file +} diff --git a/src/tests/java/org/mastermind/codes/SampledCodeTest.java b/src/tests/java/org/mastermind/codes/SampledCodeTest.java index 97be1158..c4e389b9 100644 --- a/src/tests/java/org/mastermind/codes/SampledCodeTest.java +++ b/src/tests/java/org/mastermind/codes/SampledCodeTest.java @@ -14,30 +14,6 @@ void testReturnsSampleOfCorrectSize() { assertEquals(1000, result.length); } - @Test - void testAllCodesHaveCorrectNumberOfDigits() { - int d = 4; - int[] result = SampledCode.getSample(6, d, 1000); - for (int code : result) { - int digitCount = String.valueOf(code).length(); - assertEquals(d, digitCount, "Code " + code + " does not have " + d + " digits"); - } - } - - @ParameterizedTest - @CsvSource({ "6,4", "3,3", "8,5", "2,1" }) - void testAllDigitsWithinColorRange(int c, int d) { - int[] result = SampledCode.getSample(c, d, 1000); - for (int code : result) { - String s = String.valueOf(code); - for (char ch : s.toCharArray()) { - int digit = ch - '0'; - assertTrue(digit >= 1 && digit <= c, - "Digit " + digit + " out of range [1," + c + "] in code " + code); - } - } - } - @Test void testSampleSizeZeroReturnsEmptyArray() { int[] result = SampledCode.getSample(6, 4, 0); @@ -45,33 +21,24 @@ void testSampleSizeZeroReturnsEmptyArray() { assertEquals(0, result.length); } - @Test - void testSinglePeg() { - int c = 6, d = 1; - int[] result = SampledCode.getSample(c, d, 500); - for (int code : result) { - assertTrue(code >= 1 && code <= c, - "Single-peg code " + code + " out of range [1," + c + "]"); + @ParameterizedTest + @CsvSource({ "6,4", "3,3", "8,5", "2,1" }) + void testAllIndicesWithinRange(int c, int d) { + int total = (int) Math.pow(c, d); + int[] result = SampledCode.getSample(c, d, 1000); + for (int ind : result) { + assertTrue(ind >= 0 && ind < total, + "Index " + ind + " out of range [0," + total + ")"); } } @Test void testResultIsActuallyRandom() { - // With 1000 samples from 6^4=1296 possible codes, we expect reasonable variety. + // With 1000 samples from 6^4=1296 possible indices, we expect reasonable variety. // The chance of getting fewer than 100 unique values is astronomically small. int[] result = SampledCode.getSample(6, 4, 1000); long uniqueCount = java.util.Arrays.stream(result).distinct().count(); assertTrue(uniqueCount > 100, - "Expected high variety in samples, got only " + uniqueCount + " unique codes"); - } - - @Test - void testOnlyOneColor() { - // With c=1, every code must be all 1s, e.g. 1111 for d=4 - int d = 4; - int[] result = SampledCode.getSample(1, d, 100); - for (int code : result) { - assertEquals(1111, code, "With c=1 and d=4, every code must be 1111"); - } + "Expected high variety in samples, got only " + uniqueCount + " unique indices"); } -} \ No newline at end of file +} diff --git a/src/tests/java/org/mastermind/solver/BestGuessTest.java b/src/tests/java/org/mastermind/solver/BestGuessTest.java index affa9233..7a8b8086 100644 --- a/src/tests/java/org/mastermind/solver/BestGuessTest.java +++ b/src/tests/java/org/mastermind/solver/BestGuessTest.java @@ -2,24 +2,22 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import org.mastermind.codes.AllValidCode; +import org.mastermind.codes.ConvertCode; import static org.junit.jupiter.api.Assertions.*; public class BestGuessTest { - private static final int EXPECTED_BEST_GUESS = 1123; - private final int c = 6; - private final int d = 4; - private int[] allCodes; + private static final int C = 6; + private static final int D = 4; + private int[] allInd; + + private static int ind(int code) { return ConvertCode.toIndex(C, D, code); } - /** - * Setup method that runs before each test. - * Generates all valid codes (6 pegs, 4 colors) - same as benchmark. - */ @BeforeEach public void setUp() { - allCodes = AllValidCode.generateAllCodes(c, d); + allInd = new int[(int) Math.pow(C, D)]; + for (int i = 0; i < allInd.length; i++) allInd[i] = i; } /** @@ -29,11 +27,8 @@ public void setUp() { */ @Test public void testOrdinaryVersion() { - // Act: Call the ordinary version with parallel = false - int bestGuess = (int) BestGuess.findBestGuess(allCodes, allCodes, c, d, false)[0]; - - // Assert: Verify the result matches the expected value - assertEquals(EXPECTED_BEST_GUESS, bestGuess); + int bestGuessInd = (int) BestGuess.findBestGuess(allInd, allInd, C, D, false)[0]; + assertEquals(ind(1123), bestGuessInd); } /** @@ -43,11 +38,8 @@ public void testOrdinaryVersion() { */ @Test public void testParallelVersion() { - // Act: Call the parallel version with parallel = true - int bestGuess = (int) BestGuess.findBestGuess(allCodes, allCodes, c, d, true)[0]; - - // Assert: Verify the result matches the expected value - assertEquals(EXPECTED_BEST_GUESS, bestGuess); + int bestGuessInd = (int) BestGuess.findBestGuess(allInd, allInd, C, D, true)[0]; + assertEquals(ind(1123), bestGuessInd); BestGuess.shutdown(); } diff --git a/src/tests/java/org/mastermind/solver/ExpectedSizeTest.java b/src/tests/java/org/mastermind/solver/ExpectedSizeTest.java index 37a2ef5e..927ae03a 100644 --- a/src/tests/java/org/mastermind/solver/ExpectedSizeTest.java +++ b/src/tests/java/org/mastermind/solver/ExpectedSizeTest.java @@ -1,7 +1,7 @@ package org.mastermind.solver; import org.junit.jupiter.api.Test; -import org.mastermind.codes.AllValidCode; +import org.mastermind.codes.ConvertCode; import static org.junit.jupiter.api.Assertions.*; @@ -9,31 +9,41 @@ public class ExpectedSizeTest { private static final int COLORS = 6; private static final int DIGITS = 4; + private static final int TOTAL = 1296; // 6^4 private static final float DELTA = 0.001f; private static final int[] feedbackFreq = new int[100]; private static final ExpectedSize expectedSizeObj = new ExpectedSize(DIGITS); - int[] secrets = AllValidCode.generateAllCodes(COLORS, DIGITS); - private float calcExpectedSize(int guess, int[] secrets) { - return expectedSizeObj.calcExpectedSize(guess, secrets, COLORS, DIGITS, feedbackFreq); + // All secret indices 0..TOTAL-1 + private static final int[] secretsInd; + + static { + secretsInd = new int[TOTAL]; + for (int i = 0; i < TOTAL; i++) secretsInd[i] = i; + } + + private static int ind(int code) { return ConvertCode.toIndex(COLORS, DIGITS, code); } + + private float calcExpectedSize(int guessInd) { + return expectedSizeObj.calcExpectedSize(guessInd, secretsInd, COLORS, DIGITS, feedbackFreq); } @Test public void testExpectedSize() { - assertEquals(204.5355f, calcExpectedSize(1122, secrets), DELTA); - assertEquals(185.2685f, calcExpectedSize(1123, secrets), DELTA); - assertEquals(188.1898f, calcExpectedSize(1234, secrets), DELTA); - assertEquals(235.9491f, calcExpectedSize(1112, secrets), DELTA); - assertEquals(511.9799f, calcExpectedSize(1111, secrets), DELTA); - assertEquals(204.5355f, calcExpectedSize(1212, secrets), DELTA); + assertEquals(204.5355f, calcExpectedSize(ind(1122)), DELTA); + assertEquals(185.2685f, calcExpectedSize(ind(1123)), DELTA); + assertEquals(188.1898f, calcExpectedSize(ind(1234)), DELTA); + assertEquals(235.9491f, calcExpectedSize(ind(1112)), DELTA); + assertEquals(511.9799f, calcExpectedSize(ind(1111)), DELTA); + assertEquals(204.5355f, calcExpectedSize(ind(1212)), DELTA); } @Test public void testExpectedSizeSymmetry() { // Guesses with the same color multiset should yield the same expected size - float base = calcExpectedSize(1122, secrets); - assertEquals(base, calcExpectedSize(1212, secrets), DELTA); - assertEquals(base, calcExpectedSize(2211, secrets), DELTA); - assertEquals(base, calcExpectedSize(2121, secrets), DELTA); + float base = calcExpectedSize(ind(1122)); + assertEquals(base, calcExpectedSize(ind(1212)), DELTA); + assertEquals(base, calcExpectedSize(ind(2211)), DELTA); + assertEquals(base, calcExpectedSize(ind(2121)), DELTA); } } \ No newline at end of file diff --git a/src/tests/java/org/mastermind/solver/FeedbackTest.java b/src/tests/java/org/mastermind/solver/FeedbackTest.java index 04175e37..2f246d5c 100644 --- a/src/tests/java/org/mastermind/solver/FeedbackTest.java +++ b/src/tests/java/org/mastermind/solver/FeedbackTest.java @@ -1,6 +1,7 @@ package org.mastermind.solver; import org.junit.jupiter.api.Test; +import org.mastermind.codes.ConvertCode; import static org.junit.jupiter.api.Assertions.*; @@ -11,27 +12,10 @@ public class FeedbackTest { private static final int TOTAL_COMBINATIONS = 1296; // 6^4 private static final int[] colorFreqCounter = new int[10]; - /** - * Converts a combination index to its Mastermind representation. - * For example, with 6 colors and 4 digits: - * 0 -> 1111, 1 -> 1112, 2 -> 1113, ..., 5 -> 1116, 6 -> 1121, etc. - */ - private int indexToCombination(int index) { - int result = 0; - int divisor = 1; - - for (int i = 0; i < DIGITS; i++) { - int digit = (index % COLORS) + 1; - result += digit * divisor; - divisor *= 10; - index /= COLORS; - } - - return result; - } + private static int ind(int code) { return ConvertCode.toIndex(COLORS, DIGITS, code); } - private int getFeedbackQuick(int guess, int secret) { - return Feedback.getFeedback(guess, secret, COLORS, DIGITS, colorFreqCounter); + private int getFeedbackQuick(int guessInd, int secretInd) { + return Feedback.getFeedback(guessInd, secretInd, COLORS, DIGITS, colorFreqCounter); } @Test @@ -40,26 +24,14 @@ public void testIterationPerformance() { long startTime; int totalCalls = 0; - // Pre-generate all combinations OUTSIDE the timer - int[] allCombinations = new int[TOTAL_COMBINATIONS]; - for (int i = 0; i < TOTAL_COMBINATIONS; i++) { - allCombinations[i] = indexToCombination(i); - } - - int[] secrets = new int[TOTAL_COMBINATIONS]; - System.arraycopy(allCombinations, 0, secrets, 0, TOTAL_COMBINATIONS); - startTime = System.nanoTime(); // Run multiple times for (int t = 0; t < 50; t++) { - // Call single version 1,296 times, storing results in a 2D array + // Call getFeedback for all (guess, secret) index pairs for (int guessIdx = 0; guessIdx < TOTAL_COMBINATIONS; guessIdx++) { - int guess = allCombinations[guessIdx]; - for (int secretIdx = 0; secretIdx < TOTAL_COMBINATIONS; secretIdx++) { - int secret = secrets[secretIdx]; - getFeedbackQuick(guess, secret); + getFeedbackQuick(guessIdx, secretIdx); totalCalls++; } } @@ -81,7 +53,7 @@ public void testSingleCombinationPerformance() { // Run multiple times int limit = (int) Math.pow(6, 4); for (int t = 0; t < limit; t++) { - getFeedbackQuick(1123, 3456); + getFeedbackQuick(ind(1123), ind(3456)); } long endTime = System.nanoTime(); @@ -97,28 +69,28 @@ public void testEdgeCases() { System.out.println("\n=== Edge Cases Test ==="); // Perfect match - int result1 = getFeedbackQuick(1111, 1111); + int result1 = getFeedbackQuick(ind(1111), ind(1111)); assertEquals(4, result1 / 10, "Perfect match should have 4 blacks"); assertEquals(0, result1 % 10, "Perfect match should have 0 whites"); System.out.println("✓ Perfect match (1111 vs 1111): " + result1 / 10 + " black, " + result1 % 10 + " white"); // No match - int result2 = getFeedbackQuick(1111, 2222); + int result2 = getFeedbackQuick(ind(1111), ind(2222)); assertEquals(0, result2 / 10, "No match should have 0 blacks"); assertEquals(0, result2 % 10, "No match should have 0 whites"); System.out.println("✓ No match (1111 vs 2222): " + result2 / 10 + " black, " + result2 % 10 + " white"); // All whites - int result3 = getFeedbackQuick(1234, 4321); + int result3 = getFeedbackQuick(ind(1234), ind(4321)); assertEquals(0, result3 / 10, "All whites case should have 0 blacks"); assertEquals(4, result3 % 10, "All whites case should have 4 whites"); System.out.println("✓ All whites (1234 vs 4321): " + result3 / 10 + " black, " + result3 % 10 + " white"); // Mixed - int result4 = getFeedbackQuick(5566, 5655); + int result4 = getFeedbackQuick(ind(5566), ind(5655)); assertEquals(1, result4 / 10, "Mixed case blacks"); assertEquals(2, result4 % 10, "Mixed case whites"); - System.out.println("✓ Mixed (1122 vs 1211): " + result4 / 10 + " black, " + result4 % 10 + " white"); + System.out.println("✓ Mixed (5566 vs 5655): " + result4 / 10 + " black, " + result4 % 10 + " white"); } @Test diff --git a/src/tests/java/org/mastermind/solver/SolutionSpaceTest.java b/src/tests/java/org/mastermind/solver/SolutionSpaceTest.java index d9cd8c0c..a43c9171 100644 --- a/src/tests/java/org/mastermind/solver/SolutionSpaceTest.java +++ b/src/tests/java/org/mastermind/solver/SolutionSpaceTest.java @@ -1,7 +1,7 @@ package org.mastermind.solver; import org.junit.jupiter.api.Test; -import org.mastermind.codes.CodeCache; +import org.mastermind.codes.ConvertCode; import static org.junit.jupiter.api.Assertions.*; @@ -11,37 +11,38 @@ public class SolutionSpaceTest { private static final int D = 4; private static final int TOTAL = 1296; // 6^4 + private static int ind(int code) { return ConvertCode.toIndex(C, D, code); } + @Test void testFilterSolution() { - int guess = 1123; - int secret = 4563; - int[] colorFreqCounter = new int[10]; + int guessIdx = ind(1123); + int secretIdx = ind(4563); + int[] colorFreqCounter = new int[C]; // Compute the feedback for guess vs secret - int obtainedFeedback = Feedback.getFeedback(guess, secret, C, D, colorFreqCounter); + int obtainedFeedback = Feedback.getFeedback(guessIdx, secretIdx, C, D, colorFreqCounter); - // Count how many of the 1296 codes produce the same feedback - int[] allCodes = CodeCache.getAllValid(C, D); - int expectedCount = 0; - for (int s : allCodes) { - if (Feedback.getFeedback(guess, s, C, D, colorFreqCounter) == obtainedFeedback) { + // Count how many of the 1296 indices produce the same feedback + int expectedCount = 0; + for (int s = 0; s < TOTAL; s++) { + if (Feedback.getFeedback(guessIdx, s, C, D, colorFreqCounter) == obtainedFeedback) { expectedCount++; } } - // filterSolution should retain exactly those codes + // filterSolution should retain exactly those indices SolutionSpace space = new SolutionSpace(C, D); assertEquals(TOTAL, space.getSize(), "Initial solution space should be 1296"); - space.filterSolution(guess, obtainedFeedback); + space.filterSolution(guessIdx, obtainedFeedback); assertEquals(expectedCount, space.getSize(), "After filtering, size should match manual count for feedback " + obtainedFeedback); - // Every remaining secret must produce the same feedback with the guess + // Every remaining secret index must produce the same feedback with the guess for (int s : space.getSecrets()) { - int fb = Feedback.getFeedback(guess, s, C, D, colorFreqCounter); + int fb = Feedback.getFeedback(guessIdx, s, C, D, colorFreqCounter); assertEquals(obtainedFeedback, fb, - "Remaining secret " + s + " should produce feedback " + obtainedFeedback); + "Remaining secret index " + s + " should produce feedback " + obtainedFeedback); } } } From 1ce6b5cd831095d953f2fefa74e84d09e2818712 Mon Sep 17 00:00:00 2001 From: FlysonBot <116744100+FlysonBot@users.noreply.github.com> Date: Tue, 3 Mar 2026 01:01:31 -0800 Subject: [PATCH 05/23] refactor: set threads as daemon threads so they shut down automatically --- .../java/org/mastermind/BestGuessBenchmark.java | 6 ------ src/main/java/org/mastermind/Demo.java | 2 -- src/main/java/org/mastermind/solver/BestGuess.java | 10 ++++++++-- .../java/org/mastermind/solver/BestGuessTest.java | 2 -- 4 files changed, 8 insertions(+), 12 deletions(-) diff --git a/src/benchmarks/java/org/mastermind/BestGuessBenchmark.java b/src/benchmarks/java/org/mastermind/BestGuessBenchmark.java index 8d36a6b1..4e122a26 100644 --- a/src/benchmarks/java/org/mastermind/BestGuessBenchmark.java +++ b/src/benchmarks/java/org/mastermind/BestGuessBenchmark.java @@ -38,12 +38,6 @@ public BenchmarkState() { allInd = new int[(int) Math.pow(C, D)]; for (int i = 0; i < allInd.length; i++) allInd[i] = i; } - - // TEARDOWN - Shutdown the thread pool after benchmarking - @TearDown(Level.Trial) - public void tearDown() { - BestGuess.shutdown(); - } } } diff --git a/src/main/java/org/mastermind/Demo.java b/src/main/java/org/mastermind/Demo.java index fb258684..23c52a65 100644 --- a/src/main/java/org/mastermind/Demo.java +++ b/src/main/java/org/mastermind/Demo.java @@ -1,6 +1,5 @@ package org.mastermind; -import org.mastermind.solver.BestGuess; import org.mastermind.solver.ExpectedSize; import org.mastermind.solver.Feedback; @@ -56,6 +55,5 @@ public static void main(String[] args) { double elapsedSec = (System.nanoTime() - startTime) / 1_000_000_000.0; System.out.printf("%nSolved in %d turn(s).%n", session.getTurnCount()); System.out.printf("Time: %.1f seconds%n", elapsedSec); - BestGuess.shutdown(); } } diff --git a/src/main/java/org/mastermind/solver/BestGuess.java b/src/main/java/org/mastermind/solver/BestGuess.java index 0730277c..db5b2764 100644 --- a/src/main/java/org/mastermind/solver/BestGuess.java +++ b/src/main/java/org/mastermind/solver/BestGuess.java @@ -19,10 +19,16 @@ */ public class BestGuess { private static final int THREAD_COUNT = Runtime.getRuntime().availableProcessors(); - private static final ExecutorService POOL = Executors.newFixedThreadPool(THREAD_COUNT); + private static final ExecutorService POOL; private static final long PARALLEL_THRESHOLD = 3_000_000; - public static void shutdown() { POOL.shutdown(); } + static { + POOL = Executors.newFixedThreadPool(THREAD_COUNT, r -> { + Thread t = new Thread(r); + t.setDaemon(true); + return t; + }); + } /** * Find the guess that will minimize the expected size of the solution space diff --git a/src/tests/java/org/mastermind/solver/BestGuessTest.java b/src/tests/java/org/mastermind/solver/BestGuessTest.java index 7a8b8086..2a401b48 100644 --- a/src/tests/java/org/mastermind/solver/BestGuessTest.java +++ b/src/tests/java/org/mastermind/solver/BestGuessTest.java @@ -40,7 +40,5 @@ public void testOrdinaryVersion() { public void testParallelVersion() { int bestGuessInd = (int) BestGuess.findBestGuess(allInd, allInd, C, D, true)[0]; assertEquals(ind(1123), bestGuessInd); - - BestGuess.shutdown(); } } From 58b38599c1c84c7a22addd955d25b6f88efa6304 Mon Sep 17 00:00:00 2001 From: FlysonBot <116744100+FlysonBot@users.noreply.github.com> Date: Tue, 3 Mar 2026 15:47:22 -0800 Subject: [PATCH 06/23] feat: add getValidSample method to sample only valid secrets --- CLAUDE.md | 5 +- .../org/mastermind/SampledCodeBenchmark.java | 132 ++++++++++++++++-- src/main/java/org/mastermind/Demo.java | 17 ++- .../java/org/mastermind/GuessStrategy.java | 13 +- .../org/mastermind/codes/SampledCode.java | 36 +++++ .../org/mastermind/solver/SolutionSpace.java | 3 + 6 files changed, 181 insertions(+), 25 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 36b00e13..dcc848c8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -22,12 +22,11 @@ Status: Rewriting codebase; currently focused on Java algorithm only. ### Next Move / Current Move -- IMPORTANT: Currently trying to do large scale refactor class-by-class step-by-step to deprecate int[] array - for passing combinations around, and instead uses BitSet +- Implementation done, currently refactoring to improve performance. ### Preference - Stick to primitive type unless there is a reason not to. - Unless necessary, do not write extra class and objects. Be simple. - Do not run any tests or benchmark for me unless specifically instructed. -- Do not remove the average performance footer in benchmarks when updating. \ No newline at end of file +- DO NOT TOUCH the average performance in benchmarks. Don't delete, don't modify, don't change, don't update. \ No newline at end of file diff --git a/src/benchmarks/java/org/mastermind/SampledCodeBenchmark.java b/src/benchmarks/java/org/mastermind/SampledCodeBenchmark.java index 1f47c4d4..cbe3335d 100644 --- a/src/benchmarks/java/org/mastermind/SampledCodeBenchmark.java +++ b/src/benchmarks/java/org/mastermind/SampledCodeBenchmark.java @@ -5,29 +5,139 @@ import org.openjdk.jmh.annotations.*; import org.openjdk.jmh.infra.Blackhole; +import java.util.BitSet; import java.util.concurrent.TimeUnit; +/** + * Benchmarks getValidSample for c=9, d=9 at fill rates spanning both the + * enumeration path (validCount <= 5M) and the rejection path (validCount > 5M). + * Goal: confirm that sampling time stays small regardless of fill rate. + */ @BenchmarkMode(Mode.AverageTime) @OutputTimeUnit(TimeUnit.MILLISECONDS) -@Warmup(iterations = 3, time = 1) -@Measurement(iterations = 3, time = 1) -@Fork(5) +@Warmup(iterations = 2, time = 1) +@Measurement(iterations = 2, time = 1) +@Fork(1) public class SampledCodeBenchmark { + // ── Benchmarks ──────────────────────────────────────────────────────────── + + private static BitSet buildBitSet(double fillRate) { + int total = (int) Math.pow(9, 9); + int count = (int) (fillRate * total); + BitSet bs = new BitSet(total); + if (count >= total) { + bs.set(0, total); + } else { + double step = (double) total / count; + for (int i = 0; i < count; i++) bs.set((int) (i * step)); + } + return bs; + } + + @Benchmark + public void sample_enum_1pct(Fill1pct state, Blackhole bh) { + bh.consume(SampledCode.getValidSample(state.remaining, state.validCount, 9, 9, state.sampleSize)); + } + + @Benchmark + public void sample_enum_05pct(Fill05pct state, Blackhole bh) { + bh.consume(SampledCode.getValidSample(state.remaining, state.validCount, 9, 9, state.sampleSize)); + } + + @Benchmark + public void sample_reject_2pct(Fill2pct state, Blackhole bh) { + bh.consume(SampledCode.getValidSample(state.remaining, state.validCount, 9, 9, state.sampleSize)); + } + + @Benchmark + public void sample_reject_5pct(Fill5pct state, Blackhole bh) { + bh.consume(SampledCode.getValidSample(state.remaining, state.validCount, 9, 9, state.sampleSize)); + } + + @Benchmark + public void sample_reject_10pct(Fill10pct state, Blackhole bh) { + bh.consume(SampledCode.getValidSample(state.remaining, state.validCount, 9, 9, state.sampleSize)); + } + + @Benchmark + public void sample_reject_50pct(Fill50pct state, Blackhole bh) { + bh.consume(SampledCode.getValidSample(state.remaining, state.validCount, 9, 9, state.sampleSize)); + } + + // ── States ──────────────────────────────────────────────────────────────── + @Benchmark - public void benchmarkGetSample(BenchmarkState state, Blackhole blackhole) { - int[] sample = SampledCode.getSample(9, 9, state.sampleSize); - blackhole.consume(sample); + public void sample_reject_100pct(Fill100pct state, Blackhole bh) { + bh.consume(SampledCode.getValidSample(state.remaining, state.validCount, 9, 9, state.sampleSize)); } @State(Scope.Thread) - public static class BenchmarkState { - // feedbackSize for d=9: (9+1)*(9+2)/2 = 55 + public static class BaseState { final int sampleSize = SampledCode.calcSampleSizeForSecrets(Feedback.calcFeedbackSize(9)); } + + // Enumeration path (validCount <= MAX_ENUM = 5M) + @State(Scope.Thread) + public static class Fill05pct extends BaseState { + // 0.5%: validCount ≈ 1.9M — enum + final BitSet remaining = buildBitSet(0.005); + final int validCount = remaining.cardinality(); + } + + @State(Scope.Thread) + public static class Fill1pct extends BaseState { + // 1%: validCount ≈ 3.9M — enum + final BitSet remaining = buildBitSet(0.010); + final int validCount = remaining.cardinality(); + } + + // Rejection path (validCount > MAX_ENUM = 5M) + @State(Scope.Thread) + public static class Fill2pct extends BaseState { + // 2%: validCount ≈ 7.7M — reject + final BitSet remaining = buildBitSet(0.020); + final int validCount = remaining.cardinality(); + } + + @State(Scope.Thread) + public static class Fill5pct extends BaseState { + // 5%: validCount ≈ 19M — reject + final BitSet remaining = buildBitSet(0.050); + final int validCount = remaining.cardinality(); + } + + @State(Scope.Thread) + public static class Fill10pct extends BaseState { + // 10%: validCount ≈ 39M — reject + final BitSet remaining = buildBitSet(0.100); + final int validCount = remaining.cardinality(); + } + + @State(Scope.Thread) + public static class Fill50pct extends BaseState { + // 50%: validCount ≈ 194M — reject + final BitSet remaining = buildBitSet(0.500); + final int validCount = remaining.cardinality(); + } + + // ── Helper ──────────────────────────────────────────────────────────────── + + @State(Scope.Thread) + public static class Fill100pct extends BaseState { + // 100%: validCount = 387M — reject + final BitSet remaining = buildBitSet(1.000); + final int validCount = remaining.cardinality(); + } } /* Average Performance: -Benchmark Mode Cnt Score Error Units -SampledCodeBenchmark.benchmarkGetSample avgt 15 9.964 ± 0.152 ms/op - */ \ No newline at end of file +Benchmark Mode Cnt Score Error Units +SampledCodeBenchmark.sample_enum_05pct avgt 2 12.344 ms/op +SampledCodeBenchmark.sample_enum_1pct avgt 2 19.482 ms/op +SampledCodeBenchmark.sample_reject_100pct avgt 2 0.400 ms/op +SampledCodeBenchmark.sample_reject_10pct avgt 2 3.953 ms/op +SampledCodeBenchmark.sample_reject_2pct avgt 2 19.607 ms/op +SampledCodeBenchmark.sample_reject_50pct avgt 2 0.833 ms/op +SampledCodeBenchmark.sample_reject_5pct avgt 2 7.851 ms/op + */ diff --git a/src/main/java/org/mastermind/Demo.java b/src/main/java/org/mastermind/Demo.java index 23c52a65..aad4a1ad 100644 --- a/src/main/java/org/mastermind/Demo.java +++ b/src/main/java/org/mastermind/Demo.java @@ -1,5 +1,6 @@ package org.mastermind; +import org.mastermind.codes.ConvertCode; import org.mastermind.solver.ExpectedSize; import org.mastermind.solver.Feedback; @@ -45,11 +46,17 @@ public static void main(String[] args) { session.recordGuess(guessInd, feedback); - int turn = session.getTurnCount(); - int black = feedback / 10; - int white = feedback % 10; - System.out.printf("Turn %d: guessInd=%-10d space=%-5d expected=%.2f feedback=%db%dw%n", - turn, guessInd, spaceBefore, expSize, black, white); + int turn = session.getTurnCount(); + int spaceAfter = session.getSolutionSpaceSize(); + int black = feedback / 10; + int white = feedback % 10; + float expElimPct = spaceBefore > 0 ? 100f * (spaceBefore - expSize) / spaceBefore : 0f; + float actElimPct = spaceBefore > 0 ? 100f * (spaceBefore - spaceAfter) / spaceBefore : 0f; + int guessCode = ConvertCode.toCode(C, D, guessInd); + System.out.printf( + "Turn %d: before=%-8d expAfter=%-8.1f actAfter=%-8d expElim=%5.1f%% actElim=%5.1f%% " + + "guess=%-12d feedback=%db%dw%n", + turn, spaceBefore, expSize, spaceAfter, expElimPct, actElimPct, guessCode, black, white); } double elapsedSec = (System.nanoTime() - startTime) / 1_000_000_000.0; diff --git a/src/main/java/org/mastermind/GuessStrategy.java b/src/main/java/org/mastermind/GuessStrategy.java index 4a801e97..4d8087e4 100644 --- a/src/main/java/org/mastermind/GuessStrategy.java +++ b/src/main/java/org/mastermind/GuessStrategy.java @@ -52,11 +52,11 @@ private static int[][] firstTurn(int c, int d, int secretsSize, SolutionSpace so for (double tolerance : new double[] { 0.001, 0.005 }) { if (fits(canonical.length, secretSampleSize(d, tolerance))) { - return pair(canonical, secretSample(c, d, tolerance)); + return pair(canonical, secretSample(c, d, tolerance, solutionSpace)); } } - return pair(canonical, secretSample(c, d, 0.01)); + return pair(canonical, secretSample(c, d, 0.01, solutionSpace)); } /** @@ -71,11 +71,11 @@ private static int[][] laterTurns(int c, int d, int secretsSize, SolutionSpace s for (double tolerance : new double[] { 0.001, 0.005, 0.01 }) { if (fits(secretsSize, secretSampleSize(d, tolerance))) { - return pair(solutionSpace.getSecrets(), secretSample(c, d, tolerance)); + return pair(solutionSpace.getSecrets(), secretSample(c, d, tolerance, solutionSpace)); } } - int[] sSample = secretSample(c, d, 0.01); + int[] sSample = secretSample(c, d, 0.01, solutionSpace); for (double percentile : new double[] { 0.001, 0.005, 0.01, 0.05 }) { if (fits(secretsSize, guessSampleSize(percentile))) { return pair(guessSample(c, d, percentile), sSample); @@ -101,8 +101,9 @@ private static int guessSampleSize(double percentileThreshold) { return SampledCode.calcSampleSizeForGuesses(percentileThreshold, 0.999); } - private static int[] secretSample(int c, int d, double tolerance) { - return SampledCode.getSample(c, d, secretSampleSize(d, tolerance)); + private static int[] secretSample(int c, int d, double tolerance, SolutionSpace solutionSpace) { + return SampledCode.getValidSample(solutionSpace.getRemaining(), solutionSpace.getSize(), c, d, + secretSampleSize(d, tolerance)); } private static int[] guessSample(int c, int d, double percentileThreshold) { diff --git a/src/main/java/org/mastermind/codes/SampledCode.java b/src/main/java/org/mastermind/codes/SampledCode.java index 805b9281..cbd7f4a5 100644 --- a/src/main/java/org/mastermind/codes/SampledCode.java +++ b/src/main/java/org/mastermind/codes/SampledCode.java @@ -1,5 +1,6 @@ package org.mastermind.codes; +import java.util.BitSet; import java.util.Random; /** @@ -33,6 +34,41 @@ public static int[] getSample(int c, int d, int sampleSize) { return sample; } + /** + * Maximum validCount for which enumeration is used. Above this threshold, + * int[validCount] becomes too large (>20MB) and rejection sampling is used instead. + * At this threshold, fill rate is always high enough that rejection is fast. + * Empirically derived from timing tests across c=7-9, d=7-9 game sizes. + */ + static final int MAX_ENUM = 5_000_000; + + public static int[] getValidSample(BitSet remaining, int validCount, int c, int d, int sampleSize) { + int total = (int) Math.pow(c, d); + int[] sample = new int[sampleSize]; + Random random = new Random(); + + if (validCount <= MAX_ENUM) { + // Enumeration: bounded memory (≤20MB), fast scan, fast random access. + int[] valid = new int[validCount]; + int j = 0; + for (int i = remaining.nextSetBit(0); i >= 0; i = remaining.nextSetBit(i + 1)) { + valid[j++] = i; + } + for (int i = 0; i < sampleSize; i++) { + sample[i] = valid[random.nextInt(validCount)]; + } + } else { + // Rejection sampling: validCount is large so fill rate is high and rejection is fast. + for (int i = 0; i < sampleSize; i++) { + int idx; + do { idx = random.nextInt(total); } while (!remaining.get(idx)); + sample[i] = idx; + } + } + + return sample; + } + /** * Calculates the required sample size for the secret space using a * Bias-to-Signal Ratio approach. diff --git a/src/main/java/org/mastermind/solver/SolutionSpace.java b/src/main/java/org/mastermind/solver/SolutionSpace.java index 5319bebc..52aac4f5 100644 --- a/src/main/java/org/mastermind/solver/SolutionSpace.java +++ b/src/main/java/org/mastermind/solver/SolutionSpace.java @@ -125,4 +125,7 @@ public int[] getSecrets() { /** @return size of the current solution space (or valid secrets) */ public int getSize() { return size; } + + /** @return the underlying BitSet of remaining valid secret indices */ + public BitSet getRemaining() { return remaining; } } From 3ae0da11c1e50d80f9cf97e99f2262684e77cc09 Mon Sep 17 00:00:00 2001 From: FlysonBot <116744100+FlysonBot@users.noreply.github.com> Date: Tue, 3 Mar 2026 20:58:28 -0800 Subject: [PATCH 07/23] refactor: huge improvement on CanonicalCode by utilizing positional symmetry Now CanonicalCode can cut down the guess space for 9x9 Mastermind from 9^9 to 30. --- .../java/org/mastermind/GuessStrategy.java | 6 +- .../org/mastermind/codes/CanonicalCode.java | 107 ++++++++---------- .../mastermind/codes/CanonicalCodeTest.java | 100 +++++----------- 3 files changed, 78 insertions(+), 135 deletions(-) diff --git a/src/main/java/org/mastermind/GuessStrategy.java b/src/main/java/org/mastermind/GuessStrategy.java index 4d8087e4..febaffa4 100644 --- a/src/main/java/org/mastermind/GuessStrategy.java +++ b/src/main/java/org/mastermind/GuessStrategy.java @@ -50,9 +50,9 @@ private static int[][] firstTurn(int c, int d, int secretsSize, SolutionSpace so if (fits(canonical.length, secretsSize)) return pair(canonical, solutionSpace.getSecrets()); - for (double tolerance : new double[] { 0.001, 0.005 }) { - if (fits(canonical.length, secretSampleSize(d, tolerance))) { - return pair(canonical, secretSample(c, d, tolerance, solutionSpace)); + for (double divisor : new double[] { 10, 25, 50, 100, 150, 200, 225, 250, 300 }) { + if (fits(canonical.length, (int) (secretsSize / divisor))) { + return pair(canonical, secretSample(c, d, (int) (secretsSize / divisor), solutionSpace)); } } diff --git a/src/main/java/org/mastermind/codes/CanonicalCode.java b/src/main/java/org/mastermind/codes/CanonicalCode.java index d5175c54..d51dfa63 100644 --- a/src/main/java/org/mastermind/codes/CanonicalCode.java +++ b/src/main/java/org/mastermind/codes/CanonicalCode.java @@ -1,94 +1,85 @@ package org.mastermind.codes; /** - * Canonical forms refer to a specific subset of all Mastermind code - * that starts with 1, digit ordered from small to large starting - * from the left, and the highest value digit equals to the number - * of colors used in the code. This is helpful at the beginning of - * the game before any guesses are made, where color and positional - * symmetry remain unbroken, allowing for a reduced search space to - * find the best first guess. + * Canonical forms are one representative code per symmetry equivalence class, + * used to reduce the first-guess search space. + * + *

At turn 0, both color-relabeling symmetry and position-permutation symmetry + * are intact. Two codes are equivalent if one can be obtained from the other by + * any permutation of colors and any permutation of digit positions. The equivalence + * classes are exactly the integer partitions of d into at most c parts — the + * multiset of color frequencies, or "bucket." For c=9, d=9 this gives just 30 + * canonical forms, down from 387,420,489 total codes. */ public class CanonicalCode { /** - * Calculate the number of Canonical forms in a Mastermind game using - * Stirling number of the second kind. + * Count the number of canonical forms (integer partitions of d with at most c parts). * * @param c number of colors (<= 9) * @param d number of digits (<= 9) - * @return Number of Canonical form in Mastermind + * @return number of canonical forms */ public static int countCanonicalForms(int c, int d) { - // Edge cases for empty sets or partitions - int maxK = Math.min(c, d); - if (maxK <= 0) return 0; - - // 1D DP array to save memory - int[] dp = new int[maxK + 1]; - - // Base case: S(0, 0) = 1 - dp[0] = 1; - - for (int i = 1; i <= d; i++) { - // Update the row backwards to avoid overwriting values needed - // for the current calculation: S(n,k) = k*S(n-1,k) + S(n-1,k-1) - for (int j = Math.min(i, maxK); j >= 1; j--) { - dp[j] = j * dp[j] + dp[j - 1]; + if (c <= 0 || d <= 0) return 0; + // By conjugate partition identity: partitions of d into at most c parts + // = partitions of d with the largest part <= c. + // dp[i][j] = number of partitions of i with the largest part <= j. + int[][] dp = new int[d + 1][c + 1]; + for (int i = 0; i <= d; i++) dp[i][0] = (i == 0) ? 1 : 0; + for (int maxPart = 1; maxPart <= c; maxPart++) { + for (int i = 0; i <= d; i++) { + dp[i][maxPart] = dp[i][maxPart - 1]; + if (i >= maxPart) dp[i][maxPart] += dp[i - maxPart][maxPart]; } - // S(i, 0) is 0 for all i > 0 - dp[0] = 0; } - - // Sum the results S(d, 1) through S(d, maxK) - int sum = 0; - for (int k = 1; k <= maxK; k++) { - sum += dp[k]; - } - return sum; + return dp[d][c]; } /** - * Enumerate all Canonical forms in a Mastermind game as code indices. + * Enumerate all canonical forms as code indices. + * The representative for each partition is the lex-smallest index in its + * equivalence class: the most-frequent color gets digit value 0 and occupies + * the leftmost positions, the next color gets digit value 1, and so on. * * @param c number of colors (<= 9) * @param d number of digits (<= 9) - * @return Array of indices (0-based, base-c encoding) of all Canonical forms + * @return array of canonical indices, one per integer partition of d with <= c parts */ public static int[] enumerateCanonicalForms(int c, int d) { - - // 1. Calculate the exact size needed using our Stirling Sum logic int[] results = new int[countCanonicalForms(c, d)]; - - // 2. Use a tiny wrapper array for the index to pass by reference in recursion - int[] index = { 0 }; - - // 3. Precompute positional powers: place[pos] = c^(d-1-pos) for left-to-right building - int[] place = new int[d]; + int[] index = { 0 }; + int[] place = new int[d]; place[d - 1] = 1; for (int i = d - 2; i >= 0; i--) place[i] = place[i + 1] * c; - // 4. Start recursion - backtrack(results, index, 0, 0, 0, c, d, place); - + int[] parts = new int[c]; + generatePartitions(results, index, parts, 0, d, d, c, place); return results; } - private static void backtrack( - int[] results, int[] index, int currentInd, int pos, int maxColorUsed, int c, int d, int[] place) { - // Base case: Code is complete - if (pos == d) { - results[index[0]++] = currentInd; + private static void generatePartitions( + int[] results, int[] index, int[] parts, int depth, int remaining, int maxVal, int maxParts, int[] place) { + if (remaining == 0) { + results[index[0]++] = buildIndex(parts, depth, place); return; } + if (depth == maxParts) return; - // Rule 1 & 2: Try existing colors (digit values 0..maxColorUsed-1 in index encoding) - for (int digitVal = 0; digitVal < maxColorUsed; digitVal++) { - backtrack(results, index, currentInd + digitVal * place[pos], pos + 1, maxColorUsed, c, d, place); + int limit = Math.min(maxVal, remaining); + for (int part = limit; part >= 1; part--) { + parts[depth] = part; + generatePartitions(results, index, parts, depth + 1, remaining - part, part, maxParts, place); } + } - // Rule 3: Try exactly one "new" color if limit c isn't reached (digit value maxColorUsed) - if (maxColorUsed < c) { - backtrack(results, index, currentInd + maxColorUsed * place[pos], pos + 1, maxColorUsed + 1, c, d, place); + private static int buildIndex(int[] parts, int numParts, int[] place) { + int ind = 0; + int pos = 0; + for (int color = 0; color < numParts; color++) { + for (int f = 0; f < parts[color]; f++) { + ind += color * place[pos++]; + } } + return ind; } } diff --git a/src/tests/java/org/mastermind/codes/CanonicalCodeTest.java b/src/tests/java/org/mastermind/codes/CanonicalCodeTest.java index fdaabc21..3b47e664 100644 --- a/src/tests/java/org/mastermind/codes/CanonicalCodeTest.java +++ b/src/tests/java/org/mastermind/codes/CanonicalCodeTest.java @@ -14,45 +14,43 @@ class CanonicalCodeTest { @DisplayName("Calculate Canonical Count") class CanonicalCodeCount { @Test - @DisplayName("Verify the user-provided example: c=6, d=9") - void testPromptExample() { - // Output should be 21147 - assertEquals(21147, CanonicalCode.countCanonicalForms(9, 9)); + @DisplayName("c=9, d=9 yields 30 integer partitions") + void testMainCase() { + assertEquals(30, CanonicalCode.countCanonicalForms(9, 9)); } @ParameterizedTest - @DisplayName("Small known values for Stirling sum") + @DisplayName("Small known values (integer partitions of d with at most c parts)") @CsvSource({ - "1, 1, 1", // S(1,1) = 1 - "2, 3, 4", // S(3,1) + S(3,2) = 1 + 3 = 4 - "3, 4, 14", // S(4,1) + S(4,2) + S(4,3) = 1 + 7 + 6 = 14 - "1, 10, 1" // S(n,1) is always 1 + "1, 1, 1", // {1} + "2, 3, 2", // {3}, {2,1} + "3, 4, 4", // {4}, {3,1}, {2,2}, {2,1,1} + "1, 10, 1" // only {10} }) void testSmallValues(int c, int d, int expected) { assertEquals(expected, CanonicalCode.countCanonicalForms(c, d)); } @Test - @DisplayName("When c >= d, the result is the Bell number B_d") - void testBellNumberIdentity() { - // Bell numbers: B_3=5, B_5=52 - assertEquals(5, CanonicalCode.countCanonicalForms(3, 3)); - assertEquals(52, CanonicalCode.countCanonicalForms(10, 5)); + @DisplayName("When c >= d, the result is p(d), the partition number") + void testPartitionNumberIdentity() { + // p(3)=3, p(5)=7 + assertEquals(3, CanonicalCode.countCanonicalForms(3, 3)); + assertEquals(7, CanonicalCode.countCanonicalForms(10, 5)); } @Test @DisplayName("Edge cases for zeros") void testZeroCases() { - assertEquals(0, CanonicalCode.countCanonicalForms(0, 5), "Sum up to k=0 should be 0"); - assertEquals(0, CanonicalCode.countCanonicalForms(5, 0), "Stirling numbers for d=0 are 0"); + assertEquals(0, CanonicalCode.countCanonicalForms(0, 5)); + assertEquals(0, CanonicalCode.countCanonicalForms(5, 0)); } @Test @DisplayName("Maximum safe value for 32-bit signed int") void testMaxIntSafety() { - // B_15 is 1,382,958,545, which is < 2,147,483,647 (Max Int) - int bell15 = 1382958545; - assertEquals(bell15, CanonicalCode.countCanonicalForms(15, 15)); + // p(15) = 176, well within int range + assertEquals(176, CanonicalCode.countCanonicalForms(15, 15)); } } @@ -60,71 +58,26 @@ void testMaxIntSafety() { @DisplayName("Enumerate Canonical Forms") class CanonicalCodeForms { - /** - * Helper to verify if an index is canonical. - * Extracts digits left-to-right and checks no color is skipped. - */ - private boolean isCanonicalInd(int ind, int c, int d) { - int maxSeen = -1; // highest digit value seen so far (0-based) - int place = (int) Math.pow(c, d - 1); - for (int pos = 0; pos < d; pos++) { - int digitVal = ind / place; // 0..c-1 - ind %= place; - place /= c; - if (digitVal > maxSeen + 1) return false; - if (digitVal > maxSeen) maxSeen = digitVal; - } - return true; - } - @Test - @DisplayName("Verify array size matches Stirling Sum for (9, 9)") + @DisplayName("Array size matches countCanonicalForms for (9, 9)") void testArraySize() { - int c = 9; - int d = 9; - int expectedSize = CanonicalCode.countCanonicalForms(c, d); // 21147 - int[] results = CanonicalCode.enumerateCanonicalForms(c, d); - - assertEquals(expectedSize, results.length); - assertEquals(21147, results.length); + int[] results = CanonicalCode.enumerateCanonicalForms(9, 9); + assertEquals(30, results.length); } @Test - @DisplayName("Verify specific canonical form indices for small c, d") + @DisplayName("Specific indices for c=2, d=3") void testSmallEnumeration() { - // For c=2, d=3, canonical forms are: 111, 112, 121, 122 - // As indices (c=2): 0, 1, 2, 3 - int[] expected = { 0, 1, 2, 3 }; - int[] actual = CanonicalCode.enumerateCanonicalForms(2, 3); - - assertArrayEquals(expected, actual); - } - - @Test - @DisplayName("Verify Rule 1: All codes must start with color 1 (digitVal 0)") - void testStartsWithOne() { - int c = 6, d = 4; - int threshold = (int) Math.pow(c, d - 1); // indices with leading digitVal 0 are < c^(d-1) - int[] results = CanonicalCode.enumerateCanonicalForms(c, d); - for (int ind : results) { - assertTrue(ind < threshold, "Index " + ind + " does not start with digitVal 0 (color 1)"); - } - } - - @Test - @DisplayName("Verify Rule 2: No skipping colors (Canonical check)") - void testNoSkippedColors() { - int c = 6, d = 5; - int[] results = CanonicalCode.enumerateCanonicalForms(c, d); - for (int ind : results) { - assertTrue(isCanonicalInd(ind, c, d), "Index " + ind + " violates canonical rules"); - } + // Partitions of 3 with <= 2 parts: {3} and {2,1} + // {3} → color 0 fills all 3 positions → index 0 (000 in base 2) + // {2,1} → color 0 fills pos 0,1; color 1 fills pos 2 → 0*4 + 0*2 + 1*1 = 1 + int[] expected = { 0, 1 }; + assertArrayEquals(expected, CanonicalCode.enumerateCanonicalForms(2, 3)); } @Test @DisplayName("Edge Case: c=1") void testSingleColor() { - // If only 1 color, every digit is 0 → index 0 int[] results = CanonicalCode.enumerateCanonicalForms(1, 4); assertEquals(1, results.length); assertEquals(0, results[0]); @@ -133,7 +86,6 @@ void testSingleColor() { @Test @DisplayName("Edge Case: d=1") void testSingleDigit() { - // If length is 1, only color 1 (digitVal 0) is possible → index 0 int[] results = CanonicalCode.enumerateCanonicalForms(6, 1); assertEquals(1, results.length); assertEquals(0, results[0]); From c154a1bbd547457dbb4212eb7bdf031b1be89b4c Mon Sep 17 00:00:00 2001 From: FlysonBot <116744100+FlysonBot@users.noreply.github.com> Date: Tue, 3 Mar 2026 22:51:19 -0800 Subject: [PATCH 08/23] feat: add BestFirstGuess to precompute and hardcode optimal first guesses through either brute-force or sampling --- .../java/org/mastermind/BestFirstGuess.java | 264 ++++++++++++++++++ .../java/org/mastermind/GuessStrategy.java | 27 +- 2 files changed, 269 insertions(+), 22 deletions(-) create mode 100644 src/main/java/org/mastermind/BestFirstGuess.java diff --git a/src/main/java/org/mastermind/BestFirstGuess.java b/src/main/java/org/mastermind/BestFirstGuess.java new file mode 100644 index 00000000..cded9b3e --- /dev/null +++ b/src/main/java/org/mastermind/BestFirstGuess.java @@ -0,0 +1,264 @@ +package org.mastermind; + +import org.mastermind.codes.AllValidCode; +import org.mastermind.codes.CanonicalCode; +import org.mastermind.codes.ConvertCode; +import org.mastermind.codes.SampledCode; +import org.mastermind.solver.ExpectedSize; + +/** + * Provides the best first guess for any supported Mastermind configuration, + * and a calibration tool that computes and prints the values to hardcode. + *

+ * Use {@link #of(int, int)} at runtime. Run {@link #main(String[])} once to + * regenerate the hardcoded values after algorithm changes. + */ +public class BestFirstGuess { + + // --- Calibration constants --- + private static final int TRIALS = 100; + private static final long TARGET_EVALS = 13_000_000L; + private static final double CONFIDENCE_THRESHOLD = 99.0; + private static final int BUDGET_MULTIPLIER = 2; + + /** + * Returns the best first guess code for a Mastermind game of c colors and d digits. + * + * @param c number of colors (2–9) + * @param d number of digits (1–9) + * @return best first guess as a code int (e.g. 1123) + * @throws IllegalArgumentException if c or d is out of the supported range + */ + public static int of(int c, int d) { + if (c < 2 || c > 9 || d < 1 || d > 9) + throw new IllegalArgumentException("Unsupported game size: c=" + c + ", d=" + d); + + // d=1: single digit, all guesses equivalent — always guess 1 + if (d == 1) return 1; + + return switch (c) { + case 2 -> switch (d) { + case 2 -> 11; + case 3 -> 112; + case 4 -> 1112; + case 5 -> 11112; + case 6 -> 111112; + case 7 -> 1111122; + case 8 -> 11111122; + case 9 -> 111111122; + default -> throw new IllegalArgumentException("Unsupported game size: c=" + c + ", d=" + d); + }; + case 3 -> switch (d) { + case 2 -> 12; + case 3 -> 112; + case 4 -> 1122; + case 5 -> 11123; + case 6 -> 111123; + case 7 -> 1111223; + case 8 -> 11111223; + case 9 -> 111111223; + default -> throw new IllegalArgumentException("Unsupported game size: c=" + c + ", d=" + d); + }; + case 4 -> switch (d) { + case 2 -> 12; + case 3 -> 123; + case 4 -> 1123; + case 5 -> 11223; + case 6 -> 111223; + case 7 -> 1112223; + case 8 -> 11112223; + case 9 -> 111122223; + default -> throw new IllegalArgumentException("Unsupported game size: c=" + c + ", d=" + d); + }; + case 5 -> switch (d) { + case 2 -> 12; + case 3 -> 123; + case 4 -> 1123; + case 5 -> 11223; + case 6 -> 112233; + case 7 -> 1112223; + case 8 -> 11122233; + case 9 -> 111122223; + default -> throw new IllegalArgumentException("Unsupported game size: c=" + c + ", d=" + d); + }; + case 6 -> switch (d) { + case 2 -> 12; + case 3 -> 123; + case 4 -> 1123; + case 5 -> 11223; + case 6 -> 112233; + case 7 -> 1112233; + case 8 -> 11122233; + case 9 -> 111222333; + default -> throw new IllegalArgumentException("Unsupported game size: c=" + c + ", d=" + d); + }; + case 7 -> switch (d) { + case 2 -> 12; + case 3 -> 123; + case 4 -> 1234; + case 5 -> 11223; + case 6 -> 112233; + case 7 -> 1112233; + case 8 -> 11122233; + case 9 -> 111222333; + default -> throw new IllegalArgumentException("Unsupported game size: c=" + c + ", d=" + d); + }; + case 8 -> switch (d) { + case 2 -> 12; + case 3 -> 123; + case 4 -> 1234; + case 5 -> 11234; + case 6 -> 112233; + case 7 -> 1122334; + case 8 -> 11223344; + case 9 -> 111223344; + default -> throw new IllegalArgumentException("Unsupported game size: c=" + c + ", d=" + d); + }; + case 9 -> switch (d) { + case 2 -> 12; + case 3 -> 123; + case 4 -> 1234; + case 5 -> 12345; + case 6 -> 112234; + case 7 -> 1122334; + case 8 -> 11223344; + case 9 -> 111223344; + default -> throw new IllegalArgumentException("Unsupported game size: c=" + c + ", d=" + d); + }; + default -> throw new IllegalArgumentException("Unsupported game size: c=" + c + ", d=" + d); + }; + } + + // ------------------------------------------------------------------------- + // Calibration — run main() to recompute and regenerate the switch above + // ------------------------------------------------------------------------- + + public static void main(String[] args) { + int[] feedbackFreq = new int[100]; + + // c=1 and d=1 are trivial, calibrate c in [2,9] x d in [2,9] + int total = 8 * 8; + String[] lines = new String[total]; + int[][] bestCode = new int[10][10]; + int li = 0; + + String header = String.format("%-6s %-12s %10s %10s %12s", + "Game", "BestGuess", "AvgScore", "Confidence", "TotalEvals"); + System.out.println(header); + System.out.println("-".repeat(60)); + + for (int c = 2; c <= 9; c++) { + for (int d = 2; d <= 9; d++) { + ExpectedSize expectedSize = new ExpectedSize(d); + int[] canonical = CanonicalCode.enumerateCanonicalForms(c, d); + int totalCodes = (int) Math.pow(c, d); + + long budget = TARGET_EVALS; + Result result; + do { + result = evaluate(canonical, c, d, totalCodes, budget, feedbackFreq, expectedSize); + if (result.confidence < CONFIDENCE_THRESHOLD) { + budget *= BUDGET_MULTIPLIER; + System.out.printf(" [%dx%d] confidence %.2f%% too low, retrying with budget %d%n", + c, d, result.confidence, budget); + } + } while (result.confidence < CONFIDENCE_THRESHOLD); + + int code = ConvertCode.toCode(c, d, canonical[result.bestIdx]); + bestCode[c][d] = code; + lines[li] = String.format("%-6s %-12d %10.4f %9.2f%% %12d%s", + c + "x" + d, code, + result.avgScore, result.confidence, + result.totalEvals, + result.fullEval ? " (full)" : ""); + System.out.println(lines[li]); + li++; + } + } + + // Clean summary + System.out.printf("%n%s%n", header); + System.out.println("-".repeat(60)); + for (String line : lines) System.out.println(line); + + // Print switch snippet + System.out.println("\n// --- copy below into of(int c, int d) ---"); + for (int c = 2; c <= 9; c++) { + System.out.printf(" case %d -> switch (d) {%n", c); + for (int d = 2; d <= 9; d++) { + System.out.printf(" case %d -> %d;%n", d, bestCode[c][d]); + } + System.out.printf(" default -> throw new IllegalArgumentException(\"Unsupported game size: c=\" + c + \", d=\" + d);%n"); + System.out.printf(" };%n"); + } + System.out.println("// --- copy above ---"); + } + + private static Result evaluate( + int[] canonical, int c, int d, int totalCodes, + long budget, int[] feedbackFreq, ExpectedSize expectedSize + ) { + int n = canonical.length; + int sampleSize = Math.max(1, (int) (budget / TRIALS / n)); + boolean fullEval = sampleSize >= totalCodes; + int[] allSecrets = fullEval ? AllValidCode.generateAllCodes(c, d) : null; + int normSize = fullEval ? totalCodes : sampleSize; + int trials = fullEval ? 1 : TRIALS; + + double[] sumScore = new double[n]; + double[] sumScore2 = new double[n]; + + for (int t = 0; t < trials; t++) { + int[] s = fullEval ? allSecrets : SampledCode.getSample(c, d, sampleSize); + for (int i = 0; i < n; i++) { + double score = expectedSize.calcExpectedRank(canonical[i], s, c, d, feedbackFreq) + / (double) normSize; + sumScore[i] += score; + sumScore2[i] += score * score; + } + } + + int bestIdx = 0, secondIdx = -1; + for (int i = 1; i < n; i++) { + if (sumScore[i] < sumScore[bestIdx]) { + secondIdx = bestIdx; + bestIdx = i; + } else if (secondIdx == -1 || sumScore[i] < sumScore[secondIdx]) { + secondIdx = i; + } + } + + double confidence; + if (fullEval || secondIdx == -1) { + confidence = 100.0; + } else { + double avg1 = sumScore[bestIdx] / trials; + double avg2 = sumScore[secondIdx] / trials; + double var1 = (sumScore2[bestIdx] / trials) - avg1 * avg1; + double var2 = (sumScore2[secondIdx] / trials) - avg2 * avg2; + double stdErr = Math.sqrt((var1 + var2) / trials); + double z = (avg2 - avg1) / stdErr; + confidence = 100.0 * normalCDF(z); + } + + return new Result(bestIdx, sumScore[bestIdx] / trials, confidence, + (long) n * normSize * trials, fullEval); + } + + /** Approximation of the standard normal CDF using Horner's method (Abramowitz & Stegun 26.2.17). */ + private static double normalCDF(double z) { + if (z < 0) return 1.0 - normalCDF(-z); + double t = 1.0 / (1.0 + 0.2316419 * z); + double poly = t * (0.319381530 + + t * (-0.356563782 + + t * (1.781477937 + + t * (-1.821255978 + + t * 1.330274429)))); + double pdf = Math.exp(-0.5 * z * z) / Math.sqrt(2 * Math.PI); + return 1.0 - pdf * poly; + } + + private record Result(int bestIdx, double avgScore, double confidence, + long totalEvals, boolean fullEval + ) { } +} diff --git a/src/main/java/org/mastermind/GuessStrategy.java b/src/main/java/org/mastermind/GuessStrategy.java index febaffa4..cf87ddb2 100644 --- a/src/main/java/org/mastermind/GuessStrategy.java +++ b/src/main/java/org/mastermind/GuessStrategy.java @@ -1,7 +1,7 @@ package org.mastermind; import org.mastermind.codes.AllValidCode; -import org.mastermind.codes.CanonicalCode; +import org.mastermind.codes.ConvertCode; import org.mastermind.codes.SampledCode; import org.mastermind.solver.Feedback; import org.mastermind.solver.SolutionSpace; @@ -35,28 +35,11 @@ public class GuessStrategy { * @return int[][] where [0]=guesses, [1]=secrets */ public static int[][] select(int c, int d, int turn, SolutionSpace solutionSpace) { - int secretsSize = solutionSpace.getSize(); - if (turn == 0) return firstTurn(c, d, secretsSize, solutionSpace); - return laterTurns(c, d, secretsSize, solutionSpace); - } - - /** - * First turn: always use canonical forms as guesses (exploit full color/position symmetry). - * Fall back to a Monte Carlo sample for secrets if the product exceeds the threshold. - * Tries progressively looser tolerances: 0.001, 0.005, then 0.01. - */ - private static int[][] firstTurn(int c, int d, int secretsSize, SolutionSpace solutionSpace) { - int[] canonical = CanonicalCode.enumerateCanonicalForms(c, d); - - if (fits(canonical.length, secretsSize)) return pair(canonical, solutionSpace.getSecrets()); - - for (double divisor : new double[] { 10, 25, 50, 100, 150, 200, 225, 250, 300 }) { - if (fits(canonical.length, (int) (secretsSize / divisor))) { - return pair(canonical, secretSample(c, d, (int) (secretsSize / divisor), solutionSpace)); - } + if (turn == 0) { + int[] guess = { ConvertCode.toIndex(c, d, BestFirstGuess.of(c, d)) }; + return pair(guess, solutionSpace.getSecrets()); } - - return pair(canonical, secretSample(c, d, 0.01, solutionSpace)); + return laterTurns(c, d, solutionSpace.getSize(), solutionSpace); } /** From 65efc6a702ecdd48b50a756dd83c174e84e243ff Mon Sep 17 00:00:00 2001 From: FlysonBot <116744100+FlysonBot@users.noreply.github.com> Date: Tue, 3 Mar 2026 23:06:53 -0800 Subject: [PATCH 09/23] refactor: reorganize java classes and benchmarks into folders --- .../org/mastermind/{ => codes}/SampledCodeBenchmark.java | 3 +-- .../org/mastermind/{ => solver}/BestGuessBenchmark.java | 3 +-- .../org/mastermind/{ => solver}/ExpectedSizeBenchmark.java | 3 +-- .../org/mastermind/{ => solver}/FeedbackBenchmark.java | 3 +-- .../mastermind/{ => solver}/SolutionSpaceBenchmark.java | 4 +--- src/main/java/org/mastermind/MastermindSession.java | 1 + .../java/org/mastermind/{ => solver}/BestFirstGuess.java | 7 ++++--- .../java/org/mastermind/{ => solver}/GuessStrategy.java | 4 +--- 8 files changed, 11 insertions(+), 17 deletions(-) rename src/benchmarks/java/org/mastermind/{ => codes}/SampledCodeBenchmark.java (98%) rename src/benchmarks/java/org/mastermind/{ => solver}/BestGuessBenchmark.java (96%) rename src/benchmarks/java/org/mastermind/{ => solver}/ExpectedSizeBenchmark.java (96%) rename src/benchmarks/java/org/mastermind/{ => solver}/FeedbackBenchmark.java (97%) rename src/benchmarks/java/org/mastermind/{ => solver}/SolutionSpaceBenchmark.java (96%) rename src/main/java/org/mastermind/{ => solver}/BestFirstGuess.java (97%) rename src/main/java/org/mastermind/{ => solver}/GuessStrategy.java (97%) diff --git a/src/benchmarks/java/org/mastermind/SampledCodeBenchmark.java b/src/benchmarks/java/org/mastermind/codes/SampledCodeBenchmark.java similarity index 98% rename from src/benchmarks/java/org/mastermind/SampledCodeBenchmark.java rename to src/benchmarks/java/org/mastermind/codes/SampledCodeBenchmark.java index cbe3335d..424fdd3f 100644 --- a/src/benchmarks/java/org/mastermind/SampledCodeBenchmark.java +++ b/src/benchmarks/java/org/mastermind/codes/SampledCodeBenchmark.java @@ -1,6 +1,5 @@ -package org.mastermind; +package org.mastermind.codes; -import org.mastermind.codes.SampledCode; import org.mastermind.solver.Feedback; import org.openjdk.jmh.annotations.*; import org.openjdk.jmh.infra.Blackhole; diff --git a/src/benchmarks/java/org/mastermind/BestGuessBenchmark.java b/src/benchmarks/java/org/mastermind/solver/BestGuessBenchmark.java similarity index 96% rename from src/benchmarks/java/org/mastermind/BestGuessBenchmark.java rename to src/benchmarks/java/org/mastermind/solver/BestGuessBenchmark.java index 4e122a26..7143583c 100644 --- a/src/benchmarks/java/org/mastermind/BestGuessBenchmark.java +++ b/src/benchmarks/java/org/mastermind/solver/BestGuessBenchmark.java @@ -1,6 +1,5 @@ -package org.mastermind; +package org.mastermind.solver; -import org.mastermind.solver.BestGuess; import org.openjdk.jmh.annotations.*; import org.openjdk.jmh.infra.Blackhole; diff --git a/src/benchmarks/java/org/mastermind/ExpectedSizeBenchmark.java b/src/benchmarks/java/org/mastermind/solver/ExpectedSizeBenchmark.java similarity index 96% rename from src/benchmarks/java/org/mastermind/ExpectedSizeBenchmark.java rename to src/benchmarks/java/org/mastermind/solver/ExpectedSizeBenchmark.java index fbb2ce7a..765b1b8a 100644 --- a/src/benchmarks/java/org/mastermind/ExpectedSizeBenchmark.java +++ b/src/benchmarks/java/org/mastermind/solver/ExpectedSizeBenchmark.java @@ -1,7 +1,6 @@ -package org.mastermind; +package org.mastermind.solver; import org.mastermind.codes.ConvertCode; -import org.mastermind.solver.ExpectedSize; import org.openjdk.jmh.annotations.*; import org.openjdk.jmh.infra.Blackhole; diff --git a/src/benchmarks/java/org/mastermind/FeedbackBenchmark.java b/src/benchmarks/java/org/mastermind/solver/FeedbackBenchmark.java similarity index 97% rename from src/benchmarks/java/org/mastermind/FeedbackBenchmark.java rename to src/benchmarks/java/org/mastermind/solver/FeedbackBenchmark.java index fd1b5972..6a0ebb69 100644 --- a/src/benchmarks/java/org/mastermind/FeedbackBenchmark.java +++ b/src/benchmarks/java/org/mastermind/solver/FeedbackBenchmark.java @@ -1,7 +1,6 @@ -package org.mastermind; +package org.mastermind.solver; import org.mastermind.codes.ConvertCode; -import org.mastermind.solver.Feedback; import org.openjdk.jmh.annotations.*; import org.openjdk.jmh.infra.Blackhole; diff --git a/src/benchmarks/java/org/mastermind/SolutionSpaceBenchmark.java b/src/benchmarks/java/org/mastermind/solver/SolutionSpaceBenchmark.java similarity index 96% rename from src/benchmarks/java/org/mastermind/SolutionSpaceBenchmark.java rename to src/benchmarks/java/org/mastermind/solver/SolutionSpaceBenchmark.java index 4fb6a4c9..305840d8 100644 --- a/src/benchmarks/java/org/mastermind/SolutionSpaceBenchmark.java +++ b/src/benchmarks/java/org/mastermind/solver/SolutionSpaceBenchmark.java @@ -1,8 +1,6 @@ -package org.mastermind; +package org.mastermind.solver; import org.mastermind.codes.ConvertCode; -import org.mastermind.solver.Feedback; -import org.mastermind.solver.SolutionSpace; import org.openjdk.jmh.annotations.*; import org.openjdk.jmh.infra.Blackhole; diff --git a/src/main/java/org/mastermind/MastermindSession.java b/src/main/java/org/mastermind/MastermindSession.java index 975f563f..6acafb3d 100644 --- a/src/main/java/org/mastermind/MastermindSession.java +++ b/src/main/java/org/mastermind/MastermindSession.java @@ -2,6 +2,7 @@ import org.mastermind.solver.BestGuess; import org.mastermind.solver.ExpectedSize; +import org.mastermind.solver.GuessStrategy; import org.mastermind.solver.SolutionSpace; import java.util.ArrayList; diff --git a/src/main/java/org/mastermind/BestFirstGuess.java b/src/main/java/org/mastermind/solver/BestFirstGuess.java similarity index 97% rename from src/main/java/org/mastermind/BestFirstGuess.java rename to src/main/java/org/mastermind/solver/BestFirstGuess.java index cded9b3e..85a7d138 100644 --- a/src/main/java/org/mastermind/BestFirstGuess.java +++ b/src/main/java/org/mastermind/solver/BestFirstGuess.java @@ -1,10 +1,9 @@ -package org.mastermind; +package org.mastermind.solver; import org.mastermind.codes.AllValidCode; import org.mastermind.codes.CanonicalCode; import org.mastermind.codes.ConvertCode; import org.mastermind.codes.SampledCode; -import org.mastermind.solver.ExpectedSize; /** * Provides the best first guess for any supported Mastermind configuration, @@ -188,7 +187,9 @@ public static void main(String[] args) { for (int d = 2; d <= 9; d++) { System.out.printf(" case %d -> %d;%n", d, bestCode[c][d]); } - System.out.printf(" default -> throw new IllegalArgumentException(\"Unsupported game size: c=\" + c + \", d=\" + d);%n"); + System.out.printf( + " default -> throw new IllegalArgumentException(\"Unsupported game size: c=\" + c " + + "+ \", d=\" + d);%n"); System.out.printf(" };%n"); } System.out.println("// --- copy above ---"); diff --git a/src/main/java/org/mastermind/GuessStrategy.java b/src/main/java/org/mastermind/solver/GuessStrategy.java similarity index 97% rename from src/main/java/org/mastermind/GuessStrategy.java rename to src/main/java/org/mastermind/solver/GuessStrategy.java index cf87ddb2..57a78616 100644 --- a/src/main/java/org/mastermind/GuessStrategy.java +++ b/src/main/java/org/mastermind/solver/GuessStrategy.java @@ -1,10 +1,8 @@ -package org.mastermind; +package org.mastermind.solver; import org.mastermind.codes.AllValidCode; import org.mastermind.codes.ConvertCode; import org.mastermind.codes.SampledCode; -import org.mastermind.solver.Feedback; -import org.mastermind.solver.SolutionSpace; /** * Selects which arrays to pass as guesses and secrets to BestGuess for each turn. From 1259740a4e38a0fe705ed0fefc72a57627d911fb Mon Sep 17 00:00:00 2001 From: FlysonBot <116744100+FlysonBot@users.noreply.github.com> Date: Wed, 4 Mar 2026 00:38:23 -0800 Subject: [PATCH 10/23] refactor: return rank alongside first guess from BestFirstGuess.of() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - BestFirstGuess.of() now returns long[]{code, rank} instead of int - Hardcoded table updated with precomputed true ranks for all game sizes - MastermindSession uses BestFirstGuess.of() directly for the first move - GuessStrategy.select() drops the `turn` parameter (first-move logic moved out) - Rename laterTurns → selectSearchSpace for clarity --- .../org/mastermind/MastermindSession.java | 15 +- .../org/mastermind/solver/BestFirstGuess.java | 158 ++++++++++-------- .../org/mastermind/solver/GuessStrategy.java | 18 +- 3 files changed, 105 insertions(+), 86 deletions(-) diff --git a/src/main/java/org/mastermind/MastermindSession.java b/src/main/java/org/mastermind/MastermindSession.java index 6acafb3d..bb1ce447 100644 --- a/src/main/java/org/mastermind/MastermindSession.java +++ b/src/main/java/org/mastermind/MastermindSession.java @@ -1,9 +1,7 @@ package org.mastermind; -import org.mastermind.solver.BestGuess; -import org.mastermind.solver.ExpectedSize; -import org.mastermind.solver.GuessStrategy; -import org.mastermind.solver.SolutionSpace; +import org.mastermind.codes.ConvertCode; +import org.mastermind.solver.*; import java.util.ArrayList; import java.util.Collections; @@ -69,12 +67,19 @@ public int suggestGuess() { public long[] suggestGuessWithDetails() { if (solved) throw new IllegalStateException("Game is already solved."); + if (history.isEmpty()) { + long[] first = BestFirstGuess.of(c, d); + return new long[] { + ConvertCode.toIndex(c, d, (int) first[0]), first[1], (long) solutionSpace.getSecrets().length + }; + } + if (solutionSpace.getSize() == 1) { int[] only = solutionSpace.getSecrets(); return new long[] { only[0], 1L, 1L }; } - int[][] searchSpace = GuessStrategy.select(c, d, history.size(), solutionSpace); // {guesses, secrets} + int[][] searchSpace = GuessStrategy.select(c, d, solutionSpace); // {guesses, secrets} long[] result = BestGuess.findBestGuess(searchSpace[0], searchSpace[1], c, d); return new long[] { result[0], result[1], searchSpace[1].length }; // {guess, rank, secrets length} } diff --git a/src/main/java/org/mastermind/solver/BestFirstGuess.java b/src/main/java/org/mastermind/solver/BestFirstGuess.java index 85a7d138..d10b0770 100644 --- a/src/main/java/org/mastermind/solver/BestFirstGuess.java +++ b/src/main/java/org/mastermind/solver/BestFirstGuess.java @@ -21,107 +21,112 @@ public class BestFirstGuess { private static final int BUDGET_MULTIPLIER = 2; /** - * Returns the best first guess code for a Mastermind game of c colors and d digits. + * Returns the best first guess and its true rank for a Mastermind game of c colors and d digits. * * @param c number of colors (2–9) * @param d number of digits (1–9) - * @return best first guess as a code int (e.g. 1123) + * @return long[2] where [0] = best first guess code (e.g. 1123), [1] = true rank (raw calcExpectedRank) * @throws IllegalArgumentException if c or d is out of the supported range */ - public static int of(int c, int d) { + public static long[] of(int c, int d) { if (c < 2 || c > 9 || d < 1 || d > 9) throw new IllegalArgumentException("Unsupported game size: c=" + c + ", d=" + d); // d=1: single digit, all guesses equivalent — always guess 1 - if (d == 1) return 1; + if (d == 1) { + int[] allSecrets = AllValidCode.generateAllCodes(c, 1); + long rank = new ExpectedSize(1).calcExpectedRank(ConvertCode.toIndex(c, 1, 1), allSecrets, c, 1, + new int[100]); + return new long[] { 1, rank }; + } return switch (c) { case 2 -> switch (d) { - case 2 -> 11; - case 3 -> 112; - case 4 -> 1112; - case 5 -> 11112; - case 6 -> 111112; - case 7 -> 1111122; - case 8 -> 11111122; - case 9 -> 111111122; + case 2 -> new long[] { 11, 6L }; + case 3 -> new long[] { 112, 16L }; + case 4 -> new long[] { 1112, 46L }; + case 5 -> new long[] { 11112, 148L }; + case 6 -> new long[] { 111112, 514L }; + case 7 -> new long[] { 1111122, 1752L }; + case 8 -> new long[] { 11111122, 5958L }; + case 9 -> new long[] { 111111122, 21250L }; default -> throw new IllegalArgumentException("Unsupported game size: c=" + c + ", d=" + d); }; case 3 -> switch (d) { - case 2 -> 12; - case 3 -> 112; - case 4 -> 1122; - case 5 -> 11123; - case 6 -> 111123; - case 7 -> 1111223; - case 8 -> 11111223; - case 9 -> 111111223; + case 2 -> new long[] { 12, 23L }; + case 3 -> new long[] { 112, 119L }; + case 4 -> new long[] { 1122, 775L }; + case 5 -> new long[] { 11123, 5099L }; + case 6 -> new long[] { 111123, 37271L }; + case 7 -> new long[] { 1111223, 289973L }; + case 8 -> new long[] { 11111223, 2234617L }; + case 9 -> new long[] { 111111223, 18004767L }; default -> throw new IllegalArgumentException("Unsupported game size: c=" + c + ", d=" + d); }; case 4 -> switch (d) { - case 2 -> 12; - case 3 -> 123; - case 4 -> 1123; - case 5 -> 11223; - case 6 -> 111223; - case 7 -> 1112223; - case 8 -> 11112223; - case 9 -> 111122223; + case 2 -> new long[] { 12, 70L }; + case 3 -> new long[] { 123, 690L }; + case 4 -> new long[] { 1123, 7892L }; + case 5 -> new long[] { 11223, 100950L }; + case 6 -> new long[] { 111223, 1318952L }; + case 7 -> new long[] { 1112223, 17732570L }; + case 8 -> new long[] { 11112223, 246242208L }; + case 9 -> new long[] { 111122223, 3424656050L }; default -> throw new IllegalArgumentException("Unsupported game size: c=" + c + ", d=" + d); }; case 5 -> switch (d) { - case 2 -> 12; - case 3 -> 123; - case 4 -> 1123; - case 5 -> 11223; - case 6 -> 112233; - case 7 -> 1112223; - case 8 -> 11122233; - case 9 -> 111122223; + case 2 -> new long[] { 12, 183L }; + case 3 -> new long[] { 123, 2751L }; + case 4 -> new long[] { 1123, 50807L }; + case 5 -> new long[] { 11223, 988703L }; + case 6 -> new long[] { 112233, 20472687L }; + case 7 -> new long[] { 1112223, 432675025L }; + case 8 -> new long[] { 11122233, 9432668521L }; + case 9 -> new long[] { 111122223, 207807845615L }; default -> throw new IllegalArgumentException("Unsupported game size: c=" + c + ", d=" + d); }; case 6 -> switch (d) { - case 2 -> 12; - case 3 -> 123; - case 4 -> 1123; - case 5 -> 11223; - case 6 -> 112233; - case 7 -> 1112233; - case 8 -> 11122233; - case 9 -> 111222333; + case 2 -> new long[] { 12, 422L }; + case 3 -> new long[] { 123, 8906L }; + case 4 -> new long[] { 1123, 240108L }; + case 5 -> new long[] { 11223, 6659862L }; + case 6 -> new long[] { 112233, 194108442L }; + case 7 -> new long[] { 1112233, 5987538014L }; + case 8 -> new long[] { 11122233, 185462657858L }; + case 9 -> new long[] { 111222333, 5820009319166L }; default -> throw new IllegalArgumentException("Unsupported game size: c=" + c + ", d=" + d); }; case 7 -> switch (d) { - case 2 -> 12; - case 3 -> 123; - case 4 -> 1234; - case 5 -> 11223; - case 6 -> 112233; - case 7 -> 1112233; - case 8 -> 11122233; - case 9 -> 111222333; + case 2 -> new long[] { 12, 871L }; + case 3 -> new long[] { 123, 24387L }; + case 4 -> new long[] { 1234, 882063L }; + case 5 -> new long[] { 11223, 34192827L }; + case 6 -> new long[] { 112233, 1334737119L }; + case 7 -> new long[] { 1112233, 56058606307L }; + case 8 -> new long[] { 11122233, 2362723139081L }; + case 9 -> new long[] { 111222333, 100244736768813L }; default -> throw new IllegalArgumentException("Unsupported game size: c=" + c + ", d=" + d); }; case 8 -> switch (d) { - case 2 -> 12; - case 3 -> 123; - case 4 -> 1234; - case 5 -> 11234; - case 6 -> 112233; - case 7 -> 1122334; - case 8 -> 11223344; - case 9 -> 111223344; + case 2 -> new long[] { 12, 1638L }; + case 3 -> new long[] { 123, 58866L }; + case 4 -> new long[] { 1234, 2724406L }; + case 5 -> new long[] { 11234, 140346626L }; + case 6 -> new long[] { 112233, 7211734938L }; + case 7 -> new long[] { 1122334, 386821286390L }; + case 8 -> new long[] { 11223344, 21165744470710L }; + case 9 -> new long[] { 111223344, 1201215086592578L }; default -> throw new IllegalArgumentException("Unsupported game size: c=" + c + ", d=" + d); }; case 9 -> switch (d) { - case 2 -> 12; - case 3 -> 123; - case 4 -> 1234; - case 5 -> 12345; - case 6 -> 112234; - case 7 -> 1122334; - case 8 -> 11223344; - case 9 -> 111223344; + case 2 -> new long[] { 12, 2855L }; + case 3 -> new long[] { 123, 128975L }; + case 4 -> new long[] { 1234, 7437615L }; + case 5 -> new long[] { 12345, 486776063L }; + case 6 -> new long[] { 112234, 32113088737L }; + case 7 -> new long[] { 1122334, 2148685524777L }; + case 8 -> new long[] { 11223344, 147476714738127L }; + case 9 -> new long[] { 111223344, 10597696978901189L }; default -> throw new IllegalArgumentException("Unsupported game size: c=" + c + ", d=" + d); }; default -> throw new IllegalArgumentException("Unsupported game size: c=" + c + ", d=" + d); @@ -180,12 +185,27 @@ public static void main(String[] args) { System.out.println("-".repeat(60)); for (String line : lines) System.out.println(line); + // Phase 2: full evaluation to get true rank for each best guess + System.out.printf("%n%-6s %-12s %s%n", "Game", "BestGuess", "TrueRank"); + System.out.println("-".repeat(35)); + long[][] trueRank = new long[10][10]; + for (int c = 2; c <= 9; c++) { + for (int d = 2; d <= 9; d++) { + int[] allSecrets = AllValidCode.generateAllCodes(c, d); + ExpectedSize expectedSize = new ExpectedSize(d); + int codeIndex = ConvertCode.toIndex(c, d, bestCode[c][d]); + trueRank[c][d] = expectedSize.calcExpectedRank(codeIndex, allSecrets, c, d, feedbackFreq); + System.out.printf("%-6s %-12d %d%n", c + "x" + d, bestCode[c][d], trueRank[c][d]); + } + } + // Print switch snippet System.out.println("\n// --- copy below into of(int c, int d) ---"); for (int c = 2; c <= 9; c++) { System.out.printf(" case %d -> switch (d) {%n", c); for (int d = 2; d <= 9; d++) { - System.out.printf(" case %d -> %d;%n", d, bestCode[c][d]); + System.out.printf(" case %d -> new long[]{%d, %dL};%n", d, bestCode[c][d], + trueRank[c][d]); } System.out.printf( " default -> throw new IllegalArgumentException(\"Unsupported game size: c=\" + c " + diff --git a/src/main/java/org/mastermind/solver/GuessStrategy.java b/src/main/java/org/mastermind/solver/GuessStrategy.java index 57a78616..f70cb356 100644 --- a/src/main/java/org/mastermind/solver/GuessStrategy.java +++ b/src/main/java/org/mastermind/solver/GuessStrategy.java @@ -1,7 +1,6 @@ package org.mastermind.solver; import org.mastermind.codes.AllValidCode; -import org.mastermind.codes.ConvertCode; import org.mastermind.codes.SampledCode; /** @@ -13,8 +12,8 @@ *

  • {@code [1]} — secrets used to score each guess
  • * * - *

    Edit this class to change strategy behavior. {@link #select} dispatches to - * a private method per turn phase; add new phases or conditions there. + *

    Edit this class to change strategy behavior. {@link #select} delegates to + * {@code selectSearchSpace}, which cascades through size-reduction levels. * *

    Threshold: {@code guesses.length × secrets.length} above which the * parallel BestGuess search exceeds ~1 second on the target machine. @@ -28,23 +27,18 @@ public class GuessStrategy { * * @param c number of colors * @param d number of digits - * @param turn 0-indexed turn number (0 = first guess) * @param solutionSpace current solution space * @return int[][] where [0]=guesses, [1]=secrets */ - public static int[][] select(int c, int d, int turn, SolutionSpace solutionSpace) { - if (turn == 0) { - int[] guess = { ConvertCode.toIndex(c, d, BestFirstGuess.of(c, d)) }; - return pair(guess, solutionSpace.getSecrets()); - } - return laterTurns(c, d, solutionSpace.getSize(), solutionSpace); + public static int[][] select(int c, int d, SolutionSpace solutionSpace) { + return selectSearchSpace(c, d, solutionSpace.getSize(), solutionSpace); } /** - * Later turns: cascade through several levels of size reduction until the + * Cascades through progressively smaller guess and secret arrays until the * search space fits within the threshold. */ - private static int[][] laterTurns(int c, int d, int secretsSize, SolutionSpace solutionSpace) { + private static int[][] selectSearchSpace(int c, int d, int secretsSize, SolutionSpace solutionSpace) { if (fits((int) Math.pow(c, d), secretsSize)) return pair(AllValidCode.generateAllCodes(c, d), solutionSpace.getSecrets()); From cd61ae1f8cb4ad2fdd2116d68075acde4c654133 Mon Sep 17 00:00:00 2001 From: FlysonBot <116744100+FlysonBot@users.noreply.github.com> Date: Wed, 4 Mar 2026 00:57:45 -0800 Subject: [PATCH 11/23] fix: ExpectedSize.convertSampleRankToExpectedSize overflow issue --- src/main/java/org/mastermind/solver/ExpectedSize.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/org/mastermind/solver/ExpectedSize.java b/src/main/java/org/mastermind/solver/ExpectedSize.java index c38cfa07..5e28cb25 100644 --- a/src/main/java/org/mastermind/solver/ExpectedSize.java +++ b/src/main/java/org/mastermind/solver/ExpectedSize.java @@ -63,7 +63,7 @@ public float convertRankToExpectedSize(long rank, int total) { } public float convertSampleRankToExpectedSize(long rank, int sampleSize, int populationSize) { - return rank * populationSize / (float) Math.pow(sampleSize, 2); + return (float) rank * (float) populationSize / (float) Math.pow(sampleSize, 2); } public float calcExpectedSize(int guessInd, int[] secretsInd, int c, int d, int[] feedbackFreq) { From 5d94aed0737a5ef69d751a158ecb8d07beca77dd Mon Sep 17 00:00:00 2001 From: FlysonBot <116744100+FlysonBot@users.noreply.github.com> Date: Wed, 4 Mar 2026 01:16:03 -0800 Subject: [PATCH 12/23] fix: suggestGuessWithDetails call getSecrets().length on first guess --- src/main/java/org/mastermind/MastermindSession.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/org/mastermind/MastermindSession.java b/src/main/java/org/mastermind/MastermindSession.java index bb1ce447..728cfd59 100644 --- a/src/main/java/org/mastermind/MastermindSession.java +++ b/src/main/java/org/mastermind/MastermindSession.java @@ -70,7 +70,7 @@ public long[] suggestGuessWithDetails() { if (history.isEmpty()) { long[] first = BestFirstGuess.of(c, d); return new long[] { - ConvertCode.toIndex(c, d, (int) first[0]), first[1], (long) solutionSpace.getSecrets().length + ConvertCode.toIndex(c, d, (int) first[0]), first[1], (long) solutionSpace.getSize() }; } From fa7b9f98e118cb0a3da86c1f2df26268f167b526 Mon Sep 17 00:00:00 2001 From: FlysonBot <116744100+FlysonBot@users.noreply.github.com> Date: Wed, 4 Mar 2026 18:02:27 -0800 Subject: [PATCH 13/23] feat: add incremental feedback computation for faster first filter - Add FeedbackIncremental class with incremental feedback computation - Add calcExpectedRankFirst in ExpectedSize using FeedbackIncremental - Add filterRangeFirst in SolutionSpace using FeedbackIncremental for first filter - Update BestFirstGuess to use calcExpectedRankFirst --- .../mastermind/solver/FeedbackBenchmark.java | 62 ++++- .../org/mastermind/MastermindSession.java | 11 +- .../org/mastermind/solver/BestFirstGuess.java | 239 +++++++++--------- .../org/mastermind/solver/ExpectedSize.java | 42 +++ .../solver/FeedbackIncremental.java | 141 +++++++++++ .../org/mastermind/solver/SolutionSpace.java | 87 ++++++- .../org/mastermind/solver/FeedbackTest.java | 54 ++++ 7 files changed, 498 insertions(+), 138 deletions(-) create mode 100644 src/main/java/org/mastermind/solver/FeedbackIncremental.java diff --git a/src/benchmarks/java/org/mastermind/solver/FeedbackBenchmark.java b/src/benchmarks/java/org/mastermind/solver/FeedbackBenchmark.java index 6a0ebb69..02663d66 100644 --- a/src/benchmarks/java/org/mastermind/solver/FeedbackBenchmark.java +++ b/src/benchmarks/java/org/mastermind/solver/FeedbackBenchmark.java @@ -39,11 +39,62 @@ public void doubleVariedInputBenchmark(BenchmarkState state, Blackhole blackhole } } + @OutputTimeUnit(TimeUnit.MICROSECONDS) + @Benchmark + @Fork(4) + public void oneVariedInputIncrementalBenchmark(BenchmarkState state, Blackhole blackhole) { + int guessInd = BenchmarkState.ind(1234); + int c = BenchmarkState.C, d = BenchmarkState.D; + int[] guessDigits = state.guessDigits; + int tmp = guessInd; + for (int p = 0; p < d; p++) { + guessDigits[p] = tmp % c; + tmp /= c; + } + + // Bootstrap at secretInd=0 + int[] colorFreqCounter = state.colorFreqCounter; + java.util.Arrays.fill(colorFreqCounter, 0); + int feedback0 = Feedback.getFeedback(guessInd, 0, c, d, state.freq); + int black = 0; + int[] secretDigits = state.secretDigits; + for (int p = 0; p < d; p++) { secretDigits[p] = 0; } + for (int p = 0; p < d; p++) { + int gs = guessDigits[p]; + if (gs == 0) black++; + else { + colorFreqCounter[gs]++; + colorFreqCounter[0]--; + } + } + int colorFreqTotal = 0; + for (int i = 0; i < c; i++) { + int f = colorFreqCounter[i]; + colorFreqTotal += f > 0 ? f : -f; + } + blackhole.consume(feedback0); + + int[] result = state.result; + for (int secretInd = 1; secretInd < state.total; secretInd++) { + FeedbackIncremental.getFeedbackIncremental(guessDigits, secretDigits, black, colorFreqCounter, + colorFreqTotal, c, d, + result); + black = result[1]; + colorFreqTotal = result[2]; + blackhole.consume(result[0]); + } + } + @State(Scope.Thread) public static class BenchmarkState { static final int C = 6, D = 4; - public int total = (int) Math.pow(C, D); // 1296 - public int[] freq = new int[C]; + public int total = (int) Math.pow(C, D); // 1296 + public int[] freq = new int[C]; + // Incremental benchmark state (reused across iterations to avoid allocation in hot loop) + public int[] guessDigits = new int[D]; + public int[] secretDigits = new int[D]; + public int[] colorFreqCounter = new int[C]; + public int[] result = new int[3]; public static int ind(int code) { return ConvertCode.toIndex(C, D, code); } @@ -55,7 +106,8 @@ public int getFeedbackQuick(int guessIdx, int secretIdx) { /* Benchmark average: Benchmark Mode Cnt Score Error Units -FeedbackBenchmark.doubleVariedInputBenchmark avgt 4 29.915 ± 3.150 ms/op -FeedbackBenchmark.fixInputBenchmark avgt 4 18.171 ± 1.001 ns/op -FeedbackBenchmark.oneVariedInputBenchmark avgt 4 25.012 ± 0.728 us/op +FeedbackBenchmark.doubleVariedInputBenchmark avgt 4 30.815 ± 3.831 ms/op +FeedbackBenchmark.fixInputBenchmark avgt 4 18.644 ± 0.701 ns/op +FeedbackBenchmark.oneVariedInputBenchmark avgt 4 26.329 ± 6.294 us/op +FeedbackBenchmark.oneVariedInputIncrementalBenchmark avgt 16 4.679 ± 0.150 us/op */ \ No newline at end of file diff --git a/src/main/java/org/mastermind/MastermindSession.java b/src/main/java/org/mastermind/MastermindSession.java index 728cfd59..ac28b50d 100644 --- a/src/main/java/org/mastermind/MastermindSession.java +++ b/src/main/java/org/mastermind/MastermindSession.java @@ -96,11 +96,18 @@ public void recordGuess(int guess, int feedback) { if (solved) throw new IllegalStateException("Game is already solved."); history.add(new int[] { guess, feedback }); - solutionSpace.filterSolution(guess, feedback); + // If game is solved, skip filtering directly if (feedback == winFeedback) { solved = true; - } else if (solutionSpace.getSize() == 0) { + return; + } + + // Otherwise filter solution space + solutionSpace.filterSolution(guess, feedback); + + // Handle error case when no solution remains + if (solutionSpace.getSize() == 0) { throw new IllegalArgumentException( "No valid secrets remain. The feedback provided may be inconsistent with prior guesses."); } diff --git a/src/main/java/org/mastermind/solver/BestFirstGuess.java b/src/main/java/org/mastermind/solver/BestFirstGuess.java index d10b0770..a160adc4 100644 --- a/src/main/java/org/mastermind/solver/BestFirstGuess.java +++ b/src/main/java/org/mastermind/solver/BestFirstGuess.java @@ -20,119 +20,6 @@ public class BestFirstGuess { private static final double CONFIDENCE_THRESHOLD = 99.0; private static final int BUDGET_MULTIPLIER = 2; - /** - * Returns the best first guess and its true rank for a Mastermind game of c colors and d digits. - * - * @param c number of colors (2–9) - * @param d number of digits (1–9) - * @return long[2] where [0] = best first guess code (e.g. 1123), [1] = true rank (raw calcExpectedRank) - * @throws IllegalArgumentException if c or d is out of the supported range - */ - public static long[] of(int c, int d) { - if (c < 2 || c > 9 || d < 1 || d > 9) - throw new IllegalArgumentException("Unsupported game size: c=" + c + ", d=" + d); - - // d=1: single digit, all guesses equivalent — always guess 1 - if (d == 1) { - int[] allSecrets = AllValidCode.generateAllCodes(c, 1); - long rank = new ExpectedSize(1).calcExpectedRank(ConvertCode.toIndex(c, 1, 1), allSecrets, c, 1, - new int[100]); - return new long[] { 1, rank }; - } - - return switch (c) { - case 2 -> switch (d) { - case 2 -> new long[] { 11, 6L }; - case 3 -> new long[] { 112, 16L }; - case 4 -> new long[] { 1112, 46L }; - case 5 -> new long[] { 11112, 148L }; - case 6 -> new long[] { 111112, 514L }; - case 7 -> new long[] { 1111122, 1752L }; - case 8 -> new long[] { 11111122, 5958L }; - case 9 -> new long[] { 111111122, 21250L }; - default -> throw new IllegalArgumentException("Unsupported game size: c=" + c + ", d=" + d); - }; - case 3 -> switch (d) { - case 2 -> new long[] { 12, 23L }; - case 3 -> new long[] { 112, 119L }; - case 4 -> new long[] { 1122, 775L }; - case 5 -> new long[] { 11123, 5099L }; - case 6 -> new long[] { 111123, 37271L }; - case 7 -> new long[] { 1111223, 289973L }; - case 8 -> new long[] { 11111223, 2234617L }; - case 9 -> new long[] { 111111223, 18004767L }; - default -> throw new IllegalArgumentException("Unsupported game size: c=" + c + ", d=" + d); - }; - case 4 -> switch (d) { - case 2 -> new long[] { 12, 70L }; - case 3 -> new long[] { 123, 690L }; - case 4 -> new long[] { 1123, 7892L }; - case 5 -> new long[] { 11223, 100950L }; - case 6 -> new long[] { 111223, 1318952L }; - case 7 -> new long[] { 1112223, 17732570L }; - case 8 -> new long[] { 11112223, 246242208L }; - case 9 -> new long[] { 111122223, 3424656050L }; - default -> throw new IllegalArgumentException("Unsupported game size: c=" + c + ", d=" + d); - }; - case 5 -> switch (d) { - case 2 -> new long[] { 12, 183L }; - case 3 -> new long[] { 123, 2751L }; - case 4 -> new long[] { 1123, 50807L }; - case 5 -> new long[] { 11223, 988703L }; - case 6 -> new long[] { 112233, 20472687L }; - case 7 -> new long[] { 1112223, 432675025L }; - case 8 -> new long[] { 11122233, 9432668521L }; - case 9 -> new long[] { 111122223, 207807845615L }; - default -> throw new IllegalArgumentException("Unsupported game size: c=" + c + ", d=" + d); - }; - case 6 -> switch (d) { - case 2 -> new long[] { 12, 422L }; - case 3 -> new long[] { 123, 8906L }; - case 4 -> new long[] { 1123, 240108L }; - case 5 -> new long[] { 11223, 6659862L }; - case 6 -> new long[] { 112233, 194108442L }; - case 7 -> new long[] { 1112233, 5987538014L }; - case 8 -> new long[] { 11122233, 185462657858L }; - case 9 -> new long[] { 111222333, 5820009319166L }; - default -> throw new IllegalArgumentException("Unsupported game size: c=" + c + ", d=" + d); - }; - case 7 -> switch (d) { - case 2 -> new long[] { 12, 871L }; - case 3 -> new long[] { 123, 24387L }; - case 4 -> new long[] { 1234, 882063L }; - case 5 -> new long[] { 11223, 34192827L }; - case 6 -> new long[] { 112233, 1334737119L }; - case 7 -> new long[] { 1112233, 56058606307L }; - case 8 -> new long[] { 11122233, 2362723139081L }; - case 9 -> new long[] { 111222333, 100244736768813L }; - default -> throw new IllegalArgumentException("Unsupported game size: c=" + c + ", d=" + d); - }; - case 8 -> switch (d) { - case 2 -> new long[] { 12, 1638L }; - case 3 -> new long[] { 123, 58866L }; - case 4 -> new long[] { 1234, 2724406L }; - case 5 -> new long[] { 11234, 140346626L }; - case 6 -> new long[] { 112233, 7211734938L }; - case 7 -> new long[] { 1122334, 386821286390L }; - case 8 -> new long[] { 11223344, 21165744470710L }; - case 9 -> new long[] { 111223344, 1201215086592578L }; - default -> throw new IllegalArgumentException("Unsupported game size: c=" + c + ", d=" + d); - }; - case 9 -> switch (d) { - case 2 -> new long[] { 12, 2855L }; - case 3 -> new long[] { 123, 128975L }; - case 4 -> new long[] { 1234, 7437615L }; - case 5 -> new long[] { 12345, 486776063L }; - case 6 -> new long[] { 112234, 32113088737L }; - case 7 -> new long[] { 1122334, 2148685524777L }; - case 8 -> new long[] { 11223344, 147476714738127L }; - case 9 -> new long[] { 111223344, 10597696978901189L }; - default -> throw new IllegalArgumentException("Unsupported game size: c=" + c + ", d=" + d); - }; - default -> throw new IllegalArgumentException("Unsupported game size: c=" + c + ", d=" + d); - }; - } - // ------------------------------------------------------------------------- // Calibration — run main() to recompute and regenerate the switch above // ------------------------------------------------------------------------- @@ -191,10 +78,10 @@ public static void main(String[] args) { long[][] trueRank = new long[10][10]; for (int c = 2; c <= 9; c++) { for (int d = 2; d <= 9; d++) { - int[] allSecrets = AllValidCode.generateAllCodes(c, d); ExpectedSize expectedSize = new ExpectedSize(d); + int totalCodes = (int) Math.pow(c, d); int codeIndex = ConvertCode.toIndex(c, d, bestCode[c][d]); - trueRank[c][d] = expectedSize.calcExpectedRank(codeIndex, allSecrets, c, d, feedbackFreq); + trueRank[c][d] = expectedSize.calcExpectedRankFirst(codeIndex, c, d, totalCodes, feedbackFreq); System.out.printf("%-6s %-12d %d%n", c + "x" + d, bestCode[c][d], trueRank[c][d]); } } @@ -222,7 +109,6 @@ private static Result evaluate( int n = canonical.length; int sampleSize = Math.max(1, (int) (budget / TRIALS / n)); boolean fullEval = sampleSize >= totalCodes; - int[] allSecrets = fullEval ? AllValidCode.generateAllCodes(c, d) : null; int normSize = fullEval ? totalCodes : sampleSize; int trials = fullEval ? 1 : TRIALS; @@ -230,10 +116,12 @@ private static Result evaluate( double[] sumScore2 = new double[n]; for (int t = 0; t < trials; t++) { - int[] s = fullEval ? allSecrets : SampledCode.getSample(c, d, sampleSize); + int[] s = fullEval ? null : SampledCode.getSample(c, d, sampleSize); for (int i = 0; i < n; i++) { - double score = expectedSize.calcExpectedRank(canonical[i], s, c, d, feedbackFreq) - / (double) normSize; + long rank = fullEval + ? expectedSize.calcExpectedRankFirst(canonical[i], c, d, totalCodes, feedbackFreq) + : expectedSize.calcExpectedRank(canonical[i], s, c, d, feedbackFreq); + double score = rank / (double) normSize; sumScore[i] += score; sumScore2[i] += score * score; } @@ -279,6 +167,119 @@ private static double normalCDF(double z) { return 1.0 - pdf * poly; } + /** + * Returns the best first guess and its true rank for a Mastermind game of c colors and d digits. + * + * @param c number of colors (2–9) + * @param d number of digits (1–9) + * @return long[2] where [0] = best first guess code (e.g. 1123), [1] = true rank (raw calcExpectedRank) + * @throws IllegalArgumentException if c or d is out of the supported range + */ + public static long[] of(int c, int d) { + if (c < 2 || c > 9 || d < 1 || d > 9) + throw new IllegalArgumentException("Unsupported game size: c=" + c + ", d=" + d); + + // d=1: single digit, all guesses equivalent — always guess 1 + if (d == 1) { + int[] allSecrets = AllValidCode.generateAllCodes(c, 1); + long rank = new ExpectedSize(1).calcExpectedRank(ConvertCode.toIndex(c, 1, 1), allSecrets, c, 1, + new int[100]); + return new long[] { 1, rank }; + } + + return switch (c) { + case 2 -> switch (d) { + case 2 -> new long[] { 11, 6L }; + case 3 -> new long[] { 112, 16L }; + case 4 -> new long[] { 1112, 46L }; + case 5 -> new long[] { 11112, 148L }; + case 6 -> new long[] { 111112, 514L }; + case 7 -> new long[] { 1111122, 1752L }; + case 8 -> new long[] { 11111122, 5958L }; + case 9 -> new long[] { 111111122, 21250L }; + default -> throw new IllegalArgumentException("Unsupported game size: c=" + c + ", d=" + d); + }; + case 3 -> switch (d) { + case 2 -> new long[] { 12, 23L }; + case 3 -> new long[] { 112, 119L }; + case 4 -> new long[] { 1122, 775L }; + case 5 -> new long[] { 11123, 5099L }; + case 6 -> new long[] { 111123, 37271L }; + case 7 -> new long[] { 1111223, 289973L }; + case 8 -> new long[] { 11111223, 2234617L }; + case 9 -> new long[] { 111111223, 18004767L }; + default -> throw new IllegalArgumentException("Unsupported game size: c=" + c + ", d=" + d); + }; + case 4 -> switch (d) { + case 2 -> new long[] { 12, 70L }; + case 3 -> new long[] { 123, 690L }; + case 4 -> new long[] { 1123, 7892L }; + case 5 -> new long[] { 11223, 100950L }; + case 6 -> new long[] { 111223, 1318952L }; + case 7 -> new long[] { 1112223, 17732570L }; + case 8 -> new long[] { 11112223, 246242208L }; + case 9 -> new long[] { 111122223, 3424656050L }; + default -> throw new IllegalArgumentException("Unsupported game size: c=" + c + ", d=" + d); + }; + case 5 -> switch (d) { + case 2 -> new long[] { 12, 183L }; + case 3 -> new long[] { 123, 2751L }; + case 4 -> new long[] { 1123, 50807L }; + case 5 -> new long[] { 11223, 988703L }; + case 6 -> new long[] { 112233, 20472687L }; + case 7 -> new long[] { 1112223, 432675025L }; + case 8 -> new long[] { 11122233, 9432668521L }; + case 9 -> new long[] { 111122223, 207807845615L }; + default -> throw new IllegalArgumentException("Unsupported game size: c=" + c + ", d=" + d); + }; + case 6 -> switch (d) { + case 2 -> new long[] { 12, 422L }; + case 3 -> new long[] { 123, 8906L }; + case 4 -> new long[] { 1123, 240108L }; + case 5 -> new long[] { 11223, 6659862L }; + case 6 -> new long[] { 112233, 194108442L }; + case 7 -> new long[] { 1112233, 5987538014L }; + case 8 -> new long[] { 11122233, 185462657858L }; + case 9 -> new long[] { 111222333, 5820009319166L }; + default -> throw new IllegalArgumentException("Unsupported game size: c=" + c + ", d=" + d); + }; + case 7 -> switch (d) { + case 2 -> new long[] { 12, 871L }; + case 3 -> new long[] { 123, 24387L }; + case 4 -> new long[] { 1234, 882063L }; + case 5 -> new long[] { 11223, 34192827L }; + case 6 -> new long[] { 112233, 1334737119L }; + case 7 -> new long[] { 1112233, 56058606307L }; + case 8 -> new long[] { 11122233, 2362723139081L }; + case 9 -> new long[] { 111222333, 100244736768813L }; + default -> throw new IllegalArgumentException("Unsupported game size: c=" + c + ", d=" + d); + }; + case 8 -> switch (d) { + case 2 -> new long[] { 12, 1638L }; + case 3 -> new long[] { 123, 58866L }; + case 4 -> new long[] { 1234, 2724406L }; + case 5 -> new long[] { 11234, 140346626L }; + case 6 -> new long[] { 112233, 7211734938L }; + case 7 -> new long[] { 1122334, 386821286390L }; + case 8 -> new long[] { 11223344, 21165744470710L }; + case 9 -> new long[] { 111223344, 1201215086592578L }; + default -> throw new IllegalArgumentException("Unsupported game size: c=" + c + ", d=" + d); + }; + case 9 -> switch (d) { + case 2 -> new long[] { 12, 2855L }; + case 3 -> new long[] { 123, 128975L }; + case 4 -> new long[] { 1234, 7437615L }; + case 5 -> new long[] { 12345, 486776063L }; + case 6 -> new long[] { 112234, 32113088737L }; + case 7 -> new long[] { 1122334, 2148685524777L }; + case 8 -> new long[] { 11223344, 147476714738127L }; + case 9 -> new long[] { 111223344, 10597696978901189L }; + default -> throw new IllegalArgumentException("Unsupported game size: c=" + c + ", d=" + d); + }; + default -> throw new IllegalArgumentException("Unsupported game size: c=" + c + ", d=" + d); + }; + } + private record Result(int bestIdx, double avgScore, double confidence, long totalEvals, boolean fullEval ) { } diff --git a/src/main/java/org/mastermind/solver/ExpectedSize.java b/src/main/java/org/mastermind/solver/ExpectedSize.java index 5e28cb25..57adba1e 100644 --- a/src/main/java/org/mastermind/solver/ExpectedSize.java +++ b/src/main/java/org/mastermind/solver/ExpectedSize.java @@ -69,4 +69,46 @@ public float convertSampleRankToExpectedSize(long rank, int sampleSize, int popu public float calcExpectedSize(int guessInd, int[] secretsInd, int c, int d, int[] feedbackFreq) { return convertRankToExpectedSize(calcExpectedRank(guessInd, secretsInd, c, d, feedbackFreq), secretsInd.length); } + + /** + * Incremental variant of {@link #calcExpectedRank} for the full secret space (0..c^d-1). + * Instead of an arbitrary secrets array, iterates all indices sequentially and uses + * {@link FeedbackIncremental} to avoid recomputing digit decompositions from scratch. + * + * @param guessInd index of the guess code (0-based, base-c encoding) + * @param c number of colors (<= 9) + * @param d number of digits (<= 9) + * @param total c^d (total number of codes) + * @param feedbackFreq int array of 0 with length 100 + * @return Sum of squared feedback frequencies (same semantics as {@link #calcExpectedRank}) + */ + public long calcExpectedRankFirst(int guessInd, int c, int d, int total, int[] feedbackFreq) { + FeedbackIncremental.State init = FeedbackIncremental.setupIncremental(guessInd, 0, c, d); + int[] guessDigits = init.guessDigits(); + int[] secretDigits = init.secretDigits(); + int[] colorFreqCounter = init.colorFreqCounter(); + int black = init.black(); + int colorFreqTotal = init.colorFreqTotal(); + + feedbackFreq[black * 9 + d - (colorFreqTotal >>> 1)]++; + + int[] result = new int[3]; + for (int secretInd = 1; secretInd < total; secretInd++) { + FeedbackIncremental.getFeedbackIncremental(guessDigits, secretDigits, black, colorFreqCounter, + colorFreqTotal, c, d, result); + black = result[1]; + colorFreqTotal = result[2]; + feedbackFreq[result[0]]++; + } + + long sum = 0; + long freq; + for (int feedback : validFeedback) { + freq = feedbackFreq[feedback]; + sum += freq * freq; + feedbackFreq[feedback] = 0; + } + + return sum; + } } diff --git a/src/main/java/org/mastermind/solver/FeedbackIncremental.java b/src/main/java/org/mastermind/solver/FeedbackIncremental.java new file mode 100644 index 00000000..0f9444e4 --- /dev/null +++ b/src/main/java/org/mastermind/solver/FeedbackIncremental.java @@ -0,0 +1,141 @@ +package org.mastermind.solver; + +/** + * Incremental feedback computation for sequential secret iteration. + * Extracted from {@link Feedback} to keep that class focused on the + * stateless per-call computation. + */ +public final class FeedbackIncremental { + + /** + * Set up the incremental state for the given guess and starting secret index. + * Extracts guess digits and walks {@code secretInd} digit-by-digit, leaving + * {@code colorFreqCounter} in persistent non-zeroed form ready for + * {@link #getFeedbackIncremental}. + * + * @param guessInd index of the guess code (0-based, base-c encoding) + * @param secretInd index of the starting secret code + * @param c number of colors + * @param d number of digits + * @return initial {@link State} for this (guessInd, secretInd) pair + */ + public static State setupIncremental(int guessInd, int secretInd, int c, int d) { + int[] guessDigits = new int[d]; + int[] secretDigits = new int[d]; + int[] colorFreqCounter = new int[c]; + + // Extract guess digits + int tmp = guessInd; + for (int p = 0; p < d; p++) { + guessDigits[p] = tmp % c; + tmp /= c; + } + + // Extract secret digits and update colorFreqCounter + int black = 0; + tmp = secretInd; + for (int p = 0; p < d; p++) { + int gs = guessDigits[p]; + int ss = tmp % c; + tmp /= c; + secretDigits[p] = ss; + if (gs == ss) { + black++; + } else { + colorFreqCounter[gs]++; + colorFreqCounter[ss]--; + } + } + + // Compute colorFreqTotal + int colorFreqTotal = 0; + for (int i = 0; i < c; i++) { + int freq = colorFreqCounter[i]; + colorFreqTotal += freq > 0 ? freq : -freq; + } + + return new State(guessDigits, secretDigits, colorFreqCounter, black, colorFreqTotal); + } + + /** + * Incremental variant of getFeedback for sequential secret iteration (0, 1, 2, ...). + * + *

    Requires that secretDigits[] was correctly set up for the previous secretInd, + * and that colorFreqCounter[] reflects the contribution of those previous secret + * digits (without zeroing between calls). On each call, this method: + *

      + *
    1. Detects which digit positions changed via base-c carry chain
    2. + *
    3. Undoes the contribution of changed positions from colorFreqCounter and black
    4. + *
    5. Updates secretDigits[] for changed positions
    6. + *
    7. Applies new contributions to colorFreqCounter and black
    8. + *
    9. Recomputes colorFreqTotal and returns feedback alongside new black count
    10. + *
    + * + * @param guessDigits pre-extracted guess digits [position 0..d-1], position 0 = LSD + * @param secretDigits mutable secret digits array, updated in-place + * @param black current black count (from previous call) + * @param colorFreqCounter persistent frequency-difference array, length c (NOT cleared between calls) + * @param colorFreqTotal current sum of |colorFreqCounter[i]|, updated in-place + * @param c number of colors + * @param d number of digits + * @param result int[2] output buffer: result[0]=feedback, result[1]=new black count, + * result[2]=new colorFreqTotal + */ + public static void getFeedbackIncremental( + int[] guessDigits, int[] secretDigits, int black, int[] colorFreqCounter, + int colorFreqTotal, int c, int d, int[] result + ) { + // Walk the base-c carry chain: increment secretDigits in-place + for (int p = 0; p < d; p++) { + // Extract digits + int oldDigit = secretDigits[p]; + int newDigit = oldDigit == c - 1 ? 0 : oldDigit + 1; + int gDigit = guessDigits[p]; + secretDigits[p] = newDigit; + + // Update colorFreqCounter and colorFreqTotal + int v; + if (gDigit == oldDigit) { + // Was black, now not: black--, apply newDigit contribution + black--; + v = colorFreqCounter[gDigit]; + colorFreqTotal += (v >= 0 ? 1 : -1); + colorFreqCounter[gDigit] = v + 1; + v = colorFreqCounter[newDigit]; + colorFreqTotal += (v <= 0 ? 1 : -1); + colorFreqCounter[newDigit] = v - 1; + } else if (gDigit == newDigit) { + // Was not black, now black: black++, undo oldDigit contribution + black++; + v = colorFreqCounter[gDigit]; + colorFreqTotal += (v <= 0 ? 1 : -1); + colorFreqCounter[gDigit] = v - 1; + v = colorFreqCounter[oldDigit]; + colorFreqTotal += (v >= 0 ? 1 : -1); + colorFreqCounter[oldDigit] = v + 1; + } else { + // Neither black: gDigit updates cancel, only oldDigit and newDigit change + v = colorFreqCounter[oldDigit]; + colorFreqTotal += (v >= 0 ? 1 : -1); + colorFreqCounter[oldDigit] = v + 1; + v = colorFreqCounter[newDigit]; + colorFreqTotal += (v <= 0 ? 1 : -1); + colorFreqCounter[newDigit] = v - 1; + } + + if (newDigit != 0) break; // no carry + } + + result[0] = black * 9 + d - (colorFreqTotal >>> 1); + result[1] = black; + result[2] = colorFreqTotal; + } + + /** + * Snapshot of the incremental state for a given (guessInd, secretInd) pair. + * Used to bootstrap {@link #getFeedbackIncremental} for the next secret index. + */ + public record State(int[] guessDigits, int[] secretDigits, int[] colorFreqCounter, + int black, int colorFreqTotal + ) { } +} diff --git a/src/main/java/org/mastermind/solver/SolutionSpace.java b/src/main/java/org/mastermind/solver/SolutionSpace.java index 52aac4f5..4646c73f 100644 --- a/src/main/java/org/mastermind/solver/SolutionSpace.java +++ b/src/main/java/org/mastermind/solver/SolutionSpace.java @@ -21,11 +21,12 @@ public class SolutionSpace { private static final int PARALLEL_THRESHOLD = 16384; private static final ForkJoinPool POOL = ForkJoinPool.commonPool(); - private final int c; - private final int d; - private final int totalCodes; // c^d - private final BitSet remaining; // bit i set ⟺ index i is still a valid secret - private int size; // cached cardinality of remaining + private final int c; + private final int d; + private final int totalCodes; // c^d + private final BitSet remaining; // bit i set ⟺ index i is still a valid secret + private int size; // cached cardinality of remaining + private boolean isFirstFilter = true; // flag to use specialized function for first filter public SolutionSpace(int c, int d) { this.c = c; @@ -40,6 +41,7 @@ public SolutionSpace(int c, int d) { public void reset() { remaining.set(0, totalCodes); size = totalCodes; + isFirstFilter = true; } /** @@ -53,21 +55,31 @@ public void reset() { * BitSet, so concurrent {@code clear()} calls on non-overlapping words are safe. * For small spaces the single-threaded path is used to avoid FJP overhead. * + *

    The first call uses an incremental path that avoids recomputing all digit + * comparisons from scratch for every secret index. + * * @param guessInd index of the guess code (0-based, base-c encoding) * @param obtainedFeedback feedback value (black * 9 + d - colorFreqTotal/2) */ public void filterSolution(int guessInd, int obtainedFeedback) { + // Read and update flag + final boolean isFirst = isFirstFilter; + if (isFirst) isFirstFilter = false; + + // When size is small, go single-threaded if (size < PARALLEL_THRESHOLD) { - size -= filterRange(guessInd, obtainedFeedback, 0, totalCodes); + size -= isFirst ? + filterRangeFirst(guessInd, obtainedFeedback, 0, totalCodes) : + filterRange(guessInd, obtainedFeedback, 0, totalCodes); return; } // Split into word-aligned (multiple-of-64) chunks for safe concurrent access. int parallelism = POOL.getParallelism(); - int words = (totalCodes + 63) >>> 6; // number of 64-bit words + int words = (totalCodes + 63) >>> 6; int wordsPerTask = Math.max(1, (words + parallelism - 1) / parallelism); - // Submit all tasks except the last; run the last chunk on the calling thread. + // Multi-threaded route @SuppressWarnings("unchecked") Future[] futures = new Future[parallelism]; int fromIndex = 0; @@ -75,17 +87,26 @@ public void filterSolution(int guessInd, int obtainedFeedback) { while (fromIndex + wordsPerTask * 64 < totalCodes) { final int from = fromIndex; final int to = fromIndex + wordsPerTask * 64; - futures[taskCount++] = POOL.submit(() -> filterRange(guessInd, obtainedFeedback, from, to)); + + // Submit the task + futures[taskCount++] = isFirst ? + POOL.submit(() -> filterRangeFirst(guessInd, obtainedFeedback, from, to)) : + POOL.submit(() -> filterRange(guessInd, obtainedFeedback, from, to)); + fromIndex = to; } - // Run the tail on the calling thread and sum removed counts. - int removed = filterRange(guessInd, obtainedFeedback, fromIndex, totalCodes); + // Handle the last chunk in main thread + int removed = isFirst ? + filterRangeFirst(guessInd, obtainedFeedback, fromIndex, totalCodes) : + filterRange(guessInd, obtainedFeedback, fromIndex, totalCodes); - // Wait for all submitted tasks and accumulate removed counts. + // Sum up the removed count from other threads for (int i = 0; i < taskCount; i++) { try { removed += futures[i].get(); } catch (Exception e) { throw new RuntimeException(e); } } + + // Update size size -= removed; } @@ -99,6 +120,8 @@ public void filterSolution(int guessInd, int obtainedFeedback) { private int filterRange(int guessInd, int obtainedFeedback, int from, int to) { int[] colorFreqCounter = new int[c]; int removed = 0; + + // Call getFeedback for each secret for (int i = remaining.nextSetBit(from); i >= 0 && i < to; i = remaining.nextSetBit(i + 1)) { if (Feedback.getFeedback(guessInd, i, c, d, colorFreqCounter) != obtainedFeedback) { remaining.clear(i); @@ -108,6 +131,46 @@ private int filterRange(int guessInd, int obtainedFeedback, int from, int to) { return removed; } + /** + * Incremental single-threaded filter over a contiguous {@code [from, to)} range + * (used only for the first filter when all bits are set). Iterates every index + * with a plain for-loop and computes feedback incrementally via + * {@link FeedbackIncremental#getFeedbackIncremental}. + * + * @return number of bits cleared + */ + private int filterRangeFirst(int guessInd, int obtainedFeedback, int from, int to) { + FeedbackIncremental.State init = FeedbackIncremental.setupIncremental(guessInd, from, c, d); + int[] guessDigits = init.guessDigits(); + int[] secretDigits = init.secretDigits(); + int[] colorFreqCounter = init.colorFreqCounter(); + int black = init.black(); + int colorFreqTotal = init.colorFreqTotal(); + int feedback0 = black * 9 + d - (colorFreqTotal >>> 1); + + // Handle the first secret in chunk + int removed = 0; + if (feedback0 != obtainedFeedback) { + remaining.clear(from); + removed++; + } + + // Handle the remaining secrets + int[] result = new int[3]; + for (int i = from + 1; i < to; i++) { + FeedbackIncremental.getFeedbackIncremental(guessDigits, secretDigits, black, colorFreqCounter, + colorFreqTotal, c, d, result); + black = result[1]; + colorFreqTotal = result[2]; + if (result[0] != obtainedFeedback) { + remaining.clear(i); + removed++; + } + } + + return removed; + } + /** * Materialize the remaining valid secrets as an int array of indices. * Called once per turn suggestion, not per filter. diff --git a/src/tests/java/org/mastermind/solver/FeedbackTest.java b/src/tests/java/org/mastermind/solver/FeedbackTest.java index 2f246d5c..5f975b6e 100644 --- a/src/tests/java/org/mastermind/solver/FeedbackTest.java +++ b/src/tests/java/org/mastermind/solver/FeedbackTest.java @@ -93,6 +93,60 @@ public void testEdgeCases() { System.out.println("✓ Mixed (5566 vs 5655): " + result4 / 10 + " black, " + result4 % 10 + " white"); } + @Test + void testGetFeedbackIncrementalMatchesGetFeedback() { + // For every guess in the 6x4 space, iterate all secrets sequentially using + // getFeedbackIncremental and verify each result matches getFeedback. + int c = COLORS, d = DIGITS, total = TOTAL_COMBINATIONS; + int[] colorFreqRef = new int[c]; + int[] result = new int[3]; + + for (int guessInd = 0; guessInd < total; guessInd++) { + // Pre-extract guess digits + int[] guessDigits = new int[d]; + int tmp = guessInd; + for (int p = 0; p < d; p++) { + guessDigits[p] = tmp % c; + tmp /= c; + } + + // Bootstrap incremental state at secretInd=0 + int[] colorFreqCounter = new int[c]; + int feedback0 = Feedback.getFeedback(guessInd, 0, c, d, colorFreqCounter); + int black0 = 0; + int[] secretDigits = new int[d]; + for (int p = 0; p < d; p++) { + int gs = guessDigits[p], ss = 0; + secretDigits[p] = ss; + if (gs == ss) black0++; + else { + colorFreqCounter[gs]++; + colorFreqCounter[ss]--; + } + } + assertEquals(Feedback.getFeedback(guessInd, 0, c, d, colorFreqRef), feedback0, + "Bootstrap mismatch at guessInd=" + guessInd + " secretInd=0"); + + int colorFreqTotal = 0; + for (int i = 0; i < c; i++) { + int f = colorFreqCounter[i]; + colorFreqTotal += f > 0 ? f : -f; + } + + int black = black0; + for (int secretInd = 1; secretInd < total; secretInd++) { + FeedbackIncremental.getFeedbackIncremental(guessDigits, secretDigits, black, colorFreqCounter, + colorFreqTotal, c, + d, result); + black = result[1]; + colorFreqTotal = result[2]; + int expected = Feedback.getFeedback(guessInd, secretInd, c, d, colorFreqRef); + assertEquals(expected, result[0], + "Mismatch at guessInd=" + guessInd + " secretInd=" + secretInd); + } + } + } + @Test void testCalcFeedbackSize() { assertEquals(55, Feedback.calcFeedbackSize(9)); From c504bb9ef8daf29480358d468035781feae5ca67 Mon Sep 17 00:00:00 2001 From: FlysonBot <116744100+FlysonBot@users.noreply.github.com> Date: Wed, 4 Mar 2026 20:12:06 -0800 Subject: [PATCH 14/23] docs: add inline comments explaining key algorithms - Explain counter algorithm for white-peg computation in Feedback - Clarify GuessStrategy tolerance/percentile sampling heuristics - Add minor inline comments in various places - Update CLAUDE.md status and current focus section --- CLAUDE.md | 8 +++++--- .../org/mastermind/codes/CanonicalCode.java | 7 ++++++- .../java/org/mastermind/solver/BestGuess.java | 18 +++++++++-------- .../org/mastermind/solver/ExpectedSize.java | 4 ++++ .../java/org/mastermind/solver/Feedback.java | 20 ++++++++++++++++++- .../solver/FeedbackIncremental.java | 7 +++++-- .../org/mastermind/solver/GuessStrategy.java | 14 +++++++++++-- .../org/mastermind/solver/SolutionSpace.java | 2 +- 8 files changed, 62 insertions(+), 18 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index dcc848c8..26155835 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,7 +2,8 @@ Mastermind solver using Java algorithms (performance) + Python UI (terminal). Goal: Efficiently solve c=9, d=9 cases. -Status: Rewriting codebase; currently focused on Java algorithm only. +Status: Java algorithm complete and performing well (~2s for a full 9x9 solve). Currently in cleanup phase (comments, +tests, chores). Python UI not yet started. ### Code Organization @@ -20,9 +21,10 @@ Status: Rewriting codebase; currently focused on Java algorithm only. 5. `GuessStrategy.select()` — chooses which guesses and secrets arrays to pass into `BestGuess` 6. `MastermindSession` — manages a full game: history, solution space, strategy-based suggestions, undo -### Next Move / Current Move +### Current Focus -- Implementation done, currently refactoring to improve performance. +- Java side mostly done; touching up code quality, expanding test coverage, misc chores. +- Next major phase: Python UI. ### Preference diff --git a/src/main/java/org/mastermind/codes/CanonicalCode.java b/src/main/java/org/mastermind/codes/CanonicalCode.java index d51dfa63..79a29790 100644 --- a/src/main/java/org/mastermind/codes/CanonicalCode.java +++ b/src/main/java/org/mastermind/codes/CanonicalCode.java @@ -58,7 +58,8 @@ public static int[] enumerateCanonicalForms(int c, int d) { } private static void generatePartitions( - int[] results, int[] index, int[] parts, int depth, int remaining, int maxVal, int maxParts, int[] place) { + int[] results, int[] index, int[] parts, int depth, int remaining, int maxVal, int maxParts, int[] place + ) { if (remaining == 0) { results[index[0]++] = buildIndex(parts, depth, place); return; @@ -73,6 +74,10 @@ private static void generatePartitions( } private static int buildIndex(int[] parts, int numParts, int[] place) { + // Maps a partition (color frequency array) to its lex-smallest representative index. + // Color 0 gets the highest frequency and occupies the leftmost positions, + // color 1 gets the next frequency, and so on. This ensures all codes with the + // same partition map to the same canonical representative. int ind = 0; int pos = 0; for (int color = 0; color < numParts; color++) { diff --git a/src/main/java/org/mastermind/solver/BestGuess.java b/src/main/java/org/mastermind/solver/BestGuess.java index db5b2764..8afe5922 100644 --- a/src/main/java/org/mastermind/solver/BestGuess.java +++ b/src/main/java/org/mastermind/solver/BestGuess.java @@ -9,13 +9,14 @@ /** * This is a strategy to find the best guess for Mastermind by searching - * through the space of all valid guesses and secrets to find the guess - * that minimize the average number of remaining solutions to the puzzle. - * Due to the nature of Mastermind, sometimes the search space can be huge. - * To optimize for performance, the program create a thread for each CPU - * thread precent on the machine. The algorithm go multi-threading when - * the search space exceed a threshold, which is a heuristic value for - * when the algorithm will take longer than 50 milliseconds to run. + * through the space of all candidate guesses and secrets array to find + * the guess that minimize the average number of remaining solutions to + * the puzzle. Due to the nature of Mastermind, sometimes the search + * space can be huge. To optimize for performance, the program create a + * thread for each CPU thread precent on the machine. The algorithm go + * multi-threading when the search space exceed a threshold, which is a + * heuristic value for when the algorithm will take longer than + * 50 milliseconds to run. */ public class BestGuess { private static final int THREAD_COUNT = Runtime.getRuntime().availableProcessors(); @@ -51,6 +52,7 @@ public static long[] findBestGuess(int[] guessesInd, int[] secretsInd, int c, in return findBestGuessParallel(guessesInd, secretsInd, c, d); } + // Provide a way to force specific algorithm choice for benchmarking public static long[] findBestGuess(int[] guessesInd, int[] secretsInd, int c, int d, boolean parallel) { if (!parallel) return findBestGuessAlgorithm(guessesInd, secretsInd, c, d, 0, guessesInd.length); return findBestGuessParallel(guessesInd, secretsInd, c, d); @@ -62,7 +64,7 @@ private static long[] findBestGuessParallel(int[] guessesInd, int[] secretsInd, int chunkSize = (guessesInd.length + THREAD_COUNT - 1) / THREAD_COUNT; int actualThreads = (guessesInd.length + chunkSize - 1) / chunkSize; - // Holder for function's output result + // Initialize futures list (holder for pending thread outputs) List> futures = new ArrayList<>(actualThreads); // Submit work to each threads diff --git a/src/main/java/org/mastermind/solver/ExpectedSize.java b/src/main/java/org/mastermind/solver/ExpectedSize.java index 57adba1e..ef13001c 100644 --- a/src/main/java/org/mastermind/solver/ExpectedSize.java +++ b/src/main/java/org/mastermind/solver/ExpectedSize.java @@ -83,6 +83,7 @@ public float calcExpectedSize(int guessInd, int[] secretsInd, int c, int d, int[ * @return Sum of squared feedback frequencies (same semantics as {@link #calcExpectedRank}) */ public long calcExpectedRankFirst(int guessInd, int c, int d, int total, int[] feedbackFreq) { + // Set up incremental feedback state FeedbackIncremental.State init = FeedbackIncremental.setupIncremental(guessInd, 0, c, d); int[] guessDigits = init.guessDigits(); int[] secretDigits = init.secretDigits(); @@ -90,8 +91,10 @@ public long calcExpectedRankFirst(int guessInd, int c, int d, int total, int[] f int black = init.black(); int colorFreqTotal = init.colorFreqTotal(); + // Handle secret index 0 (setup already computed its feedback) feedbackFreq[black * 9 + d - (colorFreqTotal >>> 1)]++; + // Iterate remaining secrets incrementally, updating secretDigits and feedback state in place int[] result = new int[3]; for (int secretInd = 1; secretInd < total; secretInd++) { FeedbackIncremental.getFeedbackIncremental(guessDigits, secretDigits, black, colorFreqCounter, @@ -101,6 +104,7 @@ public long calcExpectedRankFirst(int guessInd, int c, int d, int total, int[] f feedbackFreq[result[0]]++; } + // Sum squared frequencies and reset feedbackFreq for reuse long sum = 0; long freq; for (int feedback : validFeedback) { diff --git a/src/main/java/org/mastermind/solver/Feedback.java b/src/main/java/org/mastermind/solver/Feedback.java index c7c0536e..30cb830f 100644 --- a/src/main/java/org/mastermind/solver/Feedback.java +++ b/src/main/java/org/mastermind/solver/Feedback.java @@ -40,6 +40,18 @@ public static int getFeedback(int guessInd, int secretInd, int c, int d, int[] c colorFreqCounter[currGuess]++; colorFreqCounter[currSecret]--; } + /* + How the counter algorithm works: + - Label each digit in guess and secret as black, white, or gray (unmatched). + - If we incremented the counter for all digits of both guess and secret, + sum(|counter|) = 2d. + - Skipping blacks reduces it by 2*black. Now sum(|counter|) = 2d - 2*black + - If there is a partial match (white), incrementing for guess and decrementing + for secret will cause it to cancel out, reducing sum by 2*white + - So: sum(|coutner|) = 2d - 2*black - 2*white + 2*white = 2d - 2*black - sum(|counter|) + white = d - black - sum(|counter|) / 2 + */ } // Sum absolute values and reset in one pass @@ -50,6 +62,7 @@ public static int getFeedback(int guessInd, int secretInd, int c, int d, int[] c colorFreqTotal += (freq > 0) ? freq : -freq; } + // black * 10 + white // black * 10 + d - black - colorFreqTotal / 2 return black * 9 + d - (colorFreqTotal >>> 1); } @@ -58,7 +71,12 @@ public static int getFeedback(int guessInd, int secretInd, int c, int d, int[] c * @param d number of digits in the Mastermind game * @return Number of possible feedback values in the game */ - public static int calcFeedbackSize(int d) { return (d + 1) * (d + 2) / 2; } + public static int calcFeedbackSize(int d) { + // Feedback values are (b, w) pairs where b + w <= d. + // Count: for each b in [0..d], there are (d - b + 1) valid w values. + // Total = sum_{b=0}^{d} (d - b + 1) = (d+1) + d + ... + 1 = (d+1)(d+2)/2. + return (d + 1) * (d + 2) / 2; + } /** * @param d number of digits in the Mastermind game diff --git a/src/main/java/org/mastermind/solver/FeedbackIncremental.java b/src/main/java/org/mastermind/solver/FeedbackIncremental.java index 0f9444e4..7680d734 100644 --- a/src/main/java/org/mastermind/solver/FeedbackIncremental.java +++ b/src/main/java/org/mastermind/solver/FeedbackIncremental.java @@ -78,7 +78,7 @@ public static State setupIncremental(int guessInd, int secretInd, int c, int d) * @param colorFreqTotal current sum of |colorFreqCounter[i]|, updated in-place * @param c number of colors * @param d number of digits - * @param result int[2] output buffer: result[0]=feedback, result[1]=new black count, + * @param result int[3] output buffer: result[0]=feedback, result[1]=new black count, * result[2]=new colorFreqTotal */ public static void getFeedbackIncremental( @@ -123,9 +123,12 @@ public static void getFeedbackIncremental( colorFreqCounter[newDigit] = v - 1; } - if (newDigit != 0) break; // no carry + // When newDigit != 0, no carry propagates to the next position, + // meaning all higher positions are unchanged. Break early. + if (newDigit != 0) break; } + // Update result result[0] = black * 9 + d - (colorFreqTotal >>> 1); result[1] = black; result[2] = colorFreqTotal; diff --git a/src/main/java/org/mastermind/solver/GuessStrategy.java b/src/main/java/org/mastermind/solver/GuessStrategy.java index f70cb356..57758bea 100644 --- a/src/main/java/org/mastermind/solver/GuessStrategy.java +++ b/src/main/java/org/mastermind/solver/GuessStrategy.java @@ -44,14 +44,22 @@ private static int[][] selectSearchSpace(int c, int d, int secretsSize, Solution return pair(AllValidCode.generateAllCodes(c, d), solutionSpace.getSecrets()); if (fits(secretsSize, secretsSize)) return pair(solutionSpace.getSecrets(), solutionSpace.getSecrets()); - for (double tolerance : new double[] { 0.001, 0.005, 0.01 }) { + // Sample secrets with progressively looser tolerances (smaller sample = faster search). + // Tolerance controls how accurately the sample estimates expected partition sizes; + // Values are heuristically tuned for speed vs. quality. + // When tolerance 10X, sample size 0.1X + for (double tolerance : new double[] { 0.001, 0.005, 0.01 }) { // 10X, 5X, 1X if (fits(secretsSize, secretSampleSize(d, tolerance))) { return pair(solutionSpace.getSecrets(), secretSample(c, d, tolerance, solutionSpace)); } } + // Fall back to also sampling guesses, using progressively larger percentile thresholds + // (higher percentile = smaller sample needed, since a random element more likely falls + // within a larger top portion of the distribution). + // When percentile 10X, sample size ~0.1X int[] sSample = secretSample(c, d, 0.01, solutionSpace); - for (double percentile : new double[] { 0.001, 0.005, 0.01, 0.05 }) { + for (double percentile : new double[] { 0.001, 0.005, 0.01, 0.05 }) { // 50X, 10X, 5X, 1X if (fits(secretsSize, guessSampleSize(percentile))) { return pair(guessSample(c, d, percentile), sSample); } @@ -60,10 +68,12 @@ private static int[][] selectSearchSpace(int c, int d, int secretsSize, Solution return pair(guessSample(c, d, 0.01), sSample); } + /** Returns true if the guesses and secrets arrays fit within the threshold. */ private static boolean fits(int guessSpaceSize, int secretSpaceSize) { return (long) guessSpaceSize * secretSpaceSize <= THRESHOLD; } + /** Pack the input guesses and secrets into int[][] */ private static int[][] pair(int[] guesses, int[] secrets) { return new int[][] { guesses, secrets }; } diff --git a/src/main/java/org/mastermind/solver/SolutionSpace.java b/src/main/java/org/mastermind/solver/SolutionSpace.java index 4646c73f..20a3b6f0 100644 --- a/src/main/java/org/mastermind/solver/SolutionSpace.java +++ b/src/main/java/org/mastermind/solver/SolutionSpace.java @@ -88,7 +88,7 @@ public void filterSolution(int guessInd, int obtainedFeedback) { final int from = fromIndex; final int to = fromIndex + wordsPerTask * 64; - // Submit the task + // Submit the task with the appropriate function futures[taskCount++] = isFirst ? POOL.submit(() -> filterRangeFirst(guessInd, obtainedFeedback, from, to)) : POOL.submit(() -> filterRange(guessInd, obtainedFeedback, from, to)); From 63025f806f4badf2d82f5efed37ab19aa1e4607e Mon Sep 17 00:00:00 2001 From: FlysonBot <116744100+FlysonBot@users.noreply.github.com> Date: Wed, 4 Mar 2026 20:24:26 -0800 Subject: [PATCH 15/23] chore: remove currently unneeeded GitHub workflow --- .github/workflows/coverage.yaml | 44 -------------------------- .github/workflows/deploy_sphinx.yaml | 46 ---------------------------- .github/workflows/ossf_scorecard.yml | 41 ------------------------- .github/workflows/release_pypi.yaml | 39 ----------------------- 4 files changed, 170 deletions(-) delete mode 100644 .github/workflows/coverage.yaml delete mode 100644 .github/workflows/deploy_sphinx.yaml delete mode 100644 .github/workflows/ossf_scorecard.yml delete mode 100644 .github/workflows/release_pypi.yaml diff --git a/.github/workflows/coverage.yaml b/.github/workflows/coverage.yaml deleted file mode 100644 index 4bc1a630..00000000 --- a/.github/workflows/coverage.yaml +++ /dev/null @@ -1,44 +0,0 @@ -on: ["push", "pull_request"] - -name: Update Test Coverage - -permissions: - contents: read - -jobs: - coveralls: - runs-on: ubuntu-latest - - steps: - - name: Checkout code - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - - - name: Install uv (faster alternative to pip) - uses: astral-sh/setup-uv@v5 - with: - version: "0.4.4" - python-version: "3.10" - enable-cache: true - - - name: Install dependencies - run: | - uv pip install coveralls pytest - uv pip install -r pyproject.toml - - - name: Generate Coverage Report - env: - PYTHONPATH: ./src - run: | - coverage run -m pytest --doctest-modules src - coverage xml - - - name: Upload Report to Coveralls - env: - GITHUB_TOKEN: ${{ secrets. GITHUB_TOKEN }} - run: coveralls - - - name: Upload Report to Codacy - uses: codacy/codacy-coverage-reporter-action@v1.3.0 - with: - project-token: ${{ secrets.CODACY_PROJECT_TOKEN }} - coverage-reports: coverage.xml diff --git a/.github/workflows/deploy_sphinx.yaml b/.github/workflows/deploy_sphinx.yaml deleted file mode 100644 index 3e62c040..00000000 --- a/.github/workflows/deploy_sphinx.yaml +++ /dev/null @@ -1,46 +0,0 @@ -name: Deploy Sphinx Documentation - -on: - workflow_dispatch: - -permissions: - contents: read - -jobs: - build: - runs-on: ubuntu-latest - - permissions: - contents: write - - steps: - - name: Checkout code - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - - - name: Install uv (faster alternative to pip) - uses: astral-sh/setup-uv@v5 - with: - version: "0.4.4" - python-version: "3.10" - enable-cache: true - - - name: Install dependencies - run: | - uv pip install -r pyproject.toml --extra docs - - - name: Generate rst files - run: sphinx-apidoc -o docs/source src/mastermind -f --templatedir=docs/source/_templates --maxdepth=2 --module-first - - - name: Build documentation - env: - PYTHONPATH: ${{ github.workspace }}/src - run: | - cd docs - make html - - - name: Deploy to GitHub Pages - if: success() - uses: peaceiris/actions-gh-pages@373f7f263a76c20808c831209c920827a82a2847 # v3.9.3 - with: - github_token: ${{ secrets.GITHUB_TOKEN }} - publish_dir: ./docs/build/html diff --git a/.github/workflows/ossf_scorecard.yml b/.github/workflows/ossf_scorecard.yml deleted file mode 100644 index 0909036d..00000000 --- a/.github/workflows/ossf_scorecard.yml +++ /dev/null @@ -1,41 +0,0 @@ -name: OSSF Scorecard -on: - push: - branches: - - main - workflow_dispatch: - -permissions: read-all - -jobs: - analysis: - name: Scorecard analysis - runs-on: ubuntu-latest - permissions: - security-events: write - id-token: write - - steps: - - name: "Checkout code" - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 - with: - persist-credentials: false - - - name: "Run analysis" - uses: ossf/scorecard-action@0864cf19026789058feabb7e87baa5f140aac736 - with: - results_file: results.sarif - results_format: sarif - publish_results: true - - - name: "Upload artifact" - uses: actions/upload-artifact@97a0fba1372883ab732affbe8f94b823f91727db # v3.pre.node20 - with: - name: SARIF file - path: results.sarif - retention-days: 5 - - - name: "Upload to code-scanning" - uses: github/codeql-action/upload-sarif@f09c1c0a94de965c15400f5634aa42fac8fb8f88 # v3.27.5 - with: - sarif_file: results.sarif diff --git a/.github/workflows/release_pypi.yaml b/.github/workflows/release_pypi.yaml deleted file mode 100644 index b9f306be..00000000 --- a/.github/workflows/release_pypi.yaml +++ /dev/null @@ -1,39 +0,0 @@ -name: Upload Python Package - -on: - release: - types: [published] - -permissions: - contents: read - -jobs: - release-build: - name: Build and upload release artifacts - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - - - name: Install uv (faster alternative to pip) - uses: astral-sh/setup-uv@v5 - with: - version: "0.4.4" - python-version: "3.10" - enable-cache: true - - - name: Install dependencies - run: uv pip install -r pyproject.toml - - - name: Build release - run: uv build - - - name: Test release (wheel) - run: uv run --isolated --all-extras --with dist/*.whl pytest --doctest-modules src - - - - name: Test release (source distribution) - run: uv run --isolated --all-extras --with dist/*.tar.gz pytest --doctest-modules src - - - name: Upload release - run: uv publish --trusted-publishing always From 622ce51fe28a8eacfa5c7d964d6b2554db1b4b87 Mon Sep 17 00:00:00 2001 From: FlysonBot <116744100+FlysonBot@users.noreply.github.com> Date: Wed, 4 Mar 2026 21:06:09 -0800 Subject: [PATCH 16/23] refactor: rename parititon params in CanonicalCode to frequency/color for clarity --- .../org/mastermind/codes/CanonicalCode.java | 26 +++++++++---------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/src/main/java/org/mastermind/codes/CanonicalCode.java b/src/main/java/org/mastermind/codes/CanonicalCode.java index 79a29790..16725097 100644 --- a/src/main/java/org/mastermind/codes/CanonicalCode.java +++ b/src/main/java/org/mastermind/codes/CanonicalCode.java @@ -52,36 +52,36 @@ public static int[] enumerateCanonicalForms(int c, int d) { place[d - 1] = 1; for (int i = d - 2; i >= 0; i--) place[i] = place[i + 1] * c; - int[] parts = new int[c]; - generatePartitions(results, index, parts, 0, d, d, c, place); + int[] freq = new int[c]; + generateFrequencies(results, index, freq, 0, d, d, place); return results; } - private static void generatePartitions( - int[] results, int[] index, int[] parts, int depth, int remaining, int maxVal, int maxParts, int[] place + private static void generateFrequencies( + int[] results, int[] index, int[] freq, int color, int remaining, int maxFreq, int[] place ) { if (remaining == 0) { - results[index[0]++] = buildIndex(parts, depth, place); + results[index[0]++] = buildIndex(freq, color, place); return; } - if (depth == maxParts) return; + if (color == freq.length) return; - int limit = Math.min(maxVal, remaining); - for (int part = limit; part >= 1; part--) { - parts[depth] = part; - generatePartitions(results, index, parts, depth + 1, remaining - part, part, maxParts, place); + int limit = Math.min(maxFreq, remaining); + for (int f = limit; f >= 1; f--) { + freq[color] = f; + generateFrequencies(results, index, freq, color + 1, remaining - f, f, place); } } - private static int buildIndex(int[] parts, int numParts, int[] place) { + private static int buildIndex(int[] freq, int numColors, int[] place) { // Maps a partition (color frequency array) to its lex-smallest representative index. // Color 0 gets the highest frequency and occupies the leftmost positions, // color 1 gets the next frequency, and so on. This ensures all codes with the // same partition map to the same canonical representative. int ind = 0; int pos = 0; - for (int color = 0; color < numParts; color++) { - for (int f = 0; f < parts[color]; f++) { + for (int color = 0; color < numColors; color++) { + for (int f = 0; f < freq[color]; f++) { ind += color * place[pos++]; } } From 33acbdaa11767467d802e466852b680b26456ff0 Mon Sep 17 00:00:00 2001 From: FlysonBot <116744100+FlysonBot@users.noreply.github.com> Date: Wed, 4 Mar 2026 21:12:00 -0800 Subject: [PATCH 17/23] perf: replace new Random() with ThreadLocalRandom in SampledCode --- src/main/java/org/mastermind/codes/SampledCode.java | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src/main/java/org/mastermind/codes/SampledCode.java b/src/main/java/org/mastermind/codes/SampledCode.java index cbd7f4a5..a2c576ea 100644 --- a/src/main/java/org/mastermind/codes/SampledCode.java +++ b/src/main/java/org/mastermind/codes/SampledCode.java @@ -1,7 +1,7 @@ package org.mastermind.codes; import java.util.BitSet; -import java.util.Random; +import java.util.concurrent.ThreadLocalRandom; /** * The Monte Carlo method is a way to estimate population parameters @@ -23,12 +23,11 @@ public class SampledCode { * @return A random sample of code indices in [0, c^d) */ public static int[] getSample(int c, int d, int sampleSize) { - Random random = new Random(); int total = (int) Math.pow(c, d); int[] sample = new int[sampleSize]; for (int i = 0; i < sampleSize; i++) { - sample[i] = random.nextInt(total); + sample[i] = ThreadLocalRandom.current().nextInt(total); } return sample; @@ -45,7 +44,6 @@ public static int[] getSample(int c, int d, int sampleSize) { public static int[] getValidSample(BitSet remaining, int validCount, int c, int d, int sampleSize) { int total = (int) Math.pow(c, d); int[] sample = new int[sampleSize]; - Random random = new Random(); if (validCount <= MAX_ENUM) { // Enumeration: bounded memory (≤20MB), fast scan, fast random access. @@ -55,13 +53,13 @@ public static int[] getValidSample(BitSet remaining, int validCount, int c, int valid[j++] = i; } for (int i = 0; i < sampleSize; i++) { - sample[i] = valid[random.nextInt(validCount)]; + sample[i] = valid[ThreadLocalRandom.current().nextInt(validCount)]; } } else { // Rejection sampling: validCount is large so fill rate is high and rejection is fast. for (int i = 0; i < sampleSize; i++) { int idx; - do { idx = random.nextInt(total); } while (!remaining.get(idx)); + do { idx = ThreadLocalRandom.current().nextInt(total); } while (!remaining.get(idx)); sample[i] = idx; } } From 13d31b8c9be46139f20243d8e5b2236d61a6c7c6 Mon Sep 17 00:00:00 2001 From: FlysonBot <116744100+FlysonBot@users.noreply.github.com> Date: Wed, 4 Mar 2026 21:20:21 -0800 Subject: [PATCH 18/23] refactor: extract computing logic from BestFirstGuess to BestFirstGuessCalculator --- .../org/mastermind/solver/BestFirstGuess.java | 165 +----------------- .../solver/BestFirstGuessCalculator.java | 165 ++++++++++++++++++ 2 files changed, 168 insertions(+), 162 deletions(-) create mode 100644 src/main/java/org/mastermind/solver/BestFirstGuessCalculator.java diff --git a/src/main/java/org/mastermind/solver/BestFirstGuess.java b/src/main/java/org/mastermind/solver/BestFirstGuess.java index a160adc4..876f5ed7 100644 --- a/src/main/java/org/mastermind/solver/BestFirstGuess.java +++ b/src/main/java/org/mastermind/solver/BestFirstGuess.java @@ -1,172 +1,16 @@ package org.mastermind.solver; import org.mastermind.codes.AllValidCode; -import org.mastermind.codes.CanonicalCode; import org.mastermind.codes.ConvertCode; -import org.mastermind.codes.SampledCode; /** - * Provides the best first guess for any supported Mastermind configuration, - * and a calibration tool that computes and prints the values to hardcode. + * Provides the best first guess for any supported Mastermind configuration. *

    - * Use {@link #of(int, int)} at runtime. Run {@link #main(String[])} once to + * Use {@link #of(int, int)} at runtime. Run {@link BestFirstGuessCalculator#main(String[])} once to * regenerate the hardcoded values after algorithm changes. */ public class BestFirstGuess { - // --- Calibration constants --- - private static final int TRIALS = 100; - private static final long TARGET_EVALS = 13_000_000L; - private static final double CONFIDENCE_THRESHOLD = 99.0; - private static final int BUDGET_MULTIPLIER = 2; - - // ------------------------------------------------------------------------- - // Calibration — run main() to recompute and regenerate the switch above - // ------------------------------------------------------------------------- - - public static void main(String[] args) { - int[] feedbackFreq = new int[100]; - - // c=1 and d=1 are trivial, calibrate c in [2,9] x d in [2,9] - int total = 8 * 8; - String[] lines = new String[total]; - int[][] bestCode = new int[10][10]; - int li = 0; - - String header = String.format("%-6s %-12s %10s %10s %12s", - "Game", "BestGuess", "AvgScore", "Confidence", "TotalEvals"); - System.out.println(header); - System.out.println("-".repeat(60)); - - for (int c = 2; c <= 9; c++) { - for (int d = 2; d <= 9; d++) { - ExpectedSize expectedSize = new ExpectedSize(d); - int[] canonical = CanonicalCode.enumerateCanonicalForms(c, d); - int totalCodes = (int) Math.pow(c, d); - - long budget = TARGET_EVALS; - Result result; - do { - result = evaluate(canonical, c, d, totalCodes, budget, feedbackFreq, expectedSize); - if (result.confidence < CONFIDENCE_THRESHOLD) { - budget *= BUDGET_MULTIPLIER; - System.out.printf(" [%dx%d] confidence %.2f%% too low, retrying with budget %d%n", - c, d, result.confidence, budget); - } - } while (result.confidence < CONFIDENCE_THRESHOLD); - - int code = ConvertCode.toCode(c, d, canonical[result.bestIdx]); - bestCode[c][d] = code; - lines[li] = String.format("%-6s %-12d %10.4f %9.2f%% %12d%s", - c + "x" + d, code, - result.avgScore, result.confidence, - result.totalEvals, - result.fullEval ? " (full)" : ""); - System.out.println(lines[li]); - li++; - } - } - - // Clean summary - System.out.printf("%n%s%n", header); - System.out.println("-".repeat(60)); - for (String line : lines) System.out.println(line); - - // Phase 2: full evaluation to get true rank for each best guess - System.out.printf("%n%-6s %-12s %s%n", "Game", "BestGuess", "TrueRank"); - System.out.println("-".repeat(35)); - long[][] trueRank = new long[10][10]; - for (int c = 2; c <= 9; c++) { - for (int d = 2; d <= 9; d++) { - ExpectedSize expectedSize = new ExpectedSize(d); - int totalCodes = (int) Math.pow(c, d); - int codeIndex = ConvertCode.toIndex(c, d, bestCode[c][d]); - trueRank[c][d] = expectedSize.calcExpectedRankFirst(codeIndex, c, d, totalCodes, feedbackFreq); - System.out.printf("%-6s %-12d %d%n", c + "x" + d, bestCode[c][d], trueRank[c][d]); - } - } - - // Print switch snippet - System.out.println("\n// --- copy below into of(int c, int d) ---"); - for (int c = 2; c <= 9; c++) { - System.out.printf(" case %d -> switch (d) {%n", c); - for (int d = 2; d <= 9; d++) { - System.out.printf(" case %d -> new long[]{%d, %dL};%n", d, bestCode[c][d], - trueRank[c][d]); - } - System.out.printf( - " default -> throw new IllegalArgumentException(\"Unsupported game size: c=\" + c " + - "+ \", d=\" + d);%n"); - System.out.printf(" };%n"); - } - System.out.println("// --- copy above ---"); - } - - private static Result evaluate( - int[] canonical, int c, int d, int totalCodes, - long budget, int[] feedbackFreq, ExpectedSize expectedSize - ) { - int n = canonical.length; - int sampleSize = Math.max(1, (int) (budget / TRIALS / n)); - boolean fullEval = sampleSize >= totalCodes; - int normSize = fullEval ? totalCodes : sampleSize; - int trials = fullEval ? 1 : TRIALS; - - double[] sumScore = new double[n]; - double[] sumScore2 = new double[n]; - - for (int t = 0; t < trials; t++) { - int[] s = fullEval ? null : SampledCode.getSample(c, d, sampleSize); - for (int i = 0; i < n; i++) { - long rank = fullEval - ? expectedSize.calcExpectedRankFirst(canonical[i], c, d, totalCodes, feedbackFreq) - : expectedSize.calcExpectedRank(canonical[i], s, c, d, feedbackFreq); - double score = rank / (double) normSize; - sumScore[i] += score; - sumScore2[i] += score * score; - } - } - - int bestIdx = 0, secondIdx = -1; - for (int i = 1; i < n; i++) { - if (sumScore[i] < sumScore[bestIdx]) { - secondIdx = bestIdx; - bestIdx = i; - } else if (secondIdx == -1 || sumScore[i] < sumScore[secondIdx]) { - secondIdx = i; - } - } - - double confidence; - if (fullEval || secondIdx == -1) { - confidence = 100.0; - } else { - double avg1 = sumScore[bestIdx] / trials; - double avg2 = sumScore[secondIdx] / trials; - double var1 = (sumScore2[bestIdx] / trials) - avg1 * avg1; - double var2 = (sumScore2[secondIdx] / trials) - avg2 * avg2; - double stdErr = Math.sqrt((var1 + var2) / trials); - double z = (avg2 - avg1) / stdErr; - confidence = 100.0 * normalCDF(z); - } - - return new Result(bestIdx, sumScore[bestIdx] / trials, confidence, - (long) n * normSize * trials, fullEval); - } - - /** Approximation of the standard normal CDF using Horner's method (Abramowitz & Stegun 26.2.17). */ - private static double normalCDF(double z) { - if (z < 0) return 1.0 - normalCDF(-z); - double t = 1.0 / (1.0 + 0.2316419 * z); - double poly = t * (0.319381530 - + t * (-0.356563782 - + t * (1.781477937 - + t * (-1.821255978 - + t * 1.330274429)))); - double pdf = Math.exp(-0.5 * z * z) / Math.sqrt(2 * Math.PI); - return 1.0 - pdf * poly; - } - /** * Returns the best first guess and its true rank for a Mastermind game of c colors and d digits. * @@ -187,6 +31,7 @@ public static long[] of(int c, int d) { return new long[] { 1, rank }; } + // To regenerate this table, run BestFirstGuessCalculator.main() return switch (c) { case 2 -> switch (d) { case 2 -> new long[] { 11, 6L }; @@ -279,8 +124,4 @@ public static long[] of(int c, int d) { default -> throw new IllegalArgumentException("Unsupported game size: c=" + c + ", d=" + d); }; } - - private record Result(int bestIdx, double avgScore, double confidence, - long totalEvals, boolean fullEval - ) { } } diff --git a/src/main/java/org/mastermind/solver/BestFirstGuessCalculator.java b/src/main/java/org/mastermind/solver/BestFirstGuessCalculator.java new file mode 100644 index 00000000..814fa581 --- /dev/null +++ b/src/main/java/org/mastermind/solver/BestFirstGuessCalculator.java @@ -0,0 +1,165 @@ +package org.mastermind.solver; + +import org.mastermind.codes.CanonicalCode; +import org.mastermind.codes.ConvertCode; +import org.mastermind.codes.SampledCode; + +/** + * Offline calculator for regenerating the hardcoded table in {@link BestFirstGuess}. + * Not used at runtime — run main() once after algorithm changes to recompute values. + */ +final class BestFirstGuessCalculator { + + // --- Calculation constants --- + private static final int TRIALS = 100; + private static final long TARGET_EVALS = 13_000_000L; + private static final double CONFIDENCE_THRESHOLD = 99.0; + private static final int BUDGET_MULTIPLIER = 2; + + public static void main(String[] args) { + int[] feedbackFreq = new int[100]; + + // c=1 and d=1 are trivial, calibrate c in [2,9] x d in [2,9] + int total = 8 * 8; + String[] lines = new String[total]; + int[][] bestCode = new int[10][10]; + int li = 0; + + String header = String.format("%-6s %-12s %10s %10s %12s", + "Game", "BestGuess", "AvgScore", "Confidence", "TotalEvals"); + System.out.println(header); + System.out.println("-".repeat(60)); + + for (int c = 2; c <= 9; c++) { + for (int d = 2; d <= 9; d++) { + ExpectedSize expectedSize = new ExpectedSize(d); + int[] canonical = CanonicalCode.enumerateCanonicalForms(c, d); + int totalCodes = (int) Math.pow(c, d); + + long budget = TARGET_EVALS; + Result result; + do { + result = evaluate(canonical, c, d, totalCodes, budget, feedbackFreq, expectedSize); + if (result.confidence < CONFIDENCE_THRESHOLD) { + budget *= BUDGET_MULTIPLIER; + System.out.printf(" [%dx%d] confidence %.2f%% too low, retrying with budget %d%n", + c, d, result.confidence, budget); + } + } while (result.confidence < CONFIDENCE_THRESHOLD); + + int code = ConvertCode.toCode(c, d, canonical[result.bestIdx]); + bestCode[c][d] = code; + lines[li] = String.format("%-6s %-12d %10.4f %9.2f%% %12d%s", + c + "x" + d, code, + result.avgScore, result.confidence, + result.totalEvals, + result.fullEval ? " (full)" : ""); + System.out.println(lines[li]); + li++; + } + } + + // Clean summary + System.out.printf("%n%s%n", header); + System.out.println("-".repeat(60)); + for (String line : lines) System.out.println(line); + + // Phase 2: full evaluation to get true rank for each best guess + System.out.printf("%n%-6s %-12s %s%n", "Game", "BestGuess", "TrueRank"); + System.out.println("-".repeat(35)); + long[][] trueRank = new long[10][10]; + for (int c = 2; c <= 9; c++) { + for (int d = 2; d <= 9; d++) { + ExpectedSize expectedSize = new ExpectedSize(d); + int totalCodes = (int) Math.pow(c, d); + int codeIndex = ConvertCode.toIndex(c, d, bestCode[c][d]); + trueRank[c][d] = expectedSize.calcExpectedRankFirst(codeIndex, c, d, totalCodes, feedbackFreq); + System.out.printf("%-6s %-12d %d%n", c + "x" + d, bestCode[c][d], trueRank[c][d]); + } + } + + // Print switch snippet + System.out.println("\n// --- copy below into of(int c, int d) ---"); + for (int c = 2; c <= 9; c++) { + System.out.printf(" case %d -> switch (d) {%n", c); + for (int d = 2; d <= 9; d++) { + System.out.printf(" case %d -> new long[]{%d, %dL};%n", d, bestCode[c][d], + trueRank[c][d]); + } + System.out.printf( + " default -> throw new IllegalArgumentException(\"Unsupported game size: c=\" + c " + + "+ \", d=\" + d);%n"); + System.out.printf(" };%n"); + } + System.out.println("// --- copy above ---"); + } + + static Result evaluate( + int[] canonical, int c, int d, int totalCodes, + long budget, int[] feedbackFreq, ExpectedSize expectedSize + ) { + int n = canonical.length; + int sampleSize = Math.max(1, (int) (budget / TRIALS / n)); + boolean fullEval = sampleSize >= totalCodes; + int normSize = fullEval ? totalCodes : sampleSize; + int trials = fullEval ? 1 : TRIALS; + + double[] sumScore = new double[n]; + double[] sumScore2 = new double[n]; + + for (int t = 0; t < trials; t++) { + int[] s = fullEval ? null : SampledCode.getSample(c, d, sampleSize); + for (int i = 0; i < n; i++) { + long rank = fullEval + ? expectedSize.calcExpectedRankFirst(canonical[i], c, d, totalCodes, feedbackFreq) + : expectedSize.calcExpectedRank(canonical[i], s, c, d, feedbackFreq); + double score = rank / (double) normSize; + sumScore[i] += score; + sumScore2[i] += score * score; + } + } + + int bestIdx = 0, secondIdx = -1; + for (int i = 1; i < n; i++) { + if (sumScore[i] < sumScore[bestIdx]) { + secondIdx = bestIdx; + bestIdx = i; + } else if (secondIdx == -1 || sumScore[i] < sumScore[secondIdx]) { + secondIdx = i; + } + } + + double confidence; + if (fullEval || secondIdx == -1) { + confidence = 100.0; + } else { + double avg1 = sumScore[bestIdx] / trials; + double avg2 = sumScore[secondIdx] / trials; + double var1 = (sumScore2[bestIdx] / trials) - avg1 * avg1; + double var2 = (sumScore2[secondIdx] / trials) - avg2 * avg2; + double stdErr = Math.sqrt((var1 + var2) / trials); + double z = (avg2 - avg1) / stdErr; + confidence = 100.0 * normalCDF(z); + } + + return new Result(bestIdx, sumScore[bestIdx] / trials, confidence, + (long) n * normSize * trials, fullEval); + } + + /** Approximation of the standard normal CDF using Horner's method (Abramowitz & Stegun 26.2.17). */ + private static double normalCDF(double z) { + if (z < 0) return 1.0 - normalCDF(-z); + double t = 1.0 / (1.0 + 0.2316419 * z); + double poly = t * (0.319381530 + + t * (-0.356563782 + + t * (1.781477937 + + t * (-1.821255978 + + t * 1.330274429)))); + double pdf = Math.exp(-0.5 * z * z) / Math.sqrt(2 * Math.PI); + return 1.0 - pdf * poly; + } + + record Result(int bestIdx, double avgScore, double confidence, + long totalEvals, boolean fullEval + ) { } +} From 143309a64c711ee777fd5702b689baf3927dbc5a Mon Sep 17 00:00:00 2001 From: FlysonBot <116744100+FlysonBot@users.noreply.github.com> Date: Wed, 4 Mar 2026 22:33:05 -0800 Subject: [PATCH 19/23] test: add getValidSample tests covering both sampling paths and edge cases --- .../org/mastermind/codes/SampledCodeTest.java | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/src/tests/java/org/mastermind/codes/SampledCodeTest.java b/src/tests/java/org/mastermind/codes/SampledCodeTest.java index c4e389b9..a930c750 100644 --- a/src/tests/java/org/mastermind/codes/SampledCodeTest.java +++ b/src/tests/java/org/mastermind/codes/SampledCodeTest.java @@ -4,6 +4,8 @@ import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.CsvSource; +import java.util.BitSet; + import static org.junit.jupiter.api.Assertions.*; class SampledCodeTest { @@ -41,4 +43,46 @@ void testResultIsActuallyRandom() { assertTrue(uniqueCount > 100, "Expected high variety in samples, got only " + uniqueCount + " unique indices"); } + + // --- getValidSample --- + + @Test + void testGetValidSampleEnumerationPathOnlyReturnsSetBits() { + // validCount <= MAX_ENUM: enumeration path + BitSet remaining = new BitSet(10); + remaining.set(1); + remaining.set(3); + remaining.set(7); + int[] result = SampledCode.getValidSample(remaining, 3, 2, 4, 50); + for (int idx : result) { + assertTrue(remaining.get(idx), "Index " + idx + " is not a set bit"); + } + } + + @Test + void testGetValidSampleRejectionPathOnlyReturnsSetBits() { + // validCount > MAX_ENUM: rejection sampling path. + // We fake this by constructing a BitSet where all bits are set and validCount > MAX_ENUM. + int total = SampledCode.MAX_ENUM + 1; + BitSet remaining = new BitSet(total); + remaining.set(0, total); // all bits set so rejection always succeeds immediately + int[] result = SampledCode.getValidSample(remaining, total, 9, 7, 50); + for (int idx : result) { + assertTrue(idx >= 0 && idx < total, "Index " + idx + " out of range"); + assertTrue(remaining.get(idx), "Index " + idx + " is not a set bit"); + } + } + + @Test + void testGetValidSampleWithSampleSizeExceedingValidCount() { + // Sampling with replacement: duplicates are expected, should not throw + BitSet remaining = new BitSet(10); + remaining.set(2); + remaining.set(5); + int[] result = SampledCode.getValidSample(remaining, 2, 2, 4, 100); + assertEquals(100, result.length); + for (int idx : result) { + assertTrue(remaining.get(idx), "Index " + idx + " is not a set bit"); + } + } } From 77c988a33594962d624bf3055e7515f1852bd412 Mon Sep 17 00:00:00 2001 From: FlysonBot <116744100+FlysonBot@users.noreply.github.com> Date: Wed, 4 Mar 2026 22:36:56 -0800 Subject: [PATCH 20/23] test: add getValidSample tests covering both sampling paths and edge cases --- .../java/org/mastermind/solver/ExpectedSizeTest.java | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/tests/java/org/mastermind/solver/ExpectedSizeTest.java b/src/tests/java/org/mastermind/solver/ExpectedSizeTest.java index 927ae03a..0cd7b44e 100644 --- a/src/tests/java/org/mastermind/solver/ExpectedSizeTest.java +++ b/src/tests/java/org/mastermind/solver/ExpectedSizeTest.java @@ -38,6 +38,16 @@ public void testExpectedSize() { assertEquals(204.5355f, calcExpectedSize(ind(1212)), DELTA); } + @Test + public void testCalcExpectedRankFirstMatchesFullRank() { + int[] guesses = { ind(1122), ind(1123), ind(1234) }; + for (int guessInd : guesses) { + long rankFull = expectedSizeObj.calcExpectedRank(guessInd, secretsInd, COLORS, DIGITS, feedbackFreq); + long rankInc = expectedSizeObj.calcExpectedRankFirst(guessInd, COLORS, DIGITS, TOTAL, feedbackFreq); + assertEquals(rankFull, rankInc, "calcExpectedRankFirst mismatch for guess index " + guessInd); + } + } + @Test public void testExpectedSizeSymmetry() { // Guesses with the same color multiset should yield the same expected size From 91551a7437b03d03dc4a811eda99bfb354926f75 Mon Sep 17 00:00:00 2001 From: FlysonBot <116744100+FlysonBot@users.noreply.github.com> Date: Wed, 4 Mar 2026 22:39:02 -0800 Subject: [PATCH 21/23] test: add two-filter test to SolutionSpace convering the standard filter path --- .../mastermind/solver/SolutionSpaceTest.java | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/src/tests/java/org/mastermind/solver/SolutionSpaceTest.java b/src/tests/java/org/mastermind/solver/SolutionSpaceTest.java index a43c9171..f6de8ec6 100644 --- a/src/tests/java/org/mastermind/solver/SolutionSpaceTest.java +++ b/src/tests/java/org/mastermind/solver/SolutionSpaceTest.java @@ -13,6 +13,36 @@ public class SolutionSpaceTest { private static int ind(int code) { return ConvertCode.toIndex(C, D, code); } + @Test + void testFilterSolutionTwice() { + int guess1Idx = ind(1123); + int guess2Idx = ind(2456); + int secretIdx = ind(4563); + int[] colorFreqCounter = new int[C]; + + int feedback1 = Feedback.getFeedback(guess1Idx, secretIdx, C, D, colorFreqCounter); + int feedback2 = Feedback.getFeedback(guess2Idx, secretIdx, C, D, colorFreqCounter); + + // Build reference: indices consistent with both feedbacks + int expectedCount = 0; + for (int s = 0; s < TOTAL; s++) { + if (Feedback.getFeedback(guess1Idx, s, C, D, colorFreqCounter) == feedback1 + && Feedback.getFeedback(guess2Idx, s, C, D, colorFreqCounter) == feedback2) { + expectedCount++; + } + } + + SolutionSpace space = new SolutionSpace(C, D); + space.filterSolution(guess1Idx, feedback1); + space.filterSolution(guess2Idx, feedback2); + + assertEquals(expectedCount, space.getSize()); + for (int s : space.getSecrets()) { + assertEquals(feedback1, Feedback.getFeedback(guess1Idx, s, C, D, colorFreqCounter)); + assertEquals(feedback2, Feedback.getFeedback(guess2Idx, s, C, D, colorFreqCounter)); + } + } + @Test void testFilterSolution() { int guessIdx = ind(1123); From 479f5c555e559f3e612708870715ed7d983fcb6b Mon Sep 17 00:00:00 2001 From: FlysonBot <116744100+FlysonBot@users.noreply.github.com> Date: Wed, 4 Mar 2026 22:43:05 -0800 Subject: [PATCH 22/23] test: add suggestGuessWithDetails test verifying first-guess table integration --- .../org/mastermind/MastermindSessionTest.java | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/tests/java/org/mastermind/MastermindSessionTest.java b/src/tests/java/org/mastermind/MastermindSessionTest.java index 60c72f76..947e498f 100644 --- a/src/tests/java/org/mastermind/MastermindSessionTest.java +++ b/src/tests/java/org/mastermind/MastermindSessionTest.java @@ -2,6 +2,7 @@ import org.junit.jupiter.api.Test; import org.mastermind.codes.ConvertCode; +import org.mastermind.solver.BestFirstGuess; import org.mastermind.solver.ExpectedSize; import org.mastermind.solver.Feedback; @@ -30,6 +31,25 @@ void testSolveSecret6666() { runGame(ind(6666)); } + /** + * On an empty history, suggestGuessWithDetails should return the precomputed + * best first guess with the correct rank and the full solution space size. + */ + @Test + void testSuggestGuessWithDetailsOnEmptyHistory() { + MastermindSession session = new MastermindSession(C, D); + long[] details = session.suggestGuessWithDetails(); + + long[] best = BestFirstGuess.of(C, D); + int expectedGuess = ConvertCode.toIndex(C, D, (int) best[0]); + long expectedRank = best[1]; + int expectedSpace = (int) Math.pow(C, D); + + assertEquals(expectedGuess, details[0], "First guess index should match BestFirstGuess table"); + assertEquals(expectedRank, details[1], "Rank should match precomputed BestFirstGuess rank"); + assertEquals(expectedSpace, details[2], "Secrets length should be the full solution space"); + } + /** Simulate a full game with secret 1562 (arbitrary mid-range code). */ @Test void testSolveSecret1562() { From fd18153b79c5faad66ecf7d7c8a9154ec001bd8c Mon Sep 17 00:00:00 2001 From: FlysonBot <116744100+FlysonBot@users.noreply.github.com> Date: Thu, 5 Mar 2026 00:42:27 -0800 Subject: [PATCH 23/23] test: more test to improve coverage --- .../codes/SampledCodeBenchmark.java | 2 +- .../org/mastermind/codes/SampledCode.java | 35 +++--- .../org/mastermind/codes/ConvertCodeTest.java | 101 ++++++++++++++++++ .../org/mastermind/codes/SampledCodeTest.java | 69 ++++++++++++ .../mastermind/solver/BestFirstGuessTest.java | 34 ++++++ .../mastermind/solver/ExpectedSizeTest.java | 10 ++ 6 files changed, 227 insertions(+), 24 deletions(-) create mode 100644 src/tests/java/org/mastermind/codes/ConvertCodeTest.java create mode 100644 src/tests/java/org/mastermind/solver/BestFirstGuessTest.java diff --git a/src/benchmarks/java/org/mastermind/codes/SampledCodeBenchmark.java b/src/benchmarks/java/org/mastermind/codes/SampledCodeBenchmark.java index 424fdd3f..df79b081 100644 --- a/src/benchmarks/java/org/mastermind/codes/SampledCodeBenchmark.java +++ b/src/benchmarks/java/org/mastermind/codes/SampledCodeBenchmark.java @@ -73,7 +73,7 @@ public void sample_reject_100pct(Fill100pct state, Blackhole bh) { @State(Scope.Thread) public static class BaseState { - final int sampleSize = SampledCode.calcSampleSizeForSecrets(Feedback.calcFeedbackSize(9)); + final int sampleSize = SampledCode.calcSampleSizeForSecrets(Feedback.calcFeedbackSize(9), 0.01); } // Enumeration path (validCount <= MAX_ENUM = 5M) diff --git a/src/main/java/org/mastermind/codes/SampledCode.java b/src/main/java/org/mastermind/codes/SampledCode.java index a2c576ea..94e7784e 100644 --- a/src/main/java/org/mastermind/codes/SampledCode.java +++ b/src/main/java/org/mastermind/codes/SampledCode.java @@ -13,6 +13,14 @@ */ public class SampledCode { + /** + * Maximum validCount for which enumeration is used. Above this threshold, + * int[validCount] becomes too large (>20MB) and rejection sampling is used instead. + * At this threshold, fill rate is always high enough that rejection is fast. + * Empirically derived from timing tests across c=7-9, d=7-9 game sizes. + */ + static final int MAX_ENUM = 5_000_000; + /** * Generate a random Monte Carlo sample of code indices from all possible * Mastermind codes with the specified sample size. @@ -23,8 +31,8 @@ public class SampledCode { * @return A random sample of code indices in [0, c^d) */ public static int[] getSample(int c, int d, int sampleSize) { - int total = (int) Math.pow(c, d); - int[] sample = new int[sampleSize]; + int total = (int) Math.pow(c, d); + int[] sample = new int[sampleSize]; for (int i = 0; i < sampleSize; i++) { sample[i] = ThreadLocalRandom.current().nextInt(total); @@ -33,17 +41,9 @@ public static int[] getSample(int c, int d, int sampleSize) { return sample; } - /** - * Maximum validCount for which enumeration is used. Above this threshold, - * int[validCount] becomes too large (>20MB) and rejection sampling is used instead. - * At this threshold, fill rate is always high enough that rejection is fast. - * Empirically derived from timing tests across c=7-9, d=7-9 game sizes. - */ - static final int MAX_ENUM = 5_000_000; - public static int[] getValidSample(BitSet remaining, int validCount, int c, int d, int sampleSize) { - int total = (int) Math.pow(c, d); - int[] sample = new int[sampleSize]; + int total = (int) Math.pow(c, d); + int[] sample = new int[sampleSize]; if (validCount <= MAX_ENUM) { // Enumeration: bounded memory (≤20MB), fast scan, fast random access. @@ -98,17 +98,6 @@ public static int calcSampleSizeForSecrets(int feedbackSize, double tolerance) { return (int) Math.ceil((feedbackSize - 1) / tolerance); } - /** - * Calculates the required sample size with a default tolerance of 0.05 (5%). - * - * @param feedbackSize Number of possible feedback values K (e.g., 55) - * @return The required number of random secrets to sample. - * @see #calcSampleSizeForSecrets(int, double) - */ - public static int calcSampleSizeForSecrets(int feedbackSize) { - return calcSampleSizeForSecrets(feedbackSize, 0.01); - } - /** * Calculate the sample size for guesses needed to ensure that * the probability of including at least one "elite" guess in the top x% diff --git a/src/tests/java/org/mastermind/codes/ConvertCodeTest.java b/src/tests/java/org/mastermind/codes/ConvertCodeTest.java new file mode 100644 index 00000000..c972cd0c --- /dev/null +++ b/src/tests/java/org/mastermind/codes/ConvertCodeTest.java @@ -0,0 +1,101 @@ +package org.mastermind.codes; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +import static org.junit.jupiter.api.Assertions.*; + +class ConvertCodeTest { + + @Nested + @DisplayName("toIndex") + class ToIndex { + + @ParameterizedTest + @DisplayName("Boundary codes for c=6, d=4") + @CsvSource({ + "6, 4, 1111, 0", + "6, 4, 1112, 1", + "6, 4, 6666, 1295", + }) + void testBoundaries(int c, int d, int code, int expected) { + assertEquals(expected, ConvertCode.toIndex(c, d, code)); + } + + @Test + @DisplayName("Mid-range code 1234 for c=6, d=4") + void testMidRange() { + // 1234: digits (left=most-sig) 1,2,3,4 → positions 3,2,1,0 + // index = (1-1)*6^3 + (2-1)*6^2 + (3-1)*6^1 + (4-1)*6^0 + // = 0*216 + 1*36 + 2*6 + 3*1 = 0 + 36 + 12 + 3 = 51 + assertEquals(51, ConvertCode.toIndex(6, 4, 1234)); + } + + @Test + @DisplayName("Single-digit code for c=9, d=1") + void testSingleDigit() { + assertEquals(0, ConvertCode.toIndex(9, 1, 1)); + assertEquals(8, ConvertCode.toIndex(9, 1, 9)); + } + } + + @Nested + @DisplayName("toCode") + class ToCode { + + @ParameterizedTest + @DisplayName("Boundary indices for c=6, d=4") + @CsvSource({ + "6, 4, 0, 1111", + "6, 4, 1, 1112", + "6, 4, 1295, 6666", + }) + void testBoundaries(int c, int d, int index, int expected) { + assertEquals(expected, ConvertCode.toCode(c, d, index)); + } + + @Test + @DisplayName("Index 51 for c=6, d=4") + void testMidRange() { + assertEquals(1234, ConvertCode.toCode(6, 4, 51)); + } + + @Test + @DisplayName("Single-digit index for c=9, d=1") + void testSingleDigit() { + assertEquals(1, ConvertCode.toCode(9, 1, 0)); + assertEquals(9, ConvertCode.toCode(9, 1, 8)); + } + } + + @Nested + @DisplayName("Round-trip") + class RoundTrip { + + @Test + @DisplayName("toCode(toIndex(code)) == code for c=6, d=4") + void testCodeToIndexToCode() { + int c = 6, d = 4; + int total = (int) Math.pow(c, d); + for (int idx = 0; idx < total; idx++) { + int code = ConvertCode.toCode(c, d, idx); + assertEquals(idx, ConvertCode.toIndex(c, d, code), + "Round-trip failed for index " + idx); + } + } + + @Test + @DisplayName("toIndex(toCode(idx)) == idx for c=4, d=3") + void testIndexToCodeToIndex() { + int c = 4, d = 3; + int total = (int) Math.pow(c, d); + for (int idx = 0; idx < total; idx++) { + assertEquals(idx, ConvertCode.toIndex(c, d, ConvertCode.toCode(c, d, idx)), + "Round-trip failed for index " + idx); + } + } + } +} diff --git a/src/tests/java/org/mastermind/codes/SampledCodeTest.java b/src/tests/java/org/mastermind/codes/SampledCodeTest.java index a930c750..fb90d70c 100644 --- a/src/tests/java/org/mastermind/codes/SampledCodeTest.java +++ b/src/tests/java/org/mastermind/codes/SampledCodeTest.java @@ -85,4 +85,73 @@ void testGetValidSampleWithSampleSizeExceedingValidCount() { assertTrue(remaining.get(idx), "Index " + idx + " is not a set bit"); } } + + // --- calcSampleSizeForSecrets accuracy --- + + @Test + void testCalcSampleSizeForSecretsInflationWithinTolerance() { + // Setup: K=10 feedback buckets, true probabilities p_i = 1/K (uniform distribution). + // True S = Σ p_i^2 = K * (1/K)^2 = 1/K. + // The formula guarantees E[Ŝ] - S <= tolerance * S on average. + // We verify the mean estimated S across many trials stays within tolerance of true S. + int K = 10; + double tolerance = 0.05; + int N = SampledCode.calcSampleSizeForSecrets(K, tolerance); + double trueS = 1.0 / K; + + // Each trial: draw N samples from K categories uniformly, compute Ŝ = Σ (count_i/N)^2 + int trials = 2000; + double sumInflation = 0; + int[] counts = new int[K]; + int[] sample = new int[N]; + for (int t = 0; t < trials; t++) { + java.util.Arrays.fill(counts, 0); + for (int i = 0; i < N; i++) { + sample[i] = java.util.concurrent.ThreadLocalRandom.current().nextInt(K); + } + for (int s : sample) counts[s]++; + double sHat = 0; + for (int cnt : counts) { + double p = (double) cnt / N; + sHat += p * p; + } + sumInflation += (sHat - trueS) / trueS; + } + double meanInflation = sumInflation / trials; + // The formula targets mean inflation ≈ tolerance (it's an upper bound, not strict <). + // Allow 10% slack above tolerance to absorb Monte Carlo variance across 2000 trials. + assertTrue(meanInflation <= tolerance * 1.1, + "Mean relative inflation " + meanInflation + " exceeded tolerance " + tolerance); + } + + // --- calcSampleSizeForGuesses confidence --- + + @Test + void testCalcSampleSizeForGuessesHitsTopPercentileWithExpectedConfidence() { + // Setup: population of 1000 guesses; top x% = top 10 guesses (1%). + // Sample n = calcSampleSizeForGuesses(0.01, 0.99) guesses. + // In >= 99% of trials, at least one sampled guess should be in the top 1%. + double percentile = 0.01; + double confidence = 0.99; + int n = SampledCode.calcSampleSizeForGuesses(percentile, confidence); + int population = 1000; + int topK = (int) (population * percentile); // 10 + + int trials = 5000; + int success = 0; + for (int t = 0; t < trials; t++) { + boolean found = false; + for (int i = 0; i < n && !found; i++) { + // A sampled guess index is "elite" if it falls in [0, topK) + int idx = java.util.concurrent.ThreadLocalRandom.current().nextInt(population); + if (idx < topK) found = true; + } + if (found) success++; + } + + double observedConfidence = (double) success / trials; + // Allow a small margin below the stated confidence for statistical variation + assertTrue(observedConfidence >= confidence - 0.02, + "Observed confidence " + observedConfidence + " below expected " + confidence); + } } diff --git a/src/tests/java/org/mastermind/solver/BestFirstGuessTest.java b/src/tests/java/org/mastermind/solver/BestFirstGuessTest.java new file mode 100644 index 00000000..a61c4046 --- /dev/null +++ b/src/tests/java/org/mastermind/solver/BestFirstGuessTest.java @@ -0,0 +1,34 @@ +package org.mastermind.solver; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +public class BestFirstGuessTest { + + @Test + public void allValidGamesReturnNonNullResult() { + for (int c = 2; c <= 9; c++) { + for (int d = 1; d <= 9; d++) { + long[] result = BestFirstGuess.of(c, d); + assertNotNull(result, "Expected result for c=" + c + ", d=" + d); + assertEquals(2, result.length, "Expected length 2 for c=" + c + ", d=" + d); + assertTrue(result[0] > 0, "Expected positive guess code for c=" + c + ", d=" + d); + assertTrue(result[1] > 0, "Expected positive rank for c=" + c + ", d=" + d); + } + } + } + + @Test + public void invalidColorsThrowException() { + assertThrows(IllegalArgumentException.class, () -> BestFirstGuess.of(1, 4)); + assertThrows(IllegalArgumentException.class, () -> BestFirstGuess.of(10, 4)); + assertThrows(IllegalArgumentException.class, () -> BestFirstGuess.of(0, 4)); + } + + @Test + public void invalidDigitsThrowException() { + assertThrows(IllegalArgumentException.class, () -> BestFirstGuess.of(4, 0)); + assertThrows(IllegalArgumentException.class, () -> BestFirstGuess.of(4, 10)); + } +} diff --git a/src/tests/java/org/mastermind/solver/ExpectedSizeTest.java b/src/tests/java/org/mastermind/solver/ExpectedSizeTest.java index 0cd7b44e..820cd56c 100644 --- a/src/tests/java/org/mastermind/solver/ExpectedSizeTest.java +++ b/src/tests/java/org/mastermind/solver/ExpectedSizeTest.java @@ -48,6 +48,16 @@ public void testCalcExpectedRankFirstMatchesFullRank() { } } + @Test + public void testConvertSampleRankToExpectedSize() { + // Use full population as the "sample" (sampleSize == total). + // Then sampleRank == fullRank, and the conversion must recover the exact expected size. + int guessInd = ind(1234); + long fullRank = expectedSizeObj.calcExpectedRank(guessInd, secretsInd, COLORS, DIGITS, feedbackFreq); + float expectedSize = calcExpectedSize(guessInd); + assertEquals(expectedSize, expectedSizeObj.convertSampleRankToExpectedSize(fullRank, TOTAL, TOTAL), DELTA); + } + @Test public void testExpectedSizeSymmetry() { // Guesses with the same color multiset should yield the same expected size