/** * SIMILAR_TO near-clone pipeline (issue #1556 PR2 component). * * Cheap-first, deterministic: * 1. MinHash/LSH candidate generation over normalized symbol bodies. * 2. Cosine confirmation by embedding ≥ threshold when vectors exist. * * Edges carry `provenance: "semantic"` + the similarity score as * confidence. No provider → MinHash-only edges carry a distinct lower * confidence band (documented), and the pipeline still runs (MinHash is * local and deterministic). * * Rule 35 spirit: the threshold boundary (≥ vs >) is decided ONCE here * (≥ threshold confirms) and documented. The boundary test pins it. * * Rule 38: the candidate set is a pure function of (seeds, inputs). Two * runs over the same fixture produce an identical edge set. */ import { readFileSync as fsReadFileSync } from "node:fs"; import path from "node:path"; const fs = { readFileSync: fsReadFileSync }; import type { GraphStore } from "../graph-store.js"; import type { EdgeIR } from "../graph-store.js"; import { extractBodyText } from "./canonical-text.js"; import { MINHASH_ONLY_CONFIDENCE, SEMANTIC_PROVENANCE, SIMILAR_TO_EDGE_TYPE, } from "./config.js"; import type { SemanticConfig } from "./config.js"; import { cosineSimilarity, createMinHasher, tokenizeForShingling, shingleSet } from "./minhash.js"; import type { SimilarEdge, SimilarToResult } from "./types.js"; import type { SemanticFailure } from "./types.js"; import { modelIdFor } from "./vectors.js"; import type { HostEmbeddingProvider } from "@remnic/core/host-embedding-provider"; /** * Input to {@link computeSimilarTo}. */ export interface SimilarToInput { readonly store: GraphStore; readonly provider: HostEmbeddingProvider | undefined; readonly config: SemanticConfig; /** * Repo root for reading source text from disk when bodies are not * supplied. Required when bodies is absent. */ readonly repoRoot?: string; /** * Symbol bodies keyed by nodeId. The caller (the indexer or a * standalone pass) reads source text and builds canonical bodies. When * absent, the pipeline reads nodes from the store + disk itself. */ readonly bodies?: ReadonlyMap; /** * Vectors keyed by nodeId (the persisted embedding). When absent, the * pipeline reads them from the store via readAllSymbolVectors. */ readonly vectors?: ReadonlyMap; } /** * The comparison operator for cosine confirmation. Decided ONCE (rule 35 * spirit): `>= threshold`. A pair at EXACTLY the threshold confirms. The * boundary test asserts this. */ export const CONFIRM_OPERATOR = ">=" as const; /** * Jaccard gate for MinHash-only SIMILAR_TO edges (no embedding provider). * Well below the cosine similarToThreshold (0.92) because token-Jaccard * for near-clone code is typically 0.3-0.8. 0.5 catches genuine * copy-paste with minor renames while rejecting structurally-similar * but logically-unrelated pairs. */ export const MINHASH_JACCARD_GATE = 0.5; /** * Compute SIMILAR_TO edges. * * Returns the edges (for the caller to persist via store.upsertEdges) plus * counts. The caller persists; this function is pure over its inputs * (rule 38 — deterministic given seeds + bodies + vectors). * * When `config.enabled` is false → tagged semantic_disabled (no work, no * candidate generation — gate-off parity). */ export function computeSimilarTo(input: SimilarToInput): SimilarToResult | SemanticFailure { const { store, provider, config } = input; if (!config.enabled) { return { ok: false, code: "semantic_disabled" }; } // Without pre-built bodies AND without a repoRoot, the only available // text is the qualified name, which MinHashes name similarity rather // than body similarity — real copy-paste clones with different names // are silently missed. Refuse to guess: require one of the two so the // pipeline always MinHashes actual symbol bodies (chatgpt-codex- // connector P2: 'Require source bodies before MinHashing'). if (!input.bodies && !input.repoRoot) { return { ok: false, code: "repo_root_unset", message: "computeSimilarTo needs either 'bodies' or 'repoRoot' to read source text", }; } // Closed store is a distinct degradation (rule 34) — readNodesForSemantic // would return [] and we would report { ok: true, edges: [] } instead of // the documented store_closed code used by the other entry points (cursor // Bugbot: 'SimilarTo ignores closed store'). if (store.isClosed) { return { ok: false, code: "store_closed" }; } const bodies = input.bodies ?? readBodiesFromStore(store, input.repoRoot); const modelId = provider ? modelIdFor(provider) : undefined; const vectors = input.vectors ?? (modelId ? readVectorsMap(store, modelId) : new Map()); // Pass 1: MinHash/LSH candidates. const hasher = createMinHasher(); for (const [nodeId, entry] of bodies) { hasher.add({ nodeId, qualifiedName: entry.qualifiedName, body: entry.body }); } const candidates = hasher.findCandidates(); // Pass 2: cosine confirmation. const edges: SimilarEdge[] = []; let confirmed = 0; let minhashOnly = 0; for (const c of candidates) { const va = vectors.get(c.aNodeId); const vb = vectors.get(c.bNodeId); if (va && vb && va.length === vb.length) { // Require matching dimensionality — cosineSimilarity compares over the // shorter length, so mismatched-dims rows would get a misleading // partial-overlap score (cursor Bugbot: 'SimilarTo skips embedding // length check'). Pairs that fail this fall through to the no-provider // MinHash-only branch or are skipped. const cos = cosineSimilarity(va, vb); // rule 35: >= threshold confirms (decided once, here). if (cos >= config.similarToThreshold) { edges.push({ srcNodeId: c.aNodeId, dstNodeId: c.bNodeId, srcQualifiedName: c.aQualifiedName, dstQualifiedName: c.bQualifiedName, confidence: cos, confirmed: true, }); confirmed += 1; } } else if (!provider) { // MinHash-only is the documented fallback for the NO-PROVIDER // (local, deterministic) mode. When a provider IS configured we do // NOT emit MinHash-only edges for pairs missing a vector — that // would bypass the cosine confirmation path during partial / not- // yet-indexed state. Such pairs are simply skipped; they will be // cosine-confirmed once indexing completes (cursor Bugbot: 'MinHash // edges with provider set'). Use a Jaccard gate well below the // cosine threshold — MinHash Jaccard for near-clones is typically // 0.3-0.8; the MINHASH_JACCARD_GATE is the documented floor. if (c.jaccard >= MINHASH_JACCARD_GATE) { edges.push({ srcNodeId: c.aNodeId, dstNodeId: c.bNodeId, srcQualifiedName: c.aQualifiedName, dstQualifiedName: c.bQualifiedName, confidence: MINHASH_ONLY_CONFIDENCE, confirmed: false, }); minhashOnly += 1; } } // else: provider configured but a vector is missing → skip (await // indexing + cosine confirmation; do not bypass with MinHash-only). } // Stable sort: by confidence desc, then src qname, then dst qname. edges.sort((a, b) => { if (b.confidence !== a.confidence) return b.confidence - a.confidence; if (a.srcQualifiedName !== b.srcQualifiedName) return a.srcQualifiedName < b.srcQualifiedName ? -1 : 1; return a.dstQualifiedName < b.dstQualifiedName ? -1 : a.dstQualifiedName > b.dstQualifiedName ? 1 : 0; }); return { ok: true, edges, candidates: candidates.length, confirmed, minhashOnly }; } /** * Convert SimilarEdge[] to the store's EdgeIR[] for persistence via * upsertEdges. Provenance is always "semantic"; type is SIMILAR_TO. * * Carries the content-derived node ids onto the EdgeIR (issue #1677) so * the store resolves each endpoint by `nodes.id` (unique) instead of by * qualified name — two symbols that share a qualified name across files * get distinct, non-colliding SIMILAR_TO edges instead of being dropped * as ambiguous. */ export function similarEdgesToEdgeIR(edges: readonly SimilarEdge[]): EdgeIR[] { return edges.map((e) => ({ srcQualifiedName: e.srcQualifiedName, dstQualifiedName: e.dstQualifiedName, type: SIMILAR_TO_EDGE_TYPE, confidence: e.confidence, provenance: SEMANTIC_PROVENANCE, srcNodeId: e.srcNodeId, dstNodeId: e.dstNodeId, })); } /** * Read canonical bodies for every persisted node from the store + disk. * Used when the caller does not supply pre-built bodies. Returns a map * keyed by nodeId with the canonical body text (the MinHash input). * * Note: this reads from disk synchronously per node, so callers that * already have bodies in memory should pass them via `input.bodies`. */ function readBodiesFromStore(store: GraphStore, repoRoot?: string): Map { const out = new Map(); for (const node of store.readNodesForSemantic()) { let rawText = ""; if (repoRoot) { try { const abs = path.resolve(repoRoot, node.filePath); const bytes = fs.readFileSync(abs); const start = Math.max(0, node.startByte); const end = Math.min(bytes.length, node.endByte); if (start <= end) rawText = bytes.subarray(start, end).toString("utf8"); } catch { // File not readable — body stays empty, symbol is skipped by MinHasher. } } // MinHash the extracted BODY only, not the full canonical text. The // canonical form includes KIND/QNAME/SIG metadata; tokenizing it would // let empty-body stubs/declarations emit name-driven candidates among // unrelated symbols. extractBodyText returns "" for a bodyless symbol, // and the hasher skips empty bodies entirely (chatgpt-codex-connector: // 'MinHash only the extracted body text'). const body = extractBodyText(rawText, 0); out.set(node.nodeId, { qualifiedName: node.qualifiedName, body }); } return out; } /** * Read the vectors table into a nodeId → Float32Array map for cosine * confirmation. Uses the model id derived from the provider. */ function readVectorsMap(store: GraphStore, modelId: string): Map { const out = new Map(); for (const row of store.readAllSymbolVectors(modelId)) { out.set(row.nodeId, row.vector); } return out; } /** * Estimate Jaccard similarity between two bodies directly (no LSH). Used * by the hard-negative test to assert two bodies are NOT similar. */ export function estimateJaccard(bodyA: string, bodyB: string): number { const sa = shingleSet(tokenizeForShingling(bodyA)); const sb = shingleSet(tokenizeForShingling(bodyB)); if (sa.size === 0 && sb.size === 0) return 1; let inter = 0; for (const s of sa) if (sb.has(s)) inter += 1; const union = sa.size + sb.size - inter; return union === 0 ? 0 : inter / union; }