- commit
- 5a35bdd5bf55f3a002fb3c1dd52539801628dbae
- parent
- 3e53b8384f8f2e230f44da585e6ae399b8d7bc86
- author
- Dami <dami.etoile@gmail.com>
| A | openspec/ | 2 | + |
| A | openspec/ | 52 | ++++++++++++++++++++++++++++++ |
| A | openspec/ | 24 | ++++++++++++++ |
| A | openspec/ | 24 | ++++++++++++++ |
| A | openspec/ | 14 | ++++++++ |
| M | src/ | 5 | ++- |
| M | tests/ | 25 | ++++++++++++++ |
@@ -0,0 +1,52 @@
1+## Context2+3+The engine already deduplicates semantic rules so simulation does not apply the same `subject IS predicate` more than once. The designer's sentence bars need a different view: every physical sentence on the board, including exact duplicates and stacked-word paths. E3 has been implemented in the engine and tests; this change records that landed contract before the implementation is committed.4+5+## Goals / Non-Goals6+7+**Goals:**8+9+- Expose `state.sentences: [{ ids, axis }]` for presentation consumers.10+- Preserve one semantic rule per unique sentence meaning in `state.rules`.11+- Keep exact duplicate physical runs, stacked-word paths, and horizontal/vertical axes deterministic.12+- Drop strict subset spans when a longer span exists on the same axis.13+14+**Non-Goals:**15+16+- Changing rule semantics, movement, transformations, contact effects, or event payloads.17+- Drawing sentence bars or choosing their visual placement.18+- Adding new vocabulary or changing the parser beyond the physical span projection.19+20+## Decisions21+22+### 1. Keep semantic rules and physical spans as separate projections23+24+The parser discovers candidate source paths once. It emits every physical path into `state.sentences`, while the existing semantic map keeps only the first deterministic source for each rule text. A renderer uses spans for bars and rules for behavior.25+26+### 2. Store only source IDs and axis in spans27+28+The span contract is intentionally small: `ids` preserves the source word entity order and `axis` is `h` or `v`. Positions and text remain available from state objects, avoiding a second mutable representation that could drift from the board.29+30+### 3. Filter strict subsets by axis and source membership31+32+After candidates are collected, remove a span when another candidate on the same axis has more source IDs and contains every ID in the shorter span. Exact duplicates are retained because they are distinct candidate records and have equal length. This matches the prototype parser's maximal-span behavior.33+34+### 4. Recompute spans with rules35+36+`state.sentences` is rebuilt whenever rules are refreshed, including reset, word movement, undo restoration, and transformations that trigger a re-scan. It is derived state and is not independently edited by mods or renderers.37+38+## Risks / Trade-offs39+40+- [ID membership can mistake disjoint spans for subsets] → Require same-axis and strict-length comparisons while retaining candidate order; fixtures cover horizontal, vertical, stacked, and duplicate cases.41+- [Consumers assume semantic rule count equals bar count] → Document `state.sentences` as the physical source and keep `state.rules` as the semantic source of truth.42+- [Parser changes alter bar order] → Preserve deterministic discovery order and assert exact ordered spans in engine tests.43+44+## Migration Plan45+46+1. Keep existing `state.rules` consumers unchanged.47+2. Read `state.sentences` in sentence-bar rendering when that separate presentation change lands.48+3. Archive this change after the engine implementation and tests are committed.49+50+## Open Questions51+52+None for the E3 data contract.@@ -0,0 +1,24 @@
1+## Why2+3+Semantic rules are correctly deduplicated for simulation, but presentation needs to draw every physical sentence that exists on the board. Exposing maximal sentence spans lets duplicate copies receive independent bars without changing rule semantics.4+5+## What Changes6+7+- Add `state.sentences` containing every physical maximal sentence span as ordered word ids and axis.8+- Preserve semantic rule deduplication and existing source path behavior.9+- Drop spans that are strict subsets of a longer span on the same axis.10+- Cover stacked words, exact duplicate sentences, horizontal/vertical axes, and rule movement with engine fixtures.11+12+## Capabilities13+14+### New Capabilities15+16+None.17+18+### Modified Capabilities19+20+- `rule-engine`: expose physical sentence spans alongside deduplicated active rules.21+22+## Impact23+24+Extends engine state and parsing output for sentence-bar rendering. Existing semantic rules and turn behavior remain unchanged. Presentation consumers can use `state.sentences` without reconstructing board text.@@ -0,0 +1,24 @@
1+## MODIFIED Requirements2+3+### Requirement: Active rules SHALL identify their source words4+Each active semantic rule SHALL expose the word entity IDs that formed it in reading order and whether the sentence runs horizontally or vertically. Duplicate semantic rules at different physical locations SHALL remain deduplicated in the active rule table while retaining deterministic source information. The settled state SHALL additionally expose `sentences`, an ordered list of every physical maximal sentence span as `{ ids, axis }`. A span that is a strict subset of a longer span on the same axis SHALL be omitted; exact duplicate sentences at different locations SHALL both remain.5+6+#### Scenario: A horizontal sentence reports its source7+- **WHEN** adjacent word entities form `MIAU IS YOU` from left to right8+- **THEN** the resulting semantic rule identifies those word IDs in order, reports horizontal axis, and `state.sentences` contains the physical span with those ids and axis `h`9+10+#### Scenario: A vertical sentence reports its source11+- **WHEN** adjacent word entities form `ROCK IS PUSH` from top to bottom12+- **THEN** the resulting semantic rule identifies those word IDs in order, reports vertical axis, and `state.sentences` contains the physical span with those ids and axis `v`13+14+#### Scenario: Exact duplicate sentences remain physical spans15+- **WHEN** two separate word runs form the same semantic sentence16+- **THEN** the active rule table contains one semantic rule while `state.sentences` contains both physical spans17+18+#### Scenario: Strict subsets are removed19+- **WHEN** a candidate span is a strict subset of a longer valid span on the same axis20+- **THEN** `state.sentences` contains only the longer maximal span21+22+#### Scenario: Stacked words create separate spans23+- **WHEN** two noun words share a cell and each forms a valid sentence with the same following words24+- **THEN** `state.sentences` contains one span for each physical noun path@@ -0,0 +1,14 @@
1+## 1. Sentence span projection2+3+- [x] 1.1 Collect physical candidate sentence paths while preserving ordered word ids and horizontal/vertical axis.4+- [x] 1.2 Filter strict subset spans on the same axis while retaining exact duplicate physical spans.5+- [x] 1.3 Expose the ordered result as `state.sentences` whenever the engine refreshes derived rules.6+7+## 2. Verification8+9+- [x] 2.1 Verify horizontal and vertical spans, `AND` spans, stacked word paths, exact duplicates, and semantic rule deduplication with engine fixtures.10+- [x] 2.2 Run the full Node test suite and confirm existing movement, dynamic-rule, undo, and settled-event behavior remains green.11+12+## 3. Presentation handoff13+14+- [ ] 3.1 Consume `state.sentences` for sentence bars in the separate designer-owned stage/rendering change.@@ -51,6 +51,7 @@ export function createGame(level) {
5151 const noun = t => t && t !== 'IS' && t !== 'AND' && !CORE_PROPERTIES.has(t);5252 const predicate = t => noun(t) || CORE_PROPERTIES.has(t);5353 const found = new Map();54+ const spans = [];5455 for (const [origin, starts] of words) {5556 const [ox, oy] = origin.split(',').map(Number);5657 for (const first of starts.filter(entry => noun(entry.text))) for (const [dx, dy] of directions) {@@ -84,6 +85,7 @@ export function createGame(level) {
8485 }8586 // Add expanded subjects and predicates while retaining the first8687 // occurrence of each rule for deterministic stacked-word parsing.88+ for (const s of subjectPaths) for (const p of predicatePaths) spans.push({ ids: [...s.sourceIds, is.id, ...p.sourceIds], axis: dx ? 'h' : 'v' });8789 for (const s of subjectPaths) for (const p of predicatePaths) {8890 const subjectNames = s.text.split(' AND ');8991 const predicateNames = p.text.split(' AND ');@@ -94,6 +96,7 @@ export function createGame(level) {
9496 }9597 }9698 }99+ state.sentences = spans.filter(span => !spans.some(other => other !== span && other.axis === span.axis && other.ids.length > span.ids.length && span.ids.every(id => other.ids.includes(id))));97100 return [...found.values()];98101 }99102 @@ -153,7 +156,7 @@ export function createGame(level) {
153156 }154157 function reset() {155158 const before = state ? clone(state) : null;156- state = { width: original.width, height: original.height, title: original.title || 'Untitled puzzle', hint: original.hint || '', objects: original.objects.map((o, i) => ({ id: o.id || `o${i}`, kind: o.kind || (o.text != null ? 'word' : 'object'), noun: o.noun?.toUpperCase(), text: o.text?.toUpperCase(), x: o.x, y: o.y })), moves: 0, won: false, rules: [] };159+ state = { width: original.width, height: original.height, title: original.title || 'Untitled puzzle', hint: original.hint || '', objects: original.objects.map((o, i) => ({ id: o.id || `o${i}`, kind: o.kind || (o.text != null ? 'word' : 'object'), noun: o.noun?.toUpperCase(), text: o.text?.toUpperCase(), x: o.x, y: o.y })), moves: 0, won: false, rules: [], sentences: [] };157160 history = [];158161 refreshRules(); transform(); refreshRules();159162 resolveContacts();@@ -21,6 +21,31 @@ test('parses AND subjects and predicates in both axes', () => {
2121 }2222 });2323 24+test('sentences expose every physical duplicate and preserve maximal AND spans', () => {25+ const game = createGame(level([26+ word('ROCK', 0, 0, 'h-rock'), word('IS', 1, 0, 'h-is'), word('STOP', 2, 0, 'h-stop'),27+ word('ROCK', 0, 2, 'v-rock'), word('IS', 0, 3, 'v-is'), word('STOP', 0, 4, 'v-stop'),28+ word('ROCK', 3, 0, 'and-rock'), word('AND', 4, 0, 'and-op'), word('FLAG', 5, 0, 'and-flag'), word('IS', 6, 0, 'and-is'), word('WIN', 7, 0, 'and-win'),29+ ]));30+ assert.equal(game.getState().rules.filter(rule => rule.text === 'ROCK IS STOP').length, 1);31+ assert.deepEqual(game.getState().sentences, [32+ { ids: ['h-rock', 'h-is', 'h-stop'], axis: 'h' },33+ { ids: ['v-rock', 'v-is', 'v-stop'], axis: 'v' },34+ { ids: ['and-rock', 'and-op', 'and-flag', 'and-is', 'and-win'], axis: 'h' },35+ ]);36+});37+38+test('stacked words produce separate physical sentence spans', () => {39+ const game = createGame(level([40+ word('ROCK', 0, 0, 'rock-a'), word('FLAG', 0, 0, 'flag-a'),41+ word('IS', 1, 0, 'is-a'), word('WIN', 2, 0, 'win-a'),42+ ]));43+ assert.equal(game.getState().sentences.length, 2);44+ assert.deepEqual(game.getState().sentences.map(sentence => sentence.ids), [45+ ['rock-a', 'is-a', 'win-a'], ['flag-a', 'is-a', 'win-a'],46+ ]);47+});48+2449 test('pushes a whole chain and refuses a STOP wall atomically', () => {2550 const game = createGame(level([2651 word('BABA', 0, 0), word('IS', 1, 0), word('YOU', 2, 0),