@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
@@ -3,17 +3,19 @@
3
3
  * (phase 4), returning the move sequence that solves the given state.
4
4
  * The scramble for a sampled state is the inverted solution.
5
5
  */
6
- import { applyCube4Algorithm, invertCube4Algorithm, simplifyCube4Algorithm } from "../moves.js";
6
+ import { applyCube4Algorithm, invertCube4Algorithm } from "../moves.js";
7
7
  import { center1Mask, getCenter1Distance, applyCenter1Move, CENTER1_MOVES, CENTER1_GOAL_MASKS } from "./center1.js";
8
- import { center2Bridge, getWingMoveMaps, isCenter2Goal, CENTER2_MOVES, CENTER2_SEARCH_MOVES, CENTER2_SEARCH_MOVES_WIDE, CENTER2_RL_STATES } from "./center2.js";
8
+ import { center2Bridge, getWingMoveMaps, CENTER2_GOAL_RL, CENTER2_MOVES, CENTER2_SEARCH_MOVES, CENTER2_SEARCH_MOVES_WIDE, CENTER2_RL_STATES, encodeCenter2ChiralCt, generateCenter2ChiralTables } from "./center2.js";
9
9
  import { encodeCenter3 } from "./center3.js";
10
- import { encodePairingState, hasPairingTransversal } from "./pairing-coordinate.js";
11
- import { encodeHcPdb } from "./hc-pdb.js";
10
+ import { encodePairingState, PAIRING_SECOND_HALVES } from "./pairing-coordinate.js";
11
+ import { encodeHcPdb, hcDenseIndex } from "./hc-pdb.js";
12
12
  import { getSymTables, getMvSymFrom } from "./edge3.js";
13
13
  import { FastPhase3Solver } from "./phase3-search.js";
14
+ import { placeRotationFix } from "./rotation-fix.js";
15
+ import { getTransversalPairPdb, getWingPositionMoves, PAIR_INDEX, PAIR_STATES } from "./pair-transversal.js";
14
16
  import { checkCube4Reduction, toCube3State } from "./phase4.js";
15
17
  import { encodeCornerOrientation, encodeEdgeOrientation, encodeUdSlice } from "../../cube3/solver/coordinates.js";
16
- import { createSolvedCube4State } from "../state.js";
18
+ import { createSolvedCube4State, isSolvedCube4State } from "../state.js";
17
19
  function mulberry32(seed) {
18
20
  let a = seed >>> 0;
19
21
  return () => {
@@ -104,6 +106,29 @@ function getTriAxialCube3Heuristic(standardReduced, tables) {
104
106
  }
105
107
  return minH;
106
108
  }
109
+ class Cube4SolveError extends Error {
110
+ }
111
+ export class Cube4Phase2CapError extends Cube4SolveError {
112
+ code = "cube4-phase2-cap";
113
+ constructor(message) {
114
+ super(message);
115
+ this.name = "Cube4Phase2CapError";
116
+ }
117
+ }
118
+ export class Cube4Phase3CapError extends Cube4SolveError {
119
+ code = "cube4-phase3-cap";
120
+ constructor(message) {
121
+ super(message);
122
+ this.name = "Cube4Phase3CapError";
123
+ }
124
+ }
125
+ export class Cube4FinishCapError extends Cube4SolveError {
126
+ code = "cube4-finish-cap";
127
+ constructor(message) {
128
+ super(message);
129
+ this.name = "Cube4FinishCapError";
130
+ }
131
+ }
107
132
  function cornerPermutationParity(cp) {
108
133
  let parity = 0;
109
134
  const seen = new Array(8).fill(false);
@@ -183,6 +208,8 @@ const VALID_24_ORIENTATIONS = (() => {
183
208
  }
184
209
  return map;
185
210
  })();
211
+ /** Centers of the solved state — used to verify a full (not just partial) standard orientation. */
212
+ const SOLVED_CENTERS = createSolvedCube4State().centers;
186
213
  const ROT_MAP = {
187
214
  "x": { "U": "B", "R": "R", "F": "U", "D": "F", "L": "L", "B": "D" },
188
215
  "x'": { "U": "F", "R": "R", "F": "D", "D": "B", "L": "L", "B": "U" },
@@ -218,6 +245,10 @@ export function rotateAlgorithm(alg, rotSeq) {
218
245
  return current;
219
246
  });
220
247
  }
248
+ /** Physical wide-move sequence implementing a whole-cube rotation. */
249
+ export function cubeRotationMoves(rotSeq) {
250
+ return rotSeq.flatMap((rotation) => ROT_MOVES[rotation]);
251
+ }
221
252
  export function invertRotationSequence(rotSeq) {
222
253
  return [...rotSeq].reverse().map((r) => INVERT_ROT[r]);
223
254
  }
@@ -258,6 +289,49 @@ function isCancelled(previous, current) {
258
289
  export const P2_CANCEL = CENTER2_SEARCH_MOVES.map((previous) => CENTER2_SEARCH_MOVES.map((current) => isCancelled(previous, current)));
259
290
  // Same over the wide 28-move set (used when `center2Wide` is set).
260
291
  const P2_CANCEL_WIDE = CENTER2_SEARCH_MOVES_WIDE.map((previous) => CENTER2_SEARCH_MOVES_WIDE.map((current) => isCancelled(previous, current)));
292
+ /** Flat (row-major) cancel tables for the hot DFS loop; same semantics. */
293
+ function flattenCancel(table) {
294
+ const n = table.length;
295
+ const flat = new Uint8Array(n * n);
296
+ for (let i = 0; i < n; i++) {
297
+ const row = table[i];
298
+ for (let j = 0; j < n; j++)
299
+ flat[i * n + j] = row[j] ? 1 : 0;
300
+ }
301
+ return flat;
302
+ }
303
+ const P2_CANCEL_FLAT = flattenCancel(P2_CANCEL);
304
+ const P2_CANCEL_WIDE_FLAT = flattenCancel(P2_CANCEL_WIDE);
305
+ /**
306
+ * Wing transversal mask: bit k set iff pair k occupies a second-half slot.
307
+ * `hasPairingTransversal(wp)` is equivalent to `pairingMaskOf(wp) === 0xfff`
308
+ * (12 slots, 12 pair ids in 0..11: all distinct ⟺ all 12 bits set). The mask
309
+ * form is O(1) at the millions of phase-2 goal leaves.
310
+ */
311
+ function pairingMaskOf(wp) {
312
+ let mask = 0;
313
+ for (let i = 0; i < 12; i++) {
314
+ mask |= 1 << (wp[PAIRING_SECOND_HALVES[i]] >> 1);
315
+ }
316
+ return mask;
317
+ }
318
+ /** rl side of the phase-2 goal test (same flags as `isCenter2Goal`). */
319
+ const P2_GOAL_RL = (() => {
320
+ const flags = new Uint8Array(CENTER2_RL_STATES);
321
+ for (const rl of CENTER2_GOAL_RL)
322
+ flags[rl] = 1;
323
+ return flags;
324
+ })();
325
+ /** Chiral phase-2 tables, generated once per loaded center2 table set. */
326
+ const chiralTablesCache = new WeakMap();
327
+ function getCenter2ChiralTables(center2) {
328
+ let chiral = chiralTablesCache.get(center2);
329
+ if (!chiral) {
330
+ chiral = generateCenter2ChiralTables(center2.rlMove);
331
+ chiralTablesCache.set(center2, chiral);
332
+ }
333
+ return chiral;
334
+ }
261
335
  const SKIP_AXIS_2 = new Int32Array(CENTER2_SEARCH_MOVES.length);
262
336
  for (let i = 0; i < CENTER2_SEARCH_MOVES.length; i++) {
263
337
  const m1 = CENTER2_SEARCH_MOVES[i];
@@ -274,7 +348,6 @@ for (let i = 0; i < CENTER2_SEARCH_MOVES.length; i++) {
274
348
  }
275
349
  }
276
350
  }
277
- const phase3Solver = new FastPhase3Solver();
278
351
  /** Temporary instrumentation for F2 goal statistics (CUBE4_F2_STATS=1). */
279
352
  export const f2Stats = { goals: 0, transversalPass: 0, axisRejects: 0, minTransversalL2: 99, minChiralL2: 99 };
280
353
  export function resetF2Stats() {
@@ -290,13 +363,38 @@ export function resetStagnationStats() {
290
363
  stagnationStats.checks = 0;
291
364
  stagnationStats.cuts = 0;
292
365
  }
366
+ /**
367
+ * Temporary instrumentation for the finish-skip bound (CUBE4_FINISH_STATS=1):
368
+ * `gap = totalEst - finalLen` per finished reduction. A skip
369
+ * `totalEst - SLACK >= minTotalMoves` is only sound while SLACK covers the
370
+ * observed gap maximum (junction cancellation + finish-vs-h3 slack).
371
+ */
372
+ export const finishStats = {
373
+ gapMin: 99,
374
+ gapMax: -99,
375
+ gapSum: 0,
376
+ gapN: 0,
377
+ finishes: 0,
378
+ skipped: 0
379
+ };
380
+ /**
381
+ * Temporary instrumentation for the rotMoves cost (CUBE4_ROT_STATS=1):
382
+ * per built reduction records the prep frame rotation, the derived
383
+ * normalization rotation and the emitted fix length.
384
+ */
385
+ export const rotStats = [];
293
386
  export async function solveCube4State(state, tables, solveCube3, options) {
294
387
  // NOTE: no wall-clock deadline inside the search — all loops are bounded
295
- // by deterministic work caps (node counts, candidate counts, length
296
- // caps), so the same state always takes the same path. The caller keeps
297
- // its own backstop (`timeoutMs` handled at the call site).
388
+ // by deterministic work caps (node counts, candidate counts, length caps),
389
+ // so the same state always takes the same path.
298
390
  const sym = getSymTables();
391
+ // All mutable phase-3 state (TT, epoch-node count, call IDs and search
392
+ // stacks) belongs to this invocation. The same instance spans its
393
+ // deterministic restarts, retaining useful goal-free TT entries without
394
+ // allowing concurrent solves to contaminate one another.
395
+ const phase3Solver = new FastPhase3Solver();
299
396
  phase3Solver.resetEpochNodes();
397
+ const collectF2Stats = typeof process !== "undefined" && process.env?.CUBE4_F2_STATS === "1";
300
398
  const totalRestarts = Math.max(1, Math.floor(options?.restarts ?? 1));
301
399
  // Cross-restart work accumulators (reported in nodes/timings).
302
400
  let accP2 = 0;
@@ -321,8 +419,8 @@ export async function solveCube4State(state, tables, solveCube3, options) {
321
419
  const path = [];
322
420
  const p1Extra = options?.phase1ExtraDepths ?? 3;
323
421
  const p1Cap = options?.phase1Cap ?? 400;
324
- // Per-restart DFS traversal order (identity when rng is null).
325
- const p1Order = restartOrder(CENTER1_MOVES.length, rng);
422
+ // Per-restart DFS traversal order (null = identity fast path when rng is null).
423
+ const p1Order = rng === null ? null : Int32Array.from(restartOrder(CENTER1_MOVES.length, rng));
326
424
  for (let maxD = minOverallH; maxD <= minOverallH + p1Extra; maxD++) {
327
425
  for (const urf of [0, 1, 2]) {
328
426
  const minH = urfDistances[urf];
@@ -359,7 +457,7 @@ export async function solveCube4State(state, tables, solveCube3, options) {
359
457
  if (depth + h > maxD)
360
458
  return false;
361
459
  for (let mi = 0; mi < CENTER1_MOVES.length; mi++) {
362
- const mv = p1Order[mi];
460
+ const mv = p1Order === null ? mi : p1Order[mi];
363
461
  if (lm >= 0 && Math.floor(lm / 3) === Math.floor(mv / 3))
364
462
  continue;
365
463
  const next = applyCenter1Move(m, tables.center1.moveMaps, mv);
@@ -378,12 +476,24 @@ export async function solveCube4State(state, tables, solveCube3, options) {
378
476
  const minP1 = p1Sols[0]?.moves.length ?? 0;
379
477
  const tF1End = performance.now();
380
478
  const candidates = [];
381
- const moveLog = new Array(25);
479
+ const moveLog = new Int32Array(48);
382
480
  // Incremental wing tracking stack for phase-2 goal transversal checks:
383
481
  // avoids full 6-7 move replaying from scratch across hundreds of thousands of candidate goals.
384
- const wpStack = Array.from({ length: 24 }, () => new Uint8Array(24));
482
+ // `wpMaskStack[d]` = pairingMaskOf(wpStack[d]) maintained in the same lazy
483
+ // fill, so the per-leaf transversal test is a single compare.
484
+ const wpStack = Array.from({ length: 32 }, () => new Uint8Array(24));
485
+ const wpMaskStack = new Int32Array(32);
385
486
  let validWpDepth = 0;
386
487
  const wingMaps = getWingMoveMaps();
488
+ // Transversal pruning (see pair-transversal.ts): wing positions per DFS
489
+ // depth (`posStack[d][wing]`), checked against the two-pair PDB over 6
490
+ // disjoint pair-of-pairs in the last TRANSVERSAL_DEPTH levels (the PDB's
491
+ // maximum). Prunes only branches with no transversal goal, so the
492
+ // accepted candidates are unchanged.
493
+ const posStack = Array.from({ length: 33 }, () => new Uint8Array(24));
494
+ const wingPosMove = getWingPositionMoves();
495
+ const TRANSVERSAL_DEPTH = 3;
496
+ let validPosDepth = 0;
387
497
  /**
388
498
  * Anti-hang work cap for the phase-2 search (successful attempts use
389
499
  * ~0.1-0.3M nodes; this only aborts pathological explorations).
@@ -396,11 +506,21 @@ export async function solveCube4State(state, tables, solveCube3, options) {
396
506
  // indices in either mode.
397
507
  const c2Wide = options?.center2Wide === true;
398
508
  const C2_SEARCH = c2Wide ? CENTER2_SEARCH_MOVES_WIDE : CENTER2_SEARCH_MOVES;
399
- const C2_CANCEL = c2Wide ? P2_CANCEL_WIDE : P2_CANCEL;
400
- // Per-restart child order (identity when rng is null). Entries stay
509
+ const cancelFlat = c2Wide ? P2_CANCEL_WIDE_FLAT : P2_CANCEL_FLAT;
510
+ const c2Stride = C2_SEARCH.length;
511
+ const transversalPdb = getTransversalPairPdb(C2_SEARCH.length);
512
+ const canSkipAxis = rng === null && !c2Wide;
513
+ // Per-restart child order (null = identity fast path). Entries stay
401
514
  // original move-table indices, so all table lookups are unaffected.
402
- const p2Order = restartOrder(C2_SEARCH.length, rng);
403
- let c2pruning = tables.center2.pruning;
515
+ const p2Order = rng === null ? null : Int32Array.from(restartOrder(C2_SEARCH.length, rng));
516
+ // Default set: chiral tables (goal = ct == 0 && axisDet == 1 exactly, so
517
+ // wrong-chirality goal paths are pruned instead of enumerated). The wide
518
+ // set keeps the relative tables + the axisDet filter at the goal.
519
+ const chiral = c2Wide ? null : getCenter2ChiralTables(tables.center2);
520
+ const c2ctMove = chiral ? chiral.ctMove : tables.center2.ctMove;
521
+ const c2rlMove = tables.center2.rlMove;
522
+ const c2GoalCt = chiral ? chiral.goalCt : 0;
523
+ let c2pruning = chiral ? chiral.pruning : tables.center2.pruning;
404
524
  if (c2Wide) {
405
525
  if (!tables.center2.pruningWide) {
406
526
  throw new Error("4x4 solve: center2Wide requested but pruningWide tables are not loaded — regenerate center2 tables.");
@@ -408,6 +528,8 @@ export async function solveCube4State(state, tables, solveCube3, options) {
408
528
  c2pruning = tables.center2.pruningWide;
409
529
  }
410
530
  const P2_MAX_NODES = deep ? 24_000_000 : 8_000_000;
531
+ // Hoisted out of the goal path (was read per goal leaf).
532
+ const maxP1Cand = options?.p1CandCap !== undefined ? options.p1CandCap : (deep ? 24 : 16);
411
533
  // Smaller pool + shorter extension (was 180/100/14/12 on the fast path):
412
534
  // the deep length2 levels are where F2 tails burn. Validated on a 44-state
413
535
  // corpus: -16% nodes, identical average length (one +1 in 44).
@@ -421,11 +543,16 @@ export async function solveCube4State(state, tables, solveCube3, options) {
421
543
  const EXT_MX = deep ? 20 : 16;
422
544
  let p2nodes = 0;
423
545
  let f3calls = 0;
546
+ // Bound the whole sweep, not just each individual IDA* call. A hard
547
+ // candidate pool can otherwise spend thousands of capped calls before
548
+ // reaching the deep retry (corpus-73: 114M nodes in the fast attempt).
549
+ const phase3StartNodes = phase3Solver.epochNodes;
550
+ const phase3NodeBudget = deep ? 16_000_000 : 4_000_000;
424
551
  function searchPhase2(c, r, maxl, lm, depth, bridgeState, prep) {
425
552
  p2nodes++;
426
553
  if (p2nodes > P2_MAX_NODES)
427
554
  return false;
428
- if (isCenter2Goal(c, r)) {
555
+ if (c === c2GoalCt && P2_GOAL_RL[r] === 1) {
429
556
  if (maxl === 0) {
430
557
  while (validWpDepth < depth) {
431
558
  const m = moveLog[validWpDepth];
@@ -434,30 +561,34 @@ export async function solveCube4State(state, tables, solveCube3, options) {
434
561
  const dst = wpStack[validWpDepth + 1];
435
562
  for (let i = 0; i < 24; i++)
436
563
  dst[i] = src[wingMaps[base + i]];
564
+ wpMaskStack[validWpDepth + 1] = pairingMaskOf(dst);
437
565
  validWpDepth++;
438
566
  }
439
- const goalWp = wpStack[depth];
440
- if (process.env.CUBE4_F2_STATS === "1")
567
+ if (collectF2Stats)
441
568
  f2Stats.goals++;
442
- if (!hasPairingTransversal(goalWp))
569
+ // Bit-identical to `hasPairingTransversal(goalWp)`: all 12 pair bits
570
+ // distinct ⟺ the 12-bit mask is full (0xfff).
571
+ if (wpMaskStack[depth] !== 0xfff)
443
572
  return false;
444
- if (process.env.CUBE4_F2_STATS === "1")
573
+ if (collectF2Stats)
445
574
  f2Stats.transversalPass++;
446
- const moves2 = moveLog.slice(0, depth).map((m) => CENTER2_MOVES[m]);
575
+ const moves2 = [];
576
+ for (let i = 0; i < depth; i++)
577
+ moves2.push(CENTER2_MOVES[moveLog[i]]);
447
578
  const s2 = applyCube4Algorithm(bridgeState, moves2);
448
579
  const uAxis = s2.centers[0] % 3;
449
580
  const rAxis = s2.centers[4] % 3;
450
581
  const fAxis = s2.centers[8] % 3;
451
- if (process.env.CUBE4_F2_STATS === "1") {
582
+ if (collectF2Stats) {
452
583
  if (depth < f2Stats.minTransversalL2)
453
584
  f2Stats.minTransversalL2 = depth;
454
585
  }
455
586
  if (axisDet(uAxis, rAxis, fAxis) !== 1) {
456
- if (process.env.CUBE4_F2_STATS === "1")
587
+ if (collectF2Stats)
457
588
  f2Stats.axisRejects++;
458
589
  return false;
459
590
  }
460
- if (process.env.CUBE4_F2_STATS === "1") {
591
+ if (collectF2Stats) {
461
592
  if (depth < f2Stats.minChiralL2)
462
593
  f2Stats.minChiralL2 = depth;
463
594
  }
@@ -477,7 +608,7 @@ export async function solveCube4State(state, tables, solveCube3, options) {
477
608
  const moves2Remapped = prep.totalRot.length === 0
478
609
  ? moves2
479
610
  : rotateAlgorithm(moves2, invertRotationSequence(prep.totalRot));
480
- const hcCoord = tables.hc ? tables.hc.index[encodeHcPdb(s2.centers)] : -1;
611
+ const hcCoord = tables.hc ? hcDenseIndex(tables.hc, encodeHcPdb(s2.centers)) : -1;
481
612
  const hcRaw = hcCoord >= 0 && tables.hc ? tables.hc.distances[hcCoord] : 0;
482
613
  const hdist = hcRaw === 0x0f ? 0 : hcRaw;
483
614
  let pdist = 0;
@@ -524,7 +655,6 @@ export async function solveCube4State(state, tables, solveCube3, options) {
524
655
  }
525
656
  }
526
657
  p1CandCount++;
527
- const maxP1Cand = options?.p1CandCap !== undefined ? options.p1CandCap : (deep ? 96 : 64);
528
658
  if (p1CandCount >= maxP1Cand)
529
659
  return true;
530
660
  }
@@ -534,27 +664,65 @@ export async function solveCube4State(state, tables, solveCube3, options) {
534
664
  }
535
665
  if (maxl === 0)
536
666
  return false;
537
- const c2ctMove = tables.center2.ctMove;
538
- const c2rlMove = tables.center2.rlMove;
539
667
  const c2RowC = c * CENTER2_MOVES.length;
540
668
  const c2RowR = r * CENTER2_MOVES.length;
541
669
  for (let mi = 0; mi < C2_SEARCH.length; mi++) {
542
- const m = p2Order[mi];
543
- if (lm >= 0 && C2_CANCEL[lm][m]) {
670
+ const m = p2Order === null ? mi : p2Order[mi];
671
+ if (lm >= 0 && cancelFlat[lm * c2Stride + m]) {
544
672
  continue;
545
673
  }
546
674
  const nc = c2ctMove[c2RowC + m];
547
675
  const nr = c2rlMove[c2RowR + m];
548
676
  const prun = c2pruning[nc * CENTER2_RL_STATES + nr];
549
677
  if (prun >= maxl) {
550
- if (prun > maxl && rng === null && !c2Wide) {
678
+ if (prun > maxl && canSkipAxis) {
551
679
  mi = SKIP_AXIS_2[m] - 1;
552
680
  }
553
681
  continue;
554
682
  }
683
+ let childPosValid = false;
684
+ if (maxl - 1 < TRANSVERSAL_DEPTH) {
685
+ // Lazy fill (like wpStack): replay the path down to this node.
686
+ while (validPosDepth < depth) {
687
+ const src = posStack[validPosDepth];
688
+ const dst = posStack[validPosDepth + 1];
689
+ const row = moveLog[validPosDepth] * 24;
690
+ for (let w = 0; w < 24; w++)
691
+ dst[w] = wingPosMove[row + src[w]];
692
+ validPosDepth++;
693
+ }
694
+ const parentPos = posStack[depth];
695
+ const childPos = posStack[depth + 1];
696
+ const posRow = m * 24;
697
+ const limit = maxl - 1;
698
+ let blocked = false;
699
+ // Group by group (4 wings = 2 pairs each), so a blocked child only
700
+ // pays for the groups actually checked.
701
+ for (let q = 0; q < 24; q += 4) {
702
+ const a0 = wingPosMove[posRow + parentPos[q]];
703
+ const a1 = wingPosMove[posRow + parentPos[q + 1]];
704
+ const b0 = wingPosMove[posRow + parentPos[q + 2]];
705
+ const b1 = wingPosMove[posRow + parentPos[q + 3]];
706
+ if (transversalPdb[PAIR_INDEX[a0 * 24 + a1] * PAIR_STATES + PAIR_INDEX[b0 * 24 + b1]] > limit) {
707
+ blocked = true;
708
+ break;
709
+ }
710
+ childPos[q] = a0;
711
+ childPos[q + 1] = a1;
712
+ childPos[q + 2] = b0;
713
+ childPos[q + 3] = b1;
714
+ }
715
+ if (blocked)
716
+ continue;
717
+ childPosValid = true;
718
+ }
555
719
  moveLog[depth] = m;
556
720
  if (validWpDepth > depth)
557
721
  validWpDepth = depth;
722
+ if (validPosDepth > depth)
723
+ validPosDepth = depth;
724
+ if (childPosValid)
725
+ validPosDepth = depth + 1;
558
726
  if (searchPhase2(nc, nr, maxl - 1, m, depth + 1, bridgeState, prep))
559
727
  return true;
560
728
  }
@@ -576,7 +744,8 @@ export async function solveCube4State(state, tables, solveCube3, options) {
576
744
  continue;
577
745
  const totalRot = [...p1.rotAlign, ...bridge.rotation];
578
746
  const prefix = p1.moves;
579
- const prun = c2pruning[bridge.encoded.ct * CENTER2_RL_STATES + bridge.encoded.rl];
747
+ const ct = chiral ? encodeCenter2ChiralCt(bridge.state) : bridge.encoded.ct;
748
+ const prun = c2pruning[ct * CENTER2_RL_STATES + bridge.encoded.rl];
580
749
  // Paired wings lookahead: check how many wings are already paired in bridge.state
581
750
  let pairedWings = 0;
582
751
  const wp = bridge.state.wp;
@@ -588,13 +757,13 @@ export async function solveCube4State(state, tables, solveCube3, options) {
588
757
  // Lookahead composite score: total estimated centers penalized by already-paired wings
589
758
  const estCenters = prefix.length + prun;
590
759
  const score = estCenters * 2 - Math.min(4, pairedWings);
591
- preps.push({ p1, prefix, bridgeState: bridge.state, totalRot, ct: bridge.encoded.ct, rl: bridge.encoded.rl, prun, score });
760
+ preps.push({ p1, prefix, bridgeState: bridge.state, totalRot, ct, rl: bridge.encoded.rl, prun, score });
592
761
  }
593
762
  // Ascending lookahead score (lowest total estimated center moves + wing bonus),
594
763
  // tie-broken by lower phase-2 pruning bound then shorter prefix length
595
764
  preps.sort((a, b) => (a.score - b.score) || (a.prun - b.prun) || (a.prefix.length - b.prefix.length));
596
765
  // Filter: keep the best preps to avoid wasting phase-2 DFS work on suboptimal centers
597
- const PREP_CAP = options?.prepCap !== undefined ? options.prepCap : (deep ? 160 : 100);
766
+ const PREP_CAP = options?.prepCap !== undefined ? options.prepCap : (deep ? 48 : 32);
598
767
  if (preps.length > PREP_CAP) {
599
768
  preps.length = PREP_CAP;
600
769
  }
@@ -626,6 +795,10 @@ export async function solveCube4State(state, tables, solveCube3, options) {
626
795
  searchedL2.add(key);
627
796
  p1CandCount = 0;
628
797
  wpStack[0].set(prep.bridgeState.wp);
798
+ for (let p = 0; p < 24; p++)
799
+ posStack[0][prep.bridgeState.wp[p]] = p;
800
+ validPosDepth = 0;
801
+ wpMaskStack[0] = pairingMaskOf(prep.bridgeState.wp);
629
802
  validWpDepth = 0;
630
803
  searchPhase2(prep.ct, prep.rl, length2, -1, 0, prep.bridgeState, prep);
631
804
  if (candidates.length >= CAND_CAP &&
@@ -660,9 +833,13 @@ export async function solveCube4State(state, tables, solveCube3, options) {
660
833
  MAX_P2++;
661
834
  } while ((candidates.length === 0 && MAX_P2 <= P2_MAX_ABS) || (candidates.length < CAND_MIN && MAX_P2 <= P2_MAX_MIN));
662
835
  if (candidates.length === 0) {
663
- throw new Error("4x4 solve: no valid phase 2 candidates found.");
836
+ throw new Cube4Phase2CapError("4x4 solve: no valid phase 2 candidates found.");
664
837
  }
665
838
  const tF2End = performance.now();
839
+ // Cheap-fix goal filter (see Cube4SolveOptions.maxFixMoves). The deep
840
+ // fallback widens it back to 4 (legacy, always satisfiable) so a hard
841
+ // state can never fail on the filter alone.
842
+ const maxFixMoves = (options?.maxFixMoves ?? 4) + (deep ? 2 : 0);
666
843
  const validReductions = [];
667
844
  /**
668
845
  * Per-candidate IDA* proofs shared across all sweep stages: the greatest
@@ -691,31 +868,50 @@ export async function solveCube4State(state, tables, solveCube3, options) {
691
868
  return null;
692
869
  const rotToStandard = invertRotationSequence(path);
693
870
  const standardReduced = applyCubeRotation(reducedOriginal, rotToStandard);
694
- const cube3State = toCube3State(standardReduced);
695
- if (!cube3State) {
696
- console.log(" toCube3State failed!");
871
+ const rotMoves = rotToStandard.flatMap((r) => ROT_MOVES[r]);
872
+ if (rotMoves.length > maxFixMoves)
697
873
  return null;
874
+ if (typeof process !== "undefined" && process.env?.CUBE4_ROT_STATS === "1" && rotStats.length < 200) {
875
+ rotStats.push({
876
+ totalRot: totalRot.join(","),
877
+ rotToStandard: rotToStandard.join(","),
878
+ rotLen: rotToStandard.reduce((n, rr) => n + ROT_MOVES[rr].length, 0)
879
+ });
698
880
  }
881
+ // The orientation key only covers 3 of the 24 center slots. A reduced
882
+ // arrangement outside the rotation orbit (e.g. a reflected one) can
883
+ // collide with a rotation's key, in which case rotating by `path` does
884
+ // NOT leave standard centers — the 3x3 finish would solve a different
885
+ // color scheme and the resulting solution would be garbage. Reject.
886
+ for (let i = 0; i < 24; i++) {
887
+ if (standardReduced.centers[i] !== SOLVED_CENTERS[i])
888
+ return null;
889
+ }
890
+ const cube3State = toCube3State(standardReduced);
891
+ if (!cube3State)
892
+ return null;
699
893
  const h3 = tables.cube3Phase1 ? getTriAxialCube3Heuristic(standardReduced, tables.cube3Phase1) : 0;
700
- const reductionMoves = [
701
- ...rotateAlgorithm(cand.moves12, rotToStandard),
702
- ...rotateAlgorithm(moves3Remapped, rotToStandard)
703
- ];
704
- const simplifiedReduction = simplifyCube4Algorithm(reductionMoves);
894
+ // The reduction + rotation prefix as it will be emitted (original frame):
895
+ // apply(state, [...moves12, ...moves3Remapped ... rotMoves]) = standardReduced.
896
+ // Priced with the fix at its best position (see placeRotationFix).
897
+ const simplifiedReduction = placeRotationFix([...cand.moves12, ...moves3Remapped], rotToStandard, []);
705
898
  return {
706
899
  cand,
707
900
  moves3,
708
901
  moves3Remapped,
709
902
  rotPath: path,
710
903
  rotToStandard,
711
- rotMoves: rotToStandard.flatMap((r) => ROT_MOVES[r]),
904
+ rotMoves,
712
905
  standardReduced,
713
906
  h3,
714
907
  totalEst: simplifiedReduction.length + h3
715
908
  };
716
909
  }
717
- const centersBeam = Math.max(1, options?.centersBeam ?? 3);
718
- const edgesPerCenter = Math.max(1, options?.edgesPerCenter ?? 3);
910
+ // Defaults 1 × 5 (was 3 × 3): with the continuous finish, extra center
911
+ // candidates stopped paying. cc:0..31: 45.19 moves @ 673 ms vs 45.16 @
912
+ // 1042 ms (2×3: 45.25 @ 799; 1×8: 45.25 @ 691).
913
+ const centersBeam = Math.max(1, options?.centersBeam ?? 1);
914
+ const edgesPerCenter = Math.max(1, options?.edgesPerCenter ?? 5);
719
915
  const maxReductionsTarget = options?.finishTries !== undefined
720
916
  ? Math.max(1, options.finishTries)
721
917
  : (centersBeam * edgesPerCenter);
@@ -733,8 +929,14 @@ export async function solveCube4State(state, tables, solveCube3, options) {
733
929
  const candReductions = new Map();
734
930
  const distinctCenters = new Set();
735
931
  let foundAtLength = null;
932
+ // The rotation fix (rotMoves) is priced INTO totalEst and varies 2-4
933
+ // moves per goal PATH (the end scheme depends on the path). A goal one
934
+ // total-step deeper with a 2-move fix beats a shallow goal with a 4-move
935
+ // fix, so keep collecting a few steps past the first find and let
936
+ // totalEst decide (was: foundAtLength + 1 with needed + 3 goals per
937
+ // call, which discarded fix-cheap alternatives).
736
938
  OUTER_P3: for (let length123 = pool[0].value; length123 < L123_CAP; length123++) {
737
- if (foundAtLength !== null && length123 > foundAtLength + 1)
939
+ if (foundAtLength !== null && length123 > foundAtLength + 3)
738
940
  break;
739
941
  // Sweep the whole (sorted) pool: see sortTop rationale.
740
942
  for (let i = 0; i < pool.length; i++) {
@@ -755,19 +957,34 @@ export async function solveCube4State(state, tables, solveCube3, options) {
755
957
  if (distinctCenters.size >= centersBeam && !distinctCenters.has(cand))
756
958
  continue;
757
959
  if (cand.edist <= length3 && cand.cdist <= length3 && cand.hdist <= length3 && (cand.pdist ?? 0) <= length3) {
758
- const nodeLimit = length3 <= 12 ? 80000 : Math.max(45000, cfg.maxNodes);
960
+ const remainingNodes = phase3NodeBudget - (phase3Solver.epochNodes - phase3StartNodes);
961
+ if (remainingNodes <= 0) {
962
+ if (validReductions.length > 0)
963
+ return;
964
+ throw new Cube4Phase3CapError("4x4 solve: phase 3 total node budget exhausted.");
965
+ }
966
+ const nodeLimit = Math.min(remainingNodes, length3 <= 12 ? 80000 : Math.max(45000, cfg.maxNodes));
759
967
  f3calls++;
760
- const validsBefore = validReductions.length;
761
968
  const needed = edgesPerCenter - candCount;
762
969
  const candReductionsBuffer = [];
763
- const maxToCollect = Math.min(16, needed + 3);
764
- const result = phase3Solver.solveLengthProven(cand.edge, cand.ct3, cand.edist, length3, tables.center3, tables.edge3.distances, nodeLimit, 16, (moves3) => {
970
+ // Collect beyond `needed`: goal paths at the same length carry
971
+ // different rotation fixes (2 vs 4 moves), and the totalEst sort
972
+ // below needs the variety to pick a cheap-fix end.
973
+ const maxToCollect = Math.min(24, needed + 9);
974
+ const result = phase3Solver.solveLengthProven(cand.edge, cand.ct3, cand.edist, length3, tables.center3, tables.edge3.distances, nodeLimit,
975
+ // Goal budget per call: with the cheap-fix filter most goal ends
976
+ // are rejected, so the walk must be allowed to examine more of
977
+ // them before giving up on this (candidate, length3).
978
+ 48, (moves3) => {
765
979
  const red = buildValidReduction(cand, moves3);
766
980
  if (!red)
767
981
  return false;
768
982
  candReductionsBuffer.push(red);
769
- if (foundAtLength === null)
770
- foundAtLength = length123;
983
+ // <=-search: goals may land shorter than the length3 bound —
984
+ // track the true total for the post-find collection window.
985
+ const total = cand.length12 + moves3.length;
986
+ if (foundAtLength === null || total < foundAtLength)
987
+ foundAtLength = total;
771
988
  return candReductionsBuffer.length >= maxToCollect;
772
989
  },
773
990
  // hc mid-search prune disabled: on a 44-state corpus the per-node
@@ -787,20 +1004,18 @@ export async function solveCube4State(state, tables, solveCube3, options) {
787
1004
  candReductions.set(cand, candCount + toAdd.length);
788
1005
  distinctCenters.add(cand);
789
1006
  }
790
- const proven = result.provenThrough;
791
- if (process.env.CUBE4_SWEEP_STATS === "1") {
792
- // eslint-disable-next-line no-console
793
- console.log(`sweep l12=${cand.length12} l3=${length3} nodes=${result.nodesUsed}/${nodeLimit} ` +
794
- `valid=${validReductions.length > validsBefore} proven=${proven} ` +
795
- `c=${cand.cdist} e=${cand.edist} h=${cand.hdist}`);
796
- }
1007
+ // Predicate-scoped proof (same buildValidReduction filter for this
1008
+ // candidate across all its calls): skip every settled length.
1009
+ const proven = result.validatedThrough;
797
1010
  if (result.moves === null && proven >= length3) {
798
1011
  const prev = p3Proven.get(cand) ?? -1;
799
1012
  if (proven > prev)
800
1013
  p3Proven.set(cand, proven);
801
1014
  }
802
1015
  // The finish loop attempts reductions up to maxReductionsTarget
803
- if (validReductions.length >= maxReductionsTarget) {
1016
+ // (with a wider collection cap so cheap-fix ends at later length
1017
+ // steps can still displace expensive-fix ones by totalEst).
1018
+ if (validReductions.length >= maxReductionsTarget + 9) {
804
1019
  break OUTER_P3;
805
1020
  }
806
1021
  }
@@ -819,10 +1034,10 @@ export async function solveCube4State(state, tables, solveCube3, options) {
819
1034
  };
820
1035
  // Sweeps with growing budgets; longitudes extend dynamically (reference
821
1036
  // style) while nothing is found, instead of fixed caps. Easy states only
822
- // pay the first sweep.
823
- // Sweeps with growing budgets; longitudes extend dynamically (reference
824
- // style) while nothing is found, instead of fixed caps. Easy states only
825
- // pay the first sweep.
1037
+ // pay the first sweep. The length3 caps are NOT redundant re-walks: the
1038
+ // ladder widens the pairing cap ONLY when nothing was found at all, and the
1039
+ // narrower caps keep early sweeps on short pairings (measured: a single
1040
+ // merged sweep with maxLength3 24 is ~20% SLOWER with identical output).
826
1041
  // NOTE: lowering these caps was tried (20k/16k) and reverted: capped calls
827
1042
  // change which valids are found (±2 moves on the corpus). Only admissible
828
1043
  // heuristic strengthenings are length-safe here.
@@ -865,6 +1080,10 @@ export async function solveCube4State(state, tables, solveCube3, options) {
865
1080
  searchedL2.add(key);
866
1081
  p1CandCount = 0;
867
1082
  wpStack[0].set(prep.bridgeState.wp);
1083
+ for (let p = 0; p < 24; p++)
1084
+ posStack[0][prep.bridgeState.wp[p]] = p;
1085
+ validPosDepth = 0;
1086
+ wpMaskStack[0] = pairingMaskOf(prep.bridgeState.wp);
868
1087
  validWpDepth = 0;
869
1088
  searchPhase2(prep.ct, prep.rl, length2, -1, 0, prep.bridgeState, prep);
870
1089
  if (candidates.length - poolStart >= 40)
@@ -882,7 +1101,7 @@ export async function solveCube4State(state, tables, solveCube3, options) {
882
1101
  }
883
1102
  }
884
1103
  if (validReductions.length === 0) {
885
- throw new Error("4x4 solve: reduction failed across all candidates.");
1104
+ throw new Cube4Phase3CapError("4x4 solve: phase 3 reduction failed across all candidates.");
886
1105
  }
887
1106
  // Sort reductions by Total Estimated Moves (Reduction length + 3x3 Phase 1 lower bound)
888
1107
  validReductions.sort((a, b) => a.totalEst - b.totalEst);
@@ -897,28 +1116,56 @@ export async function solveCube4State(state, tables, solveCube3, options) {
897
1116
  // Best-of among the first three (phase-4 is cheap now); further
898
1117
  // reductions are only attempted when those all failed.
899
1118
  const finishTries = options?.finishTries ?? maxReductionsTarget;
900
- for (let i = 0; i < validReductions.length; i++) {
901
- if (i >= finishTries && bestSolution)
902
- break;
903
- const red = validReductions[i];
1119
+ const collectFinishStats = typeof process !== "undefined" && process.env?.CUBE4_FINISH_STATS === "1";
1120
+ // Empirical finish lower bound (see finishStats): every observed finish
1121
+ // ran 11-16 moves ABOVE `totalEst` (continuous finish: 67 finishes over 16
1122
+ // seeds, gap = totalEst - finalLen in [-16, -11]; the legacy finish was
1123
+ // [-16, -12]) because `h3` only bounds the cube3 PHASE-1 length while the
1124
+ // finish is two-phase. A reduction whose totalEst + 11 cannot strictly
1125
+ // beat the current best therefore cannot win, and its finish is skipped
1126
+ // entirely. If a state ever lands a finish within 11 of the phase-1 bound,
1127
+ // this degrades to a slightly longer (still exact,
1128
+ // still valid) scramble for that state — never an incorrect one.
1129
+ const FINISH_LB_BIAS = 11;
1130
+ const screenTop = Math.max(0, Math.floor(options?.finishScreenTop ?? 0));
1131
+ const screened = [];
1132
+ /** Finishes one reduction; records it as the best when shorter. */
1133
+ async function finishReduction(red, effort) {
904
1134
  const cube3State = toCube3State(red.standardReduced);
905
1135
  if (!cube3State)
906
- continue;
1136
+ return null;
907
1137
  let cube3Solution;
908
1138
  try {
909
- cube3Solution = await solveCube3(cube3State);
1139
+ cube3Solution = await solveCube3(cube3State, effort);
910
1140
  }
911
1141
  catch {
912
- continue;
1142
+ return null;
1143
+ }
1144
+ // Frame invariant (verified): apply(state, [...moves12, ...moves3Remapped])
1145
+ // is exactly `reducedOriginal`, and `reducedOriginal · rotMoves(r)`
1146
+ // (= applyCubeRotation(_, r)) is `standardReduced`, which the cube3
1147
+ // finish C solves. So the solution in the ORIGINAL frame is
1148
+ // X ++ rotMoves(r) ++ C.
1149
+ // Two previously-tried assemblies are WRONG:
1150
+ // - [rot_r(X), C] solves ρ_r(state), not state (the observed adaptive
1151
+ // whole-cube-rotation bug: apply(solved, invert(moves)) = ρ_r(T));
1152
+ // - [X, rot_{-r}(C)] lands on ρ_{-r}(solved) ≠ solved, because a
1153
+ // whole-cube rotation does NOT leave the canonical solved state
1154
+ // invariant (it permutes face colors in the absolute frame).
1155
+ // `placeRotationFix` keeps that effect but may move the rotMoves(r)
1156
+ // block anywhere in the sequence (re-mapping what follows it) where it
1157
+ // cancels against adjacent wide moves.
1158
+ const simplified = placeRotationFix([...red.cand.moves12, ...red.moves3Remapped], red.rotToStandard, cube3Solution.moves);
1159
+ if (collectFinishStats) {
1160
+ finishStats.finishes++;
1161
+ const gap = red.totalEst - simplified.length;
1162
+ if (gap < finishStats.gapMin)
1163
+ finishStats.gapMin = gap;
1164
+ if (gap > finishStats.gapMax)
1165
+ finishStats.gapMax = gap;
1166
+ finishStats.gapSum += gap;
1167
+ finishStats.gapN++;
913
1168
  }
914
- // Rotate the solve algorithm so it is performed from the standard orientation ("hacer los movimientos desde otro lado").
915
- // This allows the solve to transition directly to the standard solved state WITHOUT ANY WHOLE-CUBE ROTATIONS (rotMoves)!
916
- const rotatedFullSolution = [
917
- ...rotateAlgorithm(red.cand.moves12, red.rotToStandard),
918
- ...rotateAlgorithm(red.moves3Remapped, red.rotToStandard),
919
- ...cube3Solution.moves
920
- ];
921
- const simplified = simplifyCube4Algorithm(rotatedFullSolution);
922
1169
  if (simplified.length < minTotalMoves) {
923
1170
  minTotalMoves = simplified.length;
924
1171
  bestSolution = simplified;
@@ -931,9 +1178,31 @@ export async function solveCube4State(state, tables, solveCube3, options) {
931
1178
  };
932
1179
  bestRed = red;
933
1180
  }
1181
+ return simplified;
1182
+ }
1183
+ for (let i = 0; i < validReductions.length; i++) {
1184
+ if (i >= finishTries && bestSolution)
1185
+ break;
1186
+ const red = validReductions[i];
1187
+ if (bestSolution !== null && red.totalEst + FINISH_LB_BIAS >= minTotalMoves) {
1188
+ finishStats.skipped++;
1189
+ continue;
1190
+ }
1191
+ const done = await finishReduction(red, screenTop > 0 ? "screen" : "full");
1192
+ if (done)
1193
+ screened.push({ red, total: done.length });
1194
+ }
1195
+ // Stage 2: the best screened reductions get the full-effort finish.
1196
+ if (screenTop > 0) {
1197
+ screened.sort((a, b) => a.total - b.total);
1198
+ for (const { red } of screened.slice(0, screenTop)) {
1199
+ if (red.totalEst + FINISH_LB_BIAS >= minTotalMoves)
1200
+ continue;
1201
+ await finishReduction(red, "full");
1202
+ }
934
1203
  }
935
1204
  if (!bestSolution || !bestRed) {
936
- throw new Error("4x4 solve: reduction failed across all candidates.");
1205
+ throw new Cube4FinishCapError("4x4 solve: reduction failed during the 3x3 finish across all valid reductions.");
937
1206
  }
938
1207
  // Polish pass: the sweep takes the first valid sequence per exact
939
1208
  // length, which is often one above optimal. Retry the winner one length
@@ -953,13 +1222,23 @@ export async function solveCube4State(state, tables, solveCube3, options) {
953
1222
  // and a full 150k grind on tail states cost seconds for nothing. It is
954
1223
  // also skipped entirely unless the total is high enough to matter.
955
1224
  const polishBudget = r === bestRed ? 150000 : 30000;
1225
+ // NOTE: with the <=-length phase-3 search the sweep already enumerates
1226
+ // shorter goals, so the polish is a second-chance pass only (measured:
1227
+ // a wider gate/budget found nothing and cost +32% p3 nodes).
956
1228
  for (let iter = 0; iter < 4 && (minTotalMoves > 43 || curRed.moves3.length > 11); iter++) {
957
1229
  const cand = curRed.cand;
958
1230
  const shorterLen = curRed.moves3.length - 1;
959
1231
  if (shorterLen < cand.edist || shorterLen < cand.cdist || shorterLen < cand.hdist || shorterLen < (cand.pdist ?? 0))
960
1232
  break;
961
1233
  f3calls++;
962
- 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);
1234
+ // Unified polish: accept any goal path with a strictly better
1235
+ // totalEst at the same or shorter length — covers both a shorter
1236
+ // phase-3 path and a cheaper rotation fix (the fix is priced into
1237
+ // totalEst and varies 2-4 moves per goal path).
1238
+ const shorter = phase3Solver.solveLengthValid(cand.edge, cand.ct3, cand.edist, curRed.moves3.length, tables.center3, tables.edge3.distances, polishBudget, 64, (moves3) => {
1239
+ const nr = buildValidReduction(cand, moves3);
1240
+ return nr !== null && nr.totalEst < curRed.totalEst;
1241
+ }, tables.hc, cand.hcCoord, tables.c3PairDist);
963
1242
  if (!shorter)
964
1243
  break;
965
1244
  const red = buildValidReduction(cand, shorter);
@@ -975,11 +1254,8 @@ export async function solveCube4State(state, tables, solveCube3, options) {
975
1254
  catch {
976
1255
  break;
977
1256
  }
978
- const simplified = simplifyCube4Algorithm([
979
- ...rotateAlgorithm(red.cand.moves12, red.rotToStandard),
980
- ...rotateAlgorithm(red.moves3Remapped, red.rotToStandard),
981
- ...cube3Solution.moves
982
- ]);
1257
+ // Same frame invariant as the finish loop: X ++ rotMoves(r) ++ C.
1258
+ const simplified = placeRotationFix([...red.cand.moves12, ...red.moves3Remapped], red.rotToStandard, cube3Solution.moves);
983
1259
  if (simplified.length < minTotalMoves) {
984
1260
  minTotalMoves = simplified.length;
985
1261
  bestSolution = simplified;
@@ -1004,13 +1280,19 @@ export async function solveCube4State(state, tables, solveCube3, options) {
1004
1280
  accF3Ms += tFinishStart - tF2End;
1005
1281
  accFinishMs += tPolishStart - tFinishStart;
1006
1282
  accPolishMs += performance.now() - tPolishStart;
1283
+ // Final invariant: the emitted solution must solve the INPUT state
1284
+ // exactly (no rotation tolerance). Fail loudly rather than return a
1285
+ // solution that does not resolve. One replay of ~45 moves — negligible
1286
+ // next to the search cost.
1287
+ if (!isSolvedCube4State(applyCube4Algorithm(state, bestSolution))) {
1288
+ throw new Error("4x4 solve: internal verification failed — the solution does not solve the input state.");
1289
+ }
1007
1290
  return { moves: bestSolution, phases: bestPhases };
1008
1291
  };
1009
1292
  let bestMoves = null;
1010
1293
  let bestPhasesOverall;
1011
1294
  let lastRestartError = null;
1012
1295
  for (let restart = 0; restart < totalRestarts; restart++) {
1013
- FastPhase3Solver.nextEpoch();
1014
1296
  const rng = totalRestarts > 1
1015
1297
  ? mulberry32((hashCube4State(state) ^ Math.imul(restart + 1, 0x9e3779b9)) >>> 0)
1016
1298
  : null;