diff --git a/.github/workflows/coverage.yaml b/.github/workflows/coverage.yaml deleted file mode 100644 index 4bc1a630..00000000 --- a/.github/workflows/coverage.yaml +++ /dev/null @@ -1,44 +0,0 @@ -on: ["push", "pull_request"] - -name: Update Test Coverage - -permissions: - contents: read - -jobs: - coveralls: - runs-on: ubuntu-latest - - steps: - - name: Checkout code - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - - - name: Install uv (faster alternative to pip) - uses: astral-sh/setup-uv@v5 - with: - version: "0.4.4" - python-version: "3.10" - enable-cache: true - - - name: Install dependencies - run: | - uv pip install coveralls pytest - uv pip install -r pyproject.toml - - - name: Generate Coverage Report - env: - PYTHONPATH: ./src - run: | - coverage run -m pytest --doctest-modules src - coverage xml - - - name: Upload Report to Coveralls - env: - GITHUB_TOKEN: ${{ secrets. GITHUB_TOKEN }} - run: coveralls - - - name: Upload Report to Codacy - uses: codacy/codacy-coverage-reporter-action@v1.3.0 - with: - project-token: ${{ secrets.CODACY_PROJECT_TOKEN }} - coverage-reports: coverage.xml diff --git a/.github/workflows/deploy_sphinx.yaml b/.github/workflows/deploy_sphinx.yaml deleted file mode 100644 index 3e62c040..00000000 --- a/.github/workflows/deploy_sphinx.yaml +++ /dev/null @@ -1,46 +0,0 @@ -name: Deploy Sphinx Documentation - -on: - workflow_dispatch: - -permissions: - contents: read - -jobs: - build: - runs-on: ubuntu-latest - - permissions: - contents: write - - steps: - - name: Checkout code - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - - - name: Install uv (faster alternative to pip) - uses: astral-sh/setup-uv@v5 - with: - version: "0.4.4" - python-version: "3.10" - enable-cache: true - - - name: Install dependencies - run: | - uv pip install -r pyproject.toml --extra docs - - - name: Generate rst files - run: sphinx-apidoc -o docs/source src/mastermind -f --templatedir=docs/source/_templates --maxdepth=2 --module-first - - - name: Build documentation - env: - PYTHONPATH: ${{ github.workspace }}/src - run: | - cd docs - make html - - - name: Deploy to GitHub Pages - if: success() - uses: peaceiris/actions-gh-pages@373f7f263a76c20808c831209c920827a82a2847 # v3.9.3 - with: - github_token: ${{ secrets.GITHUB_TOKEN }} - publish_dir: ./docs/build/html diff --git a/.github/workflows/ossf_scorecard.yml b/.github/workflows/ossf_scorecard.yml deleted file mode 100644 index 0909036d..00000000 --- a/.github/workflows/ossf_scorecard.yml +++ /dev/null @@ -1,41 +0,0 @@ -name: OSSF Scorecard -on: - push: - branches: - - main - workflow_dispatch: - -permissions: read-all - -jobs: - analysis: - name: Scorecard analysis - runs-on: ubuntu-latest - permissions: - security-events: write - id-token: write - - steps: - - name: "Checkout code" - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 - with: - persist-credentials: false - - - name: "Run analysis" - uses: ossf/scorecard-action@0864cf19026789058feabb7e87baa5f140aac736 - with: - results_file: results.sarif - results_format: sarif - publish_results: true - - - name: "Upload artifact" - uses: actions/upload-artifact@97a0fba1372883ab732affbe8f94b823f91727db # v3.pre.node20 - with: - name: SARIF file - path: results.sarif - retention-days: 5 - - - name: "Upload to code-scanning" - uses: github/codeql-action/upload-sarif@f09c1c0a94de965c15400f5634aa42fac8fb8f88 # v3.27.5 - with: - sarif_file: results.sarif diff --git a/.github/workflows/release_pypi.yaml b/.github/workflows/release_pypi.yaml deleted file mode 100644 index b9f306be..00000000 --- a/.github/workflows/release_pypi.yaml +++ /dev/null @@ -1,39 +0,0 @@ -name: Upload Python Package - -on: - release: - types: [published] - -permissions: - contents: read - -jobs: - release-build: - name: Build and upload release artifacts - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - - - name: Install uv (faster alternative to pip) - uses: astral-sh/setup-uv@v5 - with: - version: "0.4.4" - python-version: "3.10" - enable-cache: true - - - name: Install dependencies - run: uv pip install -r pyproject.toml - - - name: Build release - run: uv build - - - name: Test release (wheel) - run: uv run --isolated --all-extras --with dist/*.whl pytest --doctest-modules src - - - - name: Test release (source distribution) - run: uv run --isolated --all-extras --with dist/*.tar.gz pytest --doctest-modules src - - - name: Upload release - run: uv publish --trusted-publishing always diff --git a/CLAUDE.md b/CLAUDE.md index 2d74a7db..26155835 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,7 +2,8 @@ Mastermind solver using Java algorithms (performance) + Python UI (terminal). Goal: Efficiently solve c=9, d=9 cases. -Status: Rewriting codebase; currently focused on Java algorithm only. +Status: Java algorithm complete and performing well (~2s for a full 9x9 solve). Currently in cleanup phase (comments, +tests, chores). Python UI not yet started. ### Code Organization @@ -20,13 +21,14 @@ Status: Rewriting codebase; currently focused on Java algorithm only. 5. `GuessStrategy.select()` — chooses which guesses and secrets arrays to pass into `BestGuess` 6. `MastermindSession` — manages a full game: history, solution space, strategy-based suggestions, undo -### Next Move / Current Move +### Current Focus -- Run a 9x9 demo and profile the code to determine where the bottleneck is. -- Continue micro-optimizing code to increase efficiency +- Java side mostly done; touching up code quality, expanding test coverage, misc chores. +- Next major phase: Python UI. ### Preference - Stick to primitive type unless there is a reason not to. - Unless necessary, do not write extra class and objects. Be simple. -- Do not run any tests or benchmark for me unless specifically instructed. \ No newline at end of file +- Do not run any tests or benchmark for me unless specifically instructed. +- DO NOT TOUCH the average performance in benchmarks. Don't delete, don't modify, don't change, don't update. \ No newline at end of file diff --git a/src/benchmarks/java/org/mastermind/FeedbackBenchmark.java b/src/benchmarks/java/org/mastermind/FeedbackBenchmark.java deleted file mode 100644 index 047ac58a..00000000 --- a/src/benchmarks/java/org/mastermind/FeedbackBenchmark.java +++ /dev/null @@ -1,59 +0,0 @@ -package org.mastermind; - -import org.mastermind.codes.AllValidCode; -import org.mastermind.solver.Feedback; -import org.openjdk.jmh.annotations.*; -import org.openjdk.jmh.infra.Blackhole; - -import java.util.concurrent.TimeUnit; - -@BenchmarkMode(Mode.AverageTime) -@Warmup(iterations = 4, time = 1) -@Measurement(iterations = 4, time = 1) -@Fork(1) -public class FeedbackBenchmark { - - @OutputTimeUnit(TimeUnit.NANOSECONDS) - @Benchmark - public void fixInputBenchmark(BenchmarkState state, Blackhole blackhole) { - int feedback = state.getFeedbackQuick(1234, 4263); - blackhole.consume(feedback); - } - - @OutputTimeUnit(TimeUnit.MICROSECONDS) - @Benchmark - public void oneVariedInputBenchmark(BenchmarkState state, Blackhole blackhole) { - for (int secret : state.secrets) { - int feedback = state.getFeedbackQuick(1234, secret); - blackhole.consume(feedback); - } - } - - @OutputTimeUnit(TimeUnit.MILLISECONDS) - @Benchmark - public void doubleVariedInputBenchmark(BenchmarkState state, Blackhole blackhole) { - for (int guess : state.secrets) { - for (int secret : state.secrets) { - int feedback = state.getFeedbackQuick(guess, secret); - blackhole.consume(feedback); - } - } - } - - @State(Scope.Thread) - public static class BenchmarkState { - public int[] secrets = AllValidCode.generateAllCodes(6, 4); - public int[] freq = new int[10]; - - public int getFeedbackQuick(int guess, int secret) { - return Feedback.getFeedback(guess, secret, 6, 4, freq); - } - } -} - -/* Benchmark average: -Benchmark Mode Cnt Score Error Units -FeedbackBenchmark.doubleVariedInputBenchmark avgt 4 28.907 ± 1.257 ms/op -FeedbackBenchmark.fixInputBenchmark avgt 4 18.134 ± 0.729 ns/op -FeedbackBenchmark.oneVariedInputBenchmark avgt 4 23.219 ± 0.912 us/op - */ \ No newline at end of file diff --git a/src/benchmarks/java/org/mastermind/SampledCodeBenchmark.java b/src/benchmarks/java/org/mastermind/SampledCodeBenchmark.java deleted file mode 100644 index 1f47c4d4..00000000 --- a/src/benchmarks/java/org/mastermind/SampledCodeBenchmark.java +++ /dev/null @@ -1,33 +0,0 @@ -package org.mastermind; - -import org.mastermind.codes.SampledCode; -import org.mastermind.solver.Feedback; -import org.openjdk.jmh.annotations.*; -import org.openjdk.jmh.infra.Blackhole; - -import java.util.concurrent.TimeUnit; - -@BenchmarkMode(Mode.AverageTime) -@OutputTimeUnit(TimeUnit.MILLISECONDS) -@Warmup(iterations = 3, time = 1) -@Measurement(iterations = 3, time = 1) -@Fork(5) -public class SampledCodeBenchmark { - - @Benchmark - public void benchmarkGetSample(BenchmarkState state, Blackhole blackhole) { - int[] sample = SampledCode.getSample(9, 9, state.sampleSize); - blackhole.consume(sample); - } - - @State(Scope.Thread) - public static class BenchmarkState { - // feedbackSize for d=9: (9+1)*(9+2)/2 = 55 - final int sampleSize = SampledCode.calcSampleSizeForSecrets(Feedback.calcFeedbackSize(9)); - } -} - -/* Average Performance: -Benchmark Mode Cnt Score Error Units -SampledCodeBenchmark.benchmarkGetSample avgt 15 9.964 ± 0.152 ms/op - */ \ No newline at end of file diff --git a/src/benchmarks/java/org/mastermind/codes/SampledCodeBenchmark.java b/src/benchmarks/java/org/mastermind/codes/SampledCodeBenchmark.java new file mode 100644 index 00000000..df79b081 --- /dev/null +++ b/src/benchmarks/java/org/mastermind/codes/SampledCodeBenchmark.java @@ -0,0 +1,142 @@ +package org.mastermind.codes; + +import org.mastermind.solver.Feedback; +import org.openjdk.jmh.annotations.*; +import org.openjdk.jmh.infra.Blackhole; + +import java.util.BitSet; +import java.util.concurrent.TimeUnit; + +/** + * Benchmarks getValidSample for c=9, d=9 at fill rates spanning both the + * enumeration path (validCount <= 5M) and the rejection path (validCount > 5M). + * Goal: confirm that sampling time stays small regardless of fill rate. + */ +@BenchmarkMode(Mode.AverageTime) +@OutputTimeUnit(TimeUnit.MILLISECONDS) +@Warmup(iterations = 2, time = 1) +@Measurement(iterations = 2, time = 1) +@Fork(1) +public class SampledCodeBenchmark { + + // ── Benchmarks ──────────────────────────────────────────────────────────── + + private static BitSet buildBitSet(double fillRate) { + int total = (int) Math.pow(9, 9); + int count = (int) (fillRate * total); + BitSet bs = new BitSet(total); + if (count >= total) { + bs.set(0, total); + } else { + double step = (double) total / count; + for (int i = 0; i < count; i++) bs.set((int) (i * step)); + } + return bs; + } + + @Benchmark + public void sample_enum_1pct(Fill1pct state, Blackhole bh) { + bh.consume(SampledCode.getValidSample(state.remaining, state.validCount, 9, 9, state.sampleSize)); + } + + @Benchmark + public void sample_enum_05pct(Fill05pct state, Blackhole bh) { + bh.consume(SampledCode.getValidSample(state.remaining, state.validCount, 9, 9, state.sampleSize)); + } + + @Benchmark + public void sample_reject_2pct(Fill2pct state, Blackhole bh) { + bh.consume(SampledCode.getValidSample(state.remaining, state.validCount, 9, 9, state.sampleSize)); + } + + @Benchmark + public void sample_reject_5pct(Fill5pct state, Blackhole bh) { + bh.consume(SampledCode.getValidSample(state.remaining, state.validCount, 9, 9, state.sampleSize)); + } + + @Benchmark + public void sample_reject_10pct(Fill10pct state, Blackhole bh) { + bh.consume(SampledCode.getValidSample(state.remaining, state.validCount, 9, 9, state.sampleSize)); + } + + @Benchmark + public void sample_reject_50pct(Fill50pct state, Blackhole bh) { + bh.consume(SampledCode.getValidSample(state.remaining, state.validCount, 9, 9, state.sampleSize)); + } + + // ── States ──────────────────────────────────────────────────────────────── + + @Benchmark + public void sample_reject_100pct(Fill100pct state, Blackhole bh) { + bh.consume(SampledCode.getValidSample(state.remaining, state.validCount, 9, 9, state.sampleSize)); + } + + @State(Scope.Thread) + public static class BaseState { + final int sampleSize = SampledCode.calcSampleSizeForSecrets(Feedback.calcFeedbackSize(9), 0.01); + } + + // Enumeration path (validCount <= MAX_ENUM = 5M) + @State(Scope.Thread) + public static class Fill05pct extends BaseState { + // 0.5%: validCount ≈ 1.9M — enum + final BitSet remaining = buildBitSet(0.005); + final int validCount = remaining.cardinality(); + } + + @State(Scope.Thread) + public static class Fill1pct extends BaseState { + // 1%: validCount ≈ 3.9M — enum + final BitSet remaining = buildBitSet(0.010); + final int validCount = remaining.cardinality(); + } + + // Rejection path (validCount > MAX_ENUM = 5M) + @State(Scope.Thread) + public static class Fill2pct extends BaseState { + // 2%: validCount ≈ 7.7M — reject + final BitSet remaining = buildBitSet(0.020); + final int validCount = remaining.cardinality(); + } + + @State(Scope.Thread) + public static class Fill5pct extends BaseState { + // 5%: validCount ≈ 19M — reject + final BitSet remaining = buildBitSet(0.050); + final int validCount = remaining.cardinality(); + } + + @State(Scope.Thread) + public static class Fill10pct extends BaseState { + // 10%: validCount ≈ 39M — reject + final BitSet remaining = buildBitSet(0.100); + final int validCount = remaining.cardinality(); + } + + @State(Scope.Thread) + public static class Fill50pct extends BaseState { + // 50%: validCount ≈ 194M — reject + final BitSet remaining = buildBitSet(0.500); + final int validCount = remaining.cardinality(); + } + + // ── Helper ──────────────────────────────────────────────────────────────── + + @State(Scope.Thread) + public static class Fill100pct extends BaseState { + // 100%: validCount = 387M — reject + final BitSet remaining = buildBitSet(1.000); + final int validCount = remaining.cardinality(); + } +} + +/* Average Performance: +Benchmark Mode Cnt Score Error Units +SampledCodeBenchmark.sample_enum_05pct avgt 2 12.344 ms/op +SampledCodeBenchmark.sample_enum_1pct avgt 2 19.482 ms/op +SampledCodeBenchmark.sample_reject_100pct avgt 2 0.400 ms/op +SampledCodeBenchmark.sample_reject_10pct avgt 2 3.953 ms/op +SampledCodeBenchmark.sample_reject_2pct avgt 2 19.607 ms/op +SampledCodeBenchmark.sample_reject_50pct avgt 2 0.833 ms/op +SampledCodeBenchmark.sample_reject_5pct avgt 2 7.851 ms/op + */ diff --git a/src/benchmarks/java/org/mastermind/BestGuessBenchmark.java b/src/benchmarks/java/org/mastermind/solver/BestGuessBenchmark.java similarity index 52% rename from src/benchmarks/java/org/mastermind/BestGuessBenchmark.java rename to src/benchmarks/java/org/mastermind/solver/BestGuessBenchmark.java index b94d3ebc..7143583c 100644 --- a/src/benchmarks/java/org/mastermind/BestGuessBenchmark.java +++ b/src/benchmarks/java/org/mastermind/solver/BestGuessBenchmark.java @@ -1,7 +1,5 @@ -package org.mastermind; +package org.mastermind.solver; -import org.mastermind.codes.AllValidCode; -import org.mastermind.solver.BestGuess; import org.openjdk.jmh.annotations.*; import org.openjdk.jmh.infra.Blackhole; @@ -17,31 +15,33 @@ public class BestGuessBenchmark { // Ordinary (Sequential) Version @Benchmark public void benchmarkOrdinaryVersion(BenchmarkState state, Blackhole blackhole) { - long[] bestGuess = BestGuess.findBestGuess(state.allCodes, state.allCodes, 6, 4, false); + long[] bestGuess = BestGuess.findBestGuess(state.allInd, state.allInd, BenchmarkState.C, BenchmarkState.D, + false); blackhole.consume(bestGuess); } // Parallel Version @Benchmark public void benchmarkParallelVersion(BenchmarkState state, Blackhole blackhole) { - long[] bestGuess = BestGuess.findBestGuess(state.allCodes, state.allCodes, 6, 4, true); + long[] bestGuess = BestGuess.findBestGuess(state.allInd, state.allInd, BenchmarkState.C, BenchmarkState.D, + true); blackhole.consume(bestGuess); } @State(Scope.Thread) public static class BenchmarkState { - private final int[] allCodes = AllValidCode.generateAllCodes(6, 4); + static final int C = 6, D = 4; + private final int[] allInd; - // TEARDOWN - Shutdown the thread pool after benchmarking - @TearDown(Level.Trial) - public void tearDown() { - BestGuess.shutdown(); + public BenchmarkState() { + allInd = new int[(int) Math.pow(C, D)]; + for (int i = 0; i < allInd.length; i++) allInd[i] = i; } } } /* Average Performance: Benchmark Mode Cnt Score Error Units -BestGuessBenchmark.benchmarkOrdinaryVersion avgt 6 34.278 ± 0.994 ms/op -BestGuessBenchmark.benchmarkParallelVersion avgt 6 12.409 ± 4.445 ms/op +BestGuessBenchmark.benchmarkOrdinaryVersion avgt 6 35.945 ± 4.358 ms/op +BestGuessBenchmark.benchmarkParallelVersion avgt 6 21.431 ± 3.777 ms/op */ \ No newline at end of file diff --git a/src/benchmarks/java/org/mastermind/ExpectedSizeBenchmark.java b/src/benchmarks/java/org/mastermind/solver/ExpectedSizeBenchmark.java similarity index 50% rename from src/benchmarks/java/org/mastermind/ExpectedSizeBenchmark.java rename to src/benchmarks/java/org/mastermind/solver/ExpectedSizeBenchmark.java index db4d8f9a..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.AllValidCode; -import org.mastermind.solver.ExpectedSize; +import org.mastermind.codes.ConvertCode; import org.openjdk.jmh.annotations.*; import org.openjdk.jmh.infra.Blackhole; @@ -16,33 +15,42 @@ public class ExpectedSizeBenchmark { @Benchmark @OutputTimeUnit(TimeUnit.MICROSECONDS) public void benchmarkTest(BenchmarkState state, Blackhole blackhole) { - long expectedSize = state.calcExpectedRank(1123); + long expectedSize = state.calcExpectedRank(BenchmarkState.ind(1123)); blackhole.consume(expectedSize); } @Benchmark @OutputTimeUnit(TimeUnit.MILLISECONDS) public void variedTest(BenchmarkState state, Blackhole blackhole) { - for (int guess : state.secrets) { - long expectedSize = state.calcExpectedRank(guess); + for (int guessInd = 0; guessInd < state.total; guessInd++) { + long expectedSize = state.calcExpectedRank(guessInd); blackhole.consume(expectedSize); } } @State(Scope.Thread) public static class BenchmarkState { - private final int[] secrets = AllValidCode.generateAllCodes(6, 4); + static final int C = 6, D = 4; + private final int total = (int) Math.pow(C, D); // 1296 + private final int[] secretsInd; private final int[] feedbackFreq = new int[100]; - private final ExpectedSize expectedSizeObj = new ExpectedSize(4); + private final ExpectedSize expectedSizeObj = new ExpectedSize(D); - public long calcExpectedRank(int guess) { - return expectedSizeObj.calcExpectedRank(guess, secrets, 6, 4, feedbackFreq); + public BenchmarkState() { + secretsInd = new int[total]; + for (int i = 0; i < total; i++) secretsInd[i] = i; + } + + static int ind(int code) { return ConvertCode.toIndex(C, D, code); } + + public long calcExpectedRank(int guessInd) { + return expectedSizeObj.calcExpectedRank(guessInd, secretsInd, C, D, feedbackFreq); } } } /* Benchmark Average: Benchmark Mode Cnt Score Error Units -ExpectedSizeBenchmark.benchmarkTest avgt 9 25.096 ± 1.178 us/op -ExpectedSizeBenchmark.variedTest avgt 9 34.095 ± 0.792 ms/op +ExpectedSizeBenchmark.benchmarkTest avgt 9 25.656 ± 1.551 us/op +ExpectedSizeBenchmark.variedTest avgt 9 32.383 ± 2.467 ms/op */ \ No newline at end of file diff --git a/src/benchmarks/java/org/mastermind/solver/FeedbackBenchmark.java b/src/benchmarks/java/org/mastermind/solver/FeedbackBenchmark.java new file mode 100644 index 00000000..02663d66 --- /dev/null +++ b/src/benchmarks/java/org/mastermind/solver/FeedbackBenchmark.java @@ -0,0 +1,113 @@ +package org.mastermind.solver; + +import org.mastermind.codes.ConvertCode; +import org.openjdk.jmh.annotations.*; +import org.openjdk.jmh.infra.Blackhole; + +import java.util.concurrent.TimeUnit; + +@BenchmarkMode(Mode.AverageTime) +@Warmup(iterations = 4, time = 1) +@Measurement(iterations = 4, time = 1) +@Fork(1) +public class FeedbackBenchmark { + + @OutputTimeUnit(TimeUnit.NANOSECONDS) + @Benchmark + public void fixInputBenchmark(BenchmarkState state, Blackhole blackhole) { + int feedback = state.getFeedbackQuick(BenchmarkState.ind(1234), BenchmarkState.ind(4263)); + blackhole.consume(feedback); + } + + @OutputTimeUnit(TimeUnit.MICROSECONDS) + @Benchmark + public void oneVariedInputBenchmark(BenchmarkState state, Blackhole blackhole) { + for (int secretIdx = 0; secretIdx < state.total; secretIdx++) { + int feedback = state.getFeedbackQuick(BenchmarkState.ind(1234), secretIdx); + blackhole.consume(feedback); + } + } + + @OutputTimeUnit(TimeUnit.MILLISECONDS) + @Benchmark + public void doubleVariedInputBenchmark(BenchmarkState state, Blackhole blackhole) { + for (int guessIdx = 0; guessIdx < state.total; guessIdx++) { + for (int secretIdx = 0; secretIdx < state.total; secretIdx++) { + int feedback = state.getFeedbackQuick(guessIdx, secretIdx); + blackhole.consume(feedback); + } + } + } + + @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]; + // 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); } + + public int getFeedbackQuick(int guessIdx, int secretIdx) { + return Feedback.getFeedback(guessIdx, secretIdx, C, D, freq); + } + } +} + +/* Benchmark average: +Benchmark Mode Cnt Score Error Units +FeedbackBenchmark.doubleVariedInputBenchmark avgt 4 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/benchmarks/java/org/mastermind/SolutionSpaceBenchmark.java b/src/benchmarks/java/org/mastermind/solver/SolutionSpaceBenchmark.java similarity index 68% rename from src/benchmarks/java/org/mastermind/SolutionSpaceBenchmark.java rename to src/benchmarks/java/org/mastermind/solver/SolutionSpaceBenchmark.java index eed63a8a..305840d8 100644 --- a/src/benchmarks/java/org/mastermind/SolutionSpaceBenchmark.java +++ b/src/benchmarks/java/org/mastermind/solver/SolutionSpaceBenchmark.java @@ -1,7 +1,6 @@ -package org.mastermind; +package org.mastermind.solver; -import org.mastermind.solver.Feedback; -import org.mastermind.solver.SolutionSpace; +import org.mastermind.codes.ConvertCode; import org.openjdk.jmh.annotations.*; import org.openjdk.jmh.infra.Blackhole; @@ -20,7 +19,7 @@ public class SolutionSpaceBenchmark { @Benchmark @OutputTimeUnit(TimeUnit.MICROSECONDS) public void filterSerialFull(SmallState state) { - state.space.filterSolution(state.guess, state.feedback); + state.space.filterSolution(state.guessInd, state.feedback); state.space.reset(); } @@ -30,7 +29,7 @@ public void filterSerialFull(SmallState state) { @Benchmark @OutputTimeUnit(TimeUnit.MILLISECONDS) public void filterParallelFull(LargeState state) { - state.space.filterSolution(state.guess, state.feedback); + state.space.filterSolution(state.guessInd, state.feedback); state.space.reset(); } @@ -56,15 +55,17 @@ public void getSize(SmallState state, Blackhole blackhole) { public static class SmallState { static final int C = 6, D = 4; SolutionSpace space; - int guess; + int guessInd; int feedback; - int[] freq = new int[10]; + int[] freq = new int[C]; + + static int ind(int code) { return ConvertCode.toIndex(C, D, code); } @Setup(Level.Trial) public void setup() { space = new SolutionSpace(C, D); - guess = 1122; - feedback = Feedback.getFeedback(guess, 3456, C, D, freq); + guessInd = ind(1122); + feedback = Feedback.getFeedback(guessInd, ind(3456), C, D, freq); } @Setup(Level.Invocation) @@ -77,15 +78,17 @@ public void reset() { public static class LargeState { static final int C = 9, D = 5; SolutionSpace space; - int guess; + int guessInd; int feedback; - int[] freq = new int[10]; + int[] freq = new int[C]; + + static int ind(int code) { return ConvertCode.toIndex(C, D, code); } @Setup(Level.Trial) public void setup() { space = new SolutionSpace(C, D); - guess = 11223; - feedback = Feedback.getFeedback(guess, 34567, C, D, freq); + guessInd = ind(11223); + feedback = Feedback.getFeedback(guessInd, ind(34567), C, D, freq); } @Setup(Level.Invocation) @@ -97,8 +100,8 @@ public void reset() { /* Average Performance: Benchmark Mode Cnt Score Error Units -SolutionSpaceBenchmark.filterParallelFull avgt 4 0.632 ± 0.672 ms/op -SolutionSpaceBenchmark.filterSerialFull avgt 4 30.677 ± 2.271 us/op -SolutionSpaceBenchmark.getSecretsFull avgt 4 6.024 ± 0.218 us/op -SolutionSpaceBenchmark.getSize avgt 4 17.925 ± 0.885 ns/op +SolutionSpaceBenchmark.filterParallelFull avgt 4 0.608 ± 0.195 ms/op +SolutionSpaceBenchmark.filterSerialFull avgt 4 38.088 ± 2.371 us/op +SolutionSpaceBenchmark.getSecretsFull avgt 4 5.723 ± 0.188 us/op +SolutionSpaceBenchmark.getSize avgt 4 18.442 ± 0.872 ns/op */ \ No newline at end of file diff --git a/src/main/java/org/mastermind/Demo.java b/src/main/java/org/mastermind/Demo.java index ee92eb83..aad4a1ad 100644 --- a/src/main/java/org/mastermind/Demo.java +++ b/src/main/java/org/mastermind/Demo.java @@ -1,6 +1,6 @@ package org.mastermind; -import org.mastermind.solver.BestGuess; +import org.mastermind.codes.ConvertCode; import org.mastermind.solver.ExpectedSize; import org.mastermind.solver.Feedback; @@ -16,46 +16,51 @@ */ public class Demo { - // ── Adjust these defaults as needed ────────────────────────────────────── - static int C = 9; // number of colors - static int D = 9; // number of digit positions - static int SECRET = 641899762; // the secret the solver tries to crack + // ── Adjust these settings as needed ────────────────────────────────────── + static int C = 9; // number of colors + static int D = 9; // number of digit positions + static int SECRET_IND = 641899762; // index of the secret code (0-based, base-c encoding) // ───────────────────────────────────────────────────────────────────────── public static void main(String[] args) { if (args.length >= 3) { C = Integer.parseInt(args[0]); D = Integer.parseInt(args[1]); - SECRET = Integer.parseInt(args[2]); + SECRET_IND = Integer.parseInt(args[2]); } - System.out.printf("Mastermind Demo [c=%d, d=%d, secret=%d]%n%n", C, D, SECRET); + System.out.printf("Mastermind Demo [c=%d, d=%d, secretInd=%d]%n%n", C, D, SECRET_IND); - long startTime = System.nanoTime(); - MastermindSession session = new MastermindSession(C, D); - ExpectedSize expectedSizeObj = new ExpectedSize(D); - int[] colorFreqCounter = new int[10]; + long startTime = System.nanoTime(); + MastermindSession session = new MastermindSession(C, D); + ExpectedSize expectedSizeObj = new ExpectedSize(D); + int[] colorFreq = new int[C]; while (!session.isSolved()) { int spaceBefore = session.getSolutionSpaceSize(); long[] details = session.suggestGuessWithDetails(); - int guess = (int) details[0]; + int guessInd = (int) details[0]; float expSize = expectedSizeObj.convertSampleRankToExpectedSize(details[1], (int) details[2], spaceBefore); - int feedback = Feedback.getFeedback(guess, SECRET, C, D, colorFreqCounter); + int feedback = Feedback.getFeedback(guessInd, SECRET_IND, C, D, colorFreq); - session.recordGuess(guess, feedback); + session.recordGuess(guessInd, feedback); - int turn = session.getTurnCount(); - int black = feedback / 10; - int white = feedback % 10; - System.out.printf("Turn %d: guess=%-6d space=%-5d expected=%.2f feedback=%db%dw%n", - turn, guess, spaceBefore, expSize, black, white); + int turn = session.getTurnCount(); + int spaceAfter = session.getSolutionSpaceSize(); + int black = feedback / 10; + int white = feedback % 10; + float expElimPct = spaceBefore > 0 ? 100f * (spaceBefore - expSize) / spaceBefore : 0f; + float actElimPct = spaceBefore > 0 ? 100f * (spaceBefore - spaceAfter) / spaceBefore : 0f; + int guessCode = ConvertCode.toCode(C, D, guessInd); + System.out.printf( + "Turn %d: before=%-8d expAfter=%-8.1f actAfter=%-8d expElim=%5.1f%% actElim=%5.1f%% " + + "guess=%-12d feedback=%db%dw%n", + turn, spaceBefore, expSize, spaceAfter, expElimPct, actElimPct, guessCode, black, white); } double elapsedSec = (System.nanoTime() - startTime) / 1_000_000_000.0; System.out.printf("%nSolved in %d turn(s).%n", session.getTurnCount()); System.out.printf("Time: %.1f seconds%n", elapsedSec); - BestGuess.shutdown(); } } diff --git a/src/main/java/org/mastermind/GuessStrategy.java b/src/main/java/org/mastermind/GuessStrategy.java deleted file mode 100644 index 958def17..00000000 --- a/src/main/java/org/mastermind/GuessStrategy.java +++ /dev/null @@ -1,108 +0,0 @@ -package org.mastermind; - -import org.mastermind.codes.CodeCache; -import org.mastermind.codes.SampledCode; -import org.mastermind.solver.Feedback; - -/** - * Selects which arrays to pass as guesses and secrets to BestGuess for each turn. - * - *

Returns {@code int[][] { guesses, secrets }} where: - *

- * - *

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

Threshold: {@code guesses.length × secrets.length} above which the - * parallel BestGuess search exceeds ~1 second on the target machine. - */ -public class GuessStrategy { - - private static final long THRESHOLD = 130_000_000L; - - /** - * 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) - * @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); - } - - /** - * 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[] secrets) { - int[] canonical = CodeCache.getCanonical(c, d); - - if (fits(canonical.length, secrets.length)) return pair(canonical, secrets); - - for (double tolerance : new double[] { 0.001, 0.005 }) { - if (fits(canonical.length, secretSampleSize(d, tolerance))) { - return pair(canonical, secretSample(c, d, tolerance)); - } - } - - return pair(canonical, secretSample(c, d, 0.01)); - } - - /** - * 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) { - 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); - - 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)); - } - } - - 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))) { - return pair(guessSample(c, d, percentile), sSample); - } - } - - return pair(guessSample(c, d, 0.01), sSample); - } - - private static boolean fits(int guessSpaceSize, int secretSpaceSize) { - return (long) guessSpaceSize * secretSpaceSize <= THRESHOLD; - } - - private static int[][] pair(int[] guesses, int[] secrets) { - return new int[][] { guesses, secrets }; - } - - private static int secretSampleSize(int d, double tolerance) { - return SampledCode.calcSampleSizeForSecrets(Feedback.calcFeedbackSize(d), tolerance); - } - - private static int guessSampleSize(double percentileThreshold) { - return SampledCode.calcSampleSizeForGuesses(percentileThreshold, 0.999); - } - - private static int[] secretSample(int c, int d, double tolerance) { - return SampledCode.getSample(c, d, secretSampleSize(d, tolerance)); - } - - private static int[] guessSample(int c, int d, double percentileThreshold) { - return SampledCode.getSample(c, d, guessSampleSize(percentileThreshold)); - } -} diff --git a/src/main/java/org/mastermind/MastermindSession.java b/src/main/java/org/mastermind/MastermindSession.java index e432128a..ac28b50d 100644 --- a/src/main/java/org/mastermind/MastermindSession.java +++ b/src/main/java/org/mastermind/MastermindSession.java @@ -1,8 +1,7 @@ package org.mastermind; -import org.mastermind.solver.BestGuess; -import org.mastermind.solver.ExpectedSize; -import org.mastermind.solver.SolutionSpace; +import org.mastermind.codes.ConvertCode; +import org.mastermind.solver.*; import java.util.ArrayList; import java.util.Collections; @@ -48,7 +47,7 @@ public MastermindSession(int c, int d) { * against) is handled by {@link GuessStrategy}. If only one secret remains, * it is returned immediately without invoking the BestGuess search. * - * @return the recommended guess as an integer code (digits 1..c, length d) + * @return the recommended guess as a code index (0-based, base-c encoding) * @throws IllegalStateException if the game is already solved */ public int suggestGuess() { @@ -68,10 +67,19 @@ 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 (history.isEmpty()) { + long[] first = BestFirstGuess.of(c, d); + return new long[] { + ConvertCode.toIndex(c, d, (int) first[0]), first[1], (long) solutionSpace.getSize() + }; + } + + 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, 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} } @@ -79,7 +87,7 @@ public long[] suggestGuessWithDetails() { /** * Record a guess and its feedback, then update the solution space. * - * @param guess the guessed code (digits 1..c, length d) + * @param guess the guess as a code index (0-based, base-c encoding) * @param feedback feedback from the game master (black*10 + white) * @throws IllegalStateException if the game is already solved * @throws IllegalArgumentException if the feedback leaves no valid secrets @@ -88,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/codes/AllValidCode.java b/src/main/java/org/mastermind/codes/AllValidCode.java index 37b11d52..9b67bc68 100644 --- a/src/main/java/org/mastermind/codes/AllValidCode.java +++ b/src/main/java/org/mastermind/codes/AllValidCode.java @@ -8,77 +8,16 @@ */ public class AllValidCode { /** - * Generate all valid Mastermind code for a game. + * Generate all valid Mastermind code indices for a game. * * @param c number of colors (<= 9) * @param d number of digits (<= 9) - * @return Array of all valid Mastermind code + * @return Array of all valid code indices in [0, c^d) */ public static int[] generateAllCodes(int c, int d) { - // Total number of codes = c^d (e.g. 6 colors, 4 pegs = 1296 codes) - int total = 1; - for (int i = 0; i < d; i++) total *= c; - - int[] codes = new int[total]; - - // digits[] tracks the current code as individual digits (0-indexed internally). - // digits[0] is the RIGHTMOST (least significant) digit, - // digits[d-1] is the LEFTMOST (most significant) digit. - // Internally digits run 0..c-1; we add 1 when building the int so output is 1..c. - int[] digits = new int[d]; - - // Precompute positional powers of 10 matching the digits[] layout: - // pow10[0] = 1 (rightmost / least significant) - // pow10[1] = 10 - // pow10[2] = 100 - // pow10[d-1] (leftmost / most significant) - // e.g. d=4: pow10 = [1, 10, 100, 1000] - int[] pow10 = new int[d]; - pow10[0] = 1; - for (int i = 1; i < d; i++) { - pow10[i] = pow10[i - 1] * 10; - } - - // The starting code is all 1s (e.g. d=4 → 1111). - // Since digits[] is initialized to all 0s (representing digit value 1), - // the base code is just the sum of all positional powers of 10. - int code = 0; - for (int i = 0; i < d; i++) code += pow10[i]; - - for (int i = 0; i < total; i++) { - // Store the current code before incrementing - codes[i] = code; - - // --- Odometer-style increment --- - // We tick the rightmost digit (index 0) up by 1, exactly like an odometer. - // If a digit exceeds c, it wraps back to 1 and carries over to the next - // digit to the left (index + 1). - int pos = 0; // start at the least significant (rightmost) digit - while (pos < d) { - digits[pos]++; // increment this digit - code += pow10[pos]; // reflect the +1 in the integer representation - - if (digits[pos] < c) { - // No carry needed — the digit is still within range [1..c]. - break; - } else { - // This digit has exceeded c, so wrap it back to 1. - // In terms of the integer: we added 1 just above, but we need - // the digit to go from c back to 1, a net change of (1 - c). - // We already added pow10[pos], so subtract c * pow10[pos] - // to get the net effect of -(c-1) * pow10[pos]. - // e.g. c=6, pos=0 (units place, pow10=1): digit went 6→1, - // code already +1, now -6, net = -5 ✓ (6→1 is -5 in value) - code -= c * pow10[pos]; - digits[pos] = 0; // reset internal digit to 0 (represents value 1) - - pos++; // carry: move left to the next digit (higher index) - } - } - // When the while loop exits without breaking (pos >= d), all digits - // have wrapped around — we've generated every code and are done. - } - - return codes; + int total = (int) Math.pow(c, d); + int[] ind = new int[total]; + for (int i = 0; i < total; i++) ind[i] = i; + return ind; } -} \ No newline at end of file +} diff --git a/src/main/java/org/mastermind/codes/CanonicalCode.java b/src/main/java/org/mastermind/codes/CanonicalCode.java index 93006326..16725097 100644 --- a/src/main/java/org/mastermind/codes/CanonicalCode.java +++ b/src/main/java/org/mastermind/codes/CanonicalCode.java @@ -1,89 +1,90 @@ package org.mastermind.codes; /** - * Canonical forms refer to a specific subset of all Mastermind code - * that starts with 1, digit ordered from small to large starting - * from the left, and the highest value digit equals to the number - * of colors used in the code. This is helpful at the beginning of - * the game before any guesses are made, where color and positional - * symmetry remain unbroken, allowing for a reduced search space to - * find the best first guess. + * Canonical forms are one representative code per symmetry equivalence class, + * used to reduce the first-guess search space. + * + *

At turn 0, both color-relabeling symmetry and position-permutation symmetry + * are intact. Two codes are equivalent if one can be obtained from the other by + * any permutation of colors and any permutation of digit positions. The equivalence + * classes are exactly the integer partitions of d into at most c parts — the + * multiset of color frequencies, or "bucket." For c=9, d=9 this gives just 30 + * canonical forms, down from 387,420,489 total codes. */ public class CanonicalCode { /** - * Calculate the number of Canonical forms in a Mastermind game using - * Stirling number of the second kind. + * Count the number of canonical forms (integer partitions of d with at most c parts). * * @param c number of colors (<= 9) * @param d number of digits (<= 9) - * @return Number of Canonical form in Mastermind + * @return number of canonical forms */ public static int countCanonicalForms(int c, int d) { - // Edge cases for empty sets or partitions - int maxK = Math.min(c, d); - if (maxK <= 0) return 0; - - // 1D DP array to save memory - int[] dp = new int[maxK + 1]; - - // Base case: S(0, 0) = 1 - dp[0] = 1; - - for (int i = 1; i <= d; i++) { - // Update the row backwards to avoid overwriting values needed - // for the current calculation: S(n,k) = k*S(n-1,k) + S(n-1,k-1) - for (int j = Math.min(i, maxK); j >= 1; j--) { - dp[j] = j * dp[j] + dp[j - 1]; + if (c <= 0 || d <= 0) return 0; + // By conjugate partition identity: partitions of d into at most c parts + // = partitions of d with the largest part <= c. + // dp[i][j] = number of partitions of i with the largest part <= j. + int[][] dp = new int[d + 1][c + 1]; + for (int i = 0; i <= d; i++) dp[i][0] = (i == 0) ? 1 : 0; + for (int maxPart = 1; maxPart <= c; maxPart++) { + for (int i = 0; i <= d; i++) { + dp[i][maxPart] = dp[i][maxPart - 1]; + if (i >= maxPart) dp[i][maxPart] += dp[i - maxPart][maxPart]; } - // S(i, 0) is 0 for all i > 0 - dp[0] = 0; - } - - // Sum the results S(d, 1) through S(d, maxK) - int sum = 0; - for (int k = 1; k <= maxK; k++) { - sum += dp[k]; } - return sum; + return dp[d][c]; } /** - * Enumerate all Canonical forms in a Mastermind game. + * 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 all Canonical forms in Mastermind + * @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)]; + 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; - // 2. Use a tiny wrapper array for the index to pass by reference in recursion - int[] index = { 0 }; - - // 3. Start recursion - backtrack(results, index, 0, 0, 0, c, d); - + int[] freq = new int[c]; + generateFrequencies(results, index, freq, 0, d, d, place); return results; } - 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) { - results[index[0]++] = currentNum; + private static void generateFrequencies( + int[] results, int[] index, int[] freq, int color, int remaining, int maxFreq, int[] place + ) { + if (remaining == 0) { + results[index[0]++] = buildIndex(freq, color, place); return; } + if (color == freq.length) return; - // Rule 1 & 2: Try existing colors - for (int color = 1; color <= maxColorUsed; color++) { - backtrack(results, index, (currentNum * 10) + color, pos + 1, maxColorUsed, c, d); + int limit = Math.min(maxFreq, remaining); + for (int f = limit; f >= 1; f--) { + freq[color] = f; + generateFrequencies(results, index, freq, color + 1, remaining - f, f, place); } + } - // Rule 3: Try exactly one "new" color if limit c isn't reached - if (maxColorUsed < c) { - int nextColor = maxColorUsed + 1; - backtrack(results, index, (currentNum * 10) + nextColor, pos + 1, nextColor, c, d); + private static int buildIndex(int[] freq, int numColors, int[] place) { + // Maps a partition (color frequency array) to its lex-smallest representative index. + // Color 0 gets the highest frequency and occupies the leftmost positions, + // color 1 gets the next frequency, and so on. This ensures all codes with the + // same partition map to the same canonical representative. + int ind = 0; + int pos = 0; + for (int color = 0; color < numColors; color++) { + for (int f = 0; f < freq[color]; f++) { + ind += color * place[pos++]; + } } + return ind; } } diff --git a/src/main/java/org/mastermind/codes/CodeCache.java b/src/main/java/org/mastermind/codes/CodeCache.java deleted file mode 100644 index 0d1f2de6..00000000 --- a/src/main/java/org/mastermind/codes/CodeCache.java +++ /dev/null @@ -1,22 +0,0 @@ -package org.mastermind.codes; - -/** - * Lazy cache for generated code arrays, shared across classes. - * - *

Keyed by [c][d]; each entry is populated on first access. - */ -public class CodeCache { - - private static final int[][][] allValidCache = new int[10][10][]; - private static final int[][][] canonicalCache = 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/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; + } +} diff --git a/src/main/java/org/mastermind/codes/SampledCode.java b/src/main/java/org/mastermind/codes/SampledCode.java index a370e26d..94e7784e 100644 --- a/src/main/java/org/mastermind/codes/SampledCode.java +++ b/src/main/java/org/mastermind/codes/SampledCode.java @@ -1,6 +1,7 @@ package org.mastermind.codes; -import java.util.Random; +import java.util.BitSet; +import java.util.concurrent.ThreadLocalRandom; /** * The Monte Carlo method is a way to estimate population parameters @@ -13,25 +14,54 @@ public class SampledCode { /** - * Generate a random Monte Carlo sample from all possible Mastermind code - * with the specified sample size. + * 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. * * @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[] sample = new int[sampleSize]; + 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] = ThreadLocalRandom.current().nextInt(total); + } + + return sample; + } + + 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]; + + if (validCount <= MAX_ENUM) { + // Enumeration: bounded memory (≤20MB), fast scan, fast random access. + int[] valid = new int[validCount]; + int j = 0; + for (int i = remaining.nextSetBit(0); i >= 0; i = remaining.nextSetBit(i + 1)) { + valid[j++] = i; + } + for (int i = 0; i < sampleSize; i++) { + sample[i] = valid[ThreadLocalRandom.current().nextInt(validCount)]; + } + } else { + // Rejection sampling: validCount is large so fill rate is high and rejection is fast. + for (int i = 0; i < sampleSize; i++) { + int idx; + do { idx = ThreadLocalRandom.current().nextInt(total); } while (!remaining.get(idx)); + sample[i] = idx; } - sample[i] = code; } return sample; @@ -68,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/main/java/org/mastermind/solver/BestFirstGuess.java b/src/main/java/org/mastermind/solver/BestFirstGuess.java new file mode 100644 index 00000000..876f5ed7 --- /dev/null +++ b/src/main/java/org/mastermind/solver/BestFirstGuess.java @@ -0,0 +1,127 @@ +package org.mastermind.solver; + +import org.mastermind.codes.AllValidCode; +import org.mastermind.codes.ConvertCode; + +/** + * Provides the best first guess for any supported Mastermind configuration. + *

+ * Use {@link #of(int, int)} at runtime. Run {@link BestFirstGuessCalculator#main(String[])} once to + * regenerate the hardcoded values after algorithm changes. + */ +public class BestFirstGuess { + + /** + * 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 }; + } + + // To regenerate this table, run BestFirstGuessCalculator.main() + 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); + }; + } +} 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 + ) { } +} diff --git a/src/main/java/org/mastermind/solver/BestGuess.java b/src/main/java/org/mastermind/solver/BestGuess.java index 36d0597c..8afe5922 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; @@ -10,66 +9,74 @@ /** * This is a strategy to find the best guess for Mastermind by searching - * through the space of all valid guesses and secrets to find the guess - * that minimize the average number of remaining solutions to the puzzle. - * Due to the nature of Mastermind, sometimes the search space can be huge. - * To optimize for performance, the program create a thread for each CPU - * thread precent on the machine. The algorithm go multi-threading when - * the search space exceed a threshold, which is a heuristic value for - * when the algorithm will take longer than 50 milliseconds to run. + * through the space of all candidate guesses and secrets array to find + * the guess that minimize the average number of remaining solutions to + * the puzzle. Due to the nature of Mastermind, sometimes the search + * space can be huge. To optimize for performance, the program create a + * thread for each CPU thread precent on the machine. The algorithm go + * multi-threading when the search space exceed a threshold, which is a + * heuristic value for when the algorithm will take longer than + * 50 milliseconds to run. */ public class BestGuess { private static final int THREAD_COUNT = Runtime.getRuntime().availableProcessors(); - private static final ExecutorService POOL = Executors.newFixedThreadPool(THREAD_COUNT); + private static final ExecutorService POOL; private static final long PARALLEL_THRESHOLD = 3_000_000; - public static void shutdown() { POOL.shutdown(); } + static { + POOL = Executors.newFixedThreadPool(THREAD_COUNT, r -> { + Thread t = new Thread(r); + t.setDaemon(true); + return t; + }); + } /** * Find the guess that will minimize the expected size of the solution space * 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); + // Provide a way to force specific algorithm choice for benchmarking + public static long[] findBestGuess(int[] guessesInd, int[] secretsInd, int c, int d, boolean parallel) { + if (!parallel) return findBestGuessAlgorithm(guessesInd, secretsInd, c, d, 0, guessesInd.length); + return findBestGuessParallel(guessesInd, secretsInd, c, d); } - 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 + // Initialize futures list (holder for pending thread outputs) List> futures = new ArrayList<>(actualThreads); // Submit work to each threads for (int t = 0; t < actualThreads; t++) { final int from = t * chunkSize; - final int to = Math.min(from + chunkSize, guesses.length); - futures.add(t, POOL.submit(() -> findBestGuessAlgorithm(guesses, secrets, c, d, from, to))); + final int to = Math.min(from + chunkSize, guessesInd.length); + futures.add(t, POOL.submit(() -> findBestGuessAlgorithm(guessesInd, secretsInd, c, d, from, to))); } // Find best guess from returned result - int bestGuess = -1; - long bestScore = Long.MAX_VALUE; + int bestGuessInd = -1; + long bestScore = Long.MAX_VALUE; for (Future future : futures) { try { @@ -78,7 +85,7 @@ private static long[] findBestGuessParallel(int[] guesses, int[] secrets, int c, // Update best guess if found better score if (result[1] < bestScore) { - bestGuess = (int) result[0]; + bestGuessInd = (int) result[0]; bestScore = result[1]; } @@ -90,65 +97,28 @@ private static long[] findBestGuessParallel(int[] guesses, int[] secrets, int c, } } - return new long[] { bestGuess, bestScore }; + return new long[] { bestGuessInd, bestScore }; } - private static long[] findBestGuessAlgorithm(int[] guesses, int[] secrets, int c, int d, int start, int end) { + private static long[] findBestGuessAlgorithm(int[] guessesInd, int[] secretsInd, int c, int d, int start, int end) { ExpectedSize expectedSizeObj = new ExpectedSize(d); int[] feedbackFreq = new int[100]; - int bestGuess = -1; - long bestScore = Long.MAX_VALUE; + int bestGuessInd = -1; + long bestScore = Long.MAX_VALUE; for (int i = start; i < end; i++) { // Compute rank - int guess = guesses[i]; - long score = expectedSizeObj.calcExpectedRank(guess, secrets, c, d, feedbackFreq); + int guessInd = guessesInd[i]; + long score = expectedSizeObj.calcExpectedRank(guessInd, secretsInd, c, d, feedbackFreq); // Update result if found a smaller rank if (score < bestScore) { bestScore = score; - bestGuess = guess; + bestGuessInd = guessInd; } } - return new long[] { bestGuess, bestScore }; - } - - /** - * Rank all guesses by the expected size of the solution space after each guess. - * - * @param guesses all candidate guesses - * @param secrets remaining possible secrets - * @param c number of colors (<= 9) - * @param d number of digits - * @return 2D array where each row is {guess, score}, sorted best to worst - */ - public static float[][] rankGuessesByExpectedSize(int[] guesses, int[] secrets, int c, int d) { - int n = guesses.length; - float[] scores = new float[n]; - Integer[] indices = new Integer[n]; // need Integer for custom comparator - int[] feedbackFreq = new int[100]; - - ExpectedSize expectedSizeObj = new ExpectedSize(d); - - // 1. Compute scores - for (int i = 0; i < n; i++) { - scores[i] = expectedSizeObj.calcExpectedSize(guesses[i], secrets, c, d, feedbackFreq); - indices[i] = i; - } - - // 2. Sort indices by score (ascending) - Arrays.sort(indices, (a, b) -> Float.compare(scores[a], scores[b])); - - // 3. Build ranked {guess, score} array - float[][] ranked = new float[n][2]; - for (int i = 0; i < n; i++) { - int idx = indices[i]; - ranked[i][0] = guesses[idx]; - ranked[i][1] = scores[idx]; - } - - return ranked; + return new long[] { bestGuessInd, bestScore }; } } diff --git a/src/main/java/org/mastermind/solver/ExpectedSize.java b/src/main/java/org/mastermind/solver/ExpectedSize.java index db4ca13e..ef13001c 100644 --- a/src/main/java/org/mastermind/solver/ExpectedSize.java +++ b/src/main/java/org/mastermind/solver/ExpectedSize.java @@ -30,19 +30,19 @@ public ExpectedSize(int d) { * and not necessary the exact expected value. *

* - * @param guess code, digits 1..c, length d - * @param secrets list of codes, digits 1..c, length d + * @param guessInd index of the guess code (0-based, base-c encoding) + * @param secretsInd list of secret indices (0-based, base-c encoding) * @param c number of colors (<= 9) * @param d number of digits (<= 9) * @param feedbackFreq int array of 0 with length 100 * @return Sum of number of remaining solution for each secret */ - public long calcExpectedRank(int guess, int[] secrets, int c, int d, int[] feedbackFreq) { + public long calcExpectedRank(int guessInd, int[] secretsInd, int c, int d, int[] feedbackFreq) { // Calculate feedback for each secret - int[] colorFreqCounter = new int[10]; - for (int secret : secrets) { - int feedback = Feedback.getFeedback(guess, secret, c, d, colorFreqCounter); + int[] colorFreqCounter = new int[c]; + for (int secretInd : secretsInd) { + int feedback = Feedback.getFeedback(guessInd, secretInd, c, d, colorFreqCounter); feedbackFreq[feedback]++; } @@ -62,27 +62,57 @@ public float convertRankToExpectedSize(long rank, int total) { return (float) rank / total; } - public float convertRankToExpectedProportion(long rank, int total) { - return rank / (float) Math.pow(total, 2); - } - public float convertSampleRankToExpectedSize(long rank, int sampleSize, int populationSize) { - return rank * populationSize / (float) Math.pow(sampleSize, 2); + return (float) rank * (float) populationSize / (float) Math.pow(sampleSize, 2); } - public float calcExpectedSize(int guess, int[] secrets, int c, int d, int[] feedbackFreq) { - return convertRankToExpectedSize(calcExpectedRank(guess, secrets, c, d, feedbackFreq), secrets.length); + public float calcExpectedSize(int guessInd, int[] secretsInd, int c, int d, int[] feedbackFreq) { + return convertRankToExpectedSize(calcExpectedRank(guessInd, secretsInd, c, d, feedbackFreq), secretsInd.length); } - public float calcExpectedProportion(int guess, int[] secrets, int c, int d, int[] feedbackFreq) { - return convertRankToExpectedProportion(calcExpectedRank(guess, secrets, c, d, feedbackFreq), secrets.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) { + // Set up incremental feedback state + 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(); - public float calcExpectedProportionFromSample( - int guess, int[] secrets, int c, int d, int[] feedbackFreq, int populationSize - ) { - return convertSampleRankToExpectedSize(calcExpectedRank( - guess, secrets, c, d, feedbackFreq), secrets.length, populationSize - ); + // Handle secret index 0 (setup already computed its feedback) + feedbackFreq[black * 9 + d - (colorFreqTotal >>> 1)]++; + + // Iterate remaining secrets incrementally, updating secretDigits and feedback state in place + int[] result = new int[3]; + for (int secretInd = 1; secretInd < total; secretInd++) { + FeedbackIncremental.getFeedbackIncremental(guessDigits, secretDigits, black, colorFreqCounter, + colorFreqTotal, c, d, result); + black = result[1]; + colorFreqTotal = result[2]; + feedbackFreq[result[0]]++; + } + + // Sum squared frequencies and reset feedbackFreq for reuse + 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/Feedback.java b/src/main/java/org/mastermind/solver/Feedback.java index 62bfedf2..30cb830f 100644 --- a/src/main/java/org/mastermind/solver/Feedback.java +++ b/src/main/java/org/mastermind/solver/Feedback.java @@ -16,22 +16,22 @@ public final class Feedback { /** * Calculate the Mastermind feedback for a guess and a secret. * - * @param guess code, digits 1..c, length d - * @param secret code, digits 1..c, length d + * @param guessInd index of the guess code (0-based, base-c encoding) + * @param secretInd index of the secret code (0-based, base-c encoding) * @param c number of colors (<= 9) * @param d number of digits (<= 9) * @param colorFreqCounter int array of 0 with length c * @return Feedback value (black * 10 + white) */ - public static int getFeedback(int guess, int secret, int c, int d, int[] colorFreqCounter) { + public static int getFeedback(int guessInd, int secretInd, int c, int d, int[] colorFreqCounter) { int black = 0; for (int i = 0; i < d; i++) { - // Extract digits - int currGuess = guess % 10; - int currSecret = secret % 10; - guess /= 10; - secret /= 10; + // Extract digits (0..c-1) + int currGuess = guessInd % c; + int currSecret = secretInd % c; + guessInd /= c; + secretInd /= c; // Increment counters if (currGuess == currSecret) { @@ -40,16 +40,29 @@ public static int getFeedback(int guess, int secret, int c, int d, int[] colorFr colorFreqCounter[currGuess]++; colorFreqCounter[currSecret]--; } + /* + How the counter algorithm works: + - Label each digit in guess and secret as black, white, or gray (unmatched). + - If we incremented the counter for all digits of both guess and secret, + sum(|counter|) = 2d. + - Skipping blacks reduces it by 2*black. Now sum(|counter|) = 2d - 2*black + - If there is a partial match (white), incrementing for guess and decrementing + for secret will cause it to cancel out, reducing sum by 2*white + - So: sum(|coutner|) = 2d - 2*black - 2*white + 2*white = 2d - 2*black - sum(|counter|) + white = d - black - sum(|counter|) / 2 + */ } // Sum absolute values and reset in one pass int colorFreqTotal = 0; - for (int i = 1; i <= c; i++) { + for (int i = 0; i < c; i++) { int freq = colorFreqCounter[i]; colorFreqCounter[i] = 0; colorFreqTotal += (freq > 0) ? freq : -freq; } + // black * 10 + white // black * 10 + d - black - colorFreqTotal / 2 return black * 9 + d - (colorFreqTotal >>> 1); } @@ -58,7 +71,12 @@ public static int getFeedback(int guess, int secret, int c, int d, int[] colorFr * @param d number of digits in the Mastermind game * @return Number of possible feedback values in the game */ - public static int calcFeedbackSize(int d) { return (d + 1) * (d + 2) / 2; } + public static int calcFeedbackSize(int d) { + // Feedback values are (b, w) pairs where b + w <= d. + // Count: for each b in [0..d], there are (d - b + 1) valid w values. + // Total = sum_{b=0}^{d} (d - b + 1) = (d+1) + d + ... + 1 = (d+1)(d+2)/2. + return (d + 1) * (d + 2) / 2; + } /** * @param d number of digits in the Mastermind game diff --git a/src/main/java/org/mastermind/solver/FeedbackIncremental.java b/src/main/java/org/mastermind/solver/FeedbackIncremental.java new file mode 100644 index 00000000..7680d734 --- /dev/null +++ b/src/main/java/org/mastermind/solver/FeedbackIncremental.java @@ -0,0 +1,144 @@ +package org.mastermind.solver; + +/** + * Incremental feedback computation for sequential secret iteration. + * Extracted from {@link Feedback} to keep that class focused on the + * stateless per-call computation. + */ +public final class FeedbackIncremental { + + /** + * Set up the incremental state for the given guess and starting secret index. + * Extracts guess digits and walks {@code secretInd} digit-by-digit, leaving + * {@code colorFreqCounter} in persistent non-zeroed form ready for + * {@link #getFeedbackIncremental}. + * + * @param guessInd index of the guess code (0-based, base-c encoding) + * @param secretInd index of the starting secret code + * @param c number of colors + * @param d number of digits + * @return initial {@link State} for this (guessInd, secretInd) pair + */ + public static State setupIncremental(int guessInd, int secretInd, int c, int d) { + int[] guessDigits = new int[d]; + int[] secretDigits = new int[d]; + int[] colorFreqCounter = new int[c]; + + // Extract guess digits + int tmp = guessInd; + for (int p = 0; p < d; p++) { + guessDigits[p] = tmp % c; + tmp /= c; + } + + // Extract secret digits and update colorFreqCounter + int black = 0; + tmp = secretInd; + for (int p = 0; p < d; p++) { + int gs = guessDigits[p]; + int ss = tmp % c; + tmp /= c; + secretDigits[p] = ss; + if (gs == ss) { + black++; + } else { + colorFreqCounter[gs]++; + colorFreqCounter[ss]--; + } + } + + // Compute colorFreqTotal + int colorFreqTotal = 0; + for (int i = 0; i < c; i++) { + int freq = colorFreqCounter[i]; + colorFreqTotal += freq > 0 ? freq : -freq; + } + + return new State(guessDigits, secretDigits, colorFreqCounter, black, colorFreqTotal); + } + + /** + * Incremental variant of getFeedback for sequential secret iteration (0, 1, 2, ...). + * + *

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

    + *
  1. Detects which digit positions changed via base-c carry chain
  2. + *
  3. Undoes the contribution of changed positions from colorFreqCounter and black
  4. + *
  5. Updates secretDigits[] for changed positions
  6. + *
  7. Applies new contributions to colorFreqCounter and black
  8. + *
  9. Recomputes colorFreqTotal and returns feedback alongside new black count
  10. + *
+ * + * @param guessDigits pre-extracted guess digits [position 0..d-1], position 0 = LSD + * @param secretDigits mutable secret digits array, updated in-place + * @param black current black count (from previous call) + * @param colorFreqCounter persistent frequency-difference array, length c (NOT cleared between calls) + * @param colorFreqTotal current sum of |colorFreqCounter[i]|, updated in-place + * @param c number of colors + * @param d number of digits + * @param result int[3] 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; + } + + // When newDigit != 0, no carry propagates to the next position, + // meaning all higher positions are unchanged. Break early. + if (newDigit != 0) break; + } + + // Update result + result[0] = black * 9 + d - (colorFreqTotal >>> 1); + result[1] = black; + result[2] = colorFreqTotal; + } + + /** + * 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/GuessStrategy.java b/src/main/java/org/mastermind/solver/GuessStrategy.java new file mode 100644 index 00000000..57758bea --- /dev/null +++ b/src/main/java/org/mastermind/solver/GuessStrategy.java @@ -0,0 +1,97 @@ +package org.mastermind.solver; + +import org.mastermind.codes.AllValidCode; +import org.mastermind.codes.SampledCode; + +/** + * Selects which arrays to pass as guesses and secrets to BestGuess for each turn. + * + *

Returns {@code int[][] { guesses, secrets }} where: + *

+ * + *

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. + */ +public class GuessStrategy { + + private static final long THRESHOLD = 130_000_000L; + + /** + * Select the guesses and secrets arrays for the current turn. + * + * @param c number of colors + * @param d number of digits + * @param solutionSpace current solution space + * @return int[][] where [0]=guesses, [1]=secrets + */ + public static int[][] select(int c, int d, SolutionSpace solutionSpace) { + return selectSearchSpace(c, d, solutionSpace.getSize(), solutionSpace); + } + + /** + * Cascades through progressively smaller guess and secret arrays until the + * search space fits within the threshold. + */ + 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()); + if (fits(secretsSize, secretsSize)) return pair(solutionSpace.getSecrets(), solutionSpace.getSecrets()); + + // Sample secrets with progressively looser tolerances (smaller sample = faster search). + // Tolerance controls how accurately the sample estimates expected partition sizes; + // Values are heuristically tuned for speed vs. quality. + // When tolerance 10X, sample size 0.1X + for (double tolerance : new double[] { 0.001, 0.005, 0.01 }) { // 10X, 5X, 1X + if (fits(secretsSize, secretSampleSize(d, tolerance))) { + return pair(solutionSpace.getSecrets(), secretSample(c, d, tolerance, solutionSpace)); + } + } + + // Fall back to also sampling guesses, using progressively larger percentile thresholds + // (higher percentile = smaller sample needed, since a random element more likely falls + // within a larger top portion of the distribution). + // When percentile 10X, sample size ~0.1X + int[] sSample = secretSample(c, d, 0.01, solutionSpace); + for (double percentile : new double[] { 0.001, 0.005, 0.01, 0.05 }) { // 50X, 10X, 5X, 1X + if (fits(secretsSize, guessSampleSize(percentile))) { + return pair(guessSample(c, d, percentile), sSample); + } + } + + return pair(guessSample(c, d, 0.01), sSample); + } + + /** Returns true if the guesses and secrets arrays fit within the threshold. */ + private static boolean fits(int guessSpaceSize, int secretSpaceSize) { + return (long) guessSpaceSize * secretSpaceSize <= THRESHOLD; + } + + /** Pack the input guesses and secrets into int[][] */ + private static int[][] pair(int[] guesses, int[] secrets) { + return new int[][] { guesses, secrets }; + } + + private static int secretSampleSize(int d, double tolerance) { + return SampledCode.calcSampleSizeForSecrets(Feedback.calcFeedbackSize(d), tolerance); + } + + private static int guessSampleSize(double percentileThreshold) { + return SampledCode.calcSampleSizeForGuesses(percentileThreshold, 0.999); + } + + private static int[] secretSample(int c, int d, double tolerance, SolutionSpace solutionSpace) { + return SampledCode.getValidSample(solutionSpace.getRemaining(), solutionSpace.getSize(), c, d, + secretSampleSize(d, tolerance)); + } + + private static int[] guessSample(int c, int d, double percentileThreshold) { + return SampledCode.getSample(c, d, guessSampleSize(percentileThreshold)); + } +} diff --git a/src/main/java/org/mastermind/solver/SolutionSpace.java b/src/main/java/org/mastermind/solver/SolutionSpace.java index 6db4641d..20a3b6f0 100644 --- a/src/main/java/org/mastermind/solver/SolutionSpace.java +++ b/src/main/java/org/mastermind/solver/SolutionSpace.java @@ -1,7 +1,5 @@ package org.mastermind.solver; -import org.mastermind.codes.CodeCache; - import java.util.BitSet; import java.util.concurrent.ForkJoinPool; import java.util.concurrent.Future; @@ -12,8 +10,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). @@ -23,25 +21,27 @@ 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[] 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 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; 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; + isFirstFilter = true; } /** @@ -55,54 +55,75 @@ public void reset() { * BitSet, so concurrent {@code clear()} calls on non-overlapping words are safe. * For small spaces the single-threaded path is used to avoid FJP overhead. * - * @param guess code, digits 1..c, length d + *

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 guess, int obtainedFeedback) { + 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(guess, obtainedFeedback, 0, allValid.length); + 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 = (allValid.length + 63) >>> 6; // number of 64-bit words + int words = (totalCodes + 63) >>> 6; int wordsPerTask = Math.max(1, (words + parallelism - 1) / parallelism); - // Submit all tasks except the last; run the last chunk on the calling thread. + // Multi-threaded route @SuppressWarnings("unchecked") Future[] futures = new Future[parallelism]; int fromIndex = 0; int taskCount = 0; - while (fromIndex + wordsPerTask * 64 < allValid.length) { + while (fromIndex + wordsPerTask * 64 < totalCodes) { final int from = fromIndex; final int to = fromIndex + wordsPerTask * 64; - futures[taskCount++] = POOL.submit(() -> filterRange(guess, obtainedFeedback, from, to)); + + // Submit the task with the appropriate function + futures[taskCount++] = isFirst ? + POOL.submit(() -> filterRangeFirst(guessInd, obtainedFeedback, from, to)) : + POOL.submit(() -> filterRange(guessInd, obtainedFeedback, from, to)); + fromIndex = to; } - // Run the tail on the calling thread and sum removed counts. - int removed = filterRange(guess, obtainedFeedback, fromIndex, allValid.length); + // Handle the last chunk in main thread + int removed = isFirst ? + filterRangeFirst(guessInd, obtainedFeedback, fromIndex, totalCodes) : + filterRange(guessInd, obtainedFeedback, fromIndex, totalCodes); - // Wait for all submitted tasks and accumulate removed counts. + // Sum up the removed count from other threads for (int i = 0; i < taskCount; i++) { try { removed += futures[i].get(); } catch (Exception e) { throw new RuntimeException(e); } } + + // Update size size -= removed; } /** - * Single-threaded filter over {@code allValid[from..to)}. + * Single-threaded filter over indices {@code [from, to)}. * Safe to call from multiple threads as long as the index ranges are word-aligned * (multiples of 64) and disjoint, since each BitSet word is only touched by one thread. * * @return number of bits cleared */ - private int filterRange(int guess, int obtainedFeedback, int from, int to) { - int[] colorFreqCounter = new int[10]; + private int filterRange(int guessInd, int obtainedFeedback, int from, int to) { + int[] colorFreqCounter = new int[c]; int removed = 0; + + // Call getFeedback for each secret for (int i = remaining.nextSetBit(from); i >= 0 && i < to; i = remaining.nextSetBit(i + 1)) { - if (Feedback.getFeedback(guess, allValid[i], c, d, colorFreqCounter) != obtainedFeedback) { + if (Feedback.getFeedback(guessInd, i, c, d, colorFreqCounter) != obtainedFeedback) { remaining.clear(i); removed++; } @@ -111,22 +132,63 @@ private int filterRange(int guess, int obtainedFeedback, int from, int to) { } /** - * Materialize the remaining valid secrets as an int array. + * Incremental single-threaded filter over a contiguous {@code [from, to)} range + * (used only for the first filter when all bits are set). Iterates every index + * with a plain for-loop and computes feedback incrementally via + * {@link FeedbackIncremental#getFeedbackIncremental}. + * + * @return number of bits cleared + */ + private int filterRangeFirst(int guessInd, int obtainedFeedback, int from, int to) { + FeedbackIncremental.State init = FeedbackIncremental.setupIncremental(guessInd, from, c, d); + int[] guessDigits = init.guessDigits(); + int[] secretDigits = init.secretDigits(); + int[] colorFreqCounter = init.colorFreqCounter(); + int black = init.black(); + int colorFreqTotal = init.colorFreqTotal(); + int feedback0 = black * 9 + d - (colorFreqTotal >>> 1); + + // Handle the first secret in chunk + int removed = 0; + if (feedback0 != obtainedFeedback) { + remaining.clear(from); + removed++; + } + + // Handle the remaining secrets + int[] result = new int[3]; + for (int i = from + 1; i < to; i++) { + FeedbackIncremental.getFeedbackIncremental(guessDigits, secretDigits, black, colorFreqCounter, + colorFreqTotal, c, d, result); + black = result[1]; + colorFreqTotal = result[2]; + if (result[0] != obtainedFeedback) { + remaining.clear(i); + removed++; + } + } + + return removed; + } + + /** + * Materialize the remaining valid secrets as an int array of indices. * Called once per turn suggestion, not per filter. * - * @return int array of currently valid secrets + * @return int array of indices of currently valid secrets */ public int[] getSecrets() { - int[] secrets = new int[size]; - int j = 0; + int[] secretsInd = new int[size]; + int j = 0; for (int i = remaining.nextSetBit(0); i >= 0; i = remaining.nextSetBit(i + 1)) { - secrets[j++] = allValid[i]; + secretsInd[j++] = i; } - return secrets; + return secretsInd; } - /** - * @return size of the current solution space (or valid secrets) - */ + /** @return size of the current solution space (or valid secrets) */ public int getSize() { return size; } + + /** @return the underlying BitSet of remaining valid secret indices */ + public BitSet getRemaining() { return remaining; } } diff --git a/src/tests/java/org/mastermind/MastermindSessionTest.java b/src/tests/java/org/mastermind/MastermindSessionTest.java index a9b20f5c..947e498f 100644 --- a/src/tests/java/org/mastermind/MastermindSessionTest.java +++ b/src/tests/java/org/mastermind/MastermindSessionTest.java @@ -1,6 +1,8 @@ package org.mastermind; 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; @@ -12,43 +14,64 @@ public class MastermindSessionTest { private static final int D = 4; private static final int MAX_TURNS = 6; + private static int ind(int code) { return ConvertCode.toIndex(C, D, code); } + /** * Simulate a full game with secret 1234 (a typical canonical starting case). * The solver must finish within MAX_TURNS turns. */ @Test void testSolveSecret1234() { - runGame(1234); + runGame(ind(1234)); } /** Simulate a full game with secret 6666 (all same color, worst-case candidate). */ @Test void testSolveSecret6666() { - runGame(6666); + runGame(ind(6666)); + } + + /** + * 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() { - runGame(1562); + runGame(ind(1562)); } /** Record two guesses, undo both at once, and verify the session is fully reset. */ @Test void testUndoMultiple() { - int[] colorFreqCounter = new int[10]; + int[] colorFreqCounter = new int[C]; MastermindSession session = new MastermindSession(C, D); int spaceAtStart = session.getSolutionSpaceSize(); // Record two arbitrary guesses with their real feedbacks against secret 1234 - int guess1 = 1122; - int fb1 = Feedback.getFeedback(guess1, 1234, C, D, colorFreqCounter); + int guess1 = ind(1122); + int fb1 = Feedback.getFeedback(guess1, ind(1234), C, D, colorFreqCounter); session.recordGuess(guess1, fb1); int spaceAfter1 = session.getSolutionSpaceSize(); - int guess2 = 1344; - int fb2 = Feedback.getFeedback(guess2, 1234, C, D, colorFreqCounter); + int guess2 = ind(1344); + int fb2 = Feedback.getFeedback(guess2, ind(1234), C, D, colorFreqCounter); session.recordGuess(guess2, fb2); int spaceAfter2 = session.getSolutionSpaceSize(); @@ -71,20 +94,20 @@ void testUndoMultiple() { */ @Test void testUndoPartial() { - int[] colorFreqCounter = new int[10]; + int[] colorFreqCounter = new int[C]; MastermindSession session = new MastermindSession(C, D); - int guess1 = 1122; - int fb1 = Feedback.getFeedback(guess1, 1234, C, D, colorFreqCounter); + int guess1 = ind(1122); + int fb1 = Feedback.getFeedback(guess1, ind(1234), C, D, colorFreqCounter); session.recordGuess(guess1, fb1); int spaceAfter1 = session.getSolutionSpaceSize(); - int guess2 = 1344; - int fb2 = Feedback.getFeedback(guess2, 1234, C, D, colorFreqCounter); + int guess2 = ind(1344); + int fb2 = Feedback.getFeedback(guess2, ind(1234), C, D, colorFreqCounter); session.recordGuess(guess2, fb2); - int guess3 = 1234; - int fb3 = Feedback.getFeedback(guess3, 1234, C, D, colorFreqCounter); + int guess3 = ind(1234); + int fb3 = Feedback.getFeedback(guess3, ind(1234), C, D, colorFreqCounter); session.recordGuess(guess3, fb3); assertTrue(session.isSolved()); @@ -106,32 +129,32 @@ void testUndoInvalidN() { assertThrows(IllegalArgumentException.class, () -> session.undo(1)); } - private void runGame(int secret) { - MastermindSession session = new MastermindSession(C, D); - int[] colorFreqCounter = new int[10]; - ExpectedSize expectedSize = new ExpectedSize(D); - int[] feedbackFreq = new int[100]; + private void runGame(int secretInd) { + MastermindSession session = new MastermindSession(C, D); + int[] colorFreq = new int[C]; + ExpectedSize expectedSize = new ExpectedSize(D); + int[] feedbackFreq = new int[100]; - System.out.println("Secret: " + secret); + System.out.println("Secret index: " + secretInd); while (!session.isSolved()) { assertFalse(session.getTurnCount() >= MAX_TURNS, - "Solver exceeded " + MAX_TURNS + " turns for secret " + secret + "Solver exceeded " + MAX_TURNS + " turns for secret " + secretInd + " (still unsolved after turn " + session.getTurnCount() + ")"); int spaceBefore = session.getSolutionSpaceSize(); - int guess = session.suggestGuess(); - float expSize = expectedSize.calcExpectedSize(guess, session.getSolutionSpaceSecrets(), C, D, + int guessInd = session.suggestGuess(); + float expSize = expectedSize.calcExpectedSize(guessInd, session.getSolutionSpaceSecrets(), C, D, feedbackFreq); - int feedback = Feedback.getFeedback(guess, secret, C, D, colorFreqCounter); - session.recordGuess(guess, feedback); + int feedback = Feedback.getFeedback(guessInd, secretInd, C, D, colorFreq); + session.recordGuess(guessInd, feedback); int turn = session.getTurnCount(); int black = feedback / 10; int white = feedback % 10; - System.out.printf(" Turn %d: guess=%d space=%d expected=%.2f feedback=%db%dw%n", - turn, guess, spaceBefore, expSize, black, white); + System.out.printf(" Turn %d: guessInd=%d space=%d expected=%.2f feedback=%db%dw%n", + turn, guessInd, spaceBefore, expSize, black, white); } System.out.println(" Solved in " + session.getTurnCount() + " turns."); diff --git a/src/tests/java/org/mastermind/codes/CanonicalCodeTest.java b/src/tests/java/org/mastermind/codes/CanonicalCodeTest.java index 431d6405..3b47e664 100644 --- a/src/tests/java/org/mastermind/codes/CanonicalCodeTest.java +++ b/src/tests/java/org/mastermind/codes/CanonicalCodeTest.java @@ -14,132 +14,81 @@ 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)); } } @Nested @DisplayName("Enumerate Canonical Forms") class CanonicalCodeForms { - /** - * Helper to verify if a number is mathematically canonical. - * New colors must be the smallest available integer. - */ - private boolean isCanonical(int code, int d) { - int maxSeen = 0; - int divisor = (int) Math.pow(10, d - 1); - - while (divisor > 0) { - int digit = code / divisor; // Get the leftmost digit - - if (digit > maxSeen + 1) return false; - if (digit > maxSeen) maxSeen = digit; - - code %= divisor; // Remove the leftmost digit - divisor /= 10; // Move to the next place value - } - return true; - } @Test - @DisplayName("Verify array size matches Stirling Sum for (6, 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 forms for small c, d") + @DisplayName("Specific indices for c=2, d=3") void testSmallEnumeration() { - // For c=2, d=3, canonical forms should be: - // 111 (uses 1 color) - // 112 (uses 2 colors) - // 121 (uses 2 colors) - // 122 (uses 2 colors) - int[] expected = { 111, 112, 121, 122 }; - int[] actual = CanonicalCode.enumerateCanonicalForms(2, 3); - - assertArrayEquals(expected, actual, "Should generate exactly 111, 112, 121, 122"); - } - - @Test - @DisplayName("Verify Rule 1: All codes must start with 1") - void testStartsWithOne() { - int[] results = CanonicalCode.enumerateCanonicalForms(6, 4); - for (int code : results) { - // A 4-digit number starting with 1 is between 1000 and 1999 - assertTrue(code >= 1000 && code <= 1999, "Code " + code + " must start with 1"); - } - } - - @Test - @DisplayName("Verify Rule 2: No skipping colors (Canonical check)") - void testNoSkippedColors() { - int[] results = CanonicalCode.enumerateCanonicalForms(6, 5); - for (int code : results) { - assertTrue(isCanonical(code, 5), "Code " + code + " violates canonical rules"); - } + // 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 is allowed, every digit must be 1 int[] results = CanonicalCode.enumerateCanonicalForms(1, 4); assertEquals(1, results.length); - assertEquals(1111, results[0]); + assertEquals(0, results[0]); } @Test @DisplayName("Edge Case: d=1") void testSingleDigit() { - // If length is 1, only '1' is possible int[] results = CanonicalCode.enumerateCanonicalForms(6, 1); assertEquals(1, results.length); - assertEquals(1, results[0]); + assertEquals(0, results[0]); } } -} \ No newline at end of file +} diff --git a/src/tests/java/org/mastermind/codes/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 97be1158..fb90d70c 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 { @@ -15,63 +17,141 @@ void testReturnsSampleOfCorrectSize() { } @Test - void testAllCodesHaveCorrectNumberOfDigits() { - int d = 4; - int[] result = SampledCode.getSample(6, d, 1000); - for (int code : result) { - int digitCount = String.valueOf(code).length(); - assertEquals(d, digitCount, "Code " + code + " does not have " + d + " digits"); - } + void testSampleSizeZeroReturnsEmptyArray() { + int[] result = SampledCode.getSample(6, 4, 0); + assertNotNull(result); + assertEquals(0, result.length); } @ParameterizedTest @CsvSource({ "6,4", "3,3", "8,5", "2,1" }) - void testAllDigitsWithinColorRange(int c, int d) { + void testAllIndicesWithinRange(int c, int d) { + int total = (int) Math.pow(c, d); int[] result = SampledCode.getSample(c, d, 1000); - for (int code : result) { - String s = String.valueOf(code); - for (char ch : s.toCharArray()) { - int digit = ch - '0'; - assertTrue(digit >= 1 && digit <= c, - "Digit " + digit + " out of range [1," + c + "] in code " + code); - } + for (int ind : result) { + assertTrue(ind >= 0 && ind < total, + "Index " + ind + " out of range [0," + total + ")"); } } @Test - void testSampleSizeZeroReturnsEmptyArray() { - int[] result = SampledCode.getSample(6, 4, 0); - assertNotNull(result); - assertEquals(0, result.length); + void testResultIsActuallyRandom() { + // With 1000 samples from 6^4=1296 possible indices, we expect reasonable variety. + // The chance of getting fewer than 100 unique values is astronomically small. + int[] result = SampledCode.getSample(6, 4, 1000); + long uniqueCount = java.util.Arrays.stream(result).distinct().count(); + assertTrue(uniqueCount > 100, + "Expected high variety in samples, got only " + uniqueCount + " unique indices"); } + // --- getValidSample --- + @Test - void testSinglePeg() { - int c = 6, d = 1; - int[] result = SampledCode.getSample(c, d, 500); - for (int code : result) { - assertTrue(code >= 1 && code <= c, - "Single-peg code " + code + " out of range [1," + c + "]"); + 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 testResultIsActuallyRandom() { - // With 1000 samples from 6^4=1296 possible codes, we expect reasonable variety. - // The chance of getting fewer than 100 unique values is astronomically small. - int[] result = SampledCode.getSample(6, 4, 1000); - long uniqueCount = java.util.Arrays.stream(result).distinct().count(); - assertTrue(uniqueCount > 100, - "Expected high variety in samples, got only " + uniqueCount + " unique codes"); + 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 testOnlyOneColor() { - // With c=1, every code must be all 1s, e.g. 1111 for d=4 - int d = 4; - int[] result = SampledCode.getSample(1, d, 100); - for (int code : result) { - assertEquals(1111, code, "With c=1 and d=4, every code must be 1111"); + 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"); } } -} \ No newline at end of file + + // --- 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/BestGuessTest.java b/src/tests/java/org/mastermind/solver/BestGuessTest.java index affa9233..2a401b48 100644 --- a/src/tests/java/org/mastermind/solver/BestGuessTest.java +++ b/src/tests/java/org/mastermind/solver/BestGuessTest.java @@ -2,24 +2,22 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import org.mastermind.codes.AllValidCode; +import org.mastermind.codes.ConvertCode; import static org.junit.jupiter.api.Assertions.*; public class BestGuessTest { - private static final int EXPECTED_BEST_GUESS = 1123; - private final int c = 6; - private final int d = 4; - private int[] allCodes; + private static final int C = 6; + private static final int D = 4; + private int[] allInd; + + private static int ind(int code) { return ConvertCode.toIndex(C, D, code); } - /** - * Setup method that runs before each test. - * Generates all valid codes (6 pegs, 4 colors) - same as benchmark. - */ @BeforeEach public void setUp() { - allCodes = AllValidCode.generateAllCodes(c, d); + allInd = new int[(int) Math.pow(C, D)]; + for (int i = 0; i < allInd.length; i++) allInd[i] = i; } /** @@ -29,11 +27,8 @@ public void setUp() { */ @Test public void testOrdinaryVersion() { - // Act: Call the ordinary version with parallel = false - int bestGuess = (int) BestGuess.findBestGuess(allCodes, allCodes, c, d, false)[0]; - - // Assert: Verify the result matches the expected value - assertEquals(EXPECTED_BEST_GUESS, bestGuess); + int bestGuessInd = (int) BestGuess.findBestGuess(allInd, allInd, C, D, false)[0]; + assertEquals(ind(1123), bestGuessInd); } /** @@ -43,12 +38,7 @@ public void testOrdinaryVersion() { */ @Test public void testParallelVersion() { - // Act: Call the parallel version with parallel = true - int bestGuess = (int) BestGuess.findBestGuess(allCodes, allCodes, c, d, true)[0]; - - // Assert: Verify the result matches the expected value - assertEquals(EXPECTED_BEST_GUESS, bestGuess); - - BestGuess.shutdown(); + int bestGuessInd = (int) BestGuess.findBestGuess(allInd, allInd, C, D, true)[0]; + assertEquals(ind(1123), bestGuessInd); } } diff --git a/src/tests/java/org/mastermind/solver/ExpectedSizeTest.java b/src/tests/java/org/mastermind/solver/ExpectedSizeTest.java index 37a2ef5e..820cd56c 100644 --- a/src/tests/java/org/mastermind/solver/ExpectedSizeTest.java +++ b/src/tests/java/org/mastermind/solver/ExpectedSizeTest.java @@ -1,7 +1,7 @@ package org.mastermind.solver; import org.junit.jupiter.api.Test; -import org.mastermind.codes.AllValidCode; +import org.mastermind.codes.ConvertCode; import static org.junit.jupiter.api.Assertions.*; @@ -9,31 +9,61 @@ public class ExpectedSizeTest { private static final int COLORS = 6; private static final int DIGITS = 4; + private static final int TOTAL = 1296; // 6^4 private static final float DELTA = 0.001f; private static final int[] feedbackFreq = new int[100]; private static final ExpectedSize expectedSizeObj = new ExpectedSize(DIGITS); - int[] secrets = AllValidCode.generateAllCodes(COLORS, DIGITS); - private float calcExpectedSize(int guess, int[] secrets) { - return expectedSizeObj.calcExpectedSize(guess, secrets, COLORS, DIGITS, feedbackFreq); + // All secret indices 0..TOTAL-1 + private static final int[] secretsInd; + + static { + secretsInd = new int[TOTAL]; + for (int i = 0; i < TOTAL; i++) secretsInd[i] = i; + } + + private static int ind(int code) { return ConvertCode.toIndex(COLORS, DIGITS, code); } + + private float calcExpectedSize(int guessInd) { + return expectedSizeObj.calcExpectedSize(guessInd, secretsInd, COLORS, DIGITS, feedbackFreq); } @Test public void testExpectedSize() { - assertEquals(204.5355f, calcExpectedSize(1122, secrets), DELTA); - assertEquals(185.2685f, calcExpectedSize(1123, secrets), DELTA); - assertEquals(188.1898f, calcExpectedSize(1234, secrets), DELTA); - assertEquals(235.9491f, calcExpectedSize(1112, secrets), DELTA); - assertEquals(511.9799f, calcExpectedSize(1111, secrets), DELTA); - assertEquals(204.5355f, calcExpectedSize(1212, secrets), DELTA); + assertEquals(204.5355f, calcExpectedSize(ind(1122)), DELTA); + assertEquals(185.2685f, calcExpectedSize(ind(1123)), DELTA); + assertEquals(188.1898f, calcExpectedSize(ind(1234)), DELTA); + assertEquals(235.9491f, calcExpectedSize(ind(1112)), DELTA); + assertEquals(511.9799f, calcExpectedSize(ind(1111)), DELTA); + assertEquals(204.5355f, calcExpectedSize(ind(1212)), DELTA); + } + + @Test + public void 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 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 - float base = calcExpectedSize(1122, secrets); - assertEquals(base, calcExpectedSize(1212, secrets), DELTA); - assertEquals(base, calcExpectedSize(2211, secrets), DELTA); - assertEquals(base, calcExpectedSize(2121, secrets), DELTA); + float base = calcExpectedSize(ind(1122)); + assertEquals(base, calcExpectedSize(ind(1212)), DELTA); + assertEquals(base, calcExpectedSize(ind(2211)), DELTA); + assertEquals(base, calcExpectedSize(ind(2121)), DELTA); } } \ No newline at end of file diff --git a/src/tests/java/org/mastermind/solver/FeedbackTest.java b/src/tests/java/org/mastermind/solver/FeedbackTest.java index 785dc990..5f975b6e 100644 --- a/src/tests/java/org/mastermind/solver/FeedbackTest.java +++ b/src/tests/java/org/mastermind/solver/FeedbackTest.java @@ -1,6 +1,7 @@ package org.mastermind.solver; import org.junit.jupiter.api.Test; +import org.mastermind.codes.ConvertCode; import static org.junit.jupiter.api.Assertions.*; @@ -11,27 +12,10 @@ public class FeedbackTest { private static final int TOTAL_COMBINATIONS = 1296; // 6^4 private static final int[] colorFreqCounter = new int[10]; - /** - * Converts a combination index to its Mastermind representation. - * For example, with 6 colors and 4 digits: - * 0 -> 1111, 1 -> 1112, 2 -> 1113, ..., 5 -> 1116, 6 -> 1121, etc. - */ - private int indexToCombination(int index) { - int result = 0; - int divisor = 1; - - for (int i = 0; i < DIGITS; i++) { - int digit = (index % COLORS) + 1; - result += digit * divisor; - divisor *= 10; - index /= COLORS; - } - - return result; - } + private static int ind(int code) { return ConvertCode.toIndex(COLORS, DIGITS, code); } - private int getFeedbackQuick(int guess, int secret) { - return Feedback.getFeedback(guess, secret, COLORS, DIGITS, colorFreqCounter); + private int getFeedbackQuick(int guessInd, int secretInd) { + return Feedback.getFeedback(guessInd, secretInd, COLORS, DIGITS, colorFreqCounter); } @Test @@ -40,26 +24,14 @@ public void testIterationPerformance() { long startTime; int totalCalls = 0; - // Pre-generate all combinations OUTSIDE the timer - int[] allCombinations = new int[TOTAL_COMBINATIONS]; - for (int i = 0; i < TOTAL_COMBINATIONS; i++) { - allCombinations[i] = indexToCombination(i); - } - - int[] secrets = new int[TOTAL_COMBINATIONS]; - System.arraycopy(allCombinations, 0, secrets, 0, TOTAL_COMBINATIONS); - startTime = System.nanoTime(); // Run multiple times - for (int t = 0; t < 100; t++) { - // Call single version 1,296 times, storing results in a 2D array + for (int t = 0; t < 50; t++) { + // Call getFeedback for all (guess, secret) index pairs for (int guessIdx = 0; guessIdx < TOTAL_COMBINATIONS; guessIdx++) { - int guess = allCombinations[guessIdx]; - for (int secretIdx = 0; secretIdx < TOTAL_COMBINATIONS; secretIdx++) { - int secret = secrets[secretIdx]; - getFeedbackQuick(guess, secret); + getFeedbackQuick(guessIdx, secretIdx); totalCalls++; } } @@ -81,7 +53,7 @@ public void testSingleCombinationPerformance() { // Run multiple times int limit = (int) Math.pow(6, 4); for (int t = 0; t < limit; t++) { - getFeedbackQuick(1123, 3456); + getFeedbackQuick(ind(1123), ind(3456)); } long endTime = System.nanoTime(); @@ -97,28 +69,82 @@ public void testEdgeCases() { System.out.println("\n=== Edge Cases Test ==="); // Perfect match - int result1 = getFeedbackQuick(1111, 1111); + int result1 = getFeedbackQuick(ind(1111), ind(1111)); assertEquals(4, result1 / 10, "Perfect match should have 4 blacks"); assertEquals(0, result1 % 10, "Perfect match should have 0 whites"); System.out.println("✓ Perfect match (1111 vs 1111): " + result1 / 10 + " black, " + result1 % 10 + " white"); // No match - int result2 = getFeedbackQuick(1111, 2222); + int result2 = getFeedbackQuick(ind(1111), ind(2222)); assertEquals(0, result2 / 10, "No match should have 0 blacks"); assertEquals(0, result2 % 10, "No match should have 0 whites"); System.out.println("✓ No match (1111 vs 2222): " + result2 / 10 + " black, " + result2 % 10 + " white"); // All whites - int result3 = getFeedbackQuick(1234, 4321); + int result3 = getFeedbackQuick(ind(1234), ind(4321)); assertEquals(0, result3 / 10, "All whites case should have 0 blacks"); assertEquals(4, result3 % 10, "All whites case should have 4 whites"); System.out.println("✓ All whites (1234 vs 4321): " + result3 / 10 + " black, " + result3 % 10 + " white"); // Mixed - int result4 = getFeedbackQuick(5566, 5655); + int result4 = getFeedbackQuick(ind(5566), ind(5655)); assertEquals(1, result4 / 10, "Mixed case blacks"); assertEquals(2, result4 % 10, "Mixed case whites"); - System.out.println("✓ Mixed (1122 vs 1211): " + result4 / 10 + " black, " + result4 % 10 + " white"); + System.out.println("✓ Mixed (5566 vs 5655): " + result4 / 10 + " black, " + result4 % 10 + " white"); + } + + @Test + void testGetFeedbackIncrementalMatchesGetFeedback() { + // For every guess in the 6x4 space, iterate all secrets sequentially using + // getFeedbackIncremental and verify each result matches getFeedback. + int c = COLORS, d = DIGITS, total = TOTAL_COMBINATIONS; + int[] colorFreqRef = new int[c]; + int[] result = new int[3]; + + for (int guessInd = 0; guessInd < total; guessInd++) { + // Pre-extract guess digits + int[] guessDigits = new int[d]; + int tmp = guessInd; + for (int p = 0; p < d; p++) { + guessDigits[p] = tmp % c; + tmp /= c; + } + + // Bootstrap incremental state at secretInd=0 + int[] colorFreqCounter = new int[c]; + int feedback0 = Feedback.getFeedback(guessInd, 0, c, d, colorFreqCounter); + int black0 = 0; + int[] secretDigits = new int[d]; + for (int p = 0; p < d; p++) { + int gs = guessDigits[p], ss = 0; + secretDigits[p] = ss; + if (gs == ss) black0++; + else { + colorFreqCounter[gs]++; + colorFreqCounter[ss]--; + } + } + assertEquals(Feedback.getFeedback(guessInd, 0, c, d, colorFreqRef), feedback0, + "Bootstrap mismatch at guessInd=" + guessInd + " secretInd=0"); + + int colorFreqTotal = 0; + for (int i = 0; i < c; i++) { + int f = colorFreqCounter[i]; + colorFreqTotal += f > 0 ? f : -f; + } + + int black = black0; + for (int secretInd = 1; secretInd < total; secretInd++) { + FeedbackIncremental.getFeedbackIncremental(guessDigits, secretDigits, black, colorFreqCounter, + colorFreqTotal, c, + d, result); + black = result[1]; + colorFreqTotal = result[2]; + int expected = Feedback.getFeedback(guessInd, secretInd, c, d, colorFreqRef); + assertEquals(expected, result[0], + "Mismatch at guessInd=" + guessInd + " secretInd=" + secretInd); + } + } } @Test diff --git a/src/tests/java/org/mastermind/solver/SolutionSpaceTest.java b/src/tests/java/org/mastermind/solver/SolutionSpaceTest.java index d9cd8c0c..f6de8ec6 100644 --- a/src/tests/java/org/mastermind/solver/SolutionSpaceTest.java +++ b/src/tests/java/org/mastermind/solver/SolutionSpaceTest.java @@ -1,7 +1,7 @@ package org.mastermind.solver; import org.junit.jupiter.api.Test; -import org.mastermind.codes.CodeCache; +import org.mastermind.codes.ConvertCode; import static org.junit.jupiter.api.Assertions.*; @@ -11,37 +11,68 @@ public class SolutionSpaceTest { private static final int D = 4; private static final int TOTAL = 1296; // 6^4 + private static int ind(int code) { return ConvertCode.toIndex(C, D, code); } + + @Test + void 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 guess = 1123; - int secret = 4563; - int[] colorFreqCounter = new int[10]; + int guessIdx = ind(1123); + int secretIdx = ind(4563); + int[] colorFreqCounter = new int[C]; // Compute the feedback for guess vs secret - int obtainedFeedback = Feedback.getFeedback(guess, secret, C, D, colorFreqCounter); + int obtainedFeedback = Feedback.getFeedback(guessIdx, secretIdx, C, D, colorFreqCounter); - // Count how many of the 1296 codes produce the same feedback - int[] allCodes = CodeCache.getAllValid(C, D); - int expectedCount = 0; - for (int s : allCodes) { - if (Feedback.getFeedback(guess, s, C, D, colorFreqCounter) == obtainedFeedback) { + // Count how many of the 1296 indices produce the same feedback + int expectedCount = 0; + for (int s = 0; s < TOTAL; s++) { + if (Feedback.getFeedback(guessIdx, s, C, D, colorFreqCounter) == obtainedFeedback) { expectedCount++; } } - // filterSolution should retain exactly those codes + // filterSolution should retain exactly those indices SolutionSpace space = new SolutionSpace(C, D); assertEquals(TOTAL, space.getSize(), "Initial solution space should be 1296"); - space.filterSolution(guess, obtainedFeedback); + space.filterSolution(guessIdx, obtainedFeedback); assertEquals(expectedCount, space.getSize(), "After filtering, size should match manual count for feedback " + obtainedFeedback); - // Every remaining secret must produce the same feedback with the guess + // Every remaining secret index must produce the same feedback with the guess for (int s : space.getSecrets()) { - int fb = Feedback.getFeedback(guess, s, C, D, colorFreqCounter); + int fb = Feedback.getFeedback(guessIdx, s, C, D, colorFreqCounter); assertEquals(obtainedFeedback, fb, - "Remaining secret " + s + " should produce feedback " + obtainedFeedback); + "Remaining secret index " + s + " should produce feedback " + obtainedFeedback); } } }