@remnic/coding-graph 9.3.759
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +130 -0
- package/dist/chunk-5I2DBHOQ.js +1042 -0
- package/dist/chunk-5I2DBHOQ.js.map +1 -0
- package/dist/chunk-CPYJACC5.js +1838 -0
- package/dist/chunk-CPYJACC5.js.map +1 -0
- package/dist/chunk-ZVCMIM4T.js +216 -0
- package/dist/chunk-ZVCMIM4T.js.map +1 -0
- package/dist/cypher/query-parser.d.ts +253 -0
- package/dist/cypher/query-parser.js +17 -0
- package/dist/cypher/query-parser.js.map +1 -0
- package/dist/graph-schema.d.ts +84 -0
- package/dist/graph-schema.js +17 -0
- package/dist/graph-schema.js.map +1 -0
- package/dist/graph-store.d.ts +938 -0
- package/dist/graph-store.js +16 -0
- package/dist/graph-store.js.map +1 -0
- package/dist/index.d.ts +1953 -0
- package/dist/index.js +3509 -0
- package/dist/index.js.map +1 -0
- package/grammars/tree-sitter-bash.wasm +0 -0
- package/grammars/tree-sitter-c.wasm +0 -0
- package/grammars/tree-sitter-c_sharp.wasm +0 -0
- package/grammars/tree-sitter-cpp.wasm +0 -0
- package/grammars/tree-sitter-go.wasm +0 -0
- package/grammars/tree-sitter-java.wasm +0 -0
- package/grammars/tree-sitter-javascript.wasm +0 -0
- package/grammars/tree-sitter-kotlin.wasm +0 -0
- package/grammars/tree-sitter-php.wasm +0 -0
- package/grammars/tree-sitter-python.wasm +0 -0
- package/grammars/tree-sitter-ruby.wasm +0 -0
- package/grammars/tree-sitter-rust.wasm +0 -0
- package/grammars/tree-sitter-swift.wasm +0 -0
- package/grammars/tree-sitter-tsx.wasm +0 -0
- package/grammars/tree-sitter-typescript.wasm +0 -0
- package/package.json +79 -0
- package/src/co-change.test.ts +175 -0
- package/src/co-change.ts +167 -0
- package/src/cypher/query-parser.test.ts +1107 -0
- package/src/cypher/query-parser.ts +1692 -0
- package/src/detect-changes.test.ts +533 -0
- package/src/detect-changes.ts +367 -0
- package/src/engine/emit.ts +556 -0
- package/src/engine/engine.test.ts +1417 -0
- package/src/engine/engine.ts +182 -0
- package/src/engine/extractors.ts +486 -0
- package/src/engine/fixtures.ts +364 -0
- package/src/engine/language-sniff.ts +56 -0
- package/src/engine/parser-backend.ts +206 -0
- package/src/engine/utf16-offsets.ts +68 -0
- package/src/git-invoker.test.ts +116 -0
- package/src/git-invoker.ts +426 -0
- package/src/graph-schema.test.ts +541 -0
- package/src/graph-schema.ts +383 -0
- package/src/graph-store-pr2.test.ts +1879 -0
- package/src/graph-store.test.ts +1420 -0
- package/src/graph-store.ts +3489 -0
- package/src/index-status.test.ts +303 -0
- package/src/index-status.ts +135 -0
- package/src/index.ts +384 -0
- package/src/lsp/byte-position.ts +173 -0
- package/src/lsp/characterization.test.ts +174 -0
- package/src/lsp/client.test.ts +275 -0
- package/src/lsp/client.ts +484 -0
- package/src/lsp/config.ts +219 -0
- package/src/lsp/degradation.ts +86 -0
- package/src/lsp/fixtures/fake-server.mjs +198 -0
- package/src/lsp/framing.test.ts +180 -0
- package/src/lsp/framing.ts +177 -0
- package/src/lsp/resolution.test.ts +497 -0
- package/src/lsp/resolution.ts +483 -0
- package/src/lsp/status.ts +140 -0
- package/src/lsp/types.ts +167 -0
- package/src/reindex.test.ts +1038 -0
- package/src/reindex.ts +908 -0
- package/src/row-types.ts +45 -0
- package/src/semantic/canonical-text.test.ts +150 -0
- package/src/semantic/canonical-text.ts +219 -0
- package/src/semantic/config.ts +235 -0
- package/src/semantic/index.ts +78 -0
- package/src/semantic/minhash.test.ts +197 -0
- package/src/semantic/minhash.ts +261 -0
- package/src/semantic/semantic-query.ts +173 -0
- package/src/semantic/semantic.test.ts +1315 -0
- package/src/semantic/similarity.ts +268 -0
- package/src/semantic/types.ts +145 -0
- package/src/semantic/vectors.ts +235 -0
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Semantic layer barrel (issue #1556).
|
|
3
|
+
*
|
|
4
|
+
* One import surface for the optional semantic layer:
|
|
5
|
+
* - config (SemanticConfig + resolveSemanticConfig)
|
|
6
|
+
* - canonical-text (buildCanonicalText + hash)
|
|
7
|
+
* - minhash (MinHasher + cosineSimilarity)
|
|
8
|
+
* - vectors (indexSymbolVectors)
|
|
9
|
+
* - similarity (computeSimilarTo + similarEdgesToEdgeIR)
|
|
10
|
+
* - semantic-query (semanticQuery)
|
|
11
|
+
* - types (tagged results)
|
|
12
|
+
*/
|
|
13
|
+
export {
|
|
14
|
+
DEFAULT_SIMILAR_TO_THRESHOLD,
|
|
15
|
+
DEFAULT_MAX_SYMBOLS_PER_RUN,
|
|
16
|
+
MINHASH_ONLY_CONFIDENCE,
|
|
17
|
+
SIMILAR_TO_EDGE_TYPE,
|
|
18
|
+
SEMANTIC_PROVENANCE,
|
|
19
|
+
DEFAULT_CANONICAL_BODY_LINES,
|
|
20
|
+
resolveSemanticConfig,
|
|
21
|
+
type SemanticConfig,
|
|
22
|
+
} from "./config.js";
|
|
23
|
+
|
|
24
|
+
export {
|
|
25
|
+
buildCanonicalText,
|
|
26
|
+
buildCanonicalTextAndHash,
|
|
27
|
+
canonicalTextHash,
|
|
28
|
+
collapseWhitespace,
|
|
29
|
+
extractSignatureLine,
|
|
30
|
+
extractBodyText,
|
|
31
|
+
type CanonicalTextInput,
|
|
32
|
+
} from "./canonical-text.js";
|
|
33
|
+
|
|
34
|
+
export {
|
|
35
|
+
MinHasher,
|
|
36
|
+
createMinHasher,
|
|
37
|
+
minHashSignature,
|
|
38
|
+
lshBandKeys,
|
|
39
|
+
shingleSet,
|
|
40
|
+
tokenizeForShingling,
|
|
41
|
+
cosineSimilarity,
|
|
42
|
+
MINHASH_SEEDS,
|
|
43
|
+
type LshIndexEntry,
|
|
44
|
+
} from "./minhash.js";
|
|
45
|
+
|
|
46
|
+
export {
|
|
47
|
+
indexSymbolVectors,
|
|
48
|
+
modelIdFor,
|
|
49
|
+
type IndexVectorsInput,
|
|
50
|
+
} from "./vectors.js";
|
|
51
|
+
|
|
52
|
+
export {
|
|
53
|
+
computeSimilarTo,
|
|
54
|
+
similarEdgesToEdgeIR,
|
|
55
|
+
estimateJaccard,
|
|
56
|
+
CONFIRM_OPERATOR,
|
|
57
|
+
type SimilarToInput,
|
|
58
|
+
} from "./similarity.js";
|
|
59
|
+
|
|
60
|
+
export {
|
|
61
|
+
semanticQuery,
|
|
62
|
+
DEFAULT_SEMANTIC_QUERY_LIMIT,
|
|
63
|
+
type SemanticQueryInput,
|
|
64
|
+
} from "./semantic-query.js";
|
|
65
|
+
|
|
66
|
+
export type {
|
|
67
|
+
SymbolVector,
|
|
68
|
+
SymbolVectorRow,
|
|
69
|
+
SemanticFailure,
|
|
70
|
+
SemanticFailureCode,
|
|
71
|
+
IndexVectorsResult,
|
|
72
|
+
SimilarCandidate,
|
|
73
|
+
SimilarEdge,
|
|
74
|
+
SimilarToResult,
|
|
75
|
+
SemanticQueryHit,
|
|
76
|
+
SemanticQuerySuccess,
|
|
77
|
+
SemanticQueryOutcome,
|
|
78
|
+
} from "./types.js";
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MinHash/LSH determinism + clone/hard-negative tests (issue #1556 steps 3–4).
|
|
3
|
+
*
|
|
4
|
+
* Rule 38: candidate set is a pure function of (seeds, inputs). Two runs
|
|
5
|
+
* over the same fixture produce an identical candidate set.
|
|
6
|
+
*
|
|
7
|
+
* Fixtures:
|
|
8
|
+
* - TRUE CLONE: copy-pasted function with a renamed variable MUST pair.
|
|
9
|
+
* - HARD NEGATIVE: similar length, different logic — must NOT pair.
|
|
10
|
+
*
|
|
11
|
+
* Rule 35 spirit: cosine confirmation boundary at exactly similarToThreshold.
|
|
12
|
+
*/
|
|
13
|
+
import assert from "node:assert/strict";
|
|
14
|
+
import test from "node:test";
|
|
15
|
+
|
|
16
|
+
import {
|
|
17
|
+
MinHasher,
|
|
18
|
+
createMinHasher,
|
|
19
|
+
cosineSimilarity,
|
|
20
|
+
} from "./minhash.js";
|
|
21
|
+
import {
|
|
22
|
+
estimateJaccard,
|
|
23
|
+
} from "./similarity.js";
|
|
24
|
+
import {
|
|
25
|
+
tokenizeForShingling,
|
|
26
|
+
shingleSet,
|
|
27
|
+
MINHASH_SEEDS,
|
|
28
|
+
} from "./minhash.js";
|
|
29
|
+
import {
|
|
30
|
+
MINHASH_NUM_PERMUTATIONS,
|
|
31
|
+
LSH_NUM_BANDS,
|
|
32
|
+
LSH_ROWS_PER_BAND,
|
|
33
|
+
} from "./config.js";
|
|
34
|
+
|
|
35
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
36
|
+
// Rule 38: determinism — same fixture, two runs, identical candidate set
|
|
37
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
const CLONE_A = `function processPayment(amount, currency) {
|
|
40
|
+
const fee = amount * 0.029;
|
|
41
|
+
const total = amount + fee;
|
|
42
|
+
return { total, currency };
|
|
43
|
+
}`;
|
|
44
|
+
|
|
45
|
+
const CLONE_B = `function processPayment(amount, currency) {
|
|
46
|
+
const fee = amount * 0.029;
|
|
47
|
+
const totalAmount = amount + fee;
|
|
48
|
+
return { total: totalAmount, currency };
|
|
49
|
+
}`;
|
|
50
|
+
|
|
51
|
+
const HARD_NEG = `function validateSchema(data, schema) {
|
|
52
|
+
for (const key of Object.keys(schema)) {
|
|
53
|
+
if (!(key in data)) return false;
|
|
54
|
+
}
|
|
55
|
+
return true;
|
|
56
|
+
}`;
|
|
57
|
+
|
|
58
|
+
test("MinHash: determinism — two runs produce identical candidate sets", () => {
|
|
59
|
+
const run1 = createMinHasher();
|
|
60
|
+
const run2 = createMinHasher();
|
|
61
|
+
const entries = [
|
|
62
|
+
{ nodeId: "n1", qualifiedName: "mod.cloneA", body: CLONE_A },
|
|
63
|
+
{ nodeId: "n2", qualifiedName: "mod.cloneB", body: CLONE_B },
|
|
64
|
+
{ nodeId: "n3", qualifiedName: "mod.hardNeg", body: HARD_NEG },
|
|
65
|
+
];
|
|
66
|
+
for (const e of entries) {
|
|
67
|
+
run1.add(e);
|
|
68
|
+
run2.add(e);
|
|
69
|
+
}
|
|
70
|
+
const c1 = run1.findCandidates();
|
|
71
|
+
const c2 = run2.findCandidates();
|
|
72
|
+
assert.equal(c1.length, c2.length);
|
|
73
|
+
for (let i = 0; i < c1.length; i++) {
|
|
74
|
+
assert.equal(c1[i]!.aNodeId, c2[i]!.aNodeId);
|
|
75
|
+
assert.equal(c1[i]!.bNodeId, c2[i]!.bNodeId);
|
|
76
|
+
assert.equal(c1[i]!.jaccard, c2[i]!.jaccard);
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("MinHash: true-clone fixtures MUST pair as candidates", () => {
|
|
81
|
+
const hasher = createMinHasher();
|
|
82
|
+
hasher.add({ nodeId: "n1", qualifiedName: "mod.cloneA", body: CLONE_A });
|
|
83
|
+
hasher.add({ nodeId: "n2", qualifiedName: "mod.cloneB", body: CLONE_B });
|
|
84
|
+
hasher.add({ nodeId: "n3", qualifiedName: "mod.hardNeg", body: HARD_NEG });
|
|
85
|
+
const candidates = hasher.findCandidates();
|
|
86
|
+
// The clone pair must appear in the candidate set.
|
|
87
|
+
const clonePair = candidates.find(
|
|
88
|
+
(c) =>
|
|
89
|
+
(c.aNodeId === "n1" && c.bNodeId === "n2") ||
|
|
90
|
+
(c.aNodeId === "n2" && c.bNodeId === "n1"),
|
|
91
|
+
);
|
|
92
|
+
assert.ok(clonePair, "true-clone fixtures must be LSH candidates");
|
|
93
|
+
assert.ok(clonePair!.jaccard > 0.3, `clone Jaccard should be high, got ${clonePair!.jaccard}`);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("MinHash: hard-negative fixtures must NOT pair as candidates", () => {
|
|
97
|
+
const hasher = createMinHasher();
|
|
98
|
+
hasher.add({ nodeId: "n1", qualifiedName: "mod.cloneA", body: CLONE_A });
|
|
99
|
+
hasher.add({ nodeId: "n3", qualifiedName: "mod.hardNeg", body: HARD_NEG });
|
|
100
|
+
const candidates = hasher.findCandidates();
|
|
101
|
+
const hardPair = candidates.find(
|
|
102
|
+
(c) =>
|
|
103
|
+
(c.aNodeId === "n1" && c.bNodeId === "n3") ||
|
|
104
|
+
(c.aNodeId === "n3" && c.bNodeId === "n1"),
|
|
105
|
+
);
|
|
106
|
+
// The hard negative must NOT be a candidate (or if it is, Jaccard must
|
|
107
|
+
// be low enough that the cosine/SIMILAR_TO gate rejects it).
|
|
108
|
+
if (hardPair) {
|
|
109
|
+
assert.ok(
|
|
110
|
+
hardPair.jaccard < 0.5,
|
|
111
|
+
`hard-negative Jaccard must be low, got ${hardPair.jaccard}`,
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
// Direct Jaccard estimate for clarity.
|
|
115
|
+
const direct = estimateJaccard(CLONE_A, HARD_NEG);
|
|
116
|
+
assert.ok(direct < 0.4, `clone vs hard-negative Jaccard should be < 0.4, got ${direct}`);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("MinHash: estimateJaccard is symmetric and bounded [0,1]", () => {
|
|
120
|
+
const j1 = estimateJaccard(CLONE_A, CLONE_B);
|
|
121
|
+
const j2 = estimateJaccard(CLONE_B, CLONE_A);
|
|
122
|
+
assert.equal(j1, j2, "Jaccard must be symmetric");
|
|
123
|
+
assert.ok(j1 >= 0 && j1 <= 1);
|
|
124
|
+
assert.ok(j1 > 0.3, `clone Jaccard > 0.3 expected, got ${j1}`);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
128
|
+
// Rule 35 spirit: cosine boundary
|
|
129
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
130
|
+
|
|
131
|
+
test("cosineSimilarity: identical vectors → 1.0, orthogonal → 0.0", () => {
|
|
132
|
+
const v = new Float32Array([1, 0, 0]);
|
|
133
|
+
assert.equal(cosineSimilarity(v, v), 1.0);
|
|
134
|
+
const w = new Float32Array([0, 1, 0]);
|
|
135
|
+
assert.equal(cosineSimilarity(v, w), 0.0);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test("cosineSimilarity: boundary ≥ threshold confirms (decided once)", () => {
|
|
139
|
+
// Two vectors at exactly cosine 0.92 should confirm (≥, not >).
|
|
140
|
+
// Construct a pair at exactly the default threshold.
|
|
141
|
+
const a = new Float32Array([1, 0]);
|
|
142
|
+
// cos(a, b) = b[0] / |b|. For cos = 0.92: b = [0.92, sqrt(1-0.92^2)].
|
|
143
|
+
const cos = 0.92;
|
|
144
|
+
const b = new Float32Array([cos, Math.sqrt(1 - cos * cos)]);
|
|
145
|
+
const actual = cosineSimilarity(a, b);
|
|
146
|
+
assert.ok(Math.abs(actual - cos) < 1e-6, `cosine should be ~0.92, got ${actual}`);
|
|
147
|
+
// The similarity pipeline uses >= threshold (CONFIRM_OPERATOR), so at
|
|
148
|
+
// exactly threshold it confirms. The similarity.test.ts covers the
|
|
149
|
+
// full pipeline; here we verify the primitive.
|
|
150
|
+
assert.ok(actual >= 0.92, "at exactly 0.92, >= confirms");
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("cosineSimilarity: zero-norm vector → 0 (no NaN)", () => {
|
|
154
|
+
const zero = new Float32Array([0, 0, 0]);
|
|
155
|
+
const v = new Float32Array([1, 2, 3]);
|
|
156
|
+
assert.equal(cosineSimilarity(zero, v), 0);
|
|
157
|
+
assert.equal(cosineSimilarity(v, zero), 0);
|
|
158
|
+
assert.ok(Number.isFinite(cosineSimilarity(zero, zero)));
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
162
|
+
// Seeds are frozen named constants (rule 38)
|
|
163
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
164
|
+
|
|
165
|
+
test("MINHASH_SEEDS: frozen, named, deterministic", () => {
|
|
166
|
+
assert.equal(MINHASH_SEEDS.length, MINHASH_NUM_PERMUTATIONS);
|
|
167
|
+
assert.ok(Object.isFrozen(MINHASH_SEEDS));
|
|
168
|
+
// Same seed values every run (determinism).
|
|
169
|
+
const first = MINHASH_SEEDS[0]!;
|
|
170
|
+
const tenth = MINHASH_SEEDS[10]!;
|
|
171
|
+
assert.ok(typeof first === "bigint");
|
|
172
|
+
assert.ok(first > 0n);
|
|
173
|
+
assert.ok(tenth > 0n);
|
|
174
|
+
assert.notEqual(first, tenth);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test("LSH banding: bands * rows = permutations", () => {
|
|
178
|
+
assert.equal(LSH_NUM_BANDS * LSH_ROWS_PER_BAND, MINHASH_NUM_PERMUTATIONS);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
182
|
+
// Tokenizer/shingle unit tests
|
|
183
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
184
|
+
|
|
185
|
+
test("tokenizeForShingling: lowercase + split on non-alphanumeric", () => {
|
|
186
|
+
assert.deepEqual(tokenizeForShingling("Hello, World! 42"), ["hello", "world", "42"]);
|
|
187
|
+
assert.deepEqual(tokenizeForShingling("foo_bar baz"), ["foo_bar", "baz"]);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test("shingleSet: width-2 shingles, deduped", () => {
|
|
191
|
+
const tokens = ["a", "b", "c", "d", "a", "b", "c"];
|
|
192
|
+
const shingles = shingleSet(tokens);
|
|
193
|
+
// width-2 shingles: "a b", "b c", "c d", "d a", "a b" (dup), "b c" (dup) → 4 unique
|
|
194
|
+
assert.ok(shingles.size >= 3);
|
|
195
|
+
assert.ok(shingles.has("a b"));
|
|
196
|
+
assert.ok(shingles.has("b c"));
|
|
197
|
+
});
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MinHash / LSH candidate generation for SIMILAR_TO near-clone detection
|
|
3
|
+
* (issue #1556).
|
|
4
|
+
*
|
|
5
|
+
* Design (per the issue): token-shingle MinHash/LSH over normalized symbol
|
|
6
|
+
* bodies (pure TS, deterministic given fixed seeds). This produces a
|
|
7
|
+
* candidate-pair set cheaply; the cosine-confirmation layer (similarity.ts)
|
|
8
|
+
* then filters candidates by embedding cosine ≥ threshold.
|
|
9
|
+
*
|
|
10
|
+
* Rule 38 — determinism. The seeds are NAMED CONSTANTS (not module-private
|
|
11
|
+
* state, not `Date.now()`/`Math.random()`). Two runs over the same fixture
|
|
12
|
+
* repo produce an identical candidate set. The determinism test asserts
|
|
13
|
+
* this byte-for-byte.
|
|
14
|
+
*
|
|
15
|
+
* Rule 11 — no module-level mutable state. The hash tables live on the
|
|
16
|
+
* MinHasher instance returned by `createMinHasher()`, never at module
|
|
17
|
+
* scope. Two indexers in one process are fully isolated.
|
|
18
|
+
*/
|
|
19
|
+
import { createHash } from "node:crypto";
|
|
20
|
+
|
|
21
|
+
import { LSH_NUM_BANDS, LSH_ROWS_PER_BAND, MINHASH_NUM_PERMUTATIONS, MINHASH_SHINGLE_WIDTH } from "./config.js";
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Fixed, named 64-bit seeds for the MinHash permutations. These are the
|
|
25
|
+
* ONLY source of randomness in the candidate pipeline. Changing them
|
|
26
|
+
* changes every candidate set, so they are frozen constants — the
|
|
27
|
+
* determinism test pins their exact values.
|
|
28
|
+
*
|
|
29
|
+
* Generated as `sha256("remnic:minhash:seed:N").slice(0,16)` interpreted
|
|
30
|
+
* as a big-endian u64. Using a hash-of-a-string keeps them reproducible
|
|
31
|
+
* (no magic-looking literals) while still being arbitrary fixed values.
|
|
32
|
+
*/
|
|
33
|
+
function seedFor(index: number): bigint {
|
|
34
|
+
const hex = createHash("sha256")
|
|
35
|
+
.update(`remnic:minhash:seed:${index}`, "utf8")
|
|
36
|
+
.digest("hex")
|
|
37
|
+
.slice(0, 16);
|
|
38
|
+
return BigInt(`0x${hex}`);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export const MINHASH_SEEDS: readonly bigint[] = Object.freeze(
|
|
42
|
+
Array.from({ length: MINHASH_NUM_PERMUTATIONS }, (_, i) => seedFor(i)),
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* A 64-bit MurmurHash3 x64 finalizer — fast, well-distributed, and
|
|
47
|
+
* deterministic. Used as the base hash; MinHash permutations are
|
|
48
|
+
* (a*x + b) mod Mersenne over this base.
|
|
49
|
+
*/
|
|
50
|
+
function hash64(data: string): bigint {
|
|
51
|
+
const buf = createHash("sha256").update(data, "utf8").digest();
|
|
52
|
+
// Read the first 8 bytes as big-endian u64 — sha256 is already a strong
|
|
53
|
+
// mixing function, so we skip the MurmurHash3 body and use sha256 as
|
|
54
|
+
// the universal hash. Determinism is the only hard requirement; speed
|
|
55
|
+
// is secondary (the candidate set is small after LSH banding).
|
|
56
|
+
let h = 0n;
|
|
57
|
+
for (let i = 0; i < 8; i++) {
|
|
58
|
+
h = (h << 8n) | BigInt(buf[i]!);
|
|
59
|
+
}
|
|
60
|
+
return h;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// 2^61 - 1, a Mersenne prime — the MinHash modulus.
|
|
64
|
+
const MINHASH_MODULUS = (1n << 61n) - 1n;
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Normalize symbol body text into a token stream for shingling.
|
|
68
|
+
*
|
|
69
|
+
* Normalization:
|
|
70
|
+
* - lowercase (case-insensitive clone detection — `MyFunc` vs `myfunc`)
|
|
71
|
+
* - split on non-alphanumeric (identifiers, numbers, operators become tokens)
|
|
72
|
+
* - drop empty tokens
|
|
73
|
+
*
|
|
74
|
+
* This is deliberately coarse: the goal is Jaccard over token sets, not
|
|
75
|
+
* semantic parsing. A renamed variable changes exactly one token per
|
|
76
|
+
* occurrence, so Jaccard stays high for genuine clones.
|
|
77
|
+
*/
|
|
78
|
+
export function tokenizeForShingling(body: string): string[] {
|
|
79
|
+
return body
|
|
80
|
+
.toLowerCase()
|
|
81
|
+
.split(/[^a-z0-9_]+/)
|
|
82
|
+
.filter((t) => t.length > 0);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Build the set of shingles (n-grams of width MINHASH_SHINGLE_WIDTH) from
|
|
87
|
+
* a token stream. Returns a Set so duplicate shingles collapse (Jaccard
|
|
88
|
+
* is over the SET of shingles, not a multiset).
|
|
89
|
+
*/
|
|
90
|
+
export function shingleSet(tokens: readonly string[]): Set<string> {
|
|
91
|
+
if (tokens.length < MINHASH_SHINGLE_WIDTH) {
|
|
92
|
+
// Too few tokens for a full shingle — use the whole token sequence as
|
|
93
|
+
// a single shingle so short symbols still participate.
|
|
94
|
+
return new Set(tokens.length > 0 ? [tokens.join(" ")] : []);
|
|
95
|
+
}
|
|
96
|
+
const out = new Set<string>();
|
|
97
|
+
for (let i = 0; i <= tokens.length - MINHASH_SHINGLE_WIDTH; i++) {
|
|
98
|
+
out.add(tokens.slice(i, i + MINHASH_SHINGLE_WIDTH).join(" "));
|
|
99
|
+
}
|
|
100
|
+
return out;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Compute the MinHash signature (array of permutation minima) for a set
|
|
105
|
+
* of shingles. Signature length = MINHASH_NUM_PERMUTATIONS. The estimated
|
|
106
|
+
* Jaccard similarity between two signatures is the fraction of matching
|
|
107
|
+
* positions.
|
|
108
|
+
*/
|
|
109
|
+
export function minHashSignature(shingles: Set<string>): bigint[] {
|
|
110
|
+
const sig: bigint[] = [];
|
|
111
|
+
if (shingles.size === 0) {
|
|
112
|
+
// Empty body → max-value signature so it never collides with anything.
|
|
113
|
+
for (let i = 0; i < MINHASH_NUM_PERMUTATIONS; i++) sig.push(MINHASH_MODULUS);
|
|
114
|
+
return sig;
|
|
115
|
+
}
|
|
116
|
+
const shingleArr = Array.from(shingles);
|
|
117
|
+
for (let p = 0; p < MINHASH_NUM_PERMUTATIONS; p++) {
|
|
118
|
+
const a = MINHASH_SEEDS[p]!;
|
|
119
|
+
const b = MINHASH_SEEDS[(p * 2 + 1) % MINHASH_SEEDS.length]!;
|
|
120
|
+
let min = MINHASH_MODULUS;
|
|
121
|
+
for (const s of shingleArr) {
|
|
122
|
+
const h = (a * hash64(s) + b) % MINHASH_MODULUS;
|
|
123
|
+
if (h < min) min = h;
|
|
124
|
+
}
|
|
125
|
+
sig.push(min);
|
|
126
|
+
}
|
|
127
|
+
return sig;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* LSH band key — the concatenation of one band's rows from the signature.
|
|
132
|
+
* Two signatures that share at least one band key are a candidate pair.
|
|
133
|
+
*/
|
|
134
|
+
export function lshBandKeys(signature: readonly bigint[]): string[] {
|
|
135
|
+
const keys: string[] = [];
|
|
136
|
+
for (let b = 0; b < LSH_NUM_BANDS; b++) {
|
|
137
|
+
const start = b * LSH_ROWS_PER_BAND;
|
|
138
|
+
const end = start + LSH_ROWS_PER_BAND;
|
|
139
|
+
keys.push(signature.slice(start, end).map((v) => v.toString(16)).join("|"));
|
|
140
|
+
}
|
|
141
|
+
return keys;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* An indexed symbol body ready for LSH bucketing.
|
|
146
|
+
*/
|
|
147
|
+
export interface LshIndexEntry {
|
|
148
|
+
readonly nodeId: string;
|
|
149
|
+
readonly qualifiedName: string;
|
|
150
|
+
readonly body: string;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* A MinHash/LSH indexer instance. Owns the band→node-id bucket map on the
|
|
155
|
+
* instance (rule 11). `findCandidates` returns the deduplicated candidate
|
|
156
|
+
* pair set with estimated Jaccard for each pair.
|
|
157
|
+
*/
|
|
158
|
+
export class MinHasher {
|
|
159
|
+
/** band key → set of node ids in that band. */
|
|
160
|
+
private readonly buckets: Map<string, Set<string>> = new Map();
|
|
161
|
+
/** node id → signature (for Jaccard estimation on candidate pairs). */
|
|
162
|
+
private readonly signatures: Map<string, bigint[]> = new Map();
|
|
163
|
+
/** node id → qualified name (for readable candidate output). */
|
|
164
|
+
private readonly qnames: Map<string, string> = new Map();
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Add a symbol body to the LSH index. Idempotent — re-adding the same
|
|
168
|
+
* (nodeId, body) is a no-op.
|
|
169
|
+
*/
|
|
170
|
+
add(entry: LshIndexEntry): void {
|
|
171
|
+
if (this.signatures.has(entry.nodeId)) return;
|
|
172
|
+
const tokens = tokenizeForShingling(entry.body);
|
|
173
|
+
const shingles = shingleSet(tokens);
|
|
174
|
+
// Skip empty bodies — they would all get the same all-max signature
|
|
175
|
+
// and flood the candidate set with false pairs
|
|
176
|
+
// (chatgpt-codex-connector: 'Keep empty bodies out of shared MinHash
|
|
177
|
+
// buckets').
|
|
178
|
+
if (shingles.size === 0) return;
|
|
179
|
+
const sig = minHashSignature(shingles);
|
|
180
|
+
this.signatures.set(entry.nodeId, sig);
|
|
181
|
+
this.qnames.set(entry.nodeId, entry.qualifiedName);
|
|
182
|
+
for (const key of lshBandKeys(sig)) {
|
|
183
|
+
let bucket = this.buckets.get(key);
|
|
184
|
+
if (!bucket) {
|
|
185
|
+
bucket = new Set();
|
|
186
|
+
this.buckets.set(key, bucket);
|
|
187
|
+
}
|
|
188
|
+
bucket.add(entry.nodeId);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Find all candidate pairs (pairs sharing at least one LSH band) with
|
|
194
|
+
* their estimated Jaccard similarity. Returns a stable-sorted array
|
|
195
|
+
* (by aNodeId then bNodeId) so the determinism test can compare runs
|
|
196
|
+
* byte-for-byte.
|
|
197
|
+
*/
|
|
198
|
+
findCandidates(): { readonly aNodeId: string; readonly bNodeId: string; readonly aQualifiedName: string; readonly bQualifiedName: string; readonly jaccard: number }[] {
|
|
199
|
+
const pairSet = new Set<string>();
|
|
200
|
+
const pairs: { aNodeId: string; bNodeId: string; aQualifiedName: string; bQualifiedName: string; jaccard: number }[] = [];
|
|
201
|
+
for (const bucket of this.buckets.values()) {
|
|
202
|
+
if (bucket.size < 2) continue;
|
|
203
|
+
const ids = Array.from(bucket).sort();
|
|
204
|
+
for (let i = 0; i < ids.length; i++) {
|
|
205
|
+
for (let j = i + 1; j < ids.length; j++) {
|
|
206
|
+
const a = ids[i]!;
|
|
207
|
+
const b = ids[j]!;
|
|
208
|
+
const key = `${a}\0${b}`;
|
|
209
|
+
if (pairSet.has(key)) continue;
|
|
210
|
+
pairSet.add(key);
|
|
211
|
+
const sigA = this.signatures.get(a)!;
|
|
212
|
+
const sigB = this.signatures.get(b)!;
|
|
213
|
+
let matches = 0;
|
|
214
|
+
for (let k = 0; k < sigA.length; k++) {
|
|
215
|
+
if (sigA[k] === sigB[k]) matches++;
|
|
216
|
+
}
|
|
217
|
+
pairs.push({
|
|
218
|
+
aNodeId: a,
|
|
219
|
+
bNodeId: b,
|
|
220
|
+
aQualifiedName: this.qnames.get(a) ?? a,
|
|
221
|
+
bQualifiedName: this.qnames.get(b) ?? b,
|
|
222
|
+
jaccard: matches / sigA.length,
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
pairs.sort((x, y) => {
|
|
228
|
+
if (x.aNodeId !== y.aNodeId) return x.aNodeId < y.aNodeId ? -1 : 1;
|
|
229
|
+
return x.bNodeId < y.bNodeId ? -1 : x.bNodeId > y.bNodeId ? 1 : 0;
|
|
230
|
+
});
|
|
231
|
+
return pairs;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Construct a fresh MinHasher (rule 11 — state on the instance).
|
|
237
|
+
*/
|
|
238
|
+
export function createMinHasher(): MinHasher {
|
|
239
|
+
return new MinHasher();
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Cosine similarity between two equal-length float vectors. Returns 0 for
|
|
244
|
+
* zero-norm vectors (no division-by-zero). This is the brute-force
|
|
245
|
+
* retrieval primitive shared by SIMILAR_TO confirmation and semantic_query.
|
|
246
|
+
*/
|
|
247
|
+
export function cosineSimilarity(a: Float32Array | number[], b: Float32Array | number[]): number {
|
|
248
|
+
const len = Math.min(a.length, b.length);
|
|
249
|
+
let dot = 0;
|
|
250
|
+
let normA = 0;
|
|
251
|
+
let normB = 0;
|
|
252
|
+
for (let i = 0; i < len; i++) {
|
|
253
|
+
const av = a[i]!;
|
|
254
|
+
const bv = b[i]!;
|
|
255
|
+
dot += av * bv;
|
|
256
|
+
normA += av * av;
|
|
257
|
+
normB += bv * bv;
|
|
258
|
+
}
|
|
259
|
+
if (normA === 0 || normB === 0) return 0;
|
|
260
|
+
return dot / (Math.sqrt(normA) * Math.sqrt(normB));
|
|
261
|
+
}
|