/** * MinHash/LSH determinism + clone/hard-negative tests (issue #1556 steps 3–4). * * Rule 38: candidate set is a pure function of (seeds, inputs). Two runs * over the same fixture produce an identical candidate set. * * Fixtures: * - TRUE CLONE: copy-pasted function with a renamed variable MUST pair. * - HARD NEGATIVE: similar length, different logic — must NOT pair. * * Rule 35 spirit: cosine confirmation boundary at exactly similarToThreshold. */ import assert from "node:assert/strict"; import test from "node:test"; import { MinHasher, createMinHasher, cosineSimilarity, } from "./minhash.js"; import { estimateJaccard, } from "./similarity.js"; import { tokenizeForShingling, shingleSet, MINHASH_SEEDS, } from "./minhash.js"; import { MINHASH_NUM_PERMUTATIONS, LSH_NUM_BANDS, LSH_ROWS_PER_BAND, } from "./config.js"; // ────────────────────────────────────────────────────────────────────────── // Rule 38: determinism — same fixture, two runs, identical candidate set // ────────────────────────────────────────────────────────────────────────── const CLONE_A = `function processPayment(amount, currency) { const fee = amount * 0.029; const total = amount + fee; return { total, currency }; }`; const CLONE_B = `function processPayment(amount, currency) { const fee = amount * 0.029; const totalAmount = amount + fee; return { total: totalAmount, currency }; }`; const HARD_NEG = `function validateSchema(data, schema) { for (const key of Object.keys(schema)) { if (!(key in data)) return false; } return true; }`; test("MinHash: determinism — two runs produce identical candidate sets", () => { const run1 = createMinHasher(); const run2 = createMinHasher(); const entries = [ { nodeId: "n1", qualifiedName: "mod.cloneA", body: CLONE_A }, { nodeId: "n2", qualifiedName: "mod.cloneB", body: CLONE_B }, { nodeId: "n3", qualifiedName: "mod.hardNeg", body: HARD_NEG }, ]; for (const e of entries) { run1.add(e); run2.add(e); } const c1 = run1.findCandidates(); const c2 = run2.findCandidates(); assert.equal(c1.length, c2.length); for (let i = 0; i < c1.length; i++) { assert.equal(c1[i]!.aNodeId, c2[i]!.aNodeId); assert.equal(c1[i]!.bNodeId, c2[i]!.bNodeId); assert.equal(c1[i]!.jaccard, c2[i]!.jaccard); } }); test("MinHash: true-clone fixtures MUST pair as candidates", () => { const hasher = createMinHasher(); hasher.add({ nodeId: "n1", qualifiedName: "mod.cloneA", body: CLONE_A }); hasher.add({ nodeId: "n2", qualifiedName: "mod.cloneB", body: CLONE_B }); hasher.add({ nodeId: "n3", qualifiedName: "mod.hardNeg", body: HARD_NEG }); const candidates = hasher.findCandidates(); // The clone pair must appear in the candidate set. const clonePair = candidates.find( (c) => (c.aNodeId === "n1" && c.bNodeId === "n2") || (c.aNodeId === "n2" && c.bNodeId === "n1"), ); assert.ok(clonePair, "true-clone fixtures must be LSH candidates"); assert.ok(clonePair!.jaccard > 0.3, `clone Jaccard should be high, got ${clonePair!.jaccard}`); }); test("MinHash: hard-negative fixtures must NOT pair as candidates", () => { const hasher = createMinHasher(); hasher.add({ nodeId: "n1", qualifiedName: "mod.cloneA", body: CLONE_A }); hasher.add({ nodeId: "n3", qualifiedName: "mod.hardNeg", body: HARD_NEG }); const candidates = hasher.findCandidates(); const hardPair = candidates.find( (c) => (c.aNodeId === "n1" && c.bNodeId === "n3") || (c.aNodeId === "n3" && c.bNodeId === "n1"), ); // The hard negative must NOT be a candidate (or if it is, Jaccard must // be low enough that the cosine/SIMILAR_TO gate rejects it). if (hardPair) { assert.ok( hardPair.jaccard < 0.5, `hard-negative Jaccard must be low, got ${hardPair.jaccard}`, ); } // Direct Jaccard estimate for clarity. const direct = estimateJaccard(CLONE_A, HARD_NEG); assert.ok(direct < 0.4, `clone vs hard-negative Jaccard should be < 0.4, got ${direct}`); }); test("MinHash: estimateJaccard is symmetric and bounded [0,1]", () => { const j1 = estimateJaccard(CLONE_A, CLONE_B); const j2 = estimateJaccard(CLONE_B, CLONE_A); assert.equal(j1, j2, "Jaccard must be symmetric"); assert.ok(j1 >= 0 && j1 <= 1); assert.ok(j1 > 0.3, `clone Jaccard > 0.3 expected, got ${j1}`); }); // ────────────────────────────────────────────────────────────────────────── // Rule 35 spirit: cosine boundary // ────────────────────────────────────────────────────────────────────────── test("cosineSimilarity: identical vectors → 1.0, orthogonal → 0.0", () => { const v = new Float32Array([1, 0, 0]); assert.equal(cosineSimilarity(v, v), 1.0); const w = new Float32Array([0, 1, 0]); assert.equal(cosineSimilarity(v, w), 0.0); }); test("cosineSimilarity: boundary ≥ threshold confirms (decided once)", () => { // Two vectors at exactly cosine 0.92 should confirm (≥, not >). // Construct a pair at exactly the default threshold. const a = new Float32Array([1, 0]); // cos(a, b) = b[0] / |b|. For cos = 0.92: b = [0.92, sqrt(1-0.92^2)]. const cos = 0.92; const b = new Float32Array([cos, Math.sqrt(1 - cos * cos)]); const actual = cosineSimilarity(a, b); assert.ok(Math.abs(actual - cos) < 1e-6, `cosine should be ~0.92, got ${actual}`); // The similarity pipeline uses >= threshold (CONFIRM_OPERATOR), so at // exactly threshold it confirms. The similarity.test.ts covers the // full pipeline; here we verify the primitive. assert.ok(actual >= 0.92, "at exactly 0.92, >= confirms"); }); test("cosineSimilarity: zero-norm vector → 0 (no NaN)", () => { const zero = new Float32Array([0, 0, 0]); const v = new Float32Array([1, 2, 3]); assert.equal(cosineSimilarity(zero, v), 0); assert.equal(cosineSimilarity(v, zero), 0); assert.ok(Number.isFinite(cosineSimilarity(zero, zero))); }); // ────────────────────────────────────────────────────────────────────────── // Seeds are frozen named constants (rule 38) // ────────────────────────────────────────────────────────────────────────── test("MINHASH_SEEDS: frozen, named, deterministic", () => { assert.equal(MINHASH_SEEDS.length, MINHASH_NUM_PERMUTATIONS); assert.ok(Object.isFrozen(MINHASH_SEEDS)); // Same seed values every run (determinism). const first = MINHASH_SEEDS[0]!; const tenth = MINHASH_SEEDS[10]!; assert.ok(typeof first === "bigint"); assert.ok(first > 0n); assert.ok(tenth > 0n); assert.notEqual(first, tenth); }); test("LSH banding: bands * rows = permutations", () => { assert.equal(LSH_NUM_BANDS * LSH_ROWS_PER_BAND, MINHASH_NUM_PERMUTATIONS); }); // ────────────────────────────────────────────────────────────────────────── // Tokenizer/shingle unit tests // ────────────────────────────────────────────────────────────────────────── test("tokenizeForShingling: lowercase + split on non-alphanumeric", () => { assert.deepEqual(tokenizeForShingling("Hello, World! 42"), ["hello", "world", "42"]); assert.deepEqual(tokenizeForShingling("foo_bar baz"), ["foo_bar", "baz"]); }); test("shingleSet: width-2 shingles, deduped", () => { const tokens = ["a", "b", "c", "d", "a", "b", "c"]; const shingles = shingleSet(tokens); // width-2 shingles: "a b", "b c", "c d", "d a", "a b" (dup), "b c" (dup) → 4 unique assert.ok(shingles.size >= 3); assert.ok(shingles.has("a b")); assert.ok(shingles.has("b c")); });