From b085cefdc99dbcac20a71448e7352faf9d46e208 Mon Sep 17 00:00:00 2001 From: jae Date: Sat, 10 Oct 2026 09:52:45 -0500 Subject: [PATCH] SUX-2: interface for the in-browser model runtime The runtime registers an Embedder {id, dims, ready(), embed(texts, kind)} via SuxShared.registerEmbedder; checkEmbedder validates the shape. sux only touches those four members, so any embedding model plugs in the same way. Adds the pure vector math (cosine, meanVector) local scoring will need. --- shared.js | 77 ++++++++++++++++++++++++++++++++++++++++++++ test/decide.test.mjs | 40 +++++++++++++++++++++++ 2 files changed, 117 insertions(+) diff --git a/shared.js b/shared.js index ae39e56..0fe1fd5 100644 --- a/shared.js +++ b/shared.js @@ -222,6 +222,78 @@ return d; } + /* ---------------- model runtime interface ---------------- + * + * SUX-2: the in-browser model runtime implements this interface and hands + * it to SuxShared.registerEmbedder. sux only ever touches these members, so + * any model that can turn post text into vectors plugs in the same way: + * + * { + * id: "embeddinggemma-300m-q4@", // identity — goes into cache keys + * dims: 768, // vector width + * ready(): Promise, // download + init, reports progress + * embed(texts: string[], kind: "post" | "query"): Promise + * } + * + * `kind` hides the model's prompt prefixes from sux (EmbeddingGemma uses + * "task: classification | query: "); the runtime owns those strings. One + * call embeds many texts. Laya does not fit this interface — the bench says + * it isn't worth keeping anyway. + */ + + var registeredEmbedder = null; + + // Returns null when e satisfies the interface, else a short reason. Pure + // shape check only; ready()/embed() behavior is the runtime's business. + function checkEmbedder(e) { + if (!e || typeof e !== "object") return "embedder must be an object"; + if (typeof e.id !== "string" || !e.id) return "id must be a non-empty string"; + if (typeof e.dims !== "number" || e.dims <= 0 || Math.floor(e.dims) !== e.dims) { + return "dims must be a positive integer"; + } + if (typeof e.ready !== "function") return "ready() missing"; + if (typeof e.embed !== "function") return "embed() missing"; + return null; + } + + function registerEmbedder(e) { + var problem = checkEmbedder(e); + if (problem) throw new Error("bad embedder: " + problem); + registeredEmbedder = e; + } + + function getEmbedder() { + return registeredEmbedder; + } + + /* ---------------- vector math (pure; used by local scoring) ---------------- */ + + // Cosine similarity; 0 when either side is a zero vector (a missing + // embedding must not produce NaN downstream). + function cosine(a, b) { + var dot = 0, na = 0, nb = 0; + var n = Math.min(a ? a.length : 0, b ? b.length : 0); + for (var i = 0; i < n; i++) { + dot += a[i] * b[i]; + na += a[i] * a[i]; + nb += b[i] * b[i]; + } + if (!na || !nb) return 0; + return dot / (Math.sqrt(na) * Math.sqrt(nb)); + } + + // Component-wise mean of a non-empty array of equal-length vectors. + function meanVector(vecs) { + if (!vecs.length) return null; + var out = new Array(vecs[0].length); + for (var d = 0; d < out.length; d++) { + var s = 0; + for (var i = 0; i < vecs.length; i++) s += vecs[i][d]; + out[d] = s / vecs.length; + } + return out; + } + var SuxShared = { TEXT_CAP: TEXT_CAP, WORD_CAP: WORD_CAP, @@ -239,6 +311,11 @@ jevToScores: jevToScores, decideFromScores: decideFromScores, answerDetails: answerDetails, + checkEmbedder: checkEmbedder, + registerEmbedder: registerEmbedder, + getEmbedder: getEmbedder, + cosine: cosine, + meanVector: meanVector, }; global.SuxShared = SuxShared; diff --git a/test/decide.test.mjs b/test/decide.test.mjs index 2dcac9f..18e1bb4 100644 --- a/test/decide.test.mjs +++ b/test/decide.test.mjs @@ -280,3 +280,43 @@ test("fixture response decides correctly for both rules", () => { b.rulesB[0].above = false; assert.equal(SH.decideFromAnswers(b, fixture.answers), "hide"); }); + +/* ---------------- SUX-2: embedder interface ---------------- */ + +const goodEmbedder = () => ({ + id: "test-model-q4@rev1", + dims: 4, + ready: async () => {}, + embed: async (texts) => texts.map(() => new Float32Array([1, 0, 0, 0])), +}); + +test("checkEmbedder accepts the documented shape and rejects broken ones", () => { + assert.equal(SH.checkEmbedder(goodEmbedder()), null); + assert.match(SH.checkEmbedder(null), /object/); + assert.match(SH.checkEmbedder({}), /id/); + assert.match(SH.checkEmbedder({ id: "x", dims: 0 }), /dims/); + assert.match(SH.checkEmbedder({ id: "x", dims: 1.5 }), /dims/); + assert.match(SH.checkEmbedder({ id: "x", dims: 4 }), /ready/); + assert.match(SH.checkEmbedder({ id: "x", dims: 4, ready() {} }), /embed/); +}); + +test("registerEmbedder stores and returns the runtime; bad ones throw", () => { + const e = goodEmbedder(); + SH.registerEmbedder(e); + assert.equal(SH.getEmbedder(), e); + assert.throws(() => SH.registerEmbedder({ id: "" }), /bad embedder/); + assert.equal(SH.getEmbedder(), e); // unchanged after a failed registration +}); + +test("cosine is 1 for identical vectors, 0 for orthogonal or zero vectors", () => { + assert.equal(SH.cosine([1, 0], [1, 0]), 1); + assert.equal(SH.cosine([1, 0], [0, 1]), 0); + assert.equal(SH.cosine([0, 0], [1, 2]), 0); + assert.ok(Math.abs(SH.cosine([1, 1], [1, 0]) - Math.SQRT1_2) < 1e-12); + assert.equal(SH.cosine(null, [1]), 0); // missing embedding → 0, never NaN +}); + +test("meanVector averages component-wise and refuses empty input", () => { + assert.deepEqual(SH.meanVector([[1, 3], [3, 5]]), [2, 4]); + assert.equal(SH.meanVector([]), null); +}); -- knotmirror 0.0.1