feat(ui): on-device move preview (local eval) with network fallback
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 17s
CI / ui (pull_request) Successful in 58s
CI / conformance (pull_request) Successful in 8s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m18s
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 17s
CI / ui (pull_request) Successful in 58s
CI / conformance (pull_request) Successful in 8s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m18s
Score and validate a tentative move on-device instead of a per-arrangement
network round trip. The dawg reader and the validate/score/direction slice of the
scrabble-solver engine are ported to TypeScript (ui/src/lib/dict), pinned
byte-for-byte to the Go engine by a new `conformance` CI job. The server stays
authoritative — submit_play re-validates — so the local result is an advisory
accelerator only; any cache miss, storage eviction or a bad-connection breaker
falls back to the network evaluate.
- backend: Registry.DictBytes + an authed GET /api/v1/user/dict/{variant}/{version}
(immutable) streaming the pinned per-game dawg.
- gateway: a session-gated /dict edge route proxying it; fetchDict on the transport.
- client: an IndexedDB blob cache (best-effort, storage.persist()) + a loader
(memory -> IndexedDB -> network, session-scoped bad-connection breaker) + a lobby
prefetch (your-turn first); an adapter over the existing premiums/alphabet; a
DictWarmup overlay while a cold dictionary loads (120ms flash-guard, 5s cap ->
network); a ?nolocal flag; the DebugPanel reset also clears the dict cache.
- parity: generators backend/cmd/{dictgen,validategen} + gated Vitest suites, run
in CI against the release dictionaries.
- docs: ARCHITECTURE §5, TESTING, UI_DESIGN, FUNCTIONAL (+ru).
This commit is contained in:
@@ -0,0 +1,192 @@
|
||||
// In-memory reader for the dafsa DAWG binary format, ported byte-for-byte from
|
||||
// github.com/iliadenisov/dafsa (bits.go / disk.go / dawg.go). It answers the one
|
||||
// query the local move validator needs: given a word as alphabet-index bytes, is
|
||||
// it stored in the dictionary and at what insertion index.
|
||||
//
|
||||
// The Go reader treats the file as a big-endian, MSB-first bit stream. Every
|
||||
// field this reader touches on the lookup path is at most ~31 bits wide, so the
|
||||
// bit reader accumulates into a plain JavaScript number (no BigInt) and stays
|
||||
// exact. Faithfulness to the Go reader is enforced by dawg.parity.test.ts, which
|
||||
// checks indexOf against the authoritative Go output across the whole dictionary.
|
||||
|
||||
// bitsLen returns the number of bits needed to represent x (Go math/bits.Len).
|
||||
function bitsLen(x: number): number {
|
||||
return x === 0 ? 0 : 32 - Math.clz32(x);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dawg is a read-only view over a serialized dafsa dictionary held in memory.
|
||||
* Construct it from the raw bytes of a `.dawg` file, then call {@link indexOf}.
|
||||
*/
|
||||
export class Dawg {
|
||||
private readonly bytes: Uint8Array;
|
||||
private p = 0; // current position, in bits
|
||||
|
||||
private readonly cbits: number;
|
||||
private readonly abits: number;
|
||||
private readonly numEdges: number;
|
||||
private readonly wbits: number;
|
||||
private readonly firstNodeOffset: number;
|
||||
private readonly hasEmptyWord: boolean;
|
||||
|
||||
/** Number of words stored (dafsa NumAdded). */
|
||||
readonly numAdded: number;
|
||||
/** Number of graph nodes (dafsa NumNodes). */
|
||||
readonly numNodes: number;
|
||||
|
||||
// Scratch for the last edge resolved by getEdge, mirroring dafsa's edgeEnd +
|
||||
// final flag. Reused to keep the lookup path allocation-free.
|
||||
private eNode = 0;
|
||||
private eCount = 0;
|
||||
private eFinal = false;
|
||||
|
||||
constructor(bytes: Uint8Array) {
|
||||
this.bytes = bytes;
|
||||
|
||||
// Header: 32-bit size (skipped — the whole file is already in memory), then
|
||||
// cbits, abits, the language code string, and the word/node/edge counts.
|
||||
this.p = 32;
|
||||
this.cbits = this.readBits(8);
|
||||
this.abits = this.readBits(8);
|
||||
this.skipString(); // language code — not needed to walk index bytes
|
||||
|
||||
const numAdded = this.readUnsigned();
|
||||
const numNodes = this.readUnsigned();
|
||||
this.numEdges = this.readUnsigned();
|
||||
this.firstNodeOffset = this.p;
|
||||
this.hasEmptyWord = this.readBits(1) === 1;
|
||||
|
||||
this.numAdded = numAdded;
|
||||
this.numNodes = numNodes;
|
||||
this.wbits = bitsLen(numAdded);
|
||||
}
|
||||
|
||||
/**
|
||||
* indexOf returns the insertion index of the given word (as alphabet-index
|
||||
* bytes), or -1 if the word was never added. Mirrors dafsa IndexOfB.
|
||||
*/
|
||||
indexOf(word: ArrayLike<number>): number {
|
||||
let skipped = 0;
|
||||
let node = 0; // rootNode
|
||||
let final = this.hasEmptyWord;
|
||||
for (let i = 0; i < word.length; i++) {
|
||||
if (!this.getEdge(node, word[i])) {
|
||||
return -1;
|
||||
}
|
||||
node = this.eNode;
|
||||
final = this.eFinal;
|
||||
skipped += this.eCount;
|
||||
}
|
||||
return final ? skipped : -1;
|
||||
}
|
||||
|
||||
/** has reports whether the word (as alphabet-index bytes) is in the dictionary. */
|
||||
has(word: ArrayLike<number>): boolean {
|
||||
return this.indexOf(word) >= 0;
|
||||
}
|
||||
|
||||
// getEdge resolves the outgoing edge for ch from the node at the given bit
|
||||
// offset. On success it fills eNode/eCount/eFinal and returns true. Mirrors
|
||||
// dafsa (*dawg).getEdge.
|
||||
private getEdge(node: number, ch: number): boolean {
|
||||
if (this.numEdges <= 0) {
|
||||
return false;
|
||||
}
|
||||
const pos = node === 0 ? this.firstNodeOffset : node;
|
||||
this.p = pos;
|
||||
const nodeFinal = this.readBits(1);
|
||||
const fallthrough = this.readBits(1);
|
||||
|
||||
if (fallthrough === 1) {
|
||||
const edgeCh = this.readBits(this.cbits);
|
||||
if (edgeCh === ch) {
|
||||
this.eCount = nodeFinal;
|
||||
this.eNode = this.p; // the fallthrough target is the physically next node
|
||||
this.eFinal = this.readBits(1) === 1;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const singleEdge = this.readBits(1);
|
||||
let numEdges = 1;
|
||||
const nskiplen = bitsLen(this.wbits);
|
||||
let nskip = 0;
|
||||
if (singleEdge !== 1) {
|
||||
numEdges = this.readUnsigned();
|
||||
nskip = this.readBits(nskiplen);
|
||||
}
|
||||
|
||||
const base = this.p; // bit offset of the first edge record
|
||||
const recordBits = this.cbits + nskip + this.abits;
|
||||
|
||||
// Binary search over the edges, which are sorted ascending by character.
|
||||
let high = numEdges;
|
||||
let low = -1;
|
||||
while (high - low > 1) {
|
||||
const probe = (high + low) >> 1;
|
||||
// The first edge omits its (zero) skip field, so every later record is
|
||||
// shifted back by nskip bits.
|
||||
let seekTo = base + probe * recordBits;
|
||||
if (probe > 0) {
|
||||
seekTo -= nskip;
|
||||
}
|
||||
this.p = seekTo;
|
||||
const edgeCh = this.readBits(this.cbits);
|
||||
const cmp = edgeCh - ch;
|
||||
if (cmp === 0) {
|
||||
this.eCount = probe > 0 ? this.readBits(nskip) : nodeFinal;
|
||||
this.eNode = this.readBits(this.abits);
|
||||
this.p = this.eNode;
|
||||
this.eFinal = this.readBits(1) === 1;
|
||||
return true;
|
||||
} else if (cmp < 0) {
|
||||
low = probe;
|
||||
} else {
|
||||
high = probe;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// readBits reads n (<= 32) bits MSB-first from the current position and
|
||||
// advances it. Mirrors dafsa (*bitSeeker).ReadBits for the widths on this path.
|
||||
private readBits(n: number): number {
|
||||
let result = 0;
|
||||
let p = this.p;
|
||||
const bytes = this.bytes;
|
||||
while (n > 0) {
|
||||
const byteIndex = p >>> 3;
|
||||
const bitInByte = p & 7;
|
||||
const avail = 8 - bitInByte;
|
||||
const take = avail < n ? avail : n;
|
||||
const shift = avail - take;
|
||||
const chunk = (bytes[byteIndex] >>> shift) & ((1 << take) - 1);
|
||||
result = result * (1 << take) + chunk;
|
||||
p += take;
|
||||
n -= take;
|
||||
}
|
||||
this.p = p;
|
||||
return result;
|
||||
}
|
||||
|
||||
// readUnsigned reads a dafsa "7code" varint (7 payload bits per byte, high bit
|
||||
// continues). Mirrors dafsa readUnsigned.
|
||||
private readUnsigned(): number {
|
||||
let result = 0;
|
||||
for (;;) {
|
||||
const d = this.readBits(8);
|
||||
result = result * 128 + (d & 0x7f);
|
||||
if ((d & 0x80) === 0) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// skipString advances past a 7code-length-prefixed byte string.
|
||||
private skipString(): void {
|
||||
const n = this.readUnsigned();
|
||||
this.p += n * 8;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user