@cubingcenter/scrambler 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. package/README.md +15 -4
  2. package/dist/core/api.d.ts.map +1 -1
  3. package/dist/core/api.js +10 -2
  4. package/dist/puzzles/cube2/solver/browser-loader.d.ts +4 -7
  5. package/dist/puzzles/cube2/solver/browser-loader.d.ts.map +1 -1
  6. package/dist/puzzles/cube2/solver/browser-loader.js +42 -14
  7. package/dist/puzzles/cube2/solver/generated-assets.d.ts.map +1 -1
  8. package/dist/puzzles/cube2/solver/generated-assets.js +8 -3
  9. package/dist/puzzles/cube3/solver/browser-loader.d.ts.map +1 -1
  10. package/dist/puzzles/cube3/solver/browser-loader.js +42 -20
  11. package/dist/puzzles/cube3/solver/generated-assets.d.ts.map +1 -1
  12. package/dist/puzzles/cube3/solver/generated-assets.js +9 -3
  13. package/dist/puzzles/cube3/solver/phase1.d.ts +19 -0
  14. package/dist/puzzles/cube3/solver/phase1.d.ts.map +1 -1
  15. package/dist/puzzles/cube3/solver/phase1.js +29 -6
  16. package/dist/puzzles/cube3/solver/phase2.d.ts +28 -0
  17. package/dist/puzzles/cube3/solver/phase2.d.ts.map +1 -1
  18. package/dist/puzzles/cube3/solver/phase2.js +56 -15
  19. package/dist/puzzles/cube3/solver/tables.d.ts.map +1 -1
  20. package/dist/puzzles/cube3/solver/tables.js +14 -12
  21. package/dist/puzzles/cube4/browser-worker-pool.d.ts +82 -0
  22. package/dist/puzzles/cube4/browser-worker-pool.d.ts.map +1 -0
  23. package/dist/puzzles/cube4/browser-worker-pool.js +205 -0
  24. package/dist/puzzles/cube4/browser-worker.d.ts +2 -0
  25. package/dist/puzzles/cube4/browser-worker.d.ts.map +1 -0
  26. package/dist/puzzles/cube4/browser-worker.js +31 -0
  27. package/dist/puzzles/cube4/index.d.ts +7 -0
  28. package/dist/puzzles/cube4/index.d.ts.map +1 -1
  29. package/dist/puzzles/cube4/index.js +4 -0
  30. package/dist/puzzles/cube4/moves.d.ts.map +1 -1
  31. package/dist/puzzles/cube4/moves.js +122 -30
  32. package/dist/puzzles/cube4/reduction/browser-loader.d.ts +26 -1
  33. package/dist/puzzles/cube4/reduction/browser-loader.d.ts.map +1 -1
  34. package/dist/puzzles/cube4/reduction/browser-loader.js +212 -48
  35. package/dist/puzzles/cube4/reduction/center2.d.ts +24 -0
  36. package/dist/puzzles/cube4/reduction/center2.d.ts.map +1 -1
  37. package/dist/puzzles/cube4/reduction/center2.js +99 -1
  38. package/dist/puzzles/cube4/reduction/finish3.d.ts +53 -0
  39. package/dist/puzzles/cube4/reduction/finish3.d.ts.map +1 -0
  40. package/dist/puzzles/cube4/reduction/finish3.js +393 -0
  41. package/dist/puzzles/cube4/reduction/generated/hc-manifest.json +6 -0
  42. package/dist/puzzles/cube4/reduction/hc-pdb.d.ts +5 -3
  43. package/dist/puzzles/cube4/reduction/hc-pdb.d.ts.map +1 -1
  44. package/dist/puzzles/cube4/reduction/hc-pdb.js +30 -12
  45. package/dist/puzzles/cube4/reduction/node-loader.d.ts +10 -1
  46. package/dist/puzzles/cube4/reduction/node-loader.d.ts.map +1 -1
  47. package/dist/puzzles/cube4/reduction/node-loader.js +170 -36
  48. package/dist/puzzles/cube4/reduction/pair-transversal.d.ts +28 -0
  49. package/dist/puzzles/cube4/reduction/pair-transversal.d.ts.map +1 -0
  50. package/dist/puzzles/cube4/reduction/pair-transversal.js +107 -0
  51. package/dist/puzzles/cube4/reduction/phase3-search.d.ts +33 -30
  52. package/dist/puzzles/cube4/reduction/phase3-search.d.ts.map +1 -1
  53. package/dist/puzzles/cube4/reduction/phase3-search.js +211 -104
  54. package/dist/puzzles/cube4/reduction/rotation-fix.d.ts +28 -0
  55. package/dist/puzzles/cube4/reduction/rotation-fix.d.ts.map +1 -0
  56. package/dist/puzzles/cube4/reduction/rotation-fix.js +212 -0
  57. package/dist/puzzles/cube4/reduction/solve.d.ts +72 -13
  58. package/dist/puzzles/cube4/reduction/solve.d.ts.map +1 -1
  59. package/dist/puzzles/cube4/reduction/solve.js +374 -92
  60. package/dist/puzzles/cube4/scramble-cache.d.ts +93 -0
  61. package/dist/puzzles/cube4/scramble-cache.d.ts.map +1 -0
  62. package/dist/puzzles/cube4/scramble-cache.js +150 -0
  63. package/dist/puzzles/cube4/scrambler.d.ts +112 -18
  64. package/dist/puzzles/cube4/scrambler.d.ts.map +1 -1
  65. package/dist/puzzles/cube4/scrambler.js +271 -83
  66. package/dist/puzzles/cube4/worker-pool.d.ts +139 -0
  67. package/dist/puzzles/cube4/worker-pool.d.ts.map +1 -0
  68. package/dist/puzzles/cube4/worker-pool.js +405 -0
  69. package/dist/puzzles/cube4/worker-protocol.d.ts +58 -0
  70. package/dist/puzzles/cube4/worker-protocol.d.ts.map +1 -0
  71. package/dist/puzzles/cube4/worker-protocol.js +58 -0
  72. package/dist/puzzles/cube4/worker.d.ts +26 -0
  73. package/dist/puzzles/cube4/worker.d.ts.map +1 -0
  74. package/dist/puzzles/cube4/worker.js +49 -0
  75. package/dist/puzzles/pyraminx/solver/browser-loader.d.ts +4 -8
  76. package/dist/puzzles/pyraminx/solver/browser-loader.d.ts.map +1 -1
  77. package/dist/puzzles/pyraminx/solver/browser-loader.js +44 -15
  78. package/dist/puzzles/pyraminx/solver/generated-assets.d.ts.map +1 -1
  79. package/dist/puzzles/pyraminx/solver/generated-assets.js +8 -3
  80. package/dist/puzzles/shared/compressed-fetch.d.ts +37 -1
  81. package/dist/puzzles/shared/compressed-fetch.d.ts.map +1 -1
  82. package/dist/puzzles/shared/compressed-fetch.js +226 -31
  83. package/dist/puzzles/shared/load-once.d.ts +16 -0
  84. package/dist/puzzles/shared/load-once.d.ts.map +1 -0
  85. package/dist/puzzles/shared/load-once.js +11 -0
  86. package/dist/puzzles/skewb/solver/browser-loader.d.ts +4 -11
  87. package/dist/puzzles/skewb/solver/browser-loader.d.ts.map +1 -1
  88. package/dist/puzzles/skewb/solver/browser-loader.js +44 -16
  89. package/dist/puzzles/skewb/solver/generated-assets.d.ts.map +1 -1
  90. package/dist/puzzles/skewb/solver/generated-assets.js +8 -3
  91. package/dist/puzzles/sq1/solver/browser-loader.d.ts.map +1 -1
  92. package/dist/puzzles/sq1/solver/browser-loader.js +58 -26
  93. package/dist-cjs/core/api.js +10 -2
  94. package/dist-cjs/puzzles/cube2/solver/browser-loader.js +42 -14
  95. package/dist-cjs/puzzles/cube2/solver/generated-assets.js +8 -3
  96. package/dist-cjs/puzzles/cube3/solver/browser-loader.js +42 -20
  97. package/dist-cjs/puzzles/cube3/solver/generated-assets.js +9 -3
  98. package/dist-cjs/puzzles/cube3/solver/phase1.js +29 -6
  99. package/dist-cjs/puzzles/cube3/solver/phase2.js +56 -15
  100. package/dist-cjs/puzzles/cube3/solver/tables.js +14 -12
  101. package/dist-cjs/puzzles/cube4/browser-worker-pool.js +211 -0
  102. package/dist-cjs/puzzles/cube4/index.js +21 -1
  103. package/dist-cjs/puzzles/cube4/moves.js +122 -30
  104. package/dist-cjs/puzzles/cube4/reduction/browser-loader.js +212 -48
  105. package/dist-cjs/puzzles/cube4/reduction/center2.js +102 -2
  106. package/dist-cjs/puzzles/cube4/reduction/finish3.js +398 -0
  107. package/dist-cjs/puzzles/cube4/reduction/generated/hc-manifest.json +6 -0
  108. package/dist-cjs/puzzles/cube4/reduction/hc-pdb.js +31 -12
  109. package/dist-cjs/puzzles/cube4/reduction/node-loader.js +171 -36
  110. package/dist-cjs/puzzles/cube4/reduction/pair-transversal.js +112 -0
  111. package/dist-cjs/puzzles/cube4/reduction/phase3-search.js +211 -104
  112. package/dist-cjs/puzzles/cube4/reduction/rotation-fix.js +216 -0
  113. package/dist-cjs/puzzles/cube4/reduction/solve.js +374 -88
  114. package/dist-cjs/puzzles/cube4/scramble-cache.js +156 -0
  115. package/dist-cjs/puzzles/cube4/scrambler.js +271 -80
  116. package/dist-cjs/puzzles/cube4/worker-pool.js +447 -0
  117. package/dist-cjs/puzzles/cube4/worker-protocol.js +62 -0
  118. package/dist-cjs/puzzles/pyraminx/solver/browser-loader.js +44 -15
  119. package/dist-cjs/puzzles/pyraminx/solver/generated-assets.js +8 -3
  120. package/dist-cjs/puzzles/shared/compressed-fetch.js +229 -31
  121. package/dist-cjs/puzzles/shared/load-once.js +14 -0
  122. package/dist-cjs/puzzles/skewb/solver/browser-loader.js +44 -16
  123. package/dist-cjs/puzzles/skewb/solver/generated-assets.js +8 -3
  124. package/dist-cjs/puzzles/sq1/solver/browser-loader.js +58 -26
  125. package/docs/development.md +19 -7
  126. package/package.json +4 -3
@@ -0,0 +1,156 @@
1
+ "use strict";
2
+ /**
3
+ * Background prefetch buffer for 4x4 scrambles — the Node-side analogue of
4
+ * TNoodle's `ScrambleCacher`.
5
+ *
6
+ * The cache keeps up to `size` scrambles generating AHEAD of consumption:
7
+ * - `take()` shifts the OLDEST slot; if it is already settled it resolves
8
+ * immediately (no waiting for later slots), otherwise it awaits only
9
+ * that slot. Order is FIFO of START (not of completion), so a seeded
10
+ * stream hands out `seed:0`, `seed:1`, … in order even when the
11
+ * underlying generator finishes out of order (parallel pool).
12
+ * - every shift triggers a refill, so the buffer repopulates in the
13
+ * background — wire `generate: (o) => pool.generate(o)` and the refill
14
+ * itself runs across worker threads (see the pool integration test).
15
+ * - failures are FIRST-CLASS slots: a slot that rejects is delivered as a
16
+ * rejection to the next `take()` (consumed exactly once) and the cache
17
+ * keeps going with the following slots. Every slot promise is wrapped
18
+ * at creation so a background failure can NEVER surface as an
19
+ * unhandledRejection, and `take()` never hangs: slots always settle
20
+ * (the generator contract is "settles eventually").
21
+ * - `dispose()` stops refilling and drops buffered slots. In-flight
22
+ * generations are left to settle silently (results discarded). There
23
+ * are NO timers anywhere, so a disposed (or even forgotten) cache never
24
+ * keeps a process alive.
25
+ *
26
+ * Seeding: when `options.seed` is defined, slot n is generated with
27
+ * `${seed}:${n}` — byte-identical to core `generateScrambles("444", …)`
28
+ * for any count > 1 (slot 0 ↔ item 0 of a batch). Without a seed every
29
+ * slot uses the base options unchanged (fresh randomness).
30
+ */
31
+ Object.defineProperty(exports, "__esModule", { value: true });
32
+ exports.Cube4ScrambleCache = void 0;
33
+ exports.deriveCube4StreamOptions = deriveCube4StreamOptions;
34
+ exports.createCube4ScrambleCache = createCube4ScrambleCache;
35
+ const scrambler_js_1 = require("./scrambler.js");
36
+ /**
37
+ * Stream slot derivation (cache analogue of core's batch derivation).
38
+ * Exported for tests and for hosts that precompute the same sequence.
39
+ */
40
+ function deriveCube4StreamOptions(options, index) {
41
+ if (options.seed === undefined)
42
+ return options;
43
+ return { ...options, seed: `${options.seed}:${index}` };
44
+ }
45
+ class Cube4ScrambleCache {
46
+ capacity;
47
+ baseOptions;
48
+ generator;
49
+ /** FIFO of slots (start order); settled or still pending. */
50
+ slots = [];
51
+ nextIndex = 0;
52
+ disposed = false;
53
+ constructor(options = {}) {
54
+ const size = options.size ?? 4;
55
+ if (!Number.isInteger(size) || size < 1) {
56
+ throw new RangeError(`[cube4] cache size must be an integer >= 1 (got ${size}).`);
57
+ }
58
+ this.capacity = size;
59
+ this.baseOptions = options.options ?? {};
60
+ this.generator = options.generate ?? scrambler_js_1.generateCube4Scramble;
61
+ // Prefetch starts immediately: that is the point of constructing a cache.
62
+ this.pump();
63
+ }
64
+ /** Configured buffer capacity. */
65
+ get size() {
66
+ return this.capacity;
67
+ }
68
+ /** Slots currently buffered (settled + still generating). */
69
+ get buffered() {
70
+ return this.slots.length;
71
+ }
72
+ /** Buffered slots that have already settled (ready for an instant take()). */
73
+ get ready() {
74
+ let count = 0;
75
+ for (const carrier of this.slots)
76
+ if (carrier.slot !== undefined)
77
+ count++;
78
+ return count;
79
+ }
80
+ /** Slots currently generating in the background. */
81
+ get inFlight() {
82
+ let count = 0;
83
+ for (const carrier of this.slots)
84
+ if (carrier.slot === undefined)
85
+ count++;
86
+ return count;
87
+ }
88
+ get isDisposed() {
89
+ return this.disposed;
90
+ }
91
+ /**
92
+ * Resolves once the buffer holds `size` settled results (i.e. a
93
+ * subsequent `take()` is served instantly). Safe to call at any time;
94
+ * rejects only if called after `dispose()`.
95
+ */
96
+ warmup() {
97
+ if (this.disposed) {
98
+ return Promise.reject(new Error("[cube4] scramble cache is disposed (warmup)."));
99
+ }
100
+ this.pump();
101
+ return Promise.all(this.slots.map((carrier) => carrier.promise)).then(() => undefined);
102
+ }
103
+ /**
104
+ * Next scramble in FIFO order. Instant when the front slot is settled;
105
+ * otherwise waits only for the front slot (never for later ones).
106
+ * A failed background generation surfaces here as a rejection — exactly
107
+ * once — and the cache continues with subsequent slots.
108
+ */
109
+ take() {
110
+ if (this.disposed) {
111
+ return Promise.reject(new Error("[cube4] scramble cache is disposed (take)."));
112
+ }
113
+ const carrier = this.slots.shift();
114
+ this.pump(); // refill BEFORE awaiting, so generation overlaps consumption
115
+ if (carrier === undefined) {
116
+ // Only reachable when capacity was somehow drained concurrently;
117
+ // generate one on the spot rather than hang.
118
+ return this.generateNow();
119
+ }
120
+ return carrier.promise.then((slot) => {
121
+ if (slot.ok)
122
+ return slot.result;
123
+ throw slot.error;
124
+ });
125
+ }
126
+ /**
127
+ * Stops prefetching, drops buffered slots and ignores in-flight
128
+ * completions. No timers are involved, so nothing can keep a process
129
+ * alive afterwards. Idempotent.
130
+ */
131
+ dispose() {
132
+ this.disposed = true;
133
+ this.slots.length = 0;
134
+ }
135
+ // ── internals ────────────────────────────────────────────────────────
136
+ pump() {
137
+ while (!this.disposed && this.slots.length < this.capacity) {
138
+ const index = this.nextIndex++;
139
+ const promise = this.generator(deriveCube4StreamOptions(this.baseOptions, index)).then((result) => ({ ok: true, result }), (error) => ({ ok: false, error }));
140
+ const carrier = { promise, slot: undefined };
141
+ void promise.then((slot) => {
142
+ carrier.slot = slot;
143
+ });
144
+ this.slots.push(carrier);
145
+ }
146
+ }
147
+ generateNow() {
148
+ const index = this.nextIndex++;
149
+ return this.generator(deriveCube4StreamOptions(this.baseOptions, index));
150
+ }
151
+ }
152
+ exports.Cube4ScrambleCache = Cube4ScrambleCache;
153
+ /** Convenience factory mirroring the other cube4 constructors. */
154
+ function createCube4ScrambleCache(options = {}) {
155
+ return new Cube4ScrambleCache(options);
156
+ }
@@ -1,12 +1,40 @@
1
1
  "use strict";
2
2
  /**
3
3
  * The 4x4 scramble generator: sample a uniform random state, solve it with
4
- * the reduction solver (phases 1-4), and invert the solution. The scramble
5
- * always reproduces the sampled target state and facelets exactly.
4
+ * the reduction solver (phases 1-4), and invert the solution. The emitted
5
+ * scramble must reproduce the sampled target state EXACTLY — enforced by
6
+ * `assertCube4ScrambleReproducesTarget` on every generated result (unless
7
+ * `skipValidation` is set, which is only for the independent verification
8
+ * harness in `scripts/verify-scrambles.ts`).
6
9
  *
7
- * Reduction-only, no resampling: each call runs one round of up to `bestOf`
8
- * independent attempts and returns the shortest success (ties keep the
9
- * lowest job index). If every attempt fails, the last error is thrown.
10
+ * On a target mismatch the error is retryable, so the existing retry loop
11
+ * re-runs the solve over the SAME sampled target (deep attempt) and throws
12
+ * once attempts are exhausted. Re-sampling a different target to "make it
13
+ * fit" and degrading to random moves are forbidden: failing loudly (WCA 4g)
14
+ * is strictly preferable to emitting a scramble that lies about its seed.
15
+ *
16
+ * ── Determinism contract: what defines the output ──────────────────────
17
+ * `generateCube4Scramble` / `generateCube4Attempt` are pure functions of
18
+ * (seed, complete options): the emitted `moves` do not depend on wall-clock
19
+ * time, CPU load, warm-up, batch order, or process identity. This holds
20
+ * because every budget that can change the search outcome is a
21
+ * deterministic work counter (nodes visited), never a wall deadline:
22
+ * - reduction phases: node budgets inside `reduction/solve.ts`;
23
+ * - the 3x3 finish: node caps below (`finish.phase2MaxNodes`,
24
+ * `finish.improveMaxNodes`, per-candidate cap), which replace the old
25
+ * 150/50 ms wall budgets that made the same seed yield different move
26
+ * counts under CPU contention.
27
+ * Options are PART of the key: `restarts`/`bestOf`, `threeAxes`, `deep`,
28
+ * the beam knobs and `finish.*` legitimately change the result for the same
29
+ * seed — the output is reproducible within each configuration (documented
30
+ * behavior, not nondeterminism).
31
+ *
32
+ * Residual wall-clock backstop (hang guard only; never observed to bind —
33
+ * max measured solve 36.5 s): the 90 s
34
+ * `Promise.race` below. It cannot preempt the synchronous search — it
35
+ * can only fire at an event-loop tick — so if one ever fired, that attempt
36
+ * fails loudly (as a retryable error) instead of hanging. Making that bound
37
+ * exact deadline support would require cooperative search checkpoints.
10
38
  */
11
39
  var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
12
40
  if (k2 === undefined) k2 = k;
@@ -42,7 +70,8 @@ var __importStar = (this && this.__importStar) || (function () {
42
70
  };
43
71
  })();
44
72
  Object.defineProperty(exports, "__esModule", { value: true });
45
- exports.cube4ScrambleGenerator = void 0;
73
+ exports.Cube4WallTimeoutError = exports.Cube4TargetMismatchError = exports.cube4ScrambleGenerator = void 0;
74
+ exports.assertCube4ScrambleReproducesTarget = assertCube4ScrambleReproducesTarget;
46
75
  exports.generateCube4Scramble = generateCube4Scramble;
47
76
  exports.generateCube4Attempt = generateCube4Attempt;
48
77
  const moves_js_1 = require("./moves.js");
@@ -52,12 +81,48 @@ const facelets_js_1 = require("./facelets.js");
52
81
  const solve_js_1 = require("./reduction/solve.js");
53
82
  const index_js_1 = require("../cube3/solver/index.js");
54
83
  const tables_js_1 = require("../cube3/solver/tables.js");
55
- const pair_tables_js_1 = require("../cube3/solver/pair-tables.js");
84
+ const finish3_js_1 = require("./reduction/finish3.js");
85
+ const rotation_fix_js_1 = require("./reduction/rotation-fix.js");
56
86
  // ────────────────────────────────────────────────────────────
57
- let cachedTables;
58
- async function loadCube4Tables(baseUrl) {
59
- if (cachedTables)
60
- return cachedTables;
87
+ /**
88
+ * Deterministic work caps for the 3x3 finish — nodes visited, never
89
+ * wall-clock. Calibrated against the former wall budgets (phase-2 50 ms,
90
+ * improvement 150 ms at a measured ~1.75k nodes/ms warm) so typical
91
+ * latency and finish length are unchanged, while the stopping point
92
+ * becomes independent of CPU load: same (seed, options) ⇒ same moves.
93
+ * A failing first finish call retries once with every cap doubled
94
+ * (equivalent of the former 150→300 / 50→100 ms retry).
95
+ */
96
+ const FINISH_PHASE2_MAX_NODES = 90_000;
97
+ const FINISH_IMPROVE_MAX_NODES = 260_000;
98
+ const FINISH_RETRY_SCALE = 2;
99
+ // Continuous finish defaults (the production finish). Measured on cc:0..15:
100
+ // 6 views × 200k nodes = 44.88 moves @ 1257 ms vs legacy 46.06 @ 1288 ms
101
+ // (50k: 45.50 @ 1102; 100k: 45.31; 300k: 44.88 @ 1371 — no gain past 200k).
102
+ const FINISH3_MAX_NODES = 200_000;
103
+ const FINISH3_HARD_MAX_NODES = 20_000_000;
104
+ // Screening budget for the two-stage finish (every candidate reduction gets
105
+ // this; only the best `finishScreenTop` get FINISH3_MAX_NODES).
106
+ const FINISH3_SCREEN_NODES = 30_000;
107
+ const FINISH3_VIEWS = 6;
108
+ const FINISH3_MAX_PHASE2_LENGTH = 12;
109
+ let cube4TablesPromise;
110
+ /**
111
+ * Loads the 4x4 reduction tables with a promise-deduplicating cache: the
112
+ * cache stores the in-flight PROMISE (not a value), so concurrent first
113
+ * calls share one load instead of racing; a rejected load is purged so the
114
+ * next call can retry instead of caching the failure forever.
115
+ */
116
+ function loadCube4Tables(baseUrl) {
117
+ if (cube4TablesPromise !== undefined)
118
+ return cube4TablesPromise;
119
+ cube4TablesPromise = loadCube4TablesOnce(baseUrl).catch((error) => {
120
+ cube4TablesPromise = undefined;
121
+ throw error;
122
+ });
123
+ return cube4TablesPromise;
124
+ }
125
+ async function loadCube4TablesOnce(baseUrl) {
61
126
  const isBrowser = typeof window !== "undefined" ||
62
127
  typeof document !== "undefined" ||
63
128
  (typeof self !== "undefined" && typeof self.importScripts === "function") ||
@@ -70,20 +135,15 @@ async function loadCube4Tables(baseUrl) {
70
135
  "./reduction/browser-loader.js")));
71
136
  const cached = loader.getCachedCube4Tables();
72
137
  if (cached) {
73
- cachedTables = cached;
74
138
  return cached;
75
139
  }
76
- const tables = await loader.loadCube4ReductionTablesBrowser(baseUrl);
77
- cachedTables = tables;
78
- return tables;
140
+ return await loader.loadCube4ReductionTablesBrowser(baseUrl);
79
141
  }
80
142
  const loader = await Promise.resolve().then(() => __importStar(require(
81
143
  /* webpackIgnore: true */
82
144
  /* @vite-ignore */
83
145
  "./reduction/node-loader.js")));
84
- const tables = await loader.loadCube4ReductionTables();
85
- cachedTables = tables;
86
- return tables;
146
+ return await loader.loadCube4ReductionTables();
87
147
  }
88
148
  exports.cube4ScrambleGenerator = {
89
149
  info: {
@@ -98,65 +158,120 @@ exports.cube4ScrambleGenerator = {
98
158
  return generateCube4Scramble(options);
99
159
  }
100
160
  };
101
- function invertRotationSequence(rotSeq) {
102
- return rotSeq.slice().reverse().map((r) => (r.endsWith("'") ? r.slice(0, -1) : r.endsWith("2") ? r : `${r}'`));
161
+ /**
162
+ * Thrown by the target gate when the emitted scramble does not reproduce
163
+ * the independently sampled state. Retryable: the retry loop re-solves the
164
+ * SAME sampled target (deep attempt) — never re-samples.
165
+ */
166
+ class Cube4TargetMismatchError extends Error {
167
+ code = "cube4-target-mismatch";
168
+ constructor(message) {
169
+ super(message);
170
+ this.name = "Cube4TargetMismatchError";
171
+ }
103
172
  }
104
- /** Shortest-first pick (ties keep `a`, i.e. the lowest job index). */
105
- function shorterScramble(a, b) {
106
- if (!a || b.moves.length < a.moves.length)
107
- return b;
108
- return a;
173
+ exports.Cube4TargetMismatchError = Cube4TargetMismatchError;
174
+ /** Thrown by the 90 s hang guard (wall-clock, fires only at a tick). */
175
+ class Cube4WallTimeoutError extends Error {
176
+ code = "cube4-wall-timeout";
177
+ constructor(message) {
178
+ super(message);
179
+ this.name = "Cube4WallTimeoutError";
180
+ }
109
181
  }
110
- function seedForBestJob(options, job) {
111
- if (job === 0)
112
- return options;
113
- const next = { ...options };
114
- if (options.seed !== undefined) {
115
- next.seed = `${options.seed}:best${job}`;
182
+ exports.Cube4WallTimeoutError = Cube4WallTimeoutError;
183
+ /**
184
+ * Message patterns for errors thrown by `reduction/solve.ts`, which cannot
185
+ * be classified by type/code without editing that file (reported: it should
186
+ * grow error codes). Every other retryable path is typed below.
187
+ */
188
+ const SOLVER_RETRYABLE_MESSAGE_PATTERNS = [
189
+ "reduction failed", // no candidates / node budget exhausted in a reduction phase
190
+ "no valid phase 2" // empty phase-2 candidate set
191
+ ];
192
+ /**
193
+ * Retry classification. Typed first (error class / `code`), message
194
+ * patterns last and only for the reduction solver's own errors. Semantics:
195
+ * a retry re-runs the solve over the SAME sampled target with deeper work
196
+ * caps; it never changes the seed's target state.
197
+ */
198
+ function isFinish3Cap(error) {
199
+ return error instanceof Error &&
200
+ error.code === "cube4-finish3-cap" &&
201
+ (error instanceof finish3_js_1.Finish3SearchError || error.name === "Finish3SearchError");
202
+ }
203
+ function isRetryableCube4Error(error) {
204
+ if (!(error instanceof Error))
205
+ return false;
206
+ const code = error.code;
207
+ if (code === "cube4-target-mismatch" || code === "cube4-wall-timeout")
208
+ return true;
209
+ if (isFinish3Cap(error))
210
+ return true;
211
+ if (error instanceof solve_js_1.Cube4Phase2CapError ||
212
+ error instanceof solve_js_1.Cube4Phase3CapError ||
213
+ error instanceof solve_js_1.Cube4FinishCapError)
214
+ return true;
215
+ // Budget/depth failures of the 3x3 finish phase 2: a deeper attempt may
216
+ // succeed (historical behavior: any Cube3Phase2* error was retryable).
217
+ if (error instanceof index_js_1.Cube3Phase2SearchError || error.name === "Cube3Phase2SearchError") {
218
+ return true;
116
219
  }
117
- else {
118
- delete next.seed;
220
+ // Phase-1 failures are deterministic depth failures (phase 1 runs
221
+ // uncapped in the 444 finish): retrying the same target cannot help.
222
+ if (error instanceof index_js_1.Cube3Phase1SearchError || error.name === "Cube3Phase1SearchError") {
223
+ return false;
119
224
  }
120
- return next;
225
+ return SOLVER_RETRYABLE_MESSAGE_PATTERNS.some((pattern) => error.message.includes(pattern));
121
226
  }
122
- function isRetryableCube4Error(error) {
123
- const msg = error instanceof Error ? error.message : String(error);
124
- const isPhase2Error = typeof error?.name === "string" &&
125
- error.name.includes("Cube3Phase2");
126
- return (error instanceof Error &&
127
- (msg.includes("reduction failed") ||
128
- msg.includes("failed to convert") ||
129
- msg.includes("no valid phase 2") ||
130
- msg.includes("timeout") ||
131
- isPhase2Error ||
132
- msg.includes("Phase 2 solution not found")));
227
+ /**
228
+ * The real random-state contract for 444 (non-tautological): replaying the
229
+ * emitted scramble on the solved cube must reproduce the exact state that
230
+ * was sampled for the seed — the reference the sampler produced, never a
231
+ * state derived from the scramble itself. Throws when it does not.
232
+ *
233
+ * Used by the production validation path in `solveCube4Attempt` and
234
+ * exported so tests can prove the gate rejects corrupted scrambles.
235
+ */
236
+ function assertCube4ScrambleReproducesTarget(scrambleMoves, targetState, seed) {
237
+ const replayed = (0, moves_js_1.applyCube4Algorithm)((0, state_js_1.createSolvedCube4State)(), scrambleMoves);
238
+ if (!(0, state_js_1.cube4StatesEqual)(replayed, targetState)) {
239
+ throw new Cube4TargetMismatchError("[cube4-scrambler] scramble does not reproduce the sampled target state" +
240
+ (seed !== undefined ? ` (seed=${seed})` : "") +
241
+ ": applying the emitted scramble to the solved cube must yield the state " +
242
+ "sampled for this seed. Refusing to emit it (no re-sampling, no random-move fallback).");
243
+ }
133
244
  }
134
245
  async function generateCube4Scramble(options = {}) {
135
- const bestOf = Math.max(1, Math.floor(options.bestOf ?? 1) || 1);
136
246
  let tables;
137
- let pairTables;
247
+ // Optional pair tables are unnecessary for the 444 finish. In browsers,
248
+ // probing for absent tables on every generation adds a network dependency
249
+ // before the search even starts (outside its timeout).
250
+ const pairTables = undefined;
138
251
  try {
139
- const [loadedTables, cube3P1Tables, loadedPairTables] = await Promise.all([
252
+ const continuousFinish = options.finish?.mode !== "legacy";
253
+ const [loadedTables, cube3P1Tables, cube3P2Tables] = await Promise.all([
140
254
  loadCube4Tables(),
141
255
  (0, tables_js_1.loadCube3Phase1Tables)(),
142
- (0, pair_tables_js_1.loadCube3PairTables)()
256
+ continuousFinish ? (0, tables_js_1.loadCube3Phase2Tables)() : Promise.resolve(undefined)
143
257
  ]);
144
258
  tables = loadedTables;
145
259
  tables.cube3Phase1 = cube3P1Tables;
146
- tables.pairTables = loadedPairTables;
147
- pairTables = loadedPairTables;
260
+ if (cube3P2Tables !== undefined)
261
+ tables.cube3Phase2 = cube3P2Tables;
148
262
  }
149
263
  catch (error) {
150
264
  throw new Error("[cube4-scrambler] Failed to load reduction tables; run pnpm cube4:center1:tables etc. " +
151
265
  `(${error instanceof Error ? error.message : String(error)})`);
152
266
  }
153
267
  const totalRestarts = Math.max(1, Math.floor(options.restarts ?? options.bestOf ?? 1) || 1);
268
+ const targetState = (0, random_state_js_1.generateRandomCube4State)(options);
154
269
  let lastError;
155
270
  try {
156
- return await generateCube4Attempt(tables, pairTables, {
271
+ return await solveCube4Attempt(tables, pairTables, {
157
272
  ...options,
158
273
  restarts: totalRestarts
159
- });
274
+ }, targetState);
160
275
  }
161
276
  catch (error) {
162
277
  if (!isRetryableCube4Error(error))
@@ -165,11 +280,11 @@ async function generateCube4Scramble(options = {}) {
165
280
  }
166
281
  if (!options.deep) {
167
282
  try {
168
- return await generateCube4Attempt(tables, pairTables, {
283
+ return await solveCube4Attempt(tables, pairTables, {
169
284
  ...options,
170
285
  restarts: totalRestarts,
171
286
  deep: true
172
- });
287
+ }, targetState);
173
288
  }
174
289
  catch (error) {
175
290
  if (isRetryableCube4Error(error))
@@ -184,12 +299,17 @@ async function generateCube4Scramble(options = {}) {
184
299
  }
185
300
  /**
186
301
  * Single-attempt 444 solve: samples ONE state (from `options.seed`) and
187
- * solves it with the reduction solver. Throws on failure — no resampling.
302
+ * solves it with the reduction solver. Throws on failure — including a
303
+ * target mismatch ("scramble does not reproduce the sampled target state")
304
+ * — no resampling; hosts may retry with the same seed, which re-uses the
305
+ * same target.
188
306
  * Fan-out primitive for hosts: run with seeds `seed`, `seed:bestN`, ...
189
307
  * across workers and keep the shortest valid result.
190
308
  */
191
309
  async function generateCube4Attempt(tables, pairTables, options = {}) {
192
- const attemptState = (0, random_state_js_1.generateRandomCube4State)(options);
310
+ return solveCube4Attempt(tables, pairTables, options, (0, random_state_js_1.generateRandomCube4State)(options));
311
+ }
312
+ async function solveCube4Attempt(tables, pairTables, options, attemptState) {
193
313
  const axes = options.threeAxes ? [[], ["z"], ["y'"]] : [[]];
194
314
  let bestScrambleMoves = null;
195
315
  let bestSolutionMoves = null;
@@ -206,36 +326,84 @@ async function generateCube4Attempt(tables, pairTables, options = {}) {
206
326
  // The reduction search is bounded by deterministic work caps (see
207
327
  // solve.ts), so these timeouts are only wall-clock backstops for
208
328
  // pathological cases.
209
- const solvePromise = (0, solve_js_1.solveCube4State)(sRot, tables, async (cube3State) => {
210
- // Single solver-side best-of-orientations finish call with
211
- // extra phase-1 improvement: same lengths as the former
212
- // hand-rolled UD/X/Z triple at a fraction of the cost.
213
- const solveFinish = async (timeBudget, phase2Budget) => {
329
+ const solvePromise = (0, solve_js_1.solveCube4State)(sRot, tables, async (cube3State, effort) => {
330
+ // Legacy batch two-phase finish. Budgets are deterministic node
331
+ // caps (see FINISH_* constants); `capScale` doubles every cap on
332
+ // the one flaky-finish retry.
333
+ const solveLegacyFinish = async (capScale) => {
214
334
  const solution = await (0, index_js_1.solveCube3State)(cube3State, {
215
335
  orientations: finishOpts.orientations ?? 3,
216
336
  phase1ExtraDepth: finishOpts.phase1ExtraDepth ?? 1,
217
337
  phase1SolutionLimit: finishOpts.phase1SolutionLimit ?? 24,
218
- targetLength: finishOpts.targetLength ?? 20,
219
- timeoutMs: timeBudget,
338
+ // 18 (not 20): the improve loop only runs when the first
339
+ // answer exceeds the target, so 20 skipped it for 19-20 move
340
+ // answers. Measured: -0.13 moves at ~+3% time.
341
+ targetLength: finishOpts.targetLength ?? 18,
342
+ improveMaxNodes: Math.round((finishOpts.improveMaxNodes ?? FINISH_IMPROVE_MAX_NODES) * capScale),
343
+ ...(finishOpts.improvePhase2MaxNodes !== undefined
344
+ ? {
345
+ improvePhase2MaxNodes: Math.round(finishOpts.improvePhase2MaxNodes * capScale)
346
+ }
347
+ : {}),
220
348
  // The 54MB pair tables are skipped for the 444 finish: pair
221
349
  // pruning changes nothing measurable here (identical lengths
222
350
  // on a 44-state corpus, zero extra finish retries), and
223
351
  // loading them dominates cold-start I/O. Hosts that already
224
352
  // hold pairTables can still pass them via generateCube4Attempt.
225
- phase2: { pairTables, skipPairTables: pairTables === undefined, timeoutMs: phase2Budget }
353
+ phase2: {
354
+ pairTables,
355
+ skipPairTables: pairTables === undefined,
356
+ maxNodes: Math.round((finishOpts.phase2MaxNodes ?? FINISH_PHASE2_MAX_NODES) * capScale)
357
+ }
226
358
  });
227
359
  return { moves: solution.moves };
228
360
  };
229
- // The first call seeds everything, so a flaky finish gets one
230
- // budgeted retry before the whole finish is declared failed.
361
+ if (finishOpts.mode !== "legacy") {
362
+ // Callers of generateCube4Attempt pass their own table bag,
363
+ // which may predate the continuous finish: load what is missing.
364
+ tables.cube3Phase1 ??= await (0, tables_js_1.loadCube3Phase1Tables)();
365
+ tables.cube3Phase2 ??= await (0, tables_js_1.loadCube3Phase2Tables)();
366
+ const views = finishOpts.continuousViews ?? FINISH3_VIEWS;
367
+ const runContinuous = (capScale) => {
368
+ if (tables.cube3Phase1 === undefined || tables.cube3Phase2 === undefined) {
369
+ throw new Error("[cube4-scrambler] continuous finish requires cube3 phase-1 and phase-2 tables");
370
+ }
371
+ return (0, finish3_js_1.solveFinish3)(cube3State, { phase1: tables.cube3Phase1, phase2: tables.cube3Phase2 }, {
372
+ targetLength: finishOpts.targetLength ?? 18,
373
+ maxNodes: Math.round((effort === "screen"
374
+ ? finishOpts.continuousScreenNodes ?? FINISH3_SCREEN_NODES
375
+ : finishOpts.continuousMaxNodes ?? FINISH3_MAX_NODES) * capScale),
376
+ hardMaxNodes: Math.round((finishOpts.continuousHardMaxNodes ?? FINISH3_HARD_MAX_NODES) * capScale),
377
+ maxPhase2Length: finishOpts.continuousMaxPhase2Length ?? FINISH3_MAX_PHASE2_LENGTH,
378
+ views
379
+ });
380
+ };
381
+ try {
382
+ return runContinuous(1);
383
+ }
384
+ catch (error) {
385
+ if (!isFinish3Cap(error))
386
+ throw error;
387
+ }
388
+ try {
389
+ return runContinuous(FINISH_RETRY_SCALE);
390
+ }
391
+ catch (error) {
392
+ if (!isFinish3Cap(error))
393
+ throw error;
394
+ return solveLegacyFinish(1);
395
+ }
396
+ }
397
+ // The first legacy call seeds everything, so a flaky finish gets
398
+ // one retry with doubled caps before the whole finish is declared
399
+ // failed (formerly 150→300 / 50→100 wall budgets).
231
400
  try {
232
- return await solveFinish(finishOpts.timeoutMs ?? 150, finishOpts.phase2TimeoutMs ?? 50);
401
+ return await solveLegacyFinish(1);
233
402
  }
234
403
  catch {
235
- return await solveFinish(finishOpts.retryTimeoutMs ?? 300, finishOpts.retryPhase2TimeoutMs ?? 100);
404
+ return solveLegacyFinish(FINISH_RETRY_SCALE);
236
405
  }
237
406
  }, {
238
- timeoutMs: 75000,
239
407
  deep: options.deep === true,
240
408
  deepPolish: options.deepPolish ?? options.threeAxes,
241
409
  ...(options.finishTries !== undefined ? { finishTries: options.finishTries } : {}),
@@ -245,14 +413,22 @@ async function generateCube4Attempt(tables, pairTables, options = {}) {
245
413
  ...(options.phase1Cap !== undefined ? { phase1Cap: options.phase1Cap } : {}),
246
414
  ...(options.center2Wide !== undefined ? { center2Wide: options.center2Wide } : {}),
247
415
  ...(options.restarts !== undefined ? { restarts: options.restarts } : {}),
416
+ finishScreenTop: finishOpts.mode === "legacy" ? 0 : finishOpts.screenTop ?? 0,
417
+ ...(options.maxFixMoves !== undefined ? { maxFixMoves: options.maxFixMoves } : {}),
248
418
  ...(options.prepCap !== undefined ? { prepCap: options.prepCap } : {}),
249
419
  ...(options.p1CandCap !== undefined ? { p1CandCap: options.p1CandCap } : {}),
250
420
  ...(options.stagnationCut !== undefined ? { stagnationCut: options.stagnationCut } : {}),
251
421
  maxReductionLen: bestReductionLen
252
422
  });
253
423
  let timeoutHandle;
424
+ // Honest scope: this race cannot interrupt the synchronous CPU-bound
425
+ // search — a setTimeout callback only runs at an event-loop tick, so
426
+ // the guard can fire only if the async solve chain yields for 90 s
427
+ // total. It exists solely so a pathological hang fails loudly
428
+ // (retryable Cube4WallTimeoutError) instead of wedging the host; it
429
+ // has never bound (max measured solve 36.5 s).
254
430
  const timeoutPromise = new Promise((_, reject) => {
255
- timeoutHandle = setTimeout(() => reject(new Error("4x4 solve: timeout")), 90000);
431
+ timeoutHandle = setTimeout(() => reject(new Cube4WallTimeoutError("4x4 solve: timeout")), 90000);
256
432
  });
257
433
  let solveOutcome;
258
434
  try {
@@ -265,9 +441,12 @@ async function generateCube4Attempt(tables, pairTables, options = {}) {
265
441
  clearTimeout(timeoutHandle);
266
442
  }
267
443
  const { scramble: scrambleMoves, moves: solutionMoves, phases, timings, nodes } = solveOutcome;
268
- const invRot = rot.length === 0 ? [] : invertRotationSequence(rot);
269
- const fullScramble = rot.length === 0 ? scrambleMoves : (0, solve_js_1.rotateAlgorithm)(scrambleMoves, invRot);
270
- const fullSolution = rot.length === 0 ? solutionMoves : (0, solve_js_1.rotateAlgorithm)(solutionMoves, invRot);
444
+ const fullSolution = rot.length === 0
445
+ ? solutionMoves
446
+ : (0, rotation_fix_js_1.placeRotationFix)([], rot, solutionMoves);
447
+ const fullScramble = rot.length === 0
448
+ ? scrambleMoves
449
+ : (0, moves_js_1.invertCube4Algorithm)(fullSolution);
271
450
  if (!bestScrambleMoves || fullScramble.length < bestScrambleMoves.length) {
272
451
  bestScrambleMoves = fullScramble;
273
452
  bestSolutionMoves = fullSolution;
@@ -287,13 +466,17 @@ async function generateCube4Attempt(tables, pairTables, options = {}) {
287
466
  throw lastError instanceof Error ? lastError : new Error(String(lastError));
288
467
  }
289
468
  const scrambleMoves = bestScrambleMoves;
290
- const solutionMoves = bestSolutionMoves;
291
469
  const scrambledState = (0, moves_js_1.applyCube4Algorithm)((0, state_js_1.createSolvedCube4State)(), scrambleMoves);
292
470
  if (!options.skipValidation) {
293
- const solvedCheck = (0, moves_js_1.applyCube4Algorithm)(scrambledState, solutionMoves);
294
- if (!(0, state_js_1.isSolvedCube4State)(solvedCheck)) {
295
- throw new Error("Generated 4x4 scramble does not solve back to solved state.");
296
- }
471
+ // Non-tautological gate: compare against `attemptState` — the state
472
+ // sampled for this seed (the same reference the sampler produced),
473
+ // NOT a state derived from the scramble. The former check
474
+ // (`apply(scramble, invert(scramble)) === solved`) passed by
475
+ // construction and hid frame bugs. A mismatch throws an error the
476
+ // retry loop classifies as retryable: `generateCube4Scramble` then
477
+ // re-runs the solve over this SAME `attemptState` (deep) and finally
478
+ // throws — it never re-samples another target.
479
+ assertCube4ScrambleReproducesTarget(scrambleMoves, attemptState, options.seed);
297
480
  }
298
481
  const facelets = (0, facelets_js_1.toCube4Facelets)(scrambledState);
299
482
  const result = {
@@ -304,10 +487,18 @@ async function generateCube4Attempt(tables, pairTables, options = {}) {
304
487
  generator: "cube4-reduction",
305
488
  metadata: {
306
489
  generator: "cube4-reduction",
307
- state: scrambledState,
490
+ // The sampled target — the independent reference the seed stands for
491
+ // (consistent with the 222/333/pymx/skwb/fto scramblers). When
492
+ // validation ran, this is exactly `apply(solved, moves)`; with
493
+ // `skipValidation` it stays the sampled truth so external checks can
494
+ // compare the emitted scramble against it instead of a derived state.
495
+ state: attemptState,
308
496
  facelets,
309
497
  faceletString: (0, facelets_js_1.formatCube4Facelets)(facelets),
310
498
  distribution: "random-state",
499
+ // The scramble's inverse: by construction it solves the state the
500
+ // scramble actually produces, and (validation on) that state is
501
+ // `attemptState`.
311
502
  solutionMoves: (0, moves_js_1.invertCube4Algorithm)(scrambleMoves),
312
503
  // Winning-reduction breakdown (unsimplified lengths) + wall-clock
313
504
  // split, for tuning and A/B comparisons.