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 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 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 @@
* 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:
+ * 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
- * 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
+ *
+ *
+ * @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.
*
+ *