clone

a browser grid puzzle game where you push words around to rewrite the rules of each level

Bound stacked rule parsing and preserve spatial reading

Dami committed 07c3d46
commit
07c3d4603e913a147696716a6e999985334539ab
parent
c655de36efeb10b0cd3d03d8b633ead3b25b1c7e
author
Dami <dami.etoile@gmail.com>
3 files changed, +217 -47
Msrc/engine/rules.js153+++++++++++++++++++++---------
Atests/engine-rules.test.js109+++++++++++++++++++++
Mtests/rules.test.js2+-
M src/engine/rules.js +107 -46
@@ -1,6 +1,53 @@
11 const key = (x, y) => `${x},${y}`;22 const directions = [[1, 0], [0, 1]];33 4+// Stacked words intentionally enumerate every valid alternative, but an5+// authored board must not be able to make the browser allocate an unbounded6+// number of paths or spans. These limits are high enough for ordinary puzzle7+// stacks and low enough to cap memory used by span indexing.8+export const MAX_RULE_PATHS = 16_384;9+export const MAX_RULE_SPANS = 65_536;10+11+export class RuleParseLimitError extends RangeError {12+ constructor(kind, limit) {13+ super(`rule parse ${kind} exceeded the limit of ${limit}`);14+ this.name = 'RuleParseLimitError';15+ this.kind = kind;16+ this.limit = limit;17+ }18+}19+20+/**21+ * Keep only physical sentence spans that are not contained by a longer span.22+ * Process longer spans first and index their source IDs so containment checks23+ * intersect only candidate spans that share every source ID.24+ */25+function maximalSpans(spans) {26+ const indexKey = (span, id) => `${span.axis}\u0000${id}`;27+ const ordered = [...spans].sort((a, b) => b.ids.length - a.ids.length || a.spanOrder - b.spanOrder);28+ const containingById = new Map();29+ const hidden = new Set();30+ for (const span of ordered) {31+ const candidates = span.ids.map(id => containingById.get(indexKey(span, id))).filter(Boolean);32+ if (candidates.length === span.ids.length) {33+ const smallest = candidates.reduce((best, set) => set.size < best.size ? set : best);34+ for (const candidate of smallest) {35+ if (candidate.ids.length > span.ids.length && candidates.every(set => set.has(candidate))) {36+ hidden.add(span);37+ break;38+ }39+ }40+ }41+ for (const id of span.ids) {42+ const key = indexKey(span, id);43+ let indexed = containingById.get(key);44+ if (!indexed) containingById.set(key, indexed = new Set());45+ indexed.add(span);46+ }47+ }48+ return spans.filter(span => !hidden.has(span));49+}50+451 /**552 * Parse physical word objects into dynamic rules and sentence spans.653 *
@@ -22,50 +69,34 @@ export function parseRules(objects, semanticProperties) {
2269 const predicate = text => noun(text) || semanticProperties.has(text);2370 const found = new Map();2471 const spans = [];72+ let pathExpansions = 0;73+ const countPath = () => {74+ pathExpansions += 1;75+ if (pathExpansions > MAX_RULE_PATHS) throw new RuleParseLimitError('paths', MAX_RULE_PATHS);76+ };77+ const countSpan = () => {78+ if (spans.length >= MAX_RULE_SPANS) throw new RuleParseLimitError('spans', MAX_RULE_SPANS);79+ };2580 26- for (const [origin, starts] of words) {81+ // Map insertion follows authored object order, which is not a rule-reading82+ // order. Sort cells by row, then column; stack order remains the stable tie83+ // break for alternatives sharing one physical cell.84+ const origins = [...words.entries()].sort(([a], [b]) => {85+ const [ax, ay] = a.split(',').map(Number);86+ const [bx, by] = b.split(',').map(Number);87+ return ay - by || ax - bx;88+ });89+ const originOrder = new Map(origins.map(([origin], index) => [origin, index]));90+ for (const [origin, starts] of origins) {2791 const [ox, oy] = origin.split(',').map(Number);2892 for (const first of starts.filter(entry => noun(entry.text))) for (const [dx, dy] of directions) {29- let x = ox;30- let y = oy;31- let subjectPaths = [{ text: first.text, sourceIds: [first.id] }];32- const has = (px, py, test) => at(px, py).some(entry => test(entry.text));33- while (has(x + dx, y + dy, text => text === 'AND') && has(x + 2 * dx, y + 2 * dy, noun)) {34- const ands = at(x + dx, y + dy).filter(entry => entry.text === 'AND');35- const nouns = at(x + 2 * dx, y + 2 * dy).filter(entry => noun(entry.text));36- x += 2 * dx;37- y += 2 * dy;38- subjectPaths = subjectPaths.flatMap(path => ands.flatMap(and => nouns.map(next => ({39- text: `${path.text} AND ${next.text}`,40- sourceIds: [...path.sourceIds, and.id, next.id],41- }))));42- }43- const isWords = at(x + dx, y + dy).filter(entry => entry.text === 'IS');44- if (!isWords.length) continue;45- const is = isWords[0];46- x += 2 * dx;47- y += 2 * dy;48- let predicatePaths = at(x, y).filter(entry => predicate(entry.text)).map(entry => ({ text: entry.text, sourceIds: [entry.id] }));49- if (!predicatePaths.length) continue;50- let endX = x;51- let endY = y;52- while (has(endX + dx, endY + dy, text => text === 'AND') && has(endX + 2 * dx, endY + 2 * dy, predicate)) {53- const ands = at(endX + dx, endY + dy).filter(entry => entry.text === 'AND');54- const predicates = at(endX + 2 * dx, endY + 2 * dy).filter(entry => predicate(entry.text));55- endX += 2 * dx;56- endY += 2 * dy;57- predicatePaths = predicatePaths.flatMap(path => ands.flatMap(and => predicates.map(next => ({58- text: `${path.text} AND ${next.text}`,59- sourceIds: [...path.sourceIds, and.id, next.id],60- }))));61- }62- // Retain every physical sentence for presentation and choose the first63- // source for each logical rule for deterministic stacked-word parsing.64- for (const subjectPath of subjectPaths) for (const predicatePath of predicatePaths) {65- spans.push({ ids: [...subjectPath.sourceIds, is.id, ...predicatePath.sourceIds], axis: dx ? 'h' : 'v' });66- const subjectNames = subjectPath.text.split(' AND ');67- const predicateNames = predicatePath.text.split(' AND ');68- for (const subject of subjectNames) for (const predicateName of predicateNames) {93+ // Parse the grammar as a sequence of cell choices. A path selects one94+ // token from each cell; a stacked AND+IS cell therefore tries both95+ // alternatives instead of greedily consuming AND.96+ const emit = (subjectPath, predicatePath, is) => {97+ countSpan();98+ spans.push({ ids: [...subjectPath.sourceIds, is.id, ...predicatePath.sourceIds], axis: dx ? 'h' : 'v', originOrder: originOrder.get(origin), spanOrder: spans.length });99+ for (const subject of subjectPath.names) for (const predicateName of predicatePath.names) {69100 const ruleKey = `${subject} IS ${predicateName}`;70101 if (!found.has(ruleKey)) found.set(ruleKey, {71102 subject,
@@ -75,13 +106,43 @@ export function parseRules(objects, semanticProperties) {
75106 axis: dx ? 'h' : 'v',76107 });77108 }78- }109+ };110+ const parsePredicates = (x, y, subjectPath, is, predicatePath) => {111+ countPath();112+ for (const token of at(x, y)) {113+ if (predicate(token.text)) {114+ const nextPath = { names: [...predicatePath.names, token.text], sourceIds: [...predicatePath.sourceIds, token.id] };115+ emit(subjectPath, nextPath, is);116+ for (const connector of at(x + dx, y + dy).filter(next => next.text === 'AND')) {117+ parsePredicates(x + 2 * dx, y + 2 * dy, subjectPath, is, {118+ names: nextPath.names,119+ sourceIds: [...nextPath.sourceIds, connector.id],120+ });121+ }122+ }123+ }124+ };125+ const parseSubject = (x, y, subjectPath) => {126+ countPath();127+ for (const token of at(x, y)) {128+ if (token.text === 'IS') {129+ parsePredicates(x + dx, y + dy, subjectPath, token, { names: [], sourceIds: [] });130+ } else if (token.text === 'AND') {131+ for (const next of at(x + dx, y + dy).filter(entry => noun(entry.text))) {132+ parseSubject(x + 2 * dx, y + 2 * dy, {133+ names: [...subjectPath.names, next.text],134+ sourceIds: [...subjectPath.sourceIds, token.id, next.id],135+ });136+ }137+ }138+ }139+ };140+ parseSubject(ox + dx, oy + dy, { names: [first.text], sourceIds: [first.id] });79141 }80142 }81143 82- const sentences = spans.filter(span => !spans.some(other => (83- other !== span && other.axis === span.axis && other.ids.length > span.ids.length && span.ids.every(id => other.ids.includes(id))84- )));144+ const sentences = maximalSpans(spans)145+ .sort((a, b) => a.originOrder - b.originOrder || a.spanOrder - b.spanOrder)146+ .map(({ originOrder: _originOrder, spanOrder: _spanOrder, ...span }) => span);85147 return { rules: [...found.values()], sentences };86148 }87-
A tests/engine-rules.test.js +109 -0
@@ -0,0 +1,109 @@
1+import test from 'node:test';2+import assert from 'node:assert/strict';3+import { MAX_RULE_PATHS, RuleParseLimitError, parseRules } from '../src/engine/rules.js';4+5+const properties = new Set(['YOU', 'PUSH', 'STOP', 'WIN']);6+const word = (text, x, y, id = `${text}-${x}-${y}`) => ({ kind: 'word', text, x, y, id });7+const rules = objects => parseRules(objects, properties).rules.map(rule => rule.text);8+9+test('stacked connector alternatives select one word per cell', () => {10+ assert.deepEqual(rules([11+ word('ROCK', 0, 0), word('AND', 1, 0), word('IS', 1, 0), word('FLAG', 2, 0),12+ ]), ['ROCK IS FLAG']);13+});14+15+test('stacked noun starts remain valid alternatives', () => {16+ assert.deepEqual(rules([17+ word('MIAU', 0, 0), word('AND', 1, 0), word('ROCK', 1, 0), word('IS', 2, 0), word('YOU', 3, 0),18+ ]), ['ROCK IS YOU']);19+});20+21+test('stacked predicates and subjects enumerate every valid rule', () => {22+ assert.deepEqual(rules([23+ word('ROCK', 0, 0), word('IS', 1, 0), word('WIN', 2, 0), word('PUSH', 2, 0),24+ ]), ['ROCK IS WIN', 'ROCK IS PUSH']);25+ assert.deepEqual(rules([26+ word('ROCK', 0, 0), word('FLAG', 0, 0), word('IS', 1, 0), word('WIN', 2, 0),27+ ]), ['ROCK IS WIN', 'FLAG IS WIN']);28+});29+30+test('stacked AND and IS keeps the valid subject chain', () => {31+ assert.deepEqual(rules([32+ word('ROCK', 0, 0), word('AND', 1, 0), word('FLAG', 2, 0), word('IS', 2, 0), word('IS', 3, 0), word('WIN', 4, 0),33+ ]), ['ROCK IS WIN', 'FLAG IS WIN']);34+});35+36+test('rule order follows spatial origin and horizontal before vertical', () => {37+ assert.deepEqual(rules([38+ word('FLAG', 4, 3, 'late'), word('IS', 5, 3), word('WIN', 6, 3),39+ word('ROCK', 0, 0, 'early'), word('IS', 1, 0), word('STOP', 2, 0),40+ ]), ['ROCK IS STOP', 'FLAG IS WIN']);41+});42+43+test('sentence order follows spatial origin instead of authored object order', () => {44+ const parsed = parseRules([45+ word('FLAG', 4, 3, 'flag'), word('IS', 5, 3, 'flag-is'), word('WIN', 6, 3, 'flag-win'),46+ word('ROCK', 0, 0, 'rock'), word('IS', 1, 0, 'rock-is'), word('STOP', 2, 0, 'rock-stop'),47+ ], properties);48+ assert.deepEqual(parsed.sentences.map(sentence => sentence.ids), [49+ ['rock', 'rock-is', 'rock-stop'],50+ ['flag', 'flag-is', 'flag-win'],51+ ]);52+});53+54+test('predicate sentence spans include the AND connector source id', () => {55+ const parsed = parseRules([56+ word('ROCK', 0, 0, 'rock'), word('IS', 1, 0, 'is'), word('WIN', 2, 0, 'win'),57+ word('AND', 3, 0, 'and'), word('PUSH', 4, 0, 'push'),58+ ], properties);59+ assert.deepEqual(parsed.sentences, [{60+ ids: ['rock', 'is', 'win', 'and', 'push'],61+ axis: 'h',62+ }]);63+});64+65+test('ordinary stacked alternatives remain fully enumerable under the parser budget', () => {66+ const parsed = parseRules([67+ word('ROCK', 0, 0, 'rock-a'), word('FLAG', 0, 0, 'rock-b'),68+ word('AND', 1, 0, 'subject-and'),69+ word('MOUSE', 2, 0, 'mouse-a'), word('BELL', 2, 0, 'mouse-b'),70+ word('IS', 3, 0, 'is'),71+ word('WIN', 4, 0, 'win-a'), word('PUSH', 4, 0, 'win-b'),72+ ], properties);73+ assert.deepEqual(parsed.rules.map(rule => rule.text).sort(), [74+ 'ROCK IS WIN', 'ROCK IS PUSH', 'FLAG IS WIN', 'FLAG IS PUSH',75+ 'MOUSE IS WIN', 'MOUSE IS PUSH', 'BELL IS WIN', 'BELL IS PUSH',76+ ].sort());77+ assert.equal(parsed.sentences.length, 8);78+});79+80+test('pathological stacked grammar fails deterministically before returning partial results', () => {81+ const objects = [];82+ const alternatives = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H'];83+ for (const [x, values] of [[0, alternatives], [2, alternatives], [4, alternatives], [6, alternatives], [8, alternatives]]) {84+ for (const [index, text] of values.entries()) objects.push(word(text, x, 0, `${text}-${x}-${index}`));85+ }86+ objects.push(word('AND', 1, 0, 'and-subject-a'), word('AND', 3, 0, 'and-subject-b'));87+ objects.push(word('IS', 5, 0, 'is'));88+ objects.push(word('AND', 7, 0, 'and-predicate-a'), word('AND', 9, 0, 'and-predicate-b'));89+ assert.throws(() => parseRules(objects, properties), error => {90+ assert.ok(error instanceof RuleParseLimitError);91+ assert.equal(error.kind, 'paths');92+ assert.equal(error.limit, MAX_RULE_PATHS);93+ return true;94+ });95+});96+97+test('near-budget stacked grammar still returns complete rules without quadratic filtering', () => {98+ const objects = [];99+ const alternatives = ['A', 'B', 'C', 'D', 'E', 'F'];100+ for (const [x, values] of [[0, alternatives], [2, alternatives], [4, alternatives], [6, alternatives], [8, alternatives]]) {101+ for (const [index, text] of values.entries()) objects.push(word(text, x, 0, `${text}-${x}-${index}`));102+ }103+ objects.push(word('AND', 1, 0, 'and-subject-a'), word('AND', 3, 0, 'and-subject-b'));104+ objects.push(word('IS', 5, 0, 'is'));105+ objects.push(word('AND', 7, 0, 'and-predicate-a'), word('AND', 9, 0, 'and-predicate-b'));106+ const parsed = parseRules(objects, properties);107+ assert.equal(parsed.rules.length, 36);108+ assert.ok(parsed.sentences.length > 0);109+});
M tests/rules.test.js +1 -1