chore: take in the 16 Sep bundle — lib, curriculum v5, prompt, gate audit
The artifact was reworked after real incidents: a week of lost data, a
student taught out of order, and spelling diagnoses the model invented.
This takes the new export in verbatim; the port catches up in the
commits that follow.
Copied byte-identical from the bundle:
lib/ lexicon.js and sync.js are new; gate.js gains enforcement,
hangul.js letter-level marking, srs.js recall evidence,
conjugation.js deconjugate(); blocks.js now takes the last
block, closes gloss at "=", and parses recall, ::result and
::confirmed
data/ curriculum.json v5 — six 다지기 phase reviews; the 371
roadmap words are unchanged and no band moves
prompt/ English-only rule, recall, LETTER-LEVEL CHECK, marking
audit-gate.mjs, run-checks.sh, fixtures/ — the word gate measured
against 54 real tutor messages
CI runs run-checks.sh in place of validate.mjs alone, and `npm run check`
gains the audit. Baselines: validate PASS 0/0; audit 7 of 41 and 2 of 13.
types/lib/ declares the new API, and test/lib/ pins it: letterCheck on
the prompt's own 짧다/빫다 case, deconjugation, the roadmap-first order
that keeps 마셔 out of Phase 1, sync's three gates, and recall evidence —
including the two ways lib's evidence is looser than PORT.md, pinned as
they are so the call site that tightens them is visibly needed.
TaskHost gains a plain recall renderer so the tree typechecks against the
wider Task union.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
115
lib/sync.js
Normal file
115
lib/sync.js
Normal file
@@ -0,0 +1,115 @@
|
||||
/* ══════════════════════════════════════════════════════════════════════
|
||||
SYNCING BETWEEN DEVICES — three gates, each paid for in lost user data
|
||||
══════════════════════════════════════════════════════════════════════
|
||||
The artifact shipped without these twice and destroyed real work twice.
|
||||
Last-write-wins is adequate for one user whose worst conflict is a
|
||||
duplicated SRS grade — but ONLY with all three of the following.
|
||||
|
||||
1. HYDRATION. A client may not push a document until the server has told
|
||||
it what it already holds. This is the one that caused the loss: a
|
||||
laptop with a week-old local copy ran a boot-time migration, which
|
||||
re-stamped that stale copy with the current time and pushed it. A
|
||||
boot-time write always looks like the newest edit in the world. A
|
||||
week of phone work — chat and roadmap both — was gone. Note that rule
|
||||
2 would NOT have saved it: the data was real, just old.
|
||||
|
||||
2. A COUNTER, NOT A CLOCK. Phone and laptop clocks disagree by minutes.
|
||||
Compare a monotonic per-document counter; fall back to the timestamp
|
||||
only when one side has none (an older client).
|
||||
|
||||
3. NO SILENT SHRINKING. A copy holding strictly less than the local one —
|
||||
fewer chat turns, fewer finished units, fewer cards — is never adopted
|
||||
by accident: keep the local copy and push it back. Deliberate
|
||||
deletions set a flag and are obeyed. This is what made the loss
|
||||
recoverable: the phone still had the real chat, refused the truncated
|
||||
server copy, and restored it.
|
||||
|
||||
And: MIGRATIONS RUN AFTER THE FIRST PULL, NEVER AT BOOT, and never
|
||||
rewrite history. The trigger for the whole incident was a one-shot repair
|
||||
that cleared the chat. It was correct for the device it was written for
|
||||
and a loaded gun for every other one. */
|
||||
|
||||
export const DOCS = ["srs", "log", "meta", "chat"];
|
||||
|
||||
/** How much a copy holds. Shrinking is always deliberate, never a race. */
|
||||
export function weigh(name, d) {
|
||||
if (!d) return 0;
|
||||
if (name === "chat") return (d.turns || []).length;
|
||||
if (name === "meta") return d.road && d.road.done ? Object.keys(d.road.done).length : 0;
|
||||
if (name === "srs") return d.cards ? Object.keys(d.cards).length : 0;
|
||||
if (name === "log") return d.days ? Object.keys(d.days).length : 0;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/** Is the copy that just arrived later than ours? */
|
||||
export function isLater(remote, localVersion, localStamp) {
|
||||
if (typeof remote.v === "number" && localVersion > 0) {
|
||||
if (remote.v !== localVersion) return remote.v > localVersion;
|
||||
return typeof remote.u === "number" && remote.u > localStamp;
|
||||
}
|
||||
return typeof remote.u === "number" && remote.u > localStamp;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide what to do with an incoming document.
|
||||
* @returns "ignore" | "adopt" | "reassert"
|
||||
* reassert = ours holds more and nothing said the shrink was
|
||||
* deliberate, so keep ours, bump past theirs, and push it back.
|
||||
*/
|
||||
export function reconcile(name, remote, local) {
|
||||
if (!remote || !remote.d) return "ignore";
|
||||
if (!isLater(remote, local.version || 0, local.stamp || 0)) return "ignore";
|
||||
if (weigh(name, local.data) > weigh(name, remote.d) && !remote.x) return "reassert";
|
||||
return "adopt";
|
||||
}
|
||||
|
||||
/**
|
||||
* The write side. Hold every edit until the document has been hydrated;
|
||||
* an edit made before then keeps its old stamp so a newer server copy
|
||||
* still wins.
|
||||
*/
|
||||
export function makeWriter({ push, now = () => Date.now() }) {
|
||||
const state = {}; // name -> {version,stamp,dirty,hydrated,intent}
|
||||
const S = n => (state[n] = state[n] || { version: 0, stamp: 0, dirty: false, hydrated: false, intent: false });
|
||||
|
||||
return {
|
||||
state,
|
||||
/** a normal user edit */
|
||||
touch(name, deliberateShrink = false) {
|
||||
const s = S(name);
|
||||
s.dirty = true;
|
||||
if (deliberateShrink) s.intent = true;
|
||||
if (s.hydrated) { s.stamp = now(); s.version += 1; } // otherwise: hold
|
||||
return s;
|
||||
},
|
||||
/** call when the first snapshot for this document arrives (or is known absent) */
|
||||
hydrate(name) {
|
||||
const s = S(name);
|
||||
if (s.hydrated) return s;
|
||||
s.hydrated = true;
|
||||
if (s.dirty) { s.stamp = now(); s.version += 1; } // the held edit is real after all
|
||||
return s;
|
||||
},
|
||||
adopted(name, remote) {
|
||||
const s = S(name);
|
||||
s.stamp = typeof remote.u === "number" ? remote.u : now();
|
||||
s.version = Math.max(s.version, typeof remote.v === "number" ? remote.v : 0);
|
||||
s.dirty = false; s.intent = false;
|
||||
return s;
|
||||
},
|
||||
reasserted(name, remote) {
|
||||
const s = S(name);
|
||||
s.version = Math.max(s.version, typeof remote.v === "number" ? remote.v : 0) + 1;
|
||||
s.stamp = now(); s.dirty = true;
|
||||
return s;
|
||||
},
|
||||
flush(name, data) {
|
||||
const s = S(name);
|
||||
if (!s.dirty || !s.hydrated) return null;
|
||||
s.dirty = false;
|
||||
const body = { u: s.stamp, v: s.version, d: data };
|
||||
if (s.intent) { body.x = 1; s.intent = false; }
|
||||
return push(name, body);
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user