@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
@@ -5,8 +5,9 @@
5
5
  * The scramble for a sampled state is the inverted solution.
6
6
  */
7
7
  Object.defineProperty(exports, "__esModule", { value: true });
8
- exports.stagnationStats = exports.f2Stats = exports.P2_CANCEL = void 0;
8
+ exports.rotStats = exports.finishStats = exports.stagnationStats = exports.f2Stats = exports.P2_CANCEL = exports.Cube4FinishCapError = exports.Cube4Phase3CapError = exports.Cube4Phase2CapError = void 0;
9
9
  exports.rotateAlgorithm = rotateAlgorithm;
10
+ exports.cubeRotationMoves = cubeRotationMoves;
10
11
  exports.invertRotationSequence = invertRotationSequence;
11
12
  exports.applyCubeRotation = applyCubeRotation;
12
13
  exports.resetF2Stats = resetF2Stats;
@@ -20,6 +21,8 @@ const pairing_coordinate_js_1 = require("./pairing-coordinate.js");
20
21
  const hc_pdb_js_1 = require("./hc-pdb.js");
21
22
  const edge3_js_1 = require("./edge3.js");
22
23
  const phase3_search_js_1 = require("./phase3-search.js");
24
+ const rotation_fix_js_1 = require("./rotation-fix.js");
25
+ const pair_transversal_js_1 = require("./pair-transversal.js");
23
26
  const phase4_js_1 = require("./phase4.js");
24
27
  const coordinates_js_1 = require("../../cube3/solver/coordinates.js");
25
28
  const state_js_1 = require("../state.js");
@@ -113,6 +116,32 @@ function getTriAxialCube3Heuristic(standardReduced, tables) {
113
116
  }
114
117
  return minH;
115
118
  }
119
+ class Cube4SolveError extends Error {
120
+ }
121
+ class Cube4Phase2CapError extends Cube4SolveError {
122
+ code = "cube4-phase2-cap";
123
+ constructor(message) {
124
+ super(message);
125
+ this.name = "Cube4Phase2CapError";
126
+ }
127
+ }
128
+ exports.Cube4Phase2CapError = Cube4Phase2CapError;
129
+ class Cube4Phase3CapError extends Cube4SolveError {
130
+ code = "cube4-phase3-cap";
131
+ constructor(message) {
132
+ super(message);
133
+ this.name = "Cube4Phase3CapError";
134
+ }
135
+ }
136
+ exports.Cube4Phase3CapError = Cube4Phase3CapError;
137
+ class Cube4FinishCapError extends Cube4SolveError {
138
+ code = "cube4-finish-cap";
139
+ constructor(message) {
140
+ super(message);
141
+ this.name = "Cube4FinishCapError";
142
+ }
143
+ }
144
+ exports.Cube4FinishCapError = Cube4FinishCapError;
116
145
  function cornerPermutationParity(cp) {
117
146
  let parity = 0;
118
147
  const seen = new Array(8).fill(false);
@@ -192,6 +221,8 @@ const VALID_24_ORIENTATIONS = (() => {
192
221
  }
193
222
  return map;
194
223
  })();
224
+ /** Centers of the solved state — used to verify a full (not just partial) standard orientation. */
225
+ const SOLVED_CENTERS = (0, state_js_1.createSolvedCube4State)().centers;
195
226
  const ROT_MAP = {
196
227
  "x": { "U": "B", "R": "R", "F": "U", "D": "F", "L": "L", "B": "D" },
197
228
  "x'": { "U": "F", "R": "R", "F": "D", "D": "B", "L": "L", "B": "U" },
@@ -227,6 +258,10 @@ function rotateAlgorithm(alg, rotSeq) {
227
258
  return current;
228
259
  });
229
260
  }
261
+ /** Physical wide-move sequence implementing a whole-cube rotation. */
262
+ function cubeRotationMoves(rotSeq) {
263
+ return rotSeq.flatMap((rotation) => ROT_MOVES[rotation]);
264
+ }
230
265
  function invertRotationSequence(rotSeq) {
231
266
  return [...rotSeq].reverse().map((r) => INVERT_ROT[r]);
232
267
  }
@@ -267,6 +302,49 @@ function isCancelled(previous, current) {
267
302
  exports.P2_CANCEL = center2_js_1.CENTER2_SEARCH_MOVES.map((previous) => center2_js_1.CENTER2_SEARCH_MOVES.map((current) => isCancelled(previous, current)));
268
303
  // Same over the wide 28-move set (used when `center2Wide` is set).
269
304
  const P2_CANCEL_WIDE = center2_js_1.CENTER2_SEARCH_MOVES_WIDE.map((previous) => center2_js_1.CENTER2_SEARCH_MOVES_WIDE.map((current) => isCancelled(previous, current)));
305
+ /** Flat (row-major) cancel tables for the hot DFS loop; same semantics. */
306
+ function flattenCancel(table) {
307
+ const n = table.length;
308
+ const flat = new Uint8Array(n * n);
309
+ for (let i = 0; i < n; i++) {
310
+ const row = table[i];
311
+ for (let j = 0; j < n; j++)
312
+ flat[i * n + j] = row[j] ? 1 : 0;
313
+ }
314
+ return flat;
315
+ }
316
+ const P2_CANCEL_FLAT = flattenCancel(exports.P2_CANCEL);
317
+ const P2_CANCEL_WIDE_FLAT = flattenCancel(P2_CANCEL_WIDE);
318
+ /**
319
+ * Wing transversal mask: bit k set iff pair k occupies a second-half slot.
320
+ * `hasPairingTransversal(wp)` is equivalent to `pairingMaskOf(wp) === 0xfff`
321
+ * (12 slots, 12 pair ids in 0..11: all distinct ⟺ all 12 bits set). The mask
322
+ * form is O(1) at the millions of phase-2 goal leaves.
323
+ */
324
+ function pairingMaskOf(wp) {
325
+ let mask = 0;
326
+ for (let i = 0; i < 12; i++) {
327
+ mask |= 1 << (wp[pairing_coordinate_js_1.PAIRING_SECOND_HALVES[i]] >> 1);
328
+ }
329
+ return mask;
330
+ }
331
+ /** rl side of the phase-2 goal test (same flags as `isCenter2Goal`). */
332
+ const P2_GOAL_RL = (() => {
333
+ const flags = new Uint8Array(center2_js_1.CENTER2_RL_STATES);
334
+ for (const rl of center2_js_1.CENTER2_GOAL_RL)
335
+ flags[rl] = 1;
336
+ return flags;
337
+ })();
338
+ /** Chiral phase-2 tables, generated once per loaded center2 table set. */
339
+ const chiralTablesCache = new WeakMap();
340
+ function getCenter2ChiralTables(center2) {
341
+ let chiral = chiralTablesCache.get(center2);
342
+ if (!chiral) {
343
+ chiral = (0, center2_js_1.generateCenter2ChiralTables)(center2.rlMove);
344
+ chiralTablesCache.set(center2, chiral);
345
+ }
346
+ return chiral;
347
+ }
270
348
  const SKIP_AXIS_2 = new Int32Array(center2_js_1.CENTER2_SEARCH_MOVES.length);
271
349
  for (let i = 0; i < center2_js_1.CENTER2_SEARCH_MOVES.length; i++) {
272
350
  const m1 = center2_js_1.CENTER2_SEARCH_MOVES[i];
@@ -283,7 +361,6 @@ for (let i = 0; i < center2_js_1.CENTER2_SEARCH_MOVES.length; i++) {
283
361
  }
284
362
  }
285
363
  }
286
- const phase3Solver = new phase3_search_js_1.FastPhase3Solver();
287
364
  /** Temporary instrumentation for F2 goal statistics (CUBE4_F2_STATS=1). */
288
365
  exports.f2Stats = { goals: 0, transversalPass: 0, axisRejects: 0, minTransversalL2: 99, minChiralL2: 99 };
289
366
  function resetF2Stats() {
@@ -299,13 +376,38 @@ function resetStagnationStats() {
299
376
  exports.stagnationStats.checks = 0;
300
377
  exports.stagnationStats.cuts = 0;
301
378
  }
379
+ /**
380
+ * Temporary instrumentation for the finish-skip bound (CUBE4_FINISH_STATS=1):
381
+ * `gap = totalEst - finalLen` per finished reduction. A skip
382
+ * `totalEst - SLACK >= minTotalMoves` is only sound while SLACK covers the
383
+ * observed gap maximum (junction cancellation + finish-vs-h3 slack).
384
+ */
385
+ exports.finishStats = {
386
+ gapMin: 99,
387
+ gapMax: -99,
388
+ gapSum: 0,
389
+ gapN: 0,
390
+ finishes: 0,
391
+ skipped: 0
392
+ };
393
+ /**
394
+ * Temporary instrumentation for the rotMoves cost (CUBE4_ROT_STATS=1):
395
+ * per built reduction records the prep frame rotation, the derived
396
+ * normalization rotation and the emitted fix length.
397
+ */
398
+ exports.rotStats = [];
302
399
  async function solveCube4State(state, tables, solveCube3, options) {
303
400
  // NOTE: no wall-clock deadline inside the search — all loops are bounded
304
- // by deterministic work caps (node counts, candidate counts, length
305
- // caps), so the same state always takes the same path. The caller keeps
306
- // its own backstop (`timeoutMs` handled at the call site).
401
+ // by deterministic work caps (node counts, candidate counts, length caps),
402
+ // so the same state always takes the same path.
307
403
  const sym = (0, edge3_js_1.getSymTables)();
404
+ // All mutable phase-3 state (TT, epoch-node count, call IDs and search
405
+ // stacks) belongs to this invocation. The same instance spans its
406
+ // deterministic restarts, retaining useful goal-free TT entries without
407
+ // allowing concurrent solves to contaminate one another.
408
+ const phase3Solver = new phase3_search_js_1.FastPhase3Solver();
308
409
  phase3Solver.resetEpochNodes();
410
+ const collectF2Stats = typeof process !== "undefined" && process.env?.CUBE4_F2_STATS === "1";
309
411
  const totalRestarts = Math.max(1, Math.floor(options?.restarts ?? 1));
310
412
  // Cross-restart work accumulators (reported in nodes/timings).
311
413
  let accP2 = 0;
@@ -330,8 +432,8 @@ async function solveCube4State(state, tables, solveCube3, options) {
330
432
  const path = [];
331
433
  const p1Extra = options?.phase1ExtraDepths ?? 3;
332
434
  const p1Cap = options?.phase1Cap ?? 400;
333
- // Per-restart DFS traversal order (identity when rng is null).
334
- const p1Order = restartOrder(center1_js_1.CENTER1_MOVES.length, rng);
435
+ // Per-restart DFS traversal order (null = identity fast path when rng is null).
436
+ const p1Order = rng === null ? null : Int32Array.from(restartOrder(center1_js_1.CENTER1_MOVES.length, rng));
335
437
  for (let maxD = minOverallH; maxD <= minOverallH + p1Extra; maxD++) {
336
438
  for (const urf of [0, 1, 2]) {
337
439
  const minH = urfDistances[urf];
@@ -368,7 +470,7 @@ async function solveCube4State(state, tables, solveCube3, options) {
368
470
  if (depth + h > maxD)
369
471
  return false;
370
472
  for (let mi = 0; mi < center1_js_1.CENTER1_MOVES.length; mi++) {
371
- const mv = p1Order[mi];
473
+ const mv = p1Order === null ? mi : p1Order[mi];
372
474
  if (lm >= 0 && Math.floor(lm / 3) === Math.floor(mv / 3))
373
475
  continue;
374
476
  const next = (0, center1_js_1.applyCenter1Move)(m, tables.center1.moveMaps, mv);
@@ -387,12 +489,24 @@ async function solveCube4State(state, tables, solveCube3, options) {
387
489
  const minP1 = p1Sols[0]?.moves.length ?? 0;
388
490
  const tF1End = performance.now();
389
491
  const candidates = [];
390
- const moveLog = new Array(25);
492
+ const moveLog = new Int32Array(48);
391
493
  // Incremental wing tracking stack for phase-2 goal transversal checks:
392
494
  // avoids full 6-7 move replaying from scratch across hundreds of thousands of candidate goals.
393
- const wpStack = Array.from({ length: 24 }, () => new Uint8Array(24));
495
+ // `wpMaskStack[d]` = pairingMaskOf(wpStack[d]) maintained in the same lazy
496
+ // fill, so the per-leaf transversal test is a single compare.
497
+ const wpStack = Array.from({ length: 32 }, () => new Uint8Array(24));
498
+ const wpMaskStack = new Int32Array(32);
394
499
  let validWpDepth = 0;
395
500
  const wingMaps = (0, center2_js_1.getWingMoveMaps)();
501
+ // Transversal pruning (see pair-transversal.ts): wing positions per DFS
502
+ // depth (`posStack[d][wing]`), checked against the two-pair PDB over 6
503
+ // disjoint pair-of-pairs in the last TRANSVERSAL_DEPTH levels (the PDB's
504
+ // maximum). Prunes only branches with no transversal goal, so the
505
+ // accepted candidates are unchanged.
506
+ const posStack = Array.from({ length: 33 }, () => new Uint8Array(24));
507
+ const wingPosMove = (0, pair_transversal_js_1.getWingPositionMoves)();
508
+ const TRANSVERSAL_DEPTH = 3;
509
+ let validPosDepth = 0;
396
510
  /**
397
511
  * Anti-hang work cap for the phase-2 search (successful attempts use
398
512
  * ~0.1-0.3M nodes; this only aborts pathological explorations).
@@ -405,11 +519,21 @@ async function solveCube4State(state, tables, solveCube3, options) {
405
519
  // indices in either mode.
406
520
  const c2Wide = options?.center2Wide === true;
407
521
  const C2_SEARCH = c2Wide ? center2_js_1.CENTER2_SEARCH_MOVES_WIDE : center2_js_1.CENTER2_SEARCH_MOVES;
408
- const C2_CANCEL = c2Wide ? P2_CANCEL_WIDE : exports.P2_CANCEL;
409
- // Per-restart child order (identity when rng is null). Entries stay
522
+ const cancelFlat = c2Wide ? P2_CANCEL_WIDE_FLAT : P2_CANCEL_FLAT;
523
+ const c2Stride = C2_SEARCH.length;
524
+ const transversalPdb = (0, pair_transversal_js_1.getTransversalPairPdb)(C2_SEARCH.length);
525
+ const canSkipAxis = rng === null && !c2Wide;
526
+ // Per-restart child order (null = identity fast path). Entries stay
410
527
  // original move-table indices, so all table lookups are unaffected.
411
- const p2Order = restartOrder(C2_SEARCH.length, rng);
412
- let c2pruning = tables.center2.pruning;
528
+ const p2Order = rng === null ? null : Int32Array.from(restartOrder(C2_SEARCH.length, rng));
529
+ // Default set: chiral tables (goal = ct == 0 && axisDet == 1 exactly, so
530
+ // wrong-chirality goal paths are pruned instead of enumerated). The wide
531
+ // set keeps the relative tables + the axisDet filter at the goal.
532
+ const chiral = c2Wide ? null : getCenter2ChiralTables(tables.center2);
533
+ const c2ctMove = chiral ? chiral.ctMove : tables.center2.ctMove;
534
+ const c2rlMove = tables.center2.rlMove;
535
+ const c2GoalCt = chiral ? chiral.goalCt : 0;
536
+ let c2pruning = chiral ? chiral.pruning : tables.center2.pruning;
413
537
  if (c2Wide) {
414
538
  if (!tables.center2.pruningWide) {
415
539
  throw new Error("4x4 solve: center2Wide requested but pruningWide tables are not loaded — regenerate center2 tables.");
@@ -417,6 +541,8 @@ async function solveCube4State(state, tables, solveCube3, options) {
417
541
  c2pruning = tables.center2.pruningWide;
418
542
  }
419
543
  const P2_MAX_NODES = deep ? 24_000_000 : 8_000_000;
544
+ // Hoisted out of the goal path (was read per goal leaf).
545
+ const maxP1Cand = options?.p1CandCap !== undefined ? options.p1CandCap : (deep ? 24 : 16);
420
546
  // Smaller pool + shorter extension (was 180/100/14/12 on the fast path):
421
547
  // the deep length2 levels are where F2 tails burn. Validated on a 44-state
422
548
  // corpus: -16% nodes, identical average length (one +1 in 44).
@@ -430,11 +556,16 @@ async function solveCube4State(state, tables, solveCube3, options) {
430
556
  const EXT_MX = deep ? 20 : 16;
431
557
  let p2nodes = 0;
432
558
  let f3calls = 0;
559
+ // Bound the whole sweep, not just each individual IDA* call. A hard
560
+ // candidate pool can otherwise spend thousands of capped calls before
561
+ // reaching the deep retry (corpus-73: 114M nodes in the fast attempt).
562
+ const phase3StartNodes = phase3Solver.epochNodes;
563
+ const phase3NodeBudget = deep ? 16_000_000 : 4_000_000;
433
564
  function searchPhase2(c, r, maxl, lm, depth, bridgeState, prep) {
434
565
  p2nodes++;
435
566
  if (p2nodes > P2_MAX_NODES)
436
567
  return false;
437
- if ((0, center2_js_1.isCenter2Goal)(c, r)) {
568
+ if (c === c2GoalCt && P2_GOAL_RL[r] === 1) {
438
569
  if (maxl === 0) {
439
570
  while (validWpDepth < depth) {
440
571
  const m = moveLog[validWpDepth];
@@ -443,30 +574,34 @@ async function solveCube4State(state, tables, solveCube3, options) {
443
574
  const dst = wpStack[validWpDepth + 1];
444
575
  for (let i = 0; i < 24; i++)
445
576
  dst[i] = src[wingMaps[base + i]];
577
+ wpMaskStack[validWpDepth + 1] = pairingMaskOf(dst);
446
578
  validWpDepth++;
447
579
  }
448
- const goalWp = wpStack[depth];
449
- if (process.env.CUBE4_F2_STATS === "1")
580
+ if (collectF2Stats)
450
581
  exports.f2Stats.goals++;
451
- if (!(0, pairing_coordinate_js_1.hasPairingTransversal)(goalWp))
582
+ // Bit-identical to `hasPairingTransversal(goalWp)`: all 12 pair bits
583
+ // distinct ⟺ the 12-bit mask is full (0xfff).
584
+ if (wpMaskStack[depth] !== 0xfff)
452
585
  return false;
453
- if (process.env.CUBE4_F2_STATS === "1")
586
+ if (collectF2Stats)
454
587
  exports.f2Stats.transversalPass++;
455
- const moves2 = moveLog.slice(0, depth).map((m) => center2_js_1.CENTER2_MOVES[m]);
588
+ const moves2 = [];
589
+ for (let i = 0; i < depth; i++)
590
+ moves2.push(center2_js_1.CENTER2_MOVES[moveLog[i]]);
456
591
  const s2 = (0, moves_js_1.applyCube4Algorithm)(bridgeState, moves2);
457
592
  const uAxis = s2.centers[0] % 3;
458
593
  const rAxis = s2.centers[4] % 3;
459
594
  const fAxis = s2.centers[8] % 3;
460
- if (process.env.CUBE4_F2_STATS === "1") {
595
+ if (collectF2Stats) {
461
596
  if (depth < exports.f2Stats.minTransversalL2)
462
597
  exports.f2Stats.minTransversalL2 = depth;
463
598
  }
464
599
  if (axisDet(uAxis, rAxis, fAxis) !== 1) {
465
- if (process.env.CUBE4_F2_STATS === "1")
600
+ if (collectF2Stats)
466
601
  exports.f2Stats.axisRejects++;
467
602
  return false;
468
603
  }
469
- if (process.env.CUBE4_F2_STATS === "1") {
604
+ if (collectF2Stats) {
470
605
  if (depth < exports.f2Stats.minChiralL2)
471
606
  exports.f2Stats.minChiralL2 = depth;
472
607
  }
@@ -486,7 +621,7 @@ async function solveCube4State(state, tables, solveCube3, options) {
486
621
  const moves2Remapped = prep.totalRot.length === 0
487
622
  ? moves2
488
623
  : rotateAlgorithm(moves2, invertRotationSequence(prep.totalRot));
489
- const hcCoord = tables.hc ? tables.hc.index[(0, hc_pdb_js_1.encodeHcPdb)(s2.centers)] : -1;
624
+ const hcCoord = tables.hc ? (0, hc_pdb_js_1.hcDenseIndex)(tables.hc, (0, hc_pdb_js_1.encodeHcPdb)(s2.centers)) : -1;
490
625
  const hcRaw = hcCoord >= 0 && tables.hc ? tables.hc.distances[hcCoord] : 0;
491
626
  const hdist = hcRaw === 0x0f ? 0 : hcRaw;
492
627
  let pdist = 0;
@@ -533,7 +668,6 @@ async function solveCube4State(state, tables, solveCube3, options) {
533
668
  }
534
669
  }
535
670
  p1CandCount++;
536
- const maxP1Cand = options?.p1CandCap !== undefined ? options.p1CandCap : (deep ? 96 : 64);
537
671
  if (p1CandCount >= maxP1Cand)
538
672
  return true;
539
673
  }
@@ -543,27 +677,65 @@ async function solveCube4State(state, tables, solveCube3, options) {
543
677
  }
544
678
  if (maxl === 0)
545
679
  return false;
546
- const c2ctMove = tables.center2.ctMove;
547
- const c2rlMove = tables.center2.rlMove;
548
680
  const c2RowC = c * center2_js_1.CENTER2_MOVES.length;
549
681
  const c2RowR = r * center2_js_1.CENTER2_MOVES.length;
550
682
  for (let mi = 0; mi < C2_SEARCH.length; mi++) {
551
- const m = p2Order[mi];
552
- if (lm >= 0 && C2_CANCEL[lm][m]) {
683
+ const m = p2Order === null ? mi : p2Order[mi];
684
+ if (lm >= 0 && cancelFlat[lm * c2Stride + m]) {
553
685
  continue;
554
686
  }
555
687
  const nc = c2ctMove[c2RowC + m];
556
688
  const nr = c2rlMove[c2RowR + m];
557
689
  const prun = c2pruning[nc * center2_js_1.CENTER2_RL_STATES + nr];
558
690
  if (prun >= maxl) {
559
- if (prun > maxl && rng === null && !c2Wide) {
691
+ if (prun > maxl && canSkipAxis) {
560
692
  mi = SKIP_AXIS_2[m] - 1;
561
693
  }
562
694
  continue;
563
695
  }
696
+ let childPosValid = false;
697
+ if (maxl - 1 < TRANSVERSAL_DEPTH) {
698
+ // Lazy fill (like wpStack): replay the path down to this node.
699
+ while (validPosDepth < depth) {
700
+ const src = posStack[validPosDepth];
701
+ const dst = posStack[validPosDepth + 1];
702
+ const row = moveLog[validPosDepth] * 24;
703
+ for (let w = 0; w < 24; w++)
704
+ dst[w] = wingPosMove[row + src[w]];
705
+ validPosDepth++;
706
+ }
707
+ const parentPos = posStack[depth];
708
+ const childPos = posStack[depth + 1];
709
+ const posRow = m * 24;
710
+ const limit = maxl - 1;
711
+ let blocked = false;
712
+ // Group by group (4 wings = 2 pairs each), so a blocked child only
713
+ // pays for the groups actually checked.
714
+ for (let q = 0; q < 24; q += 4) {
715
+ const a0 = wingPosMove[posRow + parentPos[q]];
716
+ const a1 = wingPosMove[posRow + parentPos[q + 1]];
717
+ const b0 = wingPosMove[posRow + parentPos[q + 2]];
718
+ const b1 = wingPosMove[posRow + parentPos[q + 3]];
719
+ if (transversalPdb[pair_transversal_js_1.PAIR_INDEX[a0 * 24 + a1] * pair_transversal_js_1.PAIR_STATES + pair_transversal_js_1.PAIR_INDEX[b0 * 24 + b1]] > limit) {
720
+ blocked = true;
721
+ break;
722
+ }
723
+ childPos[q] = a0;
724
+ childPos[q + 1] = a1;
725
+ childPos[q + 2] = b0;
726
+ childPos[q + 3] = b1;
727
+ }
728
+ if (blocked)
729
+ continue;
730
+ childPosValid = true;
731
+ }
564
732
  moveLog[depth] = m;
565
733
  if (validWpDepth > depth)
566
734
  validWpDepth = depth;
735
+ if (validPosDepth > depth)
736
+ validPosDepth = depth;
737
+ if (childPosValid)
738
+ validPosDepth = depth + 1;
567
739
  if (searchPhase2(nc, nr, maxl - 1, m, depth + 1, bridgeState, prep))
568
740
  return true;
569
741
  }
@@ -585,7 +757,8 @@ async function solveCube4State(state, tables, solveCube3, options) {
585
757
  continue;
586
758
  const totalRot = [...p1.rotAlign, ...bridge.rotation];
587
759
  const prefix = p1.moves;
588
- const prun = c2pruning[bridge.encoded.ct * center2_js_1.CENTER2_RL_STATES + bridge.encoded.rl];
760
+ const ct = chiral ? (0, center2_js_1.encodeCenter2ChiralCt)(bridge.state) : bridge.encoded.ct;
761
+ const prun = c2pruning[ct * center2_js_1.CENTER2_RL_STATES + bridge.encoded.rl];
589
762
  // Paired wings lookahead: check how many wings are already paired in bridge.state
590
763
  let pairedWings = 0;
591
764
  const wp = bridge.state.wp;
@@ -597,13 +770,13 @@ async function solveCube4State(state, tables, solveCube3, options) {
597
770
  // Lookahead composite score: total estimated centers penalized by already-paired wings
598
771
  const estCenters = prefix.length + prun;
599
772
  const score = estCenters * 2 - Math.min(4, pairedWings);
600
- preps.push({ p1, prefix, bridgeState: bridge.state, totalRot, ct: bridge.encoded.ct, rl: bridge.encoded.rl, prun, score });
773
+ preps.push({ p1, prefix, bridgeState: bridge.state, totalRot, ct, rl: bridge.encoded.rl, prun, score });
601
774
  }
602
775
  // Ascending lookahead score (lowest total estimated center moves + wing bonus),
603
776
  // tie-broken by lower phase-2 pruning bound then shorter prefix length
604
777
  preps.sort((a, b) => (a.score - b.score) || (a.prun - b.prun) || (a.prefix.length - b.prefix.length));
605
778
  // Filter: keep the best preps to avoid wasting phase-2 DFS work on suboptimal centers
606
- const PREP_CAP = options?.prepCap !== undefined ? options.prepCap : (deep ? 160 : 100);
779
+ const PREP_CAP = options?.prepCap !== undefined ? options.prepCap : (deep ? 48 : 32);
607
780
  if (preps.length > PREP_CAP) {
608
781
  preps.length = PREP_CAP;
609
782
  }
@@ -635,6 +808,10 @@ async function solveCube4State(state, tables, solveCube3, options) {
635
808
  searchedL2.add(key);
636
809
  p1CandCount = 0;
637
810
  wpStack[0].set(prep.bridgeState.wp);
811
+ for (let p = 0; p < 24; p++)
812
+ posStack[0][prep.bridgeState.wp[p]] = p;
813
+ validPosDepth = 0;
814
+ wpMaskStack[0] = pairingMaskOf(prep.bridgeState.wp);
638
815
  validWpDepth = 0;
639
816
  searchPhase2(prep.ct, prep.rl, length2, -1, 0, prep.bridgeState, prep);
640
817
  if (candidates.length >= CAND_CAP &&
@@ -669,9 +846,13 @@ async function solveCube4State(state, tables, solveCube3, options) {
669
846
  MAX_P2++;
670
847
  } while ((candidates.length === 0 && MAX_P2 <= P2_MAX_ABS) || (candidates.length < CAND_MIN && MAX_P2 <= P2_MAX_MIN));
671
848
  if (candidates.length === 0) {
672
- throw new Error("4x4 solve: no valid phase 2 candidates found.");
849
+ throw new Cube4Phase2CapError("4x4 solve: no valid phase 2 candidates found.");
673
850
  }
674
851
  const tF2End = performance.now();
852
+ // Cheap-fix goal filter (see Cube4SolveOptions.maxFixMoves). The deep
853
+ // fallback widens it back to 4 (legacy, always satisfiable) so a hard
854
+ // state can never fail on the filter alone.
855
+ const maxFixMoves = (options?.maxFixMoves ?? 4) + (deep ? 2 : 0);
675
856
  const validReductions = [];
676
857
  /**
677
858
  * Per-candidate IDA* proofs shared across all sweep stages: the greatest
@@ -700,31 +881,50 @@ async function solveCube4State(state, tables, solveCube3, options) {
700
881
  return null;
701
882
  const rotToStandard = invertRotationSequence(path);
702
883
  const standardReduced = applyCubeRotation(reducedOriginal, rotToStandard);
703
- const cube3State = (0, phase4_js_1.toCube3State)(standardReduced);
704
- if (!cube3State) {
705
- console.log(" toCube3State failed!");
884
+ const rotMoves = rotToStandard.flatMap((r) => ROT_MOVES[r]);
885
+ if (rotMoves.length > maxFixMoves)
706
886
  return null;
887
+ if (typeof process !== "undefined" && process.env?.CUBE4_ROT_STATS === "1" && exports.rotStats.length < 200) {
888
+ exports.rotStats.push({
889
+ totalRot: totalRot.join(","),
890
+ rotToStandard: rotToStandard.join(","),
891
+ rotLen: rotToStandard.reduce((n, rr) => n + ROT_MOVES[rr].length, 0)
892
+ });
707
893
  }
894
+ // The orientation key only covers 3 of the 24 center slots. A reduced
895
+ // arrangement outside the rotation orbit (e.g. a reflected one) can
896
+ // collide with a rotation's key, in which case rotating by `path` does
897
+ // NOT leave standard centers — the 3x3 finish would solve a different
898
+ // color scheme and the resulting solution would be garbage. Reject.
899
+ for (let i = 0; i < 24; i++) {
900
+ if (standardReduced.centers[i] !== SOLVED_CENTERS[i])
901
+ return null;
902
+ }
903
+ const cube3State = (0, phase4_js_1.toCube3State)(standardReduced);
904
+ if (!cube3State)
905
+ return null;
708
906
  const h3 = tables.cube3Phase1 ? getTriAxialCube3Heuristic(standardReduced, tables.cube3Phase1) : 0;
709
- const reductionMoves = [
710
- ...rotateAlgorithm(cand.moves12, rotToStandard),
711
- ...rotateAlgorithm(moves3Remapped, rotToStandard)
712
- ];
713
- const simplifiedReduction = (0, moves_js_1.simplifyCube4Algorithm)(reductionMoves);
907
+ // The reduction + rotation prefix as it will be emitted (original frame):
908
+ // apply(state, [...moves12, ...moves3Remapped ... rotMoves]) = standardReduced.
909
+ // Priced with the fix at its best position (see placeRotationFix).
910
+ const simplifiedReduction = (0, rotation_fix_js_1.placeRotationFix)([...cand.moves12, ...moves3Remapped], rotToStandard, []);
714
911
  return {
715
912
  cand,
716
913
  moves3,
717
914
  moves3Remapped,
718
915
  rotPath: path,
719
916
  rotToStandard,
720
- rotMoves: rotToStandard.flatMap((r) => ROT_MOVES[r]),
917
+ rotMoves,
721
918
  standardReduced,
722
919
  h3,
723
920
  totalEst: simplifiedReduction.length + h3
724
921
  };
725
922
  }
726
- const centersBeam = Math.max(1, options?.centersBeam ?? 3);
727
- const edgesPerCenter = Math.max(1, options?.edgesPerCenter ?? 3);
923
+ // Defaults 1 × 5 (was 3 × 3): with the continuous finish, extra center
924
+ // candidates stopped paying. cc:0..31: 45.19 moves @ 673 ms vs 45.16 @
925
+ // 1042 ms (2×3: 45.25 @ 799; 1×8: 45.25 @ 691).
926
+ const centersBeam = Math.max(1, options?.centersBeam ?? 1);
927
+ const edgesPerCenter = Math.max(1, options?.edgesPerCenter ?? 5);
728
928
  const maxReductionsTarget = options?.finishTries !== undefined
729
929
  ? Math.max(1, options.finishTries)
730
930
  : (centersBeam * edgesPerCenter);
@@ -742,8 +942,14 @@ async function solveCube4State(state, tables, solveCube3, options) {
742
942
  const candReductions = new Map();
743
943
  const distinctCenters = new Set();
744
944
  let foundAtLength = null;
945
+ // The rotation fix (rotMoves) is priced INTO totalEst and varies 2-4
946
+ // moves per goal PATH (the end scheme depends on the path). A goal one
947
+ // total-step deeper with a 2-move fix beats a shallow goal with a 4-move
948
+ // fix, so keep collecting a few steps past the first find and let
949
+ // totalEst decide (was: foundAtLength + 1 with needed + 3 goals per
950
+ // call, which discarded fix-cheap alternatives).
745
951
  OUTER_P3: for (let length123 = pool[0].value; length123 < L123_CAP; length123++) {
746
- if (foundAtLength !== null && length123 > foundAtLength + 1)
952
+ if (foundAtLength !== null && length123 > foundAtLength + 3)
747
953
  break;
748
954
  // Sweep the whole (sorted) pool: see sortTop rationale.
749
955
  for (let i = 0; i < pool.length; i++) {
@@ -764,19 +970,34 @@ async function solveCube4State(state, tables, solveCube3, options) {
764
970
  if (distinctCenters.size >= centersBeam && !distinctCenters.has(cand))
765
971
  continue;
766
972
  if (cand.edist <= length3 && cand.cdist <= length3 && cand.hdist <= length3 && (cand.pdist ?? 0) <= length3) {
767
- const nodeLimit = length3 <= 12 ? 80000 : Math.max(45000, cfg.maxNodes);
973
+ const remainingNodes = phase3NodeBudget - (phase3Solver.epochNodes - phase3StartNodes);
974
+ if (remainingNodes <= 0) {
975
+ if (validReductions.length > 0)
976
+ return;
977
+ throw new Cube4Phase3CapError("4x4 solve: phase 3 total node budget exhausted.");
978
+ }
979
+ const nodeLimit = Math.min(remainingNodes, length3 <= 12 ? 80000 : Math.max(45000, cfg.maxNodes));
768
980
  f3calls++;
769
- const validsBefore = validReductions.length;
770
981
  const needed = edgesPerCenter - candCount;
771
982
  const candReductionsBuffer = [];
772
- const maxToCollect = Math.min(16, needed + 3);
773
- const result = phase3Solver.solveLengthProven(cand.edge, cand.ct3, cand.edist, length3, tables.center3, tables.edge3.distances, nodeLimit, 16, (moves3) => {
983
+ // Collect beyond `needed`: goal paths at the same length carry
984
+ // different rotation fixes (2 vs 4 moves), and the totalEst sort
985
+ // below needs the variety to pick a cheap-fix end.
986
+ const maxToCollect = Math.min(24, needed + 9);
987
+ const result = phase3Solver.solveLengthProven(cand.edge, cand.ct3, cand.edist, length3, tables.center3, tables.edge3.distances, nodeLimit,
988
+ // Goal budget per call: with the cheap-fix filter most goal ends
989
+ // are rejected, so the walk must be allowed to examine more of
990
+ // them before giving up on this (candidate, length3).
991
+ 48, (moves3) => {
774
992
  const red = buildValidReduction(cand, moves3);
775
993
  if (!red)
776
994
  return false;
777
995
  candReductionsBuffer.push(red);
778
- if (foundAtLength === null)
779
- foundAtLength = length123;
996
+ // <=-search: goals may land shorter than the length3 bound —
997
+ // track the true total for the post-find collection window.
998
+ const total = cand.length12 + moves3.length;
999
+ if (foundAtLength === null || total < foundAtLength)
1000
+ foundAtLength = total;
780
1001
  return candReductionsBuffer.length >= maxToCollect;
781
1002
  },
782
1003
  // hc mid-search prune disabled: on a 44-state corpus the per-node
@@ -796,20 +1017,18 @@ async function solveCube4State(state, tables, solveCube3, options) {
796
1017
  candReductions.set(cand, candCount + toAdd.length);
797
1018
  distinctCenters.add(cand);
798
1019
  }
799
- const proven = result.provenThrough;
800
- if (process.env.CUBE4_SWEEP_STATS === "1") {
801
- // eslint-disable-next-line no-console
802
- console.log(`sweep l12=${cand.length12} l3=${length3} nodes=${result.nodesUsed}/${nodeLimit} ` +
803
- `valid=${validReductions.length > validsBefore} proven=${proven} ` +
804
- `c=${cand.cdist} e=${cand.edist} h=${cand.hdist}`);
805
- }
1020
+ // Predicate-scoped proof (same buildValidReduction filter for this
1021
+ // candidate across all its calls): skip every settled length.
1022
+ const proven = result.validatedThrough;
806
1023
  if (result.moves === null && proven >= length3) {
807
1024
  const prev = p3Proven.get(cand) ?? -1;
808
1025
  if (proven > prev)
809
1026
  p3Proven.set(cand, proven);
810
1027
  }
811
1028
  // The finish loop attempts reductions up to maxReductionsTarget
812
- if (validReductions.length >= maxReductionsTarget) {
1029
+ // (with a wider collection cap so cheap-fix ends at later length
1030
+ // steps can still displace expensive-fix ones by totalEst).
1031
+ if (validReductions.length >= maxReductionsTarget + 9) {
813
1032
  break OUTER_P3;
814
1033
  }
815
1034
  }
@@ -828,10 +1047,10 @@ async function solveCube4State(state, tables, solveCube3, options) {
828
1047
  };
829
1048
  // Sweeps with growing budgets; longitudes extend dynamically (reference
830
1049
  // style) while nothing is found, instead of fixed caps. Easy states only
831
- // pay the first sweep.
832
- // Sweeps with growing budgets; longitudes extend dynamically (reference
833
- // style) while nothing is found, instead of fixed caps. Easy states only
834
- // pay the first sweep.
1050
+ // pay the first sweep. The length3 caps are NOT redundant re-walks: the
1051
+ // ladder widens the pairing cap ONLY when nothing was found at all, and the
1052
+ // narrower caps keep early sweeps on short pairings (measured: a single
1053
+ // merged sweep with maxLength3 24 is ~20% SLOWER with identical output).
835
1054
  // NOTE: lowering these caps was tried (20k/16k) and reverted: capped calls
836
1055
  // change which valids are found (±2 moves on the corpus). Only admissible
837
1056
  // heuristic strengthenings are length-safe here.
@@ -874,6 +1093,10 @@ async function solveCube4State(state, tables, solveCube3, options) {
874
1093
  searchedL2.add(key);
875
1094
  p1CandCount = 0;
876
1095
  wpStack[0].set(prep.bridgeState.wp);
1096
+ for (let p = 0; p < 24; p++)
1097
+ posStack[0][prep.bridgeState.wp[p]] = p;
1098
+ validPosDepth = 0;
1099
+ wpMaskStack[0] = pairingMaskOf(prep.bridgeState.wp);
877
1100
  validWpDepth = 0;
878
1101
  searchPhase2(prep.ct, prep.rl, length2, -1, 0, prep.bridgeState, prep);
879
1102
  if (candidates.length - poolStart >= 40)
@@ -891,7 +1114,7 @@ async function solveCube4State(state, tables, solveCube3, options) {
891
1114
  }
892
1115
  }
893
1116
  if (validReductions.length === 0) {
894
- throw new Error("4x4 solve: reduction failed across all candidates.");
1117
+ throw new Cube4Phase3CapError("4x4 solve: phase 3 reduction failed across all candidates.");
895
1118
  }
896
1119
  // Sort reductions by Total Estimated Moves (Reduction length + 3x3 Phase 1 lower bound)
897
1120
  validReductions.sort((a, b) => a.totalEst - b.totalEst);
@@ -906,28 +1129,56 @@ async function solveCube4State(state, tables, solveCube3, options) {
906
1129
  // Best-of among the first three (phase-4 is cheap now); further
907
1130
  // reductions are only attempted when those all failed.
908
1131
  const finishTries = options?.finishTries ?? maxReductionsTarget;
909
- for (let i = 0; i < validReductions.length; i++) {
910
- if (i >= finishTries && bestSolution)
911
- break;
912
- const red = validReductions[i];
1132
+ const collectFinishStats = typeof process !== "undefined" && process.env?.CUBE4_FINISH_STATS === "1";
1133
+ // Empirical finish lower bound (see finishStats): every observed finish
1134
+ // ran 11-16 moves ABOVE `totalEst` (continuous finish: 67 finishes over 16
1135
+ // seeds, gap = totalEst - finalLen in [-16, -11]; the legacy finish was
1136
+ // [-16, -12]) because `h3` only bounds the cube3 PHASE-1 length while the
1137
+ // finish is two-phase. A reduction whose totalEst + 11 cannot strictly
1138
+ // beat the current best therefore cannot win, and its finish is skipped
1139
+ // entirely. If a state ever lands a finish within 11 of the phase-1 bound,
1140
+ // this degrades to a slightly longer (still exact,
1141
+ // still valid) scramble for that state — never an incorrect one.
1142
+ const FINISH_LB_BIAS = 11;
1143
+ const screenTop = Math.max(0, Math.floor(options?.finishScreenTop ?? 0));
1144
+ const screened = [];
1145
+ /** Finishes one reduction; records it as the best when shorter. */
1146
+ async function finishReduction(red, effort) {
913
1147
  const cube3State = (0, phase4_js_1.toCube3State)(red.standardReduced);
914
1148
  if (!cube3State)
915
- continue;
1149
+ return null;
916
1150
  let cube3Solution;
917
1151
  try {
918
- cube3Solution = await solveCube3(cube3State);
1152
+ cube3Solution = await solveCube3(cube3State, effort);
919
1153
  }
920
1154
  catch {
921
- continue;
1155
+ return null;
1156
+ }
1157
+ // Frame invariant (verified): apply(state, [...moves12, ...moves3Remapped])
1158
+ // is exactly `reducedOriginal`, and `reducedOriginal · rotMoves(r)`
1159
+ // (= applyCubeRotation(_, r)) is `standardReduced`, which the cube3
1160
+ // finish C solves. So the solution in the ORIGINAL frame is
1161
+ // X ++ rotMoves(r) ++ C.
1162
+ // Two previously-tried assemblies are WRONG:
1163
+ // - [rot_r(X), C] solves ρ_r(state), not state (the observed adaptive
1164
+ // whole-cube-rotation bug: apply(solved, invert(moves)) = ρ_r(T));
1165
+ // - [X, rot_{-r}(C)] lands on ρ_{-r}(solved) ≠ solved, because a
1166
+ // whole-cube rotation does NOT leave the canonical solved state
1167
+ // invariant (it permutes face colors in the absolute frame).
1168
+ // `placeRotationFix` keeps that effect but may move the rotMoves(r)
1169
+ // block anywhere in the sequence (re-mapping what follows it) where it
1170
+ // cancels against adjacent wide moves.
1171
+ const simplified = (0, rotation_fix_js_1.placeRotationFix)([...red.cand.moves12, ...red.moves3Remapped], red.rotToStandard, cube3Solution.moves);
1172
+ if (collectFinishStats) {
1173
+ exports.finishStats.finishes++;
1174
+ const gap = red.totalEst - simplified.length;
1175
+ if (gap < exports.finishStats.gapMin)
1176
+ exports.finishStats.gapMin = gap;
1177
+ if (gap > exports.finishStats.gapMax)
1178
+ exports.finishStats.gapMax = gap;
1179
+ exports.finishStats.gapSum += gap;
1180
+ exports.finishStats.gapN++;
922
1181
  }
923
- // Rotate the solve algorithm so it is performed from the standard orientation ("hacer los movimientos desde otro lado").
924
- // This allows the solve to transition directly to the standard solved state WITHOUT ANY WHOLE-CUBE ROTATIONS (rotMoves)!
925
- const rotatedFullSolution = [
926
- ...rotateAlgorithm(red.cand.moves12, red.rotToStandard),
927
- ...rotateAlgorithm(red.moves3Remapped, red.rotToStandard),
928
- ...cube3Solution.moves
929
- ];
930
- const simplified = (0, moves_js_1.simplifyCube4Algorithm)(rotatedFullSolution);
931
1182
  if (simplified.length < minTotalMoves) {
932
1183
  minTotalMoves = simplified.length;
933
1184
  bestSolution = simplified;
@@ -940,9 +1191,31 @@ async function solveCube4State(state, tables, solveCube3, options) {
940
1191
  };
941
1192
  bestRed = red;
942
1193
  }
1194
+ return simplified;
1195
+ }
1196
+ for (let i = 0; i < validReductions.length; i++) {
1197
+ if (i >= finishTries && bestSolution)
1198
+ break;
1199
+ const red = validReductions[i];
1200
+ if (bestSolution !== null && red.totalEst + FINISH_LB_BIAS >= minTotalMoves) {
1201
+ exports.finishStats.skipped++;
1202
+ continue;
1203
+ }
1204
+ const done = await finishReduction(red, screenTop > 0 ? "screen" : "full");
1205
+ if (done)
1206
+ screened.push({ red, total: done.length });
1207
+ }
1208
+ // Stage 2: the best screened reductions get the full-effort finish.
1209
+ if (screenTop > 0) {
1210
+ screened.sort((a, b) => a.total - b.total);
1211
+ for (const { red } of screened.slice(0, screenTop)) {
1212
+ if (red.totalEst + FINISH_LB_BIAS >= minTotalMoves)
1213
+ continue;
1214
+ await finishReduction(red, "full");
1215
+ }
943
1216
  }
944
1217
  if (!bestSolution || !bestRed) {
945
- throw new Error("4x4 solve: reduction failed across all candidates.");
1218
+ throw new Cube4FinishCapError("4x4 solve: reduction failed during the 3x3 finish across all valid reductions.");
946
1219
  }
947
1220
  // Polish pass: the sweep takes the first valid sequence per exact
948
1221
  // length, which is often one above optimal. Retry the winner one length
@@ -962,13 +1235,23 @@ async function solveCube4State(state, tables, solveCube3, options) {
962
1235
  // and a full 150k grind on tail states cost seconds for nothing. It is
963
1236
  // also skipped entirely unless the total is high enough to matter.
964
1237
  const polishBudget = r === bestRed ? 150000 : 30000;
1238
+ // NOTE: with the <=-length phase-3 search the sweep already enumerates
1239
+ // shorter goals, so the polish is a second-chance pass only (measured:
1240
+ // a wider gate/budget found nothing and cost +32% p3 nodes).
965
1241
  for (let iter = 0; iter < 4 && (minTotalMoves > 43 || curRed.moves3.length > 11); iter++) {
966
1242
  const cand = curRed.cand;
967
1243
  const shorterLen = curRed.moves3.length - 1;
968
1244
  if (shorterLen < cand.edist || shorterLen < cand.cdist || shorterLen < cand.hdist || shorterLen < (cand.pdist ?? 0))
969
1245
  break;
970
1246
  f3calls++;
971
- const shorter = phase3Solver.solveLengthValid(cand.edge, cand.ct3, cand.edist, shorterLen, tables.center3, tables.edge3.distances, polishBudget, 64, (moves3) => buildValidReduction(cand, moves3) !== null, tables.hc, cand.hcCoord, tables.c3PairDist);
1247
+ // Unified polish: accept any goal path with a strictly better
1248
+ // totalEst at the same or shorter length — covers both a shorter
1249
+ // phase-3 path and a cheaper rotation fix (the fix is priced into
1250
+ // totalEst and varies 2-4 moves per goal path).
1251
+ const shorter = phase3Solver.solveLengthValid(cand.edge, cand.ct3, cand.edist, curRed.moves3.length, tables.center3, tables.edge3.distances, polishBudget, 64, (moves3) => {
1252
+ const nr = buildValidReduction(cand, moves3);
1253
+ return nr !== null && nr.totalEst < curRed.totalEst;
1254
+ }, tables.hc, cand.hcCoord, tables.c3PairDist);
972
1255
  if (!shorter)
973
1256
  break;
974
1257
  const red = buildValidReduction(cand, shorter);
@@ -984,11 +1267,8 @@ async function solveCube4State(state, tables, solveCube3, options) {
984
1267
  catch {
985
1268
  break;
986
1269
  }
987
- const simplified = (0, moves_js_1.simplifyCube4Algorithm)([
988
- ...rotateAlgorithm(red.cand.moves12, red.rotToStandard),
989
- ...rotateAlgorithm(red.moves3Remapped, red.rotToStandard),
990
- ...cube3Solution.moves
991
- ]);
1270
+ // Same frame invariant as the finish loop: X ++ rotMoves(r) ++ C.
1271
+ const simplified = (0, rotation_fix_js_1.placeRotationFix)([...red.cand.moves12, ...red.moves3Remapped], red.rotToStandard, cube3Solution.moves);
992
1272
  if (simplified.length < minTotalMoves) {
993
1273
  minTotalMoves = simplified.length;
994
1274
  bestSolution = simplified;
@@ -1013,13 +1293,19 @@ async function solveCube4State(state, tables, solveCube3, options) {
1013
1293
  accF3Ms += tFinishStart - tF2End;
1014
1294
  accFinishMs += tPolishStart - tFinishStart;
1015
1295
  accPolishMs += performance.now() - tPolishStart;
1296
+ // Final invariant: the emitted solution must solve the INPUT state
1297
+ // exactly (no rotation tolerance). Fail loudly rather than return a
1298
+ // solution that does not resolve. One replay of ~45 moves — negligible
1299
+ // next to the search cost.
1300
+ if (!(0, state_js_1.isSolvedCube4State)((0, moves_js_1.applyCube4Algorithm)(state, bestSolution))) {
1301
+ throw new Error("4x4 solve: internal verification failed — the solution does not solve the input state.");
1302
+ }
1016
1303
  return { moves: bestSolution, phases: bestPhases };
1017
1304
  };
1018
1305
  let bestMoves = null;
1019
1306
  let bestPhasesOverall;
1020
1307
  let lastRestartError = null;
1021
1308
  for (let restart = 0; restart < totalRestarts; restart++) {
1022
- phase3_search_js_1.FastPhase3Solver.nextEpoch();
1023
1309
  const rng = totalRestarts > 1
1024
1310
  ? mulberry32((hashCube4State(state) ^ Math.imul(restart + 1, 0x9e3779b9)) >>> 0)
1025
1311
  : null;