@danielblomma/cortex-mcp 2.2.0 → 2.2.2
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/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@danielblomma/cortex-mcp",
|
|
3
3
|
"mcpName": "io.github.DanielBlomma/cortex",
|
|
4
|
-
"version": "2.2.
|
|
4
|
+
"version": "2.2.2",
|
|
5
5
|
"description": "Local, repo-scoped context platform for coding assistants. Semantic search, graph relationships, and architectural rule context.",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"author": "Daniel Blomma",
|
|
@@ -75,8 +75,12 @@ function buildRelationSearchSignals(data: ContextData): Map<string, string> {
|
|
|
75
75
|
return new Map([...signalsByEntity.entries()].map(([id, parts]) => [id, parts.join("\n")]));
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
-
|
|
79
|
-
|
|
78
|
+
function buildSearchEntityIndexes(data: ContextData): {
|
|
79
|
+
fileRuleLinks: Map<string, string[]>;
|
|
80
|
+
relationSignals: Map<string, string>;
|
|
81
|
+
adrPathSet: Set<string>;
|
|
82
|
+
filePathById: Map<string, string>;
|
|
83
|
+
} {
|
|
80
84
|
const fileRuleLinks = groupRuleLinks(data.relations);
|
|
81
85
|
const relationSignals = buildRelationSearchSignals(data);
|
|
82
86
|
const adrPathSet = new Set(
|
|
@@ -84,6 +88,15 @@ export function buildSearchEntities(data: ContextData, includeContent: boolean):
|
|
|
84
88
|
.map((adr) => adr.path.trim().toLowerCase())
|
|
85
89
|
.filter((adrPath) => adrPath.length > 0)
|
|
86
90
|
);
|
|
91
|
+
const filePathById = new Map(
|
|
92
|
+
data.documents.filter((document) => document.kind === "CODE").map((document) => [document.id, document.path])
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
return { fileRuleLinks, relationSignals, adrPathSet, filePathById };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function* iterateSearchEntities(data: ContextData, includeContent: boolean): Generator<SearchEntity> {
|
|
99
|
+
const { fileRuleLinks, relationSignals, adrPathSet, filePathById } = buildSearchEntityIndexes(data);
|
|
87
100
|
|
|
88
101
|
for (const document of data.documents) {
|
|
89
102
|
const normalizedPath = document.path.trim().toLowerCase();
|
|
@@ -91,7 +104,7 @@ export function buildSearchEntities(data: ContextData, includeContent: boolean):
|
|
|
91
104
|
continue;
|
|
92
105
|
}
|
|
93
106
|
|
|
94
|
-
|
|
107
|
+
yield {
|
|
95
108
|
id: document.id,
|
|
96
109
|
entity_type: "File",
|
|
97
110
|
kind: document.kind,
|
|
@@ -105,11 +118,11 @@ export function buildSearchEntities(data: ContextData, includeContent: boolean):
|
|
|
105
118
|
snippet: document.excerpt,
|
|
106
119
|
matched_rules: fileRuleLinks.get(document.id) ?? [],
|
|
107
120
|
content: includeContent ? document.content : undefined
|
|
108
|
-
}
|
|
121
|
+
};
|
|
109
122
|
}
|
|
110
123
|
|
|
111
124
|
for (const rule of data.rules) {
|
|
112
|
-
|
|
125
|
+
yield {
|
|
113
126
|
id: rule.id,
|
|
114
127
|
entity_type: "Rule",
|
|
115
128
|
kind: "RULE",
|
|
@@ -123,11 +136,11 @@ export function buildSearchEntities(data: ContextData, includeContent: boolean):
|
|
|
123
136
|
snippet: rule.body.slice(0, 500),
|
|
124
137
|
matched_rules: [rule.id],
|
|
125
138
|
content: includeContent ? rule.body : undefined
|
|
126
|
-
}
|
|
139
|
+
};
|
|
127
140
|
}
|
|
128
141
|
|
|
129
142
|
for (const adr of data.adrs) {
|
|
130
|
-
|
|
143
|
+
yield {
|
|
131
144
|
id: adr.id,
|
|
132
145
|
entity_type: "ADR",
|
|
133
146
|
kind: "ADR",
|
|
@@ -141,16 +154,12 @@ export function buildSearchEntities(data: ContextData, includeContent: boolean):
|
|
|
141
154
|
snippet: adr.body.slice(0, 500),
|
|
142
155
|
matched_rules: [],
|
|
143
156
|
content: includeContent ? adr.body : undefined
|
|
144
|
-
}
|
|
157
|
+
};
|
|
145
158
|
}
|
|
146
159
|
|
|
147
|
-
const filePathById = new Map(
|
|
148
|
-
data.documents.filter((document) => document.kind === "CODE").map((document) => [document.id, document.path])
|
|
149
|
-
);
|
|
150
|
-
|
|
151
160
|
for (const chunk of data.chunks) {
|
|
152
161
|
const filePath = filePathById.get(chunk.file_id) ?? "";
|
|
153
|
-
|
|
162
|
+
yield {
|
|
154
163
|
id: chunk.id,
|
|
155
164
|
entity_type: "Chunk",
|
|
156
165
|
kind: chunk.kind || "chunk",
|
|
@@ -164,11 +173,11 @@ export function buildSearchEntities(data: ContextData, includeContent: boolean):
|
|
|
164
173
|
snippet: chunk.description || chunk.body.slice(0, 500),
|
|
165
174
|
matched_rules: fileRuleLinks.get(chunk.file_id) ?? [],
|
|
166
175
|
content: includeContent ? chunk.body : undefined
|
|
167
|
-
}
|
|
176
|
+
};
|
|
168
177
|
}
|
|
169
178
|
|
|
170
179
|
for (const module of data.modules) {
|
|
171
|
-
|
|
180
|
+
yield {
|
|
172
181
|
id: module.id,
|
|
173
182
|
entity_type: "Module",
|
|
174
183
|
kind: "MODULE",
|
|
@@ -182,11 +191,11 @@ export function buildSearchEntities(data: ContextData, includeContent: boolean):
|
|
|
182
191
|
snippet: (module.summary || "").slice(0, 500),
|
|
183
192
|
matched_rules: [],
|
|
184
193
|
content: includeContent ? module.summary : undefined
|
|
185
|
-
}
|
|
194
|
+
};
|
|
186
195
|
}
|
|
187
196
|
|
|
188
197
|
for (const project of data.projects) {
|
|
189
|
-
|
|
198
|
+
yield {
|
|
190
199
|
id: project.id,
|
|
191
200
|
entity_type: "Project",
|
|
192
201
|
kind: project.kind.toUpperCase() || "PROJECT",
|
|
@@ -200,10 +209,12 @@ export function buildSearchEntities(data: ContextData, includeContent: boolean):
|
|
|
200
209
|
snippet: (project.summary || "").slice(0, 500),
|
|
201
210
|
matched_rules: [],
|
|
202
211
|
content: includeContent ? project.summary : undefined
|
|
203
|
-
}
|
|
212
|
+
};
|
|
204
213
|
}
|
|
214
|
+
}
|
|
205
215
|
|
|
206
|
-
|
|
216
|
+
export function buildSearchEntities(data: ContextData, includeContent: boolean): SearchEntity[] {
|
|
217
|
+
return Array.from(iterateSearchEntities(data, includeContent));
|
|
207
218
|
}
|
|
208
219
|
|
|
209
220
|
export function buildChunkPartOfRelations(data: ContextData): RelationRecord[] {
|
|
@@ -2,7 +2,7 @@ import { embedQuery, getEmbeddingRuntimeWarning, loadEmbeddingIndex } from "./em
|
|
|
2
2
|
import {
|
|
3
3
|
buildChunkPartOfRelations,
|
|
4
4
|
buildEntitySearchMap,
|
|
5
|
-
|
|
5
|
+
iterateSearchEntities,
|
|
6
6
|
entityCatalog
|
|
7
7
|
} from "./contextEntities.js";
|
|
8
8
|
import { relationDegree } from "./graphMetrics.js";
|
|
@@ -19,7 +19,7 @@ import {
|
|
|
19
19
|
semanticScore,
|
|
20
20
|
tokenize
|
|
21
21
|
} from "./searchCore.js";
|
|
22
|
-
import {
|
|
22
|
+
import { buildSearchResultsWithStats } from "./searchResults.js";
|
|
23
23
|
import { traverseImpactGraph } from "./impactTraversal.js";
|
|
24
24
|
import { buildEmptyRelatedResponse, buildRelatedResponseMeta } from "./relatedResponse.js";
|
|
25
25
|
import { traverseRelatedGraph } from "./relatedTraversal.js";
|
|
@@ -39,6 +39,7 @@ import type {
|
|
|
39
39
|
ImpactParams,
|
|
40
40
|
RelatedParams,
|
|
41
41
|
RelationType,
|
|
42
|
+
SearchEntity,
|
|
42
43
|
SearchParams,
|
|
43
44
|
ToolPayload
|
|
44
45
|
} from "./types.js";
|
|
@@ -57,6 +58,17 @@ const IMPACT_RELATION_TYPES = new Set([
|
|
|
57
58
|
"PART_OF"
|
|
58
59
|
]);
|
|
59
60
|
|
|
61
|
+
function* filterSearchCandidates(
|
|
62
|
+
candidates: Iterable<SearchEntity>,
|
|
63
|
+
includeDeprecated: boolean
|
|
64
|
+
): Generator<SearchEntity> {
|
|
65
|
+
for (const entity of candidates) {
|
|
66
|
+
if (includeDeprecated || entity.status.toLowerCase() !== "deprecated") {
|
|
67
|
+
yield entity;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
60
72
|
export async function runContextSearch(parsed: SearchParams): Promise<ToolPayload> {
|
|
61
73
|
const searchPresetConfig = resolveSearchResponsePreset(parsed);
|
|
62
74
|
const responsePreset = searchPresetConfig.responsePreset;
|
|
@@ -68,16 +80,15 @@ export async function runContextSearch(parsed: SearchParams): Promise<ToolPayloa
|
|
|
68
80
|
const degreeByEntity = relationDegree(allRelations);
|
|
69
81
|
const queryTokens = expandQueryTokens(Array.from(new Set(tokenize(parsed.query))));
|
|
70
82
|
const queryPhrase = normalizeText(parsed.query).trim();
|
|
71
|
-
const candidates =
|
|
72
|
-
(
|
|
73
|
-
);
|
|
83
|
+
const candidates = () =>
|
|
84
|
+
filterSearchCandidates(iterateSearchEntities(data, includeContent), parsed.include_deprecated);
|
|
74
85
|
const embeddings = loadEmbeddingIndex();
|
|
75
86
|
const queryVector =
|
|
76
87
|
embeddings.model && embeddings.vectors.size > 0
|
|
77
88
|
? await embedQuery(parsed.query, embeddings.model)
|
|
78
89
|
: null;
|
|
79
90
|
|
|
80
|
-
const results =
|
|
91
|
+
const { results, totalCandidates } = buildSearchResultsWithStats({
|
|
81
92
|
candidates,
|
|
82
93
|
degreeByEntity,
|
|
83
94
|
queryTokens,
|
|
@@ -107,7 +118,7 @@ export async function runContextSearch(parsed: SearchParams): Promise<ToolPayloa
|
|
|
107
118
|
include_matched_rules: includeMatchedRules,
|
|
108
119
|
include_content: includeContent,
|
|
109
120
|
ranking: data.ranking,
|
|
110
|
-
total_candidates:
|
|
121
|
+
total_candidates: totalCandidates,
|
|
111
122
|
context_source: data.source,
|
|
112
123
|
warning: warningMessages.length > 0 ? warningMessages.join(" | ") : undefined,
|
|
113
124
|
semantic_engine:
|
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
import type { RankingWeights, SearchEntity } from "./types.js";
|
|
2
2
|
|
|
3
|
+
type SearchResult = Record<string, unknown>;
|
|
4
|
+
|
|
5
|
+
type RankedSearchResult = {
|
|
6
|
+
result: SearchResult;
|
|
7
|
+
rankScore: number;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
type SearchCandidateSource = Iterable<SearchEntity> | (() => Iterable<SearchEntity>);
|
|
11
|
+
|
|
12
|
+
type BaseChunkMetadata = {
|
|
13
|
+
label: string;
|
|
14
|
+
path: string;
|
|
15
|
+
};
|
|
16
|
+
|
|
3
17
|
function isWindowChunkId(id: string): boolean {
|
|
4
18
|
return id.includes(":window:");
|
|
5
19
|
}
|
|
@@ -14,8 +28,26 @@ function baseChunkLabel(label: string): string {
|
|
|
14
28
|
return markerIndex === -1 ? label : label.slice(0, markerIndex);
|
|
15
29
|
}
|
|
16
30
|
|
|
17
|
-
|
|
18
|
-
|
|
31
|
+
function pruneRankedResults(bestById: Map<string, RankedSearchResult>, limit: number): void {
|
|
32
|
+
if (bestById.size <= limit) {
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const retained = [...bestById.entries()]
|
|
37
|
+
.sort(([, a], [, b]) => b.rankScore - a.rankScore)
|
|
38
|
+
.slice(0, limit);
|
|
39
|
+
bestById.clear();
|
|
40
|
+
for (const [id, result] of retained) {
|
|
41
|
+
bestById.set(id, result);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function candidatesFrom(source: SearchCandidateSource): Iterable<SearchEntity> {
|
|
46
|
+
return typeof source === "function" ? source() : source;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function buildSearchResultsWithStats(params: {
|
|
50
|
+
candidates: SearchCandidateSource;
|
|
19
51
|
degreeByEntity: Map<string, number>;
|
|
20
52
|
queryTokens: string[];
|
|
21
53
|
queryPhrase: string;
|
|
@@ -32,7 +64,11 @@ export function buildSearchResults(params: {
|
|
|
32
64
|
vectorScorer: (a: Float32Array, b: Float32Array) => number;
|
|
33
65
|
recencyScorer: (isoDate: string) => number;
|
|
34
66
|
legacyDataAccessBooster: (entity: SearchEntity, queryTokens: string[], queryPhrase: string) => number;
|
|
35
|
-
}): Record<string, unknown>[] {
|
|
67
|
+
}): { results: Record<string, unknown>[]; totalCandidates: number } {
|
|
68
|
+
if (params.topK <= 0) {
|
|
69
|
+
return { results: [], totalCandidates: 0 };
|
|
70
|
+
}
|
|
71
|
+
|
|
36
72
|
// Graph score = midrank percentile of relation degree within the entity's
|
|
37
73
|
// own type. The previous min(1, degree/4) saturated at degree >= 4, which
|
|
38
74
|
// nearly every entity exceeds, making the graph weight a constant. Per-type
|
|
@@ -40,7 +76,10 @@ export function buildSearchResults(params: {
|
|
|
40
76
|
// (every type averages ~0.5), so hub-heavy types like rules cannot drown
|
|
41
77
|
// out leaf code files or doc sections.
|
|
42
78
|
const sortedDegreesByType = new Map<string, number[]>();
|
|
43
|
-
|
|
79
|
+
const chunkCandidatesById = new Map<string, BaseChunkMetadata>();
|
|
80
|
+
let totalCandidates = 0;
|
|
81
|
+
for (const entity of candidatesFrom(params.candidates)) {
|
|
82
|
+
totalCandidates += 1;
|
|
44
83
|
const degree = params.degreeByEntity.get(entity.id) ?? 0;
|
|
45
84
|
const list = sortedDegreesByType.get(entity.entity_type);
|
|
46
85
|
if (list) {
|
|
@@ -48,6 +87,13 @@ export function buildSearchResults(params: {
|
|
|
48
87
|
} else {
|
|
49
88
|
sortedDegreesByType.set(entity.entity_type, [degree]);
|
|
50
89
|
}
|
|
90
|
+
|
|
91
|
+
if (entity.entity_type === "Chunk" && !isWindowChunkId(entity.id)) {
|
|
92
|
+
chunkCandidatesById.set(entity.id, {
|
|
93
|
+
label: entity.label,
|
|
94
|
+
path: entity.path
|
|
95
|
+
});
|
|
96
|
+
}
|
|
51
97
|
}
|
|
52
98
|
for (const list of sortedDegreesByType.values()) {
|
|
53
99
|
list.sort((a, b) => a - b);
|
|
@@ -65,102 +111,100 @@ export function buildSearchResults(params: {
|
|
|
65
111
|
return sorted.length > 0 ? (lo + upper) / (2 * sorted.length) : 0;
|
|
66
112
|
};
|
|
67
113
|
|
|
68
|
-
const
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
const entityVector = params.embeddingVectors.get(entity.id);
|
|
72
|
-
const vectorSemantic =
|
|
73
|
-
params.queryVector && entityVector
|
|
74
|
-
? Math.max(0, Math.min(1, params.vectorScorer(params.queryVector, entityVector)))
|
|
75
|
-
: 0;
|
|
76
|
-
const hasRelevanceSignal =
|
|
77
|
-
lexicalSemantic >= params.minLexicalRelevance || vectorSemantic >= params.minVectorRelevance;
|
|
78
|
-
if (!hasRelevanceSignal) {
|
|
79
|
-
return null;
|
|
80
|
-
}
|
|
114
|
+
const resultLimit = Math.max(1, params.topK);
|
|
115
|
+
const pruneThreshold = Math.max(resultLimit * 4, 64);
|
|
116
|
+
const bestById = new Map<string, RankedSearchResult>();
|
|
81
117
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
score += params.legacyDataAccessBooster(entity, params.queryTokens, params.queryPhrase);
|
|
95
|
-
|
|
96
|
-
if (entity.source_of_truth) {
|
|
97
|
-
score += 0.1 * semantic;
|
|
98
|
-
}
|
|
118
|
+
for (const entity of candidatesFrom(params.candidates)) {
|
|
119
|
+
const lexicalSemantic = params.semanticScorer(params.queryTokens, params.queryPhrase, entity.text);
|
|
120
|
+
const entityVector = params.embeddingVectors.get(entity.id);
|
|
121
|
+
const vectorSemantic =
|
|
122
|
+
params.queryVector && entityVector
|
|
123
|
+
? Math.max(0, Math.min(1, params.vectorScorer(params.queryVector, entityVector)))
|
|
124
|
+
: 0;
|
|
125
|
+
const hasRelevanceSignal =
|
|
126
|
+
lexicalSemantic >= params.minLexicalRelevance || vectorSemantic >= params.minVectorRelevance;
|
|
127
|
+
if (!hasRelevanceSignal) {
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
99
130
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
131
|
+
const semantic =
|
|
132
|
+
vectorSemantic > 0 ? vectorSemantic * 0.75 + lexicalSemantic * 0.25 : lexicalSemantic;
|
|
133
|
+
const degree = params.degreeByEntity.get(entity.id) ?? 0;
|
|
134
|
+
const graphScore = midrankPercentile(sortedDegreesByType.get(entity.entity_type) ?? [], degree);
|
|
135
|
+
const trustScore = Math.max(0, Math.min(1, entity.trust_level / 100));
|
|
136
|
+
const dateScore = params.recencyScorer(entity.updated_at);
|
|
137
|
+
|
|
138
|
+
let score = 0;
|
|
139
|
+
score += params.ranking.semantic * semantic;
|
|
140
|
+
score += params.ranking.graph * graphScore;
|
|
141
|
+
score += params.ranking.trust * trustScore;
|
|
142
|
+
score += params.ranking.recency * dateScore;
|
|
143
|
+
score += params.legacyDataAccessBooster(entity, params.queryTokens, params.queryPhrase);
|
|
144
|
+
|
|
145
|
+
if (entity.source_of_truth) {
|
|
146
|
+
score += 0.1 * semantic;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const result: SearchResult = {
|
|
150
|
+
id: entity.id,
|
|
151
|
+
entity_type: entity.entity_type,
|
|
152
|
+
kind: entity.kind,
|
|
153
|
+
title: entity.label,
|
|
154
|
+
path: entity.path || undefined,
|
|
155
|
+
source_of_truth: entity.source_of_truth,
|
|
156
|
+
status: entity.status,
|
|
157
|
+
updated_at: entity.updated_at,
|
|
158
|
+
excerpt: entity.snippet,
|
|
159
|
+
...(params.includeScores
|
|
160
|
+
? {
|
|
161
|
+
score: Number(score.toFixed(4)),
|
|
162
|
+
semantic_score: Number(semantic.toFixed(4)),
|
|
163
|
+
embedding_score: Number(vectorSemantic.toFixed(4)),
|
|
164
|
+
lexical_score: Number(lexicalSemantic.toFixed(4)),
|
|
165
|
+
graph_score: Number(graphScore.toFixed(4))
|
|
166
|
+
}
|
|
167
|
+
: {}),
|
|
168
|
+
...(params.includeMatchedRules
|
|
169
|
+
? {
|
|
170
|
+
matched_rules: entity.matched_rules
|
|
171
|
+
}
|
|
172
|
+
: {}),
|
|
173
|
+
...(params.includeContent
|
|
174
|
+
? {
|
|
175
|
+
content: entity.content
|
|
176
|
+
}
|
|
177
|
+
: {})
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
if (entity.entity_type === "Chunk" && isWindowChunkId(entity.id)) {
|
|
181
|
+
const canonicalId = baseChunkId(entity.id);
|
|
182
|
+
const baseChunk = chunkCandidatesById.get(canonicalId);
|
|
183
|
+
if (baseChunk) {
|
|
184
|
+
result.id = canonicalId;
|
|
185
|
+
result.title = baseChunkLabel(baseChunk.label);
|
|
186
|
+
result.path = baseChunk.path || undefined;
|
|
143
187
|
}
|
|
144
|
-
continue;
|
|
145
188
|
}
|
|
146
189
|
|
|
147
|
-
const
|
|
148
|
-
const
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
}
|
|
156
|
-
: result;
|
|
157
|
-
const existing = normalizedById.get(String(normalizedResult.id));
|
|
158
|
-
if (!existing || Number(normalizedResult.score ?? 0) > Number(existing.score ?? 0)) {
|
|
159
|
-
normalizedById.set(String(normalizedResult.id), normalizedResult);
|
|
190
|
+
const resultId = String(result.id);
|
|
191
|
+
const existing = bestById.get(resultId);
|
|
192
|
+
if (!existing || score > existing.rankScore) {
|
|
193
|
+
bestById.set(resultId, { result, rankScore: score });
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (bestById.size > pruneThreshold) {
|
|
197
|
+
pruneRankedResults(bestById, resultLimit);
|
|
160
198
|
}
|
|
161
199
|
}
|
|
162
200
|
|
|
163
|
-
|
|
164
|
-
.sort((a, b) =>
|
|
165
|
-
.slice(0, params.topK)
|
|
201
|
+
const results = [...bestById.values()]
|
|
202
|
+
.sort((a, b) => b.rankScore - a.rankScore)
|
|
203
|
+
.slice(0, params.topK)
|
|
204
|
+
.map((ranked) => ranked.result);
|
|
205
|
+
return { results, totalCandidates };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export function buildSearchResults(params: Parameters<typeof buildSearchResultsWithStats>[0]): Record<string, unknown>[] {
|
|
209
|
+
return buildSearchResultsWithStats(params).results;
|
|
166
210
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import test from "node:test";
|
|
2
2
|
import assert from "node:assert/strict";
|
|
3
|
-
import { buildSearchResults } from "../dist/searchResults.js";
|
|
3
|
+
import { buildSearchResults, buildSearchResultsWithStats } from "../dist/searchResults.js";
|
|
4
4
|
|
|
5
5
|
function makeEntity(id, entityType, overrides = {}) {
|
|
6
6
|
return {
|
|
@@ -130,3 +130,76 @@ test("graph score never saturates to a shared constant for common degrees", () =
|
|
|
130
130
|
|
|
131
131
|
assert.equal(unique.size, 3);
|
|
132
132
|
});
|
|
133
|
+
|
|
134
|
+
test("ranking is preserved when score fields are omitted from the response", () => {
|
|
135
|
+
const candidates = [
|
|
136
|
+
makeEntity("weak", "Chunk", { text: "weak query" }),
|
|
137
|
+
makeEntity("strong", "Chunk", { text: "strong query" }),
|
|
138
|
+
makeEntity("middle", "Chunk", { text: "middle query" })
|
|
139
|
+
];
|
|
140
|
+
|
|
141
|
+
const results = buildSearchResults({
|
|
142
|
+
candidates,
|
|
143
|
+
degreeByEntity: new Map(),
|
|
144
|
+
queryTokens: ["query"],
|
|
145
|
+
queryPhrase: "query",
|
|
146
|
+
ranking: { semantic: 1, graph: 0, trust: 0, recency: 0 },
|
|
147
|
+
includeScores: false,
|
|
148
|
+
includeMatchedRules: false,
|
|
149
|
+
includeContent: false,
|
|
150
|
+
queryVector: null,
|
|
151
|
+
embeddingVectors: new Map(),
|
|
152
|
+
topK: 2,
|
|
153
|
+
minLexicalRelevance: 0,
|
|
154
|
+
minVectorRelevance: 0,
|
|
155
|
+
semanticScorer: (_tokens, _phrase, text) => {
|
|
156
|
+
if (text.includes("strong")) return 0.9;
|
|
157
|
+
if (text.includes("middle")) return 0.5;
|
|
158
|
+
return 0.1;
|
|
159
|
+
},
|
|
160
|
+
vectorScorer: () => 0,
|
|
161
|
+
recencyScorer: () => 0,
|
|
162
|
+
legacyDataAccessBooster: () => 0
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
assert.deepEqual(results.map((result) => result.id), ["strong", "middle"]);
|
|
166
|
+
assert.equal("score" in results[0], false);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test("search results can consume a repeatable candidate generator with stats", () => {
|
|
170
|
+
let passes = 0;
|
|
171
|
+
const candidateSource = function* () {
|
|
172
|
+
passes += 1;
|
|
173
|
+
yield makeEntity("weak", "Chunk", { text: "weak query" });
|
|
174
|
+
yield makeEntity("strong", "Chunk", { text: "strong query" });
|
|
175
|
+
yield makeEntity("middle", "Chunk", { text: "middle query" });
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
const { results, totalCandidates } = buildSearchResultsWithStats({
|
|
179
|
+
candidates: candidateSource,
|
|
180
|
+
degreeByEntity: new Map(),
|
|
181
|
+
queryTokens: ["query"],
|
|
182
|
+
queryPhrase: "query",
|
|
183
|
+
ranking: { semantic: 1, graph: 0, trust: 0, recency: 0 },
|
|
184
|
+
includeScores: false,
|
|
185
|
+
includeMatchedRules: false,
|
|
186
|
+
includeContent: false,
|
|
187
|
+
queryVector: null,
|
|
188
|
+
embeddingVectors: new Map(),
|
|
189
|
+
topK: 2,
|
|
190
|
+
minLexicalRelevance: 0,
|
|
191
|
+
minVectorRelevance: 0,
|
|
192
|
+
semanticScorer: (_tokens, _phrase, text) => {
|
|
193
|
+
if (text.includes("strong")) return 0.9;
|
|
194
|
+
if (text.includes("middle")) return 0.5;
|
|
195
|
+
return 0.1;
|
|
196
|
+
},
|
|
197
|
+
vectorScorer: () => 0,
|
|
198
|
+
recencyScorer: () => 0,
|
|
199
|
+
legacyDataAccessBooster: () => 0
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
assert.equal(passes, 2);
|
|
203
|
+
assert.equal(totalCandidates, 3);
|
|
204
|
+
assert.deepEqual(results.map((result) => result.id), ["strong", "middle"]);
|
|
205
|
+
});
|
|
@@ -758,6 +758,29 @@ function writeJsonl(filePath, records) {
|
|
|
758
758
|
}
|
|
759
759
|
}
|
|
760
760
|
|
|
761
|
+
function stageJsonl(filePath, records) {
|
|
762
|
+
const stagedPath = `${filePath}.tmp-${process.pid}-${Date.now()}`;
|
|
763
|
+
writeJsonl(stagedPath, records);
|
|
764
|
+
return {
|
|
765
|
+
commit() {
|
|
766
|
+
fs.renameSync(stagedPath, filePath);
|
|
767
|
+
},
|
|
768
|
+
discard() {
|
|
769
|
+
fs.rmSync(stagedPath, { force: true });
|
|
770
|
+
}
|
|
771
|
+
};
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
function countFileContentRecords(fileRecords) {
|
|
775
|
+
let count = 0;
|
|
776
|
+
for (const fileRecord of fileRecords) {
|
|
777
|
+
if (Object.prototype.hasOwnProperty.call(fileRecord, "content")) {
|
|
778
|
+
count += 1;
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
return count;
|
|
782
|
+
}
|
|
783
|
+
|
|
761
784
|
function sanitizeTsvCell(value) {
|
|
762
785
|
if (value === null || value === undefined) return "";
|
|
763
786
|
return String(value).replace(/\t/g, " ").replace(/\r?\n/g, " ");
|
|
@@ -2056,6 +2079,27 @@ function fileTokenSet(fileRecord) {
|
|
|
2056
2079
|
return new Set(tokenizeKeywords(tokenSource));
|
|
2057
2080
|
}
|
|
2058
2081
|
|
|
2082
|
+
function collectRuleKeywordMatch(ruleKeywords, tokens, minimumMatches) {
|
|
2083
|
+
const required = Math.min(minimumMatches, Math.max(1, ruleKeywords.length));
|
|
2084
|
+
let count = 0;
|
|
2085
|
+
const sample = [];
|
|
2086
|
+
|
|
2087
|
+
for (const keyword of ruleKeywords) {
|
|
2088
|
+
if (!tokens.has(keyword)) {
|
|
2089
|
+
continue;
|
|
2090
|
+
}
|
|
2091
|
+
count += 1;
|
|
2092
|
+
if (sample.length < 5) {
|
|
2093
|
+
sample.push(keyword);
|
|
2094
|
+
}
|
|
2095
|
+
if (count >= required && sample.length >= 5) {
|
|
2096
|
+
break;
|
|
2097
|
+
}
|
|
2098
|
+
}
|
|
2099
|
+
|
|
2100
|
+
return { matched: count >= required, sample };
|
|
2101
|
+
}
|
|
2102
|
+
|
|
2059
2103
|
function chunkIdFor(filePath, chunk) {
|
|
2060
2104
|
const startLine = Number.isFinite(chunk.startLine) ? chunk.startLine : 0;
|
|
2061
2105
|
const endLine = Number.isFinite(chunk.endLine) ? chunk.endLine : startLine;
|
|
@@ -2267,7 +2311,8 @@ function resolveIngestWorkerCount(taskCount) {
|
|
|
2267
2311
|
const raw = process.env.CORTEX_INGEST_WORKERS;
|
|
2268
2312
|
const configured = raw !== undefined ? Number.parseInt(raw, 10) : Number.NaN;
|
|
2269
2313
|
const cpuBudget = Math.max(1, (os.availableParallelism?.() ?? os.cpus().length) - 1);
|
|
2270
|
-
const
|
|
2314
|
+
const defaultWorkerLimit = taskCount >= 1000 ? 4 : 8;
|
|
2315
|
+
const desired = Number.isFinite(configured) && configured >= 0 ? configured : Math.min(cpuBudget, defaultWorkerLimit);
|
|
2271
2316
|
if (desired <= 1) return 1;
|
|
2272
2317
|
// Worker spin-up plus per-worker WASM grammar init dominates on small or
|
|
2273
2318
|
// incremental runs; stay sequential until there is enough work to amortize.
|
|
@@ -3331,59 +3376,98 @@ async function main() {
|
|
|
3331
3376
|
|
|
3332
3377
|
const constrainsRelations = [];
|
|
3333
3378
|
const implementsRelations = [];
|
|
3334
|
-
const
|
|
3335
|
-
const
|
|
3379
|
+
const stagedDocumentCache = stageJsonl(path.join(CACHE_DIR, "documents.jsonl"), fileRecords);
|
|
3380
|
+
const stagedFileEntityCache = stageJsonl(path.join(CACHE_DIR, "entities.file.jsonl"), fileRecords);
|
|
3381
|
+
memoryTrace.checkpoint("writes:file_cache_staged", {
|
|
3382
|
+
files: fileRecords.length,
|
|
3383
|
+
file_content_records: countFileContentRecords(fileRecords)
|
|
3384
|
+
});
|
|
3336
3385
|
memoryTrace.checkpoint("tokens:rule_matching_start", {
|
|
3337
3386
|
files: fileRecords.length,
|
|
3338
3387
|
rules: ruleRecords.length
|
|
3339
3388
|
});
|
|
3340
|
-
const tokenByFileId = new Map(fileRecords.map((fileRecord) => [fileRecord.id, fileTokenSet(fileRecord)]));
|
|
3341
3389
|
|
|
3342
|
-
|
|
3343
|
-
|
|
3344
|
-
|
|
3390
|
+
const ruleMatchers = ruleRecords.map((ruleRecord) => ({
|
|
3391
|
+
ruleRecord,
|
|
3392
|
+
needle: ruleRecord.id.toLowerCase(),
|
|
3393
|
+
keywords: normalizeRuleTokens(ruleRecord)
|
|
3394
|
+
}));
|
|
3395
|
+
const constrainsByKey = new Map();
|
|
3396
|
+
const implementsByKey = new Map();
|
|
3397
|
+
let fileTokenSetsProcessed = 0;
|
|
3398
|
+
let fileContentRecordsReleased = 0;
|
|
3345
3399
|
|
|
3346
|
-
|
|
3347
|
-
|
|
3400
|
+
for (const fileRecord of fileRecords) {
|
|
3401
|
+
const lower = String(fileRecord.content ?? "").toLowerCase();
|
|
3402
|
+
const tokens = fileTokenSet(fileRecord);
|
|
3403
|
+
fileTokenSetsProcessed += 1;
|
|
3404
|
+
|
|
3405
|
+
for (const { ruleRecord, needle, keywords } of ruleMatchers) {
|
|
3348
3406
|
const explicitMention = lower.includes(needle);
|
|
3349
|
-
const tokens = tokenByFileId.get(fileRecord.id) ?? new Set();
|
|
3350
|
-
const matchedKeywords = ruleKeywords.filter((keyword) => tokens.has(keyword));
|
|
3351
3407
|
const minimumMatches = fileRecord.kind === "CODE" ? 1 : 2;
|
|
3352
|
-
const
|
|
3408
|
+
const keywordResult = explicitMention
|
|
3409
|
+
? { matched: false, sample: [] }
|
|
3410
|
+
: collectRuleKeywordMatch(keywords, tokens, minimumMatches);
|
|
3353
3411
|
|
|
3354
|
-
if (!explicitMention && !
|
|
3412
|
+
if (!explicitMention && !keywordResult.matched) {
|
|
3355
3413
|
continue;
|
|
3356
3414
|
}
|
|
3357
3415
|
|
|
3358
3416
|
const constrainsKey = `${ruleRecord.id}|${fileRecord.id}`;
|
|
3359
|
-
if (!
|
|
3360
|
-
|
|
3361
|
-
constrainsRelations.push({
|
|
3417
|
+
if (!constrainsByKey.has(constrainsKey)) {
|
|
3418
|
+
constrainsByKey.set(constrainsKey, {
|
|
3362
3419
|
from: ruleRecord.id,
|
|
3363
3420
|
to: fileRecord.id,
|
|
3364
3421
|
note: explicitMention
|
|
3365
3422
|
? `Mentions ${ruleRecord.id}`
|
|
3366
|
-
: `Keyword match ${
|
|
3423
|
+
: `Keyword match ${keywordResult.sample.join(", ")}`
|
|
3367
3424
|
});
|
|
3368
3425
|
}
|
|
3369
3426
|
|
|
3370
3427
|
if (fileRecord.kind === "CODE") {
|
|
3371
3428
|
const implementsKey = `${fileRecord.id}|${ruleRecord.id}`;
|
|
3372
|
-
if (!
|
|
3373
|
-
|
|
3374
|
-
implementsRelations.push({
|
|
3429
|
+
if (!implementsByKey.has(implementsKey)) {
|
|
3430
|
+
implementsByKey.set(implementsKey, {
|
|
3375
3431
|
from: fileRecord.id,
|
|
3376
3432
|
to: ruleRecord.id,
|
|
3377
3433
|
note: explicitMention
|
|
3378
3434
|
? `Code references ${ruleRecord.id}`
|
|
3379
|
-
: `Code keywords ${
|
|
3435
|
+
: `Code keywords ${keywordResult.sample.join(", ")}`
|
|
3380
3436
|
});
|
|
3381
3437
|
}
|
|
3382
3438
|
}
|
|
3383
3439
|
}
|
|
3440
|
+
|
|
3441
|
+
if (Object.prototype.hasOwnProperty.call(fileRecord, "content")) {
|
|
3442
|
+
delete fileRecord.content;
|
|
3443
|
+
fileContentRecordsReleased += 1;
|
|
3444
|
+
}
|
|
3445
|
+
}
|
|
3446
|
+
|
|
3447
|
+
for (const { ruleRecord } of ruleMatchers) {
|
|
3448
|
+
for (const fileRecord of fileRecords) {
|
|
3449
|
+
const constrainsKey = `${ruleRecord.id}|${fileRecord.id}`;
|
|
3450
|
+
const constrainsRelation = constrainsByKey.get(constrainsKey);
|
|
3451
|
+
if (constrainsRelation) {
|
|
3452
|
+
constrainsRelations.push(constrainsRelation);
|
|
3453
|
+
constrainsByKey.delete(constrainsKey);
|
|
3454
|
+
}
|
|
3455
|
+
if (fileRecord.kind === "CODE") {
|
|
3456
|
+
const implementsKey = `${fileRecord.id}|${ruleRecord.id}`;
|
|
3457
|
+
const implementsRelation = implementsByKey.get(implementsKey);
|
|
3458
|
+
if (implementsRelation) {
|
|
3459
|
+
implementsRelations.push(implementsRelation);
|
|
3460
|
+
implementsByKey.delete(implementsKey);
|
|
3461
|
+
}
|
|
3462
|
+
}
|
|
3463
|
+
}
|
|
3384
3464
|
}
|
|
3465
|
+
|
|
3385
3466
|
memoryTrace.checkpoint("tokens:rule_matching_complete", {
|
|
3386
|
-
file_token_sets:
|
|
3467
|
+
file_token_sets: fileTokenSetsProcessed,
|
|
3468
|
+
file_token_sets_retained: 0,
|
|
3469
|
+
file_content_records_released: fileContentRecordsReleased,
|
|
3470
|
+
file_content_records_retained: countFileContentRecords(fileRecords),
|
|
3387
3471
|
rules: ruleRecords.length,
|
|
3388
3472
|
relations_constrains: constrainsRelations.length,
|
|
3389
3473
|
relations_implements: implementsRelations.length,
|
|
@@ -3396,8 +3480,8 @@ async function main() {
|
|
|
3396
3480
|
rules: ruleRecords.length,
|
|
3397
3481
|
chunks: chunkRecords.length
|
|
3398
3482
|
});
|
|
3399
|
-
|
|
3400
|
-
|
|
3483
|
+
stagedDocumentCache.commit();
|
|
3484
|
+
stagedFileEntityCache.commit();
|
|
3401
3485
|
writeJsonl(path.join(CACHE_DIR, "entities.adr.jsonl"), adrRecords);
|
|
3402
3486
|
writeJsonl(path.join(CACHE_DIR, "entities.rule.jsonl"), ruleRecords);
|
|
3403
3487
|
writeJsonl(path.join(CACHE_DIR, "entities.chunk.jsonl"), chunkRecords);
|
|
@@ -3788,5 +3872,6 @@ export {
|
|
|
3788
3872
|
generateSectionHandlerRelations,
|
|
3789
3873
|
getChunkParserForExtension,
|
|
3790
3874
|
parseFilesInWorkers,
|
|
3875
|
+
resolveIngestWorkerCount,
|
|
3791
3876
|
resolveRelativeImportTargetId
|
|
3792
3877
|
};
|