- commit
- 54b39f72d7344ac9928bcfc5c9f72f3f07abd15b
- parent
- 163c20756a671a08c904e58b9c8352864772442a
- author
- Dami <dami.etoile@gmail.com>
| M | src/ | 44 | ++++++++++++++++++++++++++---- |
| M | tests/ | 7 | +++++ |
@@ -33,6 +33,21 @@ function resultFor(status, fields = {}) {
3333 return Object.freeze({ status, path: [], attempts: 0, expanded: 0, visited: 0, ...fields });3434 }3535 36+// A numeric hash keeps the visited index small when a level has millions of37+// states. Equality always compares the full codec bytes, so hash collisions38+// cannot merge different futures.39+function hashBytes(bytes) {40+ let hash = 2166136261;41+ for (const byte of bytes) hash = Math.imul(hash ^ byte, 16777619);42+ return hash >>> 0;43+}44+45+function sameBytes(left, right) {46+ if (left.length !== right.length) return false;47+ for (let index = 0; index < left.length; index += 1) if (left[index] !== right[index]) return false;48+ return true;49+}50+3651 /**3752 * Search a level's settled state graph in breadth-first order.3853 *@@ -49,6 +64,9 @@ function resultFor(status, fields = {}) {
4964 * transitions both count toward `attempts`.5065 * @param {(snapshot: object, result?: object) => boolean} [options.isWin]5166 * Terminal predicate. Defaults to `snapshot.won === true`.67+ * @param {(bytes: Uint8Array) => number} [options.hash]68+ * Numeric bucket hash; full bytes are still compared for identity. This is69+ * injectable so collision behavior can be tested deterministically.5270 * @returns {{status: 'solved'|'exhausted'|'limit', path: unknown[], attempts: number,5371 * expanded: number, visited: number, state?: object, meta?: object}}5472 */@@ -60,6 +78,7 @@ export function searchStates({
6078 cap = 100_000,6179 filter,6280 isWin = snapshot => snapshot?.won === true,81+ hash = hashBytes,6382 } = {}) {6483 if (!isRecord(initial)) throw new TypeError('search initial snapshot is required');6584 if (!codec || typeof codec.encode !== 'function' || typeof codec.decode !== 'function' || typeof codec.key !== 'function') {@@ -69,14 +88,29 @@ export function searchStates({
6988 if (typeof transition !== 'function') throw new TypeError('search transition must be a function');7089 if (!Number.isSafeInteger(cap) || cap < 0) throw new RangeError('search cap must be a non-negative safe integer');7190 const allowTransition = filter || (() => true);72- if (typeof allowTransition !== 'function' || typeof isWin !== 'function') throw new TypeError('search filter and isWin must be functions');91+ if (typeof allowTransition !== 'function' || typeof isWin !== 'function' || typeof hash !== 'function') throw new TypeError('search filter, isWin, and hash must be functions');7392 7493 if (isWin(initial)) return resultFor('solved', { path: [], attempts: 0, expanded: 0, visited: 0, state: initial });7594 7695 const initialBlob = codec.encode(initial);77- const initialKey = codec.key(initialBlob);7896 const nodes = [{ blob: initialBlob, parent: -1, action: undefined }];79- const seen = new Set([initialKey]);97+ // Most buckets contain one node index. Allocate a collision array only for98+ // hashes shared by different payloads, avoiding a second long string per99+ // frontier state.100+ const seen = new Map([[hash(initialBlob), 0]]);101+ function remember(blob) {102+ const bucketHash = hash(blob);103+ const bucket = seen.get(bucketHash);104+ if (bucket !== undefined) {105+ const indexes = Array.isArray(bucket) ? bucket : [bucket];106+ if (indexes.some(index => sameBytes(nodes[index].blob, blob))) return false;107+ }108+ const index = nodes.length;109+ if (bucket === undefined) seen.set(bucketHash, index);110+ else if (Array.isArray(bucket)) bucket.push(index);111+ else seen.set(bucketHash, [bucket, index]);112+ return true;113+ }80114 let head = 0;81115 let attempts = 0;82116 let expanded = 0;@@ -108,9 +142,7 @@ export function searchStates({
108142 }109143 110144 const blob = codec.encode(result.state);111- const key = codec.key(blob);112- if (seen.has(key)) continue;113- seen.add(key);145+ if (!remember(blob)) continue;114146 nodes.push({ blob, parent: nodeIndex, action });115147 }116148 }@@ -66,6 +66,13 @@ test('prunes duplicate compact identities', () => {
6666 assert.equal(result.attempts, 8);6767 });6868 69+test('keeps distinct states even when every compact hash collides', () => {70+ const result = searchStates(options({ hash: () => 1 }));71+ assert.equal(result.status, 'solved');72+ assert.deepEqual(result.path, ['R', 'R', 'R']);73+ assert.equal(result.visited, 3);74+});75+6976 test('reports a win at the initial state without encoding a terminal snapshot', () => {7077 const initial = { ...initialState(), won: true };7178 const result = searchStates(options({ initial }));