feat(offline): port DAWG cursor + move generator to TS (parity-pinned)
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 1m8s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m38s
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 1m8s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m38s
First engine-first step of PWA offline mode (Phase A): the client-side move generator — the "robot brain" a local vs_ai game will run on-device — with no runtime wiring yet (Phase B). - dawg.ts: add the step-by-step cursor (root/final/next/arcs), a faithful port of dafsa traverse.go over the reader's existing bitstream. - generate.ts: the Appel-Jacobson generator (leftPart/extendRight + cross-sets + counts-rack + board transpose + moveKey ranking), reusing the cursor and validate.ts evaluate/connected. A cross-set LetterSet is a Uint8Array, so the 33-letter Russian alphabet (index 32) is exact under JS bit ops. - validate.ts: export connected for the generator's connectivity filter. - backend/cmd/movegen: dev tool building small sample dictionaries and emitting golden move-generation fixtures from the real Go solver (EN + RU). - tests: dawg.cursor.test.ts (enumeration bijection vs indexOf) and generate.parity.test.ts (7/7 vs the Go solver: empty board, mid-game, blank, single-word rule, Russian index-32 cross-set). The committed EN sample also unblocks the existing skipped dawg.parity.test.ts once wired with DICT_* in CI. Pure additive library code; no runtime behavior change.
This commit is contained in:
@@ -0,0 +1,68 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { Dawg } from './dawg';
|
||||
|
||||
// The step-by-step DAWG cursor (root/final/next/arcs) is the primitive the move
|
||||
// generator walks. These fast unit tests pin it against a small committed sample
|
||||
// dictionary (backend/cmd/movegen); the full parity vs the Go solver lands with
|
||||
// the generator's conformance fixtures.
|
||||
const bytes = new Uint8Array(readFileSync(new URL('./testdata/sample_en.dawg', import.meta.url)));
|
||||
const fixture = JSON.parse(
|
||||
readFileSync(new URL('./testdata/sample_en.words.json', import.meta.url), 'utf8'),
|
||||
) as { numAdded: number; words: string[]; indexes: number[][] };
|
||||
|
||||
const key = (w: number[]): string => w.join(',');
|
||||
|
||||
// enumerateWords walks the whole automaton depth-first, collecting the index path
|
||||
// at every accepting node — i.e. every stored word.
|
||||
function enumerateWords(d: Dawg): number[][] {
|
||||
const out: number[][] = [];
|
||||
const path: number[] = [];
|
||||
const visit = (node: number): void => {
|
||||
d.arcs(node, (label, dest, final) => {
|
||||
path.push(label);
|
||||
if (final) out.push(path.slice());
|
||||
visit(dest);
|
||||
path.pop();
|
||||
return true;
|
||||
});
|
||||
};
|
||||
if (d.final(d.root())) out.push([]);
|
||||
visit(d.root());
|
||||
return out;
|
||||
}
|
||||
|
||||
describe('dawg cursor', () => {
|
||||
it('parses the sample fixture', () => {
|
||||
const d = new Dawg(bytes);
|
||||
expect(d.numAdded).toBe(fixture.numAdded);
|
||||
});
|
||||
|
||||
it('root is not an accepting state (the sample has no empty word)', () => {
|
||||
const d = new Dawg(bytes);
|
||||
expect(d.final(d.root())).toBe(false);
|
||||
});
|
||||
|
||||
it('enumerates exactly the stored words', () => {
|
||||
const d = new Dawg(bytes);
|
||||
const got = enumerateWords(d).map(key).sort();
|
||||
const want = fixture.indexes.map(key).sort();
|
||||
expect(got).toEqual(want);
|
||||
});
|
||||
|
||||
it('next walks a stored word to an accepting node and rejects a non-edge', () => {
|
||||
const d = new Dawg(bytes);
|
||||
let node = d.root();
|
||||
for (const ch of [2, 0, 17, 4, 3]) {
|
||||
// "cared"
|
||||
node = d.next(node, ch);
|
||||
expect(node).toBeGreaterThanOrEqual(0);
|
||||
}
|
||||
expect(d.final(node)).toBe(true);
|
||||
// "care" (index [2,0,17,4]) is an internal accepting node on the way to "cared".
|
||||
const care = [2, 0, 17, 4].reduce((n, ch) => d.next(n, ch), d.root());
|
||||
expect(d.final(care)).toBe(true);
|
||||
// No stored word starts with 'z' (index 25).
|
||||
expect(d.next(d.root(), 25)).toBe(-1);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user