- commit
- 07c3d4603e913a147696716a6e999985334539ab
- parent
- c655de36efeb10b0cd3d03d8b633ead3b25b1c7e
- author
- Dami <dami.etoile@gmail.com>
| M | src/ | 153 | +++++++++++++++++++++--------- |
| A | tests/ | 109 | +++++++++++++++++++++ |
| M | tests/ | 2 | +- |
@@ -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-@@ -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+});@@ -16,8 +16,8 @@ test('pure rule parser preserves stacked sources and maximal physical spans', ()
1616 assert.equal(parsed.rules.filter(rule => rule.text === 'ROCK IS STOP').length, 1);1717 assert.deepEqual(parsed.sentences, [1818 { ids: ['h-rock', 'h-is', 'h-stop'], axis: 'h' },19- { ids: ['v-rock', 'v-is', 'v-stop'], axis: 'v' },2019 { ids: ['and-rock', 'and-op', 'and-flag', 'and-is', 'and-win'], axis: 'h' },20+ { ids: ['v-rock', 'v-is', 'v-stop'], axis: 'v' },2121 ]);2222 });2323