- commit
- 428011bbb1c134f29563174d12aac8924896ef80
- parent
- 5946dd7e1c5ea5b70acd0a09e9423e337611a87a
- author
- Dami <dami.etoile@gmail.com>
| M | openspec/ | 7 | +++++++ |
| M | src/ | 12 | ++++++++++-- |
| M | src/ | 3 | +++ |
| M | tests/ | 5 | +++++ |
| M | tests/ | 10 | ++++++++++ |
@@ -31,6 +31,13 @@ Alternative considered: canonical JSON of every full snapshot. It is easy to ins
3131 3232 Use one queue with a head index and one compact blob per visited state. Each node stores its parent index and action; reconstruct a path only for a solved node. Keep transition-expansion counting and the configured search cap stable. The existing real-engine transition remains the baseline and differential oracle.3333 34+The search API also accepts an explicit `maxVisited` bound. It checks the full35+codec bytes before counting a new state, so hash collisions cannot hide a36+state or consume the bound for a duplicate. Hitting the retained-state bound37+returns `LIMIT` with reason `visited`; the transition cap returns reason38+`attempts`. The default remains unrestricted by visited count until the39+world-authoring workflow chooses per-proof budgets.40+3441 Alternative considered: retain one frontier array per depth and shorten only the key. That still stores full snapshots and copies path strings at every branch.3542 3643 ### The fast path shares mechanics, not a second rule engine@@ -59,6 +59,8 @@ function sameBytes(left, right) {
5959 * every frontier state.6060 * @param {(snapshot: object, action: unknown) => {accepted?: boolean, state?: object, meta?: object}} options.transition6161 * @param {number} [options.cap=100000] Maximum number of attempted actions.62+ * @param {number} [options.maxVisited=Infinity] Maximum number of compact63+ * frontier states retained, including the initial state.6264 * @param {(context: {snapshot: object, action: unknown, result: object}) => boolean} [options.filter]6365 * Optional post-transition filter. Rejected transitions and filtered6466 * transitions both count toward `attempts`.@@ -76,6 +78,7 @@ export function searchStates({
7678 actions,7779 transition,7880 cap = 100_000,81+ maxVisited = Infinity,7982 filter,8083 isWin = snapshot => snapshot?.won === true,8184 hash = hashBytes,@@ -87,10 +90,12 @@ export function searchStates({
8790 const moveSet = asActions(actions);8891 if (typeof transition !== 'function') throw new TypeError('search transition must be a function');8992 if (!Number.isSafeInteger(cap) || cap < 0) throw new RangeError('search cap must be a non-negative safe integer');93+ if (maxVisited !== Infinity && (!Number.isSafeInteger(maxVisited) || maxVisited < 0)) throw new RangeError('search maxVisited must be a non-negative safe integer');9094 const allowTransition = filter || (() => true);9195 if (typeof allowTransition !== 'function' || typeof isWin !== 'function' || typeof hash !== 'function') throw new TypeError('search filter, isWin, and hash must be functions');9296 9397 if (isWin(initial)) return resultFor('solved', { path: [], attempts: 0, expanded: 0, visited: 0, state: initial });98+ if (maxVisited === 0) return resultFor('limit', { reason: 'visited' });9499 95100 const initialBlob = codec.encode(initial);96101 const nodes = [{ blob: initialBlob, parent: -1, action: undefined }];@@ -105,6 +110,7 @@ export function searchStates({
105110 const indexes = Array.isArray(bucket) ? bucket : [bucket];106111 if (indexes.some(index => sameBytes(nodes[index].blob, blob))) return false;107112 }113+ if (nodes.length >= maxVisited) return null;108114 const index = nodes.length;109115 if (bucket === undefined) seen.set(bucketHash, index);110116 else if (Array.isArray(bucket)) bucket.push(index);@@ -122,7 +128,7 @@ export function searchStates({
122128 expanded += 1;123129 124130 for (const action of moveSet) {125- if (attempts >= cap) return resultFor('limit', { attempts, expanded, visited: nodes.length });131+ if (attempts >= cap) return resultFor('limit', { reason: 'attempts', attempts, expanded, visited: nodes.length });126132 attempts += 1;127133 const result = transition(snapshot, action);128134 if (!isRecord(result)) throw new TypeError('search transition must return an object');@@ -142,7 +148,9 @@ export function searchStates({
142148 }143149 144150 const blob = codec.encode(result.state);145- if (!remember(blob)) continue;151+ const remembered = remember(blob);152+ if (remembered === null) return resultFor('limit', { reason: 'visited', attempts, expanded, visited: nodes.length });153+ if (!remembered) continue;146154 nodes.push({ blob, parent: nodeIndex, action });147155 }148156 }@@ -100,6 +100,7 @@ function fastTransition(game, snapshot, action) {
100100 */101101 export function solveLevel(level, {102102 cap = 300_000,103+ maxVisited = Infinity,103104 forbid,104105 wordMotion = 'allow',105106 transitionMode = 'public',@@ -133,6 +134,7 @@ export function solveLevel(level, {
133134 codec,134135 actions,135136 cap,137+ maxVisited,136138 transition,137139 filter: ({ result: candidate }) => {138140 const event = candidate.meta.event;@@ -158,6 +160,7 @@ export function solveLevel(level, {
158160 explored: result.attempts,159161 expanded: result.expanded,160162 visited: result.visited,163+ ...(result.reason ? { limitReason: result.reason } : {}),161164 wordMotionMode,162165 ...(solved ? {163166 routeBypass: !classification.playerWordMoved,@@ -134,6 +134,11 @@ test('returns LIMIT at the transition cap', () => {
134134 const result = solveLevel(walkLevel, { cap: 1 });135135 assert.equal(result.status, 'LIMIT');136136 assert.equal(result.explored, 1);137+ assert.equal(result.limitReason, 'attempts');138+ const memoryBound = solveLevel(walkLevel, { maxVisited: 1 });139+ assert.equal(memoryBound.status, 'LIMIT');140+ assert.equal(memoryBound.limitReason, 'visited');141+ assert.equal(memoryBound.visited, 1);137142 });138143 139144 test('fast mode keeps the public route and status parity', () => {@@ -49,6 +49,16 @@ test('counts attempted transitions and stops at the configured cap', () => {
4949 const result = searchStates(options({ cap: 2 }));5050 assert.equal(result.status, 'limit');5151 assert.equal(result.attempts, 2);52+ assert.equal(result.reason, 'attempts');53+});54+55+test('limits retained frontier states without treating hash collisions as duplicates', () => {56+ const result = searchStates(options({ maxVisited: 2, hash: () => 1 }));57+ assert.equal(result.status, 'limit');58+ assert.equal(result.reason, 'visited');59+ assert.equal(result.visited, 2);60+ assert.equal(result.attempts, 4);61+ assert.throws(() => searchStates(options({ maxVisited: -1 })), /maxVisited/);5262 });5363 5464 test('prunes duplicate compact identities', () => {