import assert from "node:assert/strict"; import { describe, it } from "node:test"; import { TERMS_PER_NOTE, TermTable, countTerms, documentFrequency, internTerms, nearest, similarity, weigh, type NearbyCandidate, } from "../shared/nearby.ts"; /** A tiny vault: the notes tokenized, interned, weighed. */ function build(notes: Record) { const table = new TermTable(); const vectors = new Map(Object.entries(notes).map(([path, n]) => [path, internTerms(countTerms(n.text, n.tags ?? [], path), table)])); const df = documentFrequency(vectors.values(), table.size); const weighed = new Map([...vectors].map(([path, v]) => [path, weigh(v, df, vectors.size)])); const candidates = (except: string): NearbyCandidate[] => [...weighed].filter(([p]) => p !== except).map(([path, vector]) => ({ path, title: path, vector })); return { table, weighed, candidates }; } describe("countTerms", () => { it("folds, drops short words and bare numbers, and keeps marks with their letters", () => { const c = countTerms("A résumé, a Resume — 2024 — and «الْمُقَدِّمَة» beside «المقدمة»."); assert.equal(c.get("resume"), 2); assert.equal(c.get("المقدمه"), 2); // ة folds to ه, as in search assert.equal(c.has("a"), false); assert.equal(c.has("«"), false); assert.equal(c.has("2024"), false); assert.equal(c.get("and"), 1); }); it("hands back the author's first spelling of each folded term, case aside", () => { const spellings = new Map(); countTerms("«الْمُقَدِّمَة» then «المقدمة»; a Résumé and a resume.", ["#Book/History"], "", spellings); assert.equal(spellings.get("المقدمه"), "الْمُقَدِّمَة"); assert.equal(spellings.get("resume"), "résumé"); assert.equal(spellings.get("#book/history"), "#Book/History"); assert.equal(spellings.get("#book"), "#Book"); }); it("weighs tags and titles more than a word, and a nested tag as its parent too", () => { const c = countTerms("plain words", ["#Book/History"], "Words of History"); assert.equal(c.get("#book/history"), 4); assert.equal(c.get("#book"), 2); assert.equal(c.get("words"), 3); // once in the text, twice for the title assert.equal(c.get("history"), 2); }); it("keeps the most frequent terms only, the author's first at a tie", () => { const many = Array.from({ length: TERMS_PER_NOTE + 50 }, (_, i) => `word${i}`).join(" "); const c = countTerms(`${many} word330 word330 word330`); assert.equal(c.size, TERMS_PER_NOTE); assert.equal([...c.keys()][0], "word330"); assert.equal(c.has("word0"), true); assert.equal(c.has("word349"), false); }); }); describe("TermTable", () => { it("shows a term by its kept spelling and falls back to the folded name", () => { const table = new TermTable(); const spellings = new Map(); const counts = countTerms("الْمُقَدِّمَة résumé plain", [], "", spellings); internTerms(counts, table, spellings); assert.equal(table.shownOf(table.idOf("المقدمه")), "الْمُقَدِّمَة"); assert.equal(table.shownOf(table.idOf("resume")), "résumé"); assert.equal(table.shownOf(table.idOf("plain")), "plain"); // The first spelling offered wins; a later note cannot respell the word. table.spell("resume", "RESUME"); assert.equal(table.shownOf(table.idOf("resume")), "résumé"); }); it("nearest names the ties by their spelling", () => { const table = new TermTable(); const docs = { a: "الْمُقَدِّمَة العصبية كتاب", b: "المقدمة العصبية دولة", c: "خبز وماء" }; const vectors = new Map( Object.entries(docs).map(([p, text]) => { const spellings = new Map(); return [p, internTerms(countTerms(text, [], "", spellings), table, spellings)]; }), ); const df = documentFrequency(vectors.values(), table.size); const weighed = new Map([...vectors].map(([p, v]) => [p, weigh(v, df, vectors.size)])); const hits = nearest(weighed.get("a")!, [{ path: "b", title: "b", vector: weighed.get("b")! }, { path: "c", title: "c", vector: weighed.get("c")! }], table); assert.equal(hits[0].path, "b"); assert.deepEqual([...hits[0].terms].sort(), ["العصبية", "الْمُقَدِّمَة"].sort()); }); }); describe("weigh", () => { it("a word in every note weighs nothing; a rare word weighs more than a common one", () => { const { table, weighed } = build({ a: { text: "the cat sat on the mat" }, b: { text: "the dog sat on the log" }, c: { text: "the bird sang" }, }); const a = weighed.get("a")!; const names = [...a.ids].map((id) => table.names[id]); assert.equal(names.includes("the"), false); const weightOf = (term: string) => a.weights[names.indexOf(term)]; assert.ok(weightOf("cat") > weightOf("sat"), "cat (in 1 note) outweighs sat (in 2)"); assert.ok(a.norm > 0); }); }); describe("similarity and nearest", () => { const vault = { "Physics/Entropy": { text: "Entropy counts the microstates of a system; the second law says entropy never falls in an isolated system.", tags: ["physics"] }, "Physics/Heat": { text: "Heat flows from hot to cold, and the entropy of the isolated system rises as it does. Carnot bounded every engine.", tags: ["physics"] }, "Kitchen/Bread": { text: "Flour, water, salt, and time. The dough proves overnight and bakes in a hot oven.", tags: ["recipes"] }, "Kitchen/Soup": { text: "Onions sweat slowly in butter; add stock, simmer, and salt at the end.", tags: ["recipes"] }, "Journal/Monday": { text: "Woke late. Read a little about entropy on the train, then made soup.", tags: [] }, }; it("ranks the note that shares rare words and a tag first, with the terms that tie them", () => { const { table, weighed, candidates } = build(vault); const hits = nearest(weighed.get("Physics/Entropy")!, candidates("Physics/Entropy"), table); assert.equal(hits[0].path, "Physics/Heat"); assert.deepEqual(hits[0].terms.length, 2); assert.ok(hits[0].terms.every((t) => ["#physics", "physics", "entropy", "isolated", "system"].includes(t)), hits[0].terms.join(",")); assert.ok(hits.every((h) => h.path !== "Physics/Entropy")); assert.ok(hits.every((h, i) => i === 0 || hits[i - 1].score >= h.score)); }); it("is symmetric and bounded", () => { const { weighed } = build(vault); const ab = similarity(weighed.get("Kitchen/Bread")!, weighed.get("Kitchen/Soup")!); const ba = similarity(weighed.get("Kitchen/Soup")!, weighed.get("Kitchen/Bread")!); assert.ok(Math.abs(ab.score - ba.score) < 1e-6); assert.ok(ab.score > 0 && ab.score <= 1); assert.equal(similarity(weighed.get("Kitchen/Bread")!, weighed.get("Kitchen/Bread")!).score > 0.999, true); }); it("leaves strangers out and honours the limit", () => { const { table, weighed, candidates } = build(vault); const all = nearest(weighed.get("Kitchen/Bread")!, candidates("Kitchen/Bread"), table, 10, 0); assert.equal(all.length, 4); const two = nearest(weighed.get("Kitchen/Bread")!, candidates("Kitchen/Bread"), table, 2, 0); assert.equal(two.length, 2); // A high bar leaves the cooking notes and drops the physics. const near = nearest(weighed.get("Kitchen/Bread")!, candidates("Kitchen/Bread"), table, 10, 0.1); assert.ok(near.every((h) => h.path.startsWith("Kitchen/") || h.path.startsWith("Journal/")), near.map((h) => h.path).join(",")); }); it("an empty note is nobody's neighbour", () => { const { table, weighed, candidates } = build({ ...vault, "Empty": { text: "" } }); assert.deepEqual(nearest(weighed.get("Empty")!, candidates("Empty"), table), []); }); });