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