@holmes-lab/holmes-kit 0.2.1 → 0.3.1
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/CHANGELOG.md +55 -0
- package/dist/.build-id +1 -1
- package/dist/holmes/assoc/assoc-arm.d.ts +94 -0
- package/dist/holmes/assoc/assoc-arm.js +187 -0
- package/dist/holmes/assoc/explore.d.ts +21 -0
- package/dist/holmes/assoc/explore.js +160 -0
- package/dist/holmes/assoc/impact-baseline.d.ts +13 -0
- package/dist/holmes/assoc/impact-baseline.js +48 -0
- package/dist/holmes/assoc/ppr.d.ts +64 -0
- package/dist/holmes/assoc/ppr.js +110 -0
- package/dist/holmes/cli/agents.d.ts +24 -11
- package/dist/holmes/cli/agents.js +93 -21
- package/dist/holmes/cli/doctor.d.ts +56 -0
- package/dist/holmes/cli/doctor.js +311 -5
- package/dist/holmes/cli/index.js +13 -1
- package/dist/holmes/cli/semantic-key.d.ts +19 -0
- package/dist/holmes/cli/semantic-key.js +93 -0
- package/dist/holmes/config/config.d.ts +9 -0
- package/dist/holmes/config/config.js +8 -1
- package/dist/holmes/cpg/language-capability.d.ts +65 -0
- package/dist/holmes/cpg/language-capability.js +145 -0
- package/dist/holmes/cpg/language-parser-walk.js +179 -34
- package/dist/holmes/cpg/language-parser.d.ts +1 -1
- package/dist/holmes/governance/ledger-rechain.d.ts +12 -0
- package/dist/holmes/governance/ledger-rechain.js +17 -2
- package/dist/holmes/governance/provenance-ledger.js +21 -0
- package/dist/holmes/guardrail/blind-spots.js +12 -1
- package/dist/holmes/guardrail/impact-gate.d.ts +77 -0
- package/dist/holmes/guardrail/impact-gate.js +263 -0
- package/dist/holmes/guardrail/write-target.d.ts +38 -1
- package/dist/holmes/guardrail/write-target.js +41 -4
- package/dist/holmes/hooks/adapters/antigravity.js +12 -1
- package/dist/holmes/hooks/corrupt-state-run.d.ts +33 -0
- package/dist/holmes/hooks/corrupt-state-run.js +16 -0
- package/dist/holmes/hooks/pre-tool-use.d.ts +26 -0
- package/dist/holmes/hooks/pre-tool-use.js +203 -11
- package/dist/holmes/hooks/stop.d.ts +65 -0
- package/dist/holmes/hooks/stop.js +200 -2
- package/dist/holmes/mcp/elicit-approval.d.ts +32 -2
- package/dist/holmes/mcp/elicit-approval.js +30 -2
- package/dist/holmes/mcp/handlers.d.ts +88 -0
- package/dist/holmes/mcp/handlers.js +636 -35
- package/dist/holmes/mcp/maintenance-analyze.d.ts +435 -0
- package/dist/holmes/mcp/maintenance-analyze.js +994 -0
- package/dist/holmes/mcp/maintenance-evidence.d.ts +140 -0
- package/dist/holmes/mcp/maintenance-evidence.js +253 -0
- package/dist/holmes/mcp/server.js +13 -10
- package/dist/holmes/mcp/tool-schemas.js +71 -0
- package/dist/holmes/project/root.js +3 -1
- package/dist/holmes/review/baseline-arm.d.ts +37 -0
- package/dist/holmes/review/baseline-arm.js +51 -0
- package/dist/holmes/review/captured-stdin-guard.d.ts +8 -0
- package/dist/holmes/review/captured-stdin-guard.js +48 -0
- package/dist/holmes/review/coherence-verify.d.ts +31 -0
- package/dist/holmes/review/coherence-verify.js +144 -0
- package/dist/holmes/review/commit-text.d.ts +50 -0
- package/dist/holmes/review/commit-text.js +76 -0
- package/dist/holmes/review/confidence-calibration.d.ts +39 -0
- package/dist/holmes/review/confidence-calibration.js +39 -0
- package/dist/holmes/review/content-baseline.d.ts +38 -0
- package/dist/holmes/review/content-baseline.js +103 -0
- package/dist/holmes/review/content-verify.d.ts +20 -0
- package/dist/holmes/review/content-verify.js +73 -0
- package/dist/holmes/review/dense-retrieval.d.ts +66 -0
- package/dist/holmes/review/dense-retrieval.js +97 -0
- package/dist/holmes/review/edge-quality.d.ts +44 -0
- package/dist/holmes/review/edge-quality.js +117 -0
- package/dist/holmes/review/evaluation-metrics.d.ts +138 -0
- package/dist/holmes/review/evaluation-metrics.js +175 -0
- package/dist/holmes/review/graph-verifier.d.ts +34 -0
- package/dist/holmes/review/graph-verifier.js +62 -0
- package/dist/holmes/review/hop-ablation.d.ts +100 -0
- package/dist/holmes/review/hop-ablation.js +89 -0
- package/dist/holmes/review/manual-baseline.d.ts +209 -0
- package/dist/holmes/review/manual-baseline.js +2846 -0
- package/dist/holmes/review/oracle-gap.d.ts +32 -0
- package/dist/holmes/review/oracle-gap.js +102 -0
- package/dist/holmes/review/point-in-time-replay.d.ts +41 -0
- package/dist/holmes/review/point-in-time-replay.js +161 -0
- package/dist/holmes/review/rank-diagnosis.d.ts +43 -0
- package/dist/holmes/review/rank-diagnosis.js +163 -0
- package/dist/holmes/review/replay-calibration.d.ts +62 -0
- package/dist/holmes/review/replay-calibration.js +83 -0
- package/dist/holmes/review/replay-corpus.d.ts +135 -0
- package/dist/holmes/review/replay-corpus.js +210 -0
- package/dist/holmes/review/run-replay.d.ts +260 -0
- package/dist/holmes/review/run-replay.js +729 -0
- package/dist/holmes/review/semantic-arm.d.ts +271 -0
- package/dist/holmes/review/semantic-arm.js +717 -0
- package/dist/holmes/review/semantic-retrieval.d.ts +55 -0
- package/dist/holmes/review/semantic-retrieval.js +156 -0
- package/dist/holmes/review/spec-layer-stats.d.ts +38 -0
- package/dist/holmes/review/spec-layer-stats.js +52 -0
- package/dist/holmes/review/temporal-prior.d.ts +33 -0
- package/dist/holmes/review/temporal-prior.js +53 -0
- package/dist/holmes/review/test-runner.d.ts +15 -0
- package/dist/holmes/review/test-runner.js +41 -4
- package/dist/holmes/review/test-selection-breadth.d.ts +75 -0
- package/dist/holmes/review/test-selection-breadth.js +57 -0
- package/dist/holmes/review/traceability-benchmark.d.ts +81 -0
- package/dist/holmes/review/traceability-benchmark.js +135 -0
- package/dist/holmes/review/union-verify.d.ts +12 -0
- package/dist/holmes/review/union-verify.js +70 -0
- package/dist/holmes/rtm/graph-store.d.ts +51 -0
- package/dist/holmes/rtm/graph-store.js +122 -0
- package/dist/holmes/rtm/incremental.d.ts +25 -1
- package/dist/holmes/rtm/incremental.js +18 -1
- package/dist/holmes/rtm/localize.d.ts +28 -0
- package/dist/holmes/rtm/localize.js +272 -14
- package/dist/holmes/rtm/rtm-builder.d.ts +118 -3
- package/dist/holmes/rtm/rtm-builder.js +265 -28
- package/dist/holmes/rtm/rtm-graph.d.ts +117 -2
- package/dist/holmes/rtm/rtm-graph.js +194 -31
- package/dist/holmes/rtm/taint-benchmark.d.ts +97 -0
- package/dist/holmes/rtm/taint-benchmark.js +141 -0
- package/dist/holmes/rtm/test-scope.js +24 -1
- package/dist/holmes/semantic/credentials.d.ts +15 -0
- package/dist/holmes/semantic/credentials.js +134 -0
- package/dist/holmes/semantic/embedder.d.ts +44 -0
- package/dist/holmes/semantic/embedder.js +185 -0
- package/dist/holmes/semantic/hit-rerank.d.ts +4 -0
- package/dist/holmes/semantic/hit-rerank.js +38 -0
- package/dist/holmes/semantic/tier.d.ts +37 -0
- package/dist/holmes/semantic/tier.js +54 -0
- package/dist/holmes/semantic/vector-cache.d.ts +11 -0
- package/dist/holmes/semantic/vector-cache.js +91 -0
- package/dist/holmes/spec/acceptance-quality.d.ts +81 -0
- package/dist/holmes/spec/acceptance-quality.js +169 -0
- package/dist/holmes/spec/validator.js +33 -1
- package/dist/holmes/spec/yaml-scalar.d.ts +1 -0
- package/dist/holmes/spec/yaml-scalar.js +43 -0
- package/package.json +1 -1
|
@@ -0,0 +1,717 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.fileTokens = fileTokens;
|
|
4
|
+
exports.corpusIdf = corpusIdf;
|
|
5
|
+
exports.rankIdf = rankIdf;
|
|
6
|
+
exports.normalisedIdf = normalisedIdf;
|
|
7
|
+
exports.rankWeighted = rankWeighted;
|
|
8
|
+
exports.rankExpanded = rankExpanded;
|
|
9
|
+
exports.matchedSpecsWeighted = matchedSpecsWeighted;
|
|
10
|
+
exports.rankBreadthDamped = rankBreadthDamped;
|
|
11
|
+
exports.anchorCoverage = anchorCoverage;
|
|
12
|
+
exports.rankCoverageAware = rankCoverageAware;
|
|
13
|
+
exports.rankCitationConditioned = rankCitationConditioned;
|
|
14
|
+
exports.rankCapped = rankCapped;
|
|
15
|
+
exports.rankUncitedCapped = rankUncitedCapped;
|
|
16
|
+
exports.rankWithCallHop = rankWithCallHop;
|
|
17
|
+
exports.ablate = ablate;
|
|
18
|
+
exports.rankSpecLiftLimited = rankSpecLiftLimited;
|
|
19
|
+
exports.rankWithBonusScale = rankWithBonusScale;
|
|
20
|
+
exports.rankSpecReorderOnly = rankSpecReorderOnly;
|
|
21
|
+
const localize_1 = require("../rtm/localize");
|
|
22
|
+
const edge_quality_1 = require("./edge-quality");
|
|
23
|
+
/**
|
|
24
|
+
* An alternative ranker, scored beside the product's — never in place of it.
|
|
25
|
+
*
|
|
26
|
+
* Goal Phase C asks for the semantic layer as an ARM: added to graph-off / current / enhanced and
|
|
27
|
+
* scored on the same corpus, adopted only if the measurement moves. Nothing here is wired into
|
|
28
|
+
* `localizeIssue`; if the numbers below do not beat the current arm, the honest outcome is a
|
|
29
|
+
* recorded negative result, which this repository already has several of.
|
|
30
|
+
*
|
|
31
|
+
* The mechanism is the oldest fix for the failure mode the current scorer has: every term is worth
|
|
32
|
+
* the same. `significantTerms` drops stop-words, but a corpus has its OWN ubiquitous vocabulary that
|
|
33
|
+
* no global stop-list knows — in this repository `spec`, `holmes` and `test` appear nearly
|
|
34
|
+
* everywhere, and matching one of them is close to no evidence at all. Precision, not recall, is
|
|
35
|
+
* what that costs, and precision is the axis Phase C targets.
|
|
36
|
+
*/
|
|
37
|
+
const tokensOf = (s) => new Set((0, localize_1.significantTerms)(s));
|
|
38
|
+
/** Tokens of a file as the ranker sees it: its symbol names and its path. */
|
|
39
|
+
function fileTokens(f) {
|
|
40
|
+
const out = tokensOf(f.sourcePath);
|
|
41
|
+
for (const sym of f.symbols)
|
|
42
|
+
for (const t of tokensOf(sym.qualifiedName))
|
|
43
|
+
out.add(t);
|
|
44
|
+
return out;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Inverse document frequency over the scanned corpus.
|
|
48
|
+
*
|
|
49
|
+
* Smoothed (`(N+1)/(df+1)`) and floored at zero: a term present in every file must be WEAK evidence,
|
|
50
|
+
* never negative evidence. Unsmoothed IDF would make it exactly 0 and let a file that matches only
|
|
51
|
+
* ubiquitous terms tie with a file that matches nothing, which reads as "no opinion" when the truth
|
|
52
|
+
* is "a little opinion".
|
|
53
|
+
*/
|
|
54
|
+
function corpusIdf(scanned) {
|
|
55
|
+
const df = new Map();
|
|
56
|
+
for (const f of scanned)
|
|
57
|
+
for (const t of fileTokens(f))
|
|
58
|
+
df.set(t, (df.get(t) ?? 0) + 1);
|
|
59
|
+
const n = scanned.length;
|
|
60
|
+
const idf = new Map();
|
|
61
|
+
for (const [t, d] of df)
|
|
62
|
+
idf.set(t, Math.max(Math.log((n + 1) / (d + 1)), 1e-6));
|
|
63
|
+
return idf;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Rank files by IDF-weighted term overlap.
|
|
67
|
+
*
|
|
68
|
+
* Symbol matches stay worth 3× a path match, as in the product's scorer — changing two things at
|
|
69
|
+
* once would leave the measurement unable to say which one moved. The only difference from the
|
|
70
|
+
* current arm is that each match is multiplied by how rare its term is in this corpus.
|
|
71
|
+
*/
|
|
72
|
+
function rankIdf(issueText, scanned, topN) {
|
|
73
|
+
const terms = new Set((0, localize_1.significantTerms)(issueText));
|
|
74
|
+
if (terms.size === 0 || scanned.length === 0)
|
|
75
|
+
return [];
|
|
76
|
+
const idf = corpusIdf(scanned);
|
|
77
|
+
const hits = [];
|
|
78
|
+
for (const f of scanned) {
|
|
79
|
+
let score = 0;
|
|
80
|
+
for (const sym of f.symbols) {
|
|
81
|
+
const st = tokensOf(sym.qualifiedName);
|
|
82
|
+
for (const t of terms)
|
|
83
|
+
if (st.has(t))
|
|
84
|
+
score += 3 * (idf.get(t) ?? 0);
|
|
85
|
+
}
|
|
86
|
+
const pt = tokensOf(f.sourcePath);
|
|
87
|
+
for (const t of terms)
|
|
88
|
+
if (pt.has(t))
|
|
89
|
+
score += idf.get(t) ?? 0;
|
|
90
|
+
if (score > 0)
|
|
91
|
+
hits.push({ file: f.sourcePath, score });
|
|
92
|
+
}
|
|
93
|
+
return hits.sort((a, b) => b.score - a.score || a.file.localeCompare(b.file)).slice(0, topN);
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* IDF, rescaled so the MEAN weight over the corpus vocabulary is 1.
|
|
97
|
+
*
|
|
98
|
+
* Without this the arm would not be a one-variable change. Raw IDF values sit well below 1 on a
|
|
99
|
+
* corpus of a few hundred files, so every lexical contribution shrinks at once while the spec-anchor
|
|
100
|
+
* bonus (+4/sqrt(anchors)) and the citation bonus (+20/sqrt(anchors)) keep their size — the arm would
|
|
101
|
+
* then be measuring how much the graph bonuses dominate, not how much term rarity helps. Rescaling
|
|
102
|
+
* keeps the lexical term's total magnitude comparable and changes only its DISTRIBUTION across
|
|
103
|
+
* terms, which is the hypothesis under test.
|
|
104
|
+
*
|
|
105
|
+
* An unseen term weighs exactly 1: a word absent from the corpus is not evidence against a file, and
|
|
106
|
+
* mapping it to 0 would silently delete the request's most distinctive vocabulary.
|
|
107
|
+
*/
|
|
108
|
+
function normalisedIdf(scanned) {
|
|
109
|
+
const idf = corpusIdf(scanned);
|
|
110
|
+
const vals = [...idf.values()];
|
|
111
|
+
if (vals.length === 0)
|
|
112
|
+
return idf;
|
|
113
|
+
const mean = vals.reduce((a, b) => a + b, 0) / vals.length;
|
|
114
|
+
if (mean <= 0)
|
|
115
|
+
return new Map([...idf.keys()].map((t) => [t, 1]));
|
|
116
|
+
const out = new Map();
|
|
117
|
+
for (const [t, v] of idf)
|
|
118
|
+
out.set(t, v / mean);
|
|
119
|
+
return out;
|
|
120
|
+
}
|
|
121
|
+
const CITATION_BONUS = 20;
|
|
122
|
+
const isVendorPath = (p) => /(^|\/)(reference|vendor|vendors|third_party|third-party|external)\//i.test(p);
|
|
123
|
+
/**
|
|
124
|
+
* The product's scorer with the term weight lifted out as a parameter.
|
|
125
|
+
*
|
|
126
|
+
* This mirrors `localizeIssue` deliberately and the mirror is load-bearing, so a test pins it: with
|
|
127
|
+
* `() => 1` this function must return the product's ranking and its scores EXACTLY. If someone
|
|
128
|
+
* changes the product's scoring and not this one, that test goes red rather than the replay quietly
|
|
129
|
+
* reporting a difference between rankers that is really a difference between vintages.
|
|
130
|
+
*/
|
|
131
|
+
function rankWeighted(issueText, scanned, specs, topN, weight,
|
|
132
|
+
/**
|
|
133
|
+
* @implements A-SPEC-352
|
|
134
|
+
* Weighted spec gate. Absent = the product's `overlap >= 2` count.
|
|
135
|
+
*
|
|
136
|
+
* Passed in here rather than reimplemented by the caller for a reason paid for three times: the
|
|
137
|
+
* first IDF arm hand-rolled a scorer and silently dropped the spec and citation bonuses; a probe
|
|
138
|
+
* of this very gate hand-rolled one again and dropped the citation path, whose +20 bonus is the
|
|
139
|
+
* largest single gain this repository has measured. Both produced confident, worthless numbers.
|
|
140
|
+
* Every arm goes through this function so the features it does NOT vary cannot go missing.
|
|
141
|
+
*/
|
|
142
|
+
gateThreshold) {
|
|
143
|
+
const terms = (0, localize_1.significantTerms)(issueText);
|
|
144
|
+
const citations = (0, localize_1.citationsIn)(issueText, new Set(specs.map((s) => s.id)));
|
|
145
|
+
const citedSet = new Set(citations.cited);
|
|
146
|
+
if (terms.length === 0 && citations.cited.length === 0)
|
|
147
|
+
return [];
|
|
148
|
+
const termSet = new Set(terms);
|
|
149
|
+
const matchedSpecs = gateThreshold === undefined
|
|
150
|
+
? (() => {
|
|
151
|
+
const out = [];
|
|
152
|
+
for (const s of specs) {
|
|
153
|
+
if (s.type !== 'A-SPEC')
|
|
154
|
+
continue;
|
|
155
|
+
const st = tokensOf(`${s.title} ${Object.values(s.sections).join(' ')}`);
|
|
156
|
+
let overlap = 0;
|
|
157
|
+
for (const t of termSet)
|
|
158
|
+
if (st.has(t))
|
|
159
|
+
overlap++;
|
|
160
|
+
if (overlap >= 2 || (overlap === 1 && termSet.size <= 3))
|
|
161
|
+
out.push(s.id);
|
|
162
|
+
}
|
|
163
|
+
return out;
|
|
164
|
+
})()
|
|
165
|
+
: matchedSpecsWeighted(issueText, specs, weight, gateThreshold);
|
|
166
|
+
for (const id of citations.cited)
|
|
167
|
+
if (!matchedSpecs.includes(id))
|
|
168
|
+
matchedSpecs.push(id);
|
|
169
|
+
const matchedSpecSet = new Set(matchedSpecs);
|
|
170
|
+
const hits = [];
|
|
171
|
+
for (const f of scanned) {
|
|
172
|
+
let score = 0;
|
|
173
|
+
for (const sym of f.symbols) {
|
|
174
|
+
const st = tokensOf(sym.qualifiedName);
|
|
175
|
+
const hit = [...termSet].filter((t) => st.has(t));
|
|
176
|
+
for (const t of hit)
|
|
177
|
+
score += 3 * weight(t);
|
|
178
|
+
}
|
|
179
|
+
const pt = tokensOf(f.sourcePath);
|
|
180
|
+
for (const t of termSet)
|
|
181
|
+
if (pt.has(t))
|
|
182
|
+
score += weight(t);
|
|
183
|
+
const anchors = [...new Set(f.implementsSpecs ?? [])];
|
|
184
|
+
const viaSpecs = anchors.filter((id) => matchedSpecSet.has(id));
|
|
185
|
+
if (viaSpecs.length > 0)
|
|
186
|
+
score += Math.round(4 * viaSpecs.length / Math.sqrt(anchors.length) * 100) / 100;
|
|
187
|
+
const citedAnchors = anchors.filter((id) => citedSet.has(id));
|
|
188
|
+
if (citedAnchors.length > 0) {
|
|
189
|
+
score += Math.round(CITATION_BONUS * citedAnchors.length / Math.sqrt(anchors.length) * 100) / 100;
|
|
190
|
+
}
|
|
191
|
+
if (isVendorPath(f.sourcePath) && score > 0)
|
|
192
|
+
score = score / 2;
|
|
193
|
+
if (score > 0)
|
|
194
|
+
hits.push({ file: f.sourcePath, score });
|
|
195
|
+
}
|
|
196
|
+
hits.sort((a, b) => Number(isVendorPath(a.file)) - Number(isVendorPath(b.file)) ||
|
|
197
|
+
b.score - a.score ||
|
|
198
|
+
a.file.localeCompare(b.file));
|
|
199
|
+
return hits.slice(0, topN);
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* The spec graph, undirected.
|
|
203
|
+
*
|
|
204
|
+
* `dependsOn` points from a child to its parent, so the semantically interesting neighbour — another
|
|
205
|
+
* A-SPEC under the same H-SPEC — is TWO hops away: up to the parent and back down. Walking the
|
|
206
|
+
* edges in one direction only would make siblings unreachable and the expansion would look useless
|
|
207
|
+
* for a reason that is about the walk, not about the idea.
|
|
208
|
+
*/
|
|
209
|
+
function specAdjacency(specs) {
|
|
210
|
+
const known = new Set(specs.map((s) => s.id));
|
|
211
|
+
const adj = new Map();
|
|
212
|
+
const link = (a, b) => {
|
|
213
|
+
(adj.get(a) ?? adj.set(a, []).get(a)).push(b);
|
|
214
|
+
(adj.get(b) ?? adj.set(b, []).get(b)).push(a);
|
|
215
|
+
};
|
|
216
|
+
for (const s of specs)
|
|
217
|
+
for (const p of s.dependsOn ?? [])
|
|
218
|
+
if (known.has(p))
|
|
219
|
+
link(s.id, p);
|
|
220
|
+
return adj;
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* Rank with GraphRAG-style expansion over the spec graph.
|
|
224
|
+
*
|
|
225
|
+
* The gap this addresses is the one graph retrieval exists for: the answer does not contain the
|
|
226
|
+
* request's words, but it is the neighbour of something that does. The lexical layer scores a file
|
|
227
|
+
* only if the request's terms reach the file or a spec it anchors — one hop. A sibling requirement,
|
|
228
|
+
* written in different words for the same feature, is invisible however closely it is related.
|
|
229
|
+
*
|
|
230
|
+
* Deliberately weaker than a hit at every hop: the bonus is the matched-spec bonus times `decay^k`.
|
|
231
|
+
* A neighbour is evidence, not an answer, and if decay ever let it outrank a direct match the
|
|
232
|
+
* retrieval would be trading the answer for its neighbourhood. A test pins that ordering.
|
|
233
|
+
*
|
|
234
|
+
* Everything else is `rankWeighted` unchanged — the one-variable rule from A-SPEC-349 applies here
|
|
235
|
+
* too, or a win could not be attributed to expansion rather than to weighting.
|
|
236
|
+
*/
|
|
237
|
+
function rankExpanded(issueText, scanned, specs, topN, weight, opts) {
|
|
238
|
+
const base = rankWeighted(issueText, scanned, specs, scanned.length, weight);
|
|
239
|
+
if (opts.hops <= 0 || opts.decay <= 0)
|
|
240
|
+
return base.slice(0, topN);
|
|
241
|
+
// Which specs the request matched — recomputed exactly as `rankWeighted` does, so the two agree.
|
|
242
|
+
const terms = new Set((0, localize_1.significantTerms)(issueText));
|
|
243
|
+
const citations = (0, localize_1.citationsIn)(issueText, new Set(specs.map((s) => s.id)));
|
|
244
|
+
const matched = new Set(citations.cited);
|
|
245
|
+
for (const s of specs) {
|
|
246
|
+
if (s.type !== 'A-SPEC')
|
|
247
|
+
continue;
|
|
248
|
+
const st = tokensOf(`${s.title} ${Object.values(s.sections).join(' ')}`);
|
|
249
|
+
let overlap = 0;
|
|
250
|
+
for (const t of terms)
|
|
251
|
+
if (st.has(t))
|
|
252
|
+
overlap++;
|
|
253
|
+
if (overlap >= 2 || (overlap === 1 && terms.size <= 3))
|
|
254
|
+
matched.add(s.id);
|
|
255
|
+
}
|
|
256
|
+
if (matched.size === 0)
|
|
257
|
+
return base.slice(0, topN);
|
|
258
|
+
const adj = specAdjacency(specs);
|
|
259
|
+
const hopOf = new Map([...matched].map((id) => [id, 0]));
|
|
260
|
+
let wave = [...matched];
|
|
261
|
+
for (let k = 1; k <= opts.hops && wave.length > 0; k++) {
|
|
262
|
+
const next = [];
|
|
263
|
+
for (const id of wave)
|
|
264
|
+
for (const n of adj.get(id) ?? []) {
|
|
265
|
+
if (hopOf.has(n))
|
|
266
|
+
continue; // first arrival wins: the shortest path is the strongest claim
|
|
267
|
+
hopOf.set(n, k);
|
|
268
|
+
next.push(n);
|
|
269
|
+
}
|
|
270
|
+
wave = next;
|
|
271
|
+
}
|
|
272
|
+
const score = new Map(base.map((h) => [h.file, h.score]));
|
|
273
|
+
for (const f of scanned) {
|
|
274
|
+
const anchors = [...new Set(f.implementsSpecs ?? [])];
|
|
275
|
+
if (anchors.length === 0)
|
|
276
|
+
continue; // expansion reaches files THROUGH specs; anchor or nothing
|
|
277
|
+
let bonus = 0;
|
|
278
|
+
for (const id of anchors) {
|
|
279
|
+
const k = hopOf.get(id);
|
|
280
|
+
if (k === undefined || k === 0)
|
|
281
|
+
continue; // hop 0 is already in the base score
|
|
282
|
+
bonus += 4 * Math.pow(opts.decay, k) / Math.sqrt(anchors.length);
|
|
283
|
+
}
|
|
284
|
+
if (bonus > 0)
|
|
285
|
+
score.set(f.sourcePath, (score.get(f.sourcePath) ?? 0) + Math.round(bonus * 100) / 100);
|
|
286
|
+
}
|
|
287
|
+
return [...score.entries()]
|
|
288
|
+
.filter(([, v]) => v > 0)
|
|
289
|
+
.map(([file, s]) => ({ file, score: s }))
|
|
290
|
+
.sort((a, b) => Number(isVendorPath(a.file)) - Number(isVendorPath(b.file)) ||
|
|
291
|
+
b.score - a.score ||
|
|
292
|
+
a.file.localeCompare(b.file))
|
|
293
|
+
.slice(0, topN);
|
|
294
|
+
}
|
|
295
|
+
/**
|
|
296
|
+
* Which A-SPECs the request is taken to have matched — the entry point into the knowledge graph.
|
|
297
|
+
*
|
|
298
|
+
* The product's rule counts overlapping terms: two shared words admit a spec, one does not (unless
|
|
299
|
+
* the request is very short). That treats a shared `handler` exactly like a shared `quarantine`, and
|
|
300
|
+
* it costs more here than it does in file scoring: an admitted spec sprays its bonus over EVERY file
|
|
301
|
+
* anchoring it, so one wrongly-opened gate promotes a whole group of unrelated files at once.
|
|
302
|
+
*
|
|
303
|
+
* Weighing the overlap instead of counting it means a request has to bring either several ordinary
|
|
304
|
+
* words or one genuinely distinctive one. With `weight = () => 1` and `threshold = 2` this is the
|
|
305
|
+
* product's original rule exactly, which is what makes any measured difference attributable.
|
|
306
|
+
*
|
|
307
|
+
* A-SPEC only: files anchor A-SPECs, so admitting any other kind can never pull a file through the
|
|
308
|
+
* hop and would only overstate what influenced the ranking.
|
|
309
|
+
*/
|
|
310
|
+
function matchedSpecsWeighted(issueText, specs, weight, threshold) {
|
|
311
|
+
const terms = new Set((0, localize_1.significantTerms)(issueText));
|
|
312
|
+
const out = [];
|
|
313
|
+
for (const s of specs) {
|
|
314
|
+
if (s.type !== 'A-SPEC')
|
|
315
|
+
continue;
|
|
316
|
+
const st = tokensOf(`${s.title} ${Object.values(s.sections).join(' ')}`);
|
|
317
|
+
let mass = 0;
|
|
318
|
+
for (const t of terms)
|
|
319
|
+
if (st.has(t))
|
|
320
|
+
mass += weight(t);
|
|
321
|
+
// The short-request escape hatch survives: a three-word request cannot reach a threshold built
|
|
322
|
+
// for prose, and dropping it would change two things at once.
|
|
323
|
+
if (mass >= threshold || (mass > 0 && terms.size <= 3))
|
|
324
|
+
out.push(s.id);
|
|
325
|
+
}
|
|
326
|
+
return out;
|
|
327
|
+
}
|
|
328
|
+
/**
|
|
329
|
+
* The spec bonus, damped by how much code the spec covers.
|
|
330
|
+
*
|
|
331
|
+
* The existing normalisation divides by how many anchors a FILE carries (A-SPEC-270) — it answers
|
|
332
|
+
* "how much of this file's identity is this spec?". It never asks the other question: "how much of
|
|
333
|
+
* this spec's territory is this file?". A spec spanning twenty files says little about any one of
|
|
334
|
+
* them; a spec covering one says a great deal, and today they pay the same +4.
|
|
335
|
+
*
|
|
336
|
+
* That asymmetry is invisible in a repository with hundreds of narrow specs and decisive in one with
|
|
337
|
+
* a few broad ones — which is exactly where the measured precision loss appeared (A-SPEC-362: on a
|
|
338
|
+
* 54-spec corpus the product loses Top-5 recall and Top-10 precision to plain word matching, and the
|
|
339
|
+
* weighting-only arm is indistinguishable from that baseline).
|
|
340
|
+
*
|
|
341
|
+
* sqrt, matching the existing normalisation's shape: a one-file spec is untaxed, a twenty-file spec
|
|
342
|
+
* is damped by ~4.5x rather than erased. Damping to zero would delete the very hop that carries all
|
|
343
|
+
* of this harness's measured advantage.
|
|
344
|
+
*/
|
|
345
|
+
function rankBreadthDamped(issueText, scanned, specs, topN, weight, gateThreshold) {
|
|
346
|
+
const filesPerSpec = new Map();
|
|
347
|
+
for (const f of scanned) {
|
|
348
|
+
for (const id of new Set(f.implementsSpecs ?? []))
|
|
349
|
+
filesPerSpec.set(id, (filesPerSpec.get(id) ?? 0) + 1);
|
|
350
|
+
}
|
|
351
|
+
const base = rankWeighted(issueText, scanned, specs, scanned.length, weight, gateThreshold);
|
|
352
|
+
const byFile = new Map(scanned.map((f) => [f.sourcePath, f]));
|
|
353
|
+
const adjusted = base.map((hit) => {
|
|
354
|
+
const f = byFile.get(hit.file);
|
|
355
|
+
const anchors = [...new Set(f?.implementsSpecs ?? [])];
|
|
356
|
+
if (anchors.length === 0)
|
|
357
|
+
return hit;
|
|
358
|
+
// Recover the spec component and re-pay it damped, leaving the lexical part untouched: the arm
|
|
359
|
+
// must differ from the product in this one term only.
|
|
360
|
+
let credited = 0, damped = 0;
|
|
361
|
+
for (const id of anchors) {
|
|
362
|
+
const breadth = filesPerSpec.get(id) ?? 1;
|
|
363
|
+
const share = 4 / Math.sqrt(anchors.length);
|
|
364
|
+
credited += share;
|
|
365
|
+
damped += share / Math.sqrt(breadth);
|
|
366
|
+
}
|
|
367
|
+
// `credited` overstates when the gate admitted only some anchors, so scale by what the base
|
|
368
|
+
// actually paid rather than subtracting a number it may never have added.
|
|
369
|
+
const paid = Math.min(credited, hit.score);
|
|
370
|
+
return { file: hit.file, score: hit.score - paid + paid * (credited === 0 ? 1 : damped / credited) };
|
|
371
|
+
});
|
|
372
|
+
return adjusted
|
|
373
|
+
.filter((h) => h.score > 0)
|
|
374
|
+
.sort((a, b) => Number(isVendorPath(a.file)) - Number(isVendorPath(b.file)) ||
|
|
375
|
+
b.score - a.score ||
|
|
376
|
+
a.file.localeCompare(b.file))
|
|
377
|
+
.slice(0, topN);
|
|
378
|
+
}
|
|
379
|
+
/**
|
|
380
|
+
* How much of the corpus carries an `@implements` anchor at all.
|
|
381
|
+
*
|
|
382
|
+
* The spec layer's evidence is only representative when a decent share of the tree is governed. If
|
|
383
|
+
* five files in a thousand carry anchors, matching one of their specs says something about those
|
|
384
|
+
* five and nothing about the other nine hundred and ninety-five — yet the bonus is paid at full
|
|
385
|
+
* strength and displaces lexically correct candidates.
|
|
386
|
+
*/
|
|
387
|
+
function anchorCoverage(scanned, specs = []) {
|
|
388
|
+
if (scanned.length === 0)
|
|
389
|
+
return 0;
|
|
390
|
+
// RESOLVED anchors, not merely present ones. The first version of this counted files carrying any
|
|
391
|
+
// `@implements` marker and returned exactly 1.000 on both corpora — a metric that separated
|
|
392
|
+
// nothing, and the two arms it fed were byte-identical at every spec density. Thinning the spec
|
|
393
|
+
// store does not remove the markers in the code; it strands them. A marker pointing at a spec that
|
|
394
|
+
// is not in the store is precisely the sparse-layer condition, so it must count as UNCOVERED.
|
|
395
|
+
const known = new Set(specs.map((s) => s.id));
|
|
396
|
+
if (known.size === 0)
|
|
397
|
+
return 0;
|
|
398
|
+
return scanned.filter((f) => (f.implementsSpecs ?? []).some((id) => known.has(id))).length / scanned.length;
|
|
399
|
+
}
|
|
400
|
+
/**
|
|
401
|
+
* The spec bonus scaled by how much of the tree the spec layer actually covers.
|
|
402
|
+
*
|
|
403
|
+
* Measured on one repository with only the spec count varied (A-SPEC-364): 585 specs gave Top-10
|
|
404
|
+
* recall 0.770, but 146 gave 0.253 and 59 gave 0.264 — BELOW the 0.305 of no spec layer at all. A
|
|
405
|
+
* partly populated spec store is worse than an empty one, and every project that adopts Holmes
|
|
406
|
+
* passes through that valley on its way to the peak. jarvis, at 54 specs, is sitting in it.
|
|
407
|
+
*
|
|
408
|
+
* Scaling by coverage makes the layer fade in rather than switch on: at zero coverage the ranking is
|
|
409
|
+
* exactly the lexical arm (the floor a project starts from), and at full coverage the bonus is
|
|
410
|
+
* unchanged (the peak this repository measures). No threshold, because a threshold would create a
|
|
411
|
+
* cliff where authoring one more spec changes every ranking at once.
|
|
412
|
+
*/
|
|
413
|
+
function rankCoverageAware(issueText, scanned, specs, topN, weight, gateThreshold) {
|
|
414
|
+
const coverage = anchorCoverage(scanned, specs);
|
|
415
|
+
const withSpecs = rankWeighted(issueText, scanned, specs, scanned.length, weight, gateThreshold);
|
|
416
|
+
if (coverage >= 1)
|
|
417
|
+
return withSpecs.slice(0, topN);
|
|
418
|
+
const withoutSpecs = new Map(rankWeighted(issueText, scanned, [], scanned.length, weight, gateThreshold).map((h) => [h.file, h.score]));
|
|
419
|
+
// Interpolate between the two arms rather than rescaling the bonus in place: the spec layer also
|
|
420
|
+
// decides WHICH specs matched and which files were admitted, and a bonus-only adjustment would
|
|
421
|
+
// leave those effects at full strength while claiming to have damped them.
|
|
422
|
+
const merged = new Map();
|
|
423
|
+
for (const h of withSpecs) {
|
|
424
|
+
const lexical = withoutSpecs.get(h.file) ?? 0;
|
|
425
|
+
merged.set(h.file, lexical + (h.score - lexical) * coverage);
|
|
426
|
+
}
|
|
427
|
+
for (const [file, score] of withoutSpecs)
|
|
428
|
+
if (!merged.has(file))
|
|
429
|
+
merged.set(file, score);
|
|
430
|
+
return [...merged.entries()]
|
|
431
|
+
.filter(([, v]) => v > 0)
|
|
432
|
+
.map(([file, score]) => ({ file, score }))
|
|
433
|
+
.sort((a, b) => Number(isVendorPath(a.file)) - Number(isVendorPath(b.file)) ||
|
|
434
|
+
b.score - a.score ||
|
|
435
|
+
a.file.localeCompare(b.file))
|
|
436
|
+
.slice(0, topN);
|
|
437
|
+
}
|
|
438
|
+
/**
|
|
439
|
+
* The gate, chosen by whether the request names a spec at all.
|
|
440
|
+
*
|
|
441
|
+
* No single threshold serves both conditions. Measured over 60 point-in-time cases (A-SPEC-369):
|
|
442
|
+
*
|
|
443
|
+
* threshold 1 — cited Top-10 recall 0.770, uncited 0.275 (a no-graph baseline gets 0.357)
|
|
444
|
+
* threshold 4 — cited 0.743 and Top-1 0.333 → 0.283, uncited 0.349
|
|
445
|
+
*
|
|
446
|
+
* Under a citation, the named spec locates the request in the corpus, and the specs that merely
|
|
447
|
+
* share vocabulary with it are corroborated rather than speculative — the permissive gate pays. With
|
|
448
|
+
* no citation, those same matches are twenty-six unanchored guesses per case whose bonuses bury the
|
|
449
|
+
* lexical signal, and the strict gate keeps them out.
|
|
450
|
+
*
|
|
451
|
+
* The condition is read from the request itself, and only a citation the store can RESOLVE counts:
|
|
452
|
+
* a stray `A-SPEC-999` in a bug report must not unlock the permissive path.
|
|
453
|
+
*/
|
|
454
|
+
function rankCitationConditioned(issueText, scanned, specs, topN, weight, citedThreshold, uncitedThreshold) {
|
|
455
|
+
const cited = (0, localize_1.citationsIn)(issueText, new Set(specs.map((s) => s.id))).cited.length > 0;
|
|
456
|
+
return rankWeighted(issueText, scanned, specs, topN, weight, cited ? citedThreshold : uncitedThreshold);
|
|
457
|
+
}
|
|
458
|
+
/**
|
|
459
|
+
* Drop the lexically-matched specs when too many of them match.
|
|
460
|
+
*
|
|
461
|
+
* Conditioning on whether the request cited a spec repaired this repository and cost the second
|
|
462
|
+
* corpus its lead, because that corpus cites nothing and would always take the strict path. Both
|
|
463
|
+
* measurements point at the same underlying quantity: HOW MANY specs the request matched. jarvis
|
|
464
|
+
* matches 0.87 per case, this repository 26.6 (A-SPEC-367).
|
|
465
|
+
*
|
|
466
|
+
* One matching spec is a claim about where the request belongs. Twenty-six matching specs is a fact
|
|
467
|
+
* about the request's vocabulary — it was written in words the corpus uses everywhere — and their
|
|
468
|
+
* bonuses, spread over a hundred anchored files, bury the lexical ranking that would otherwise work.
|
|
469
|
+
*
|
|
470
|
+
* A citation is never capped: it names something rather than resembling it, and it is the one signal
|
|
471
|
+
* in this scorer measured to carry the localization result.
|
|
472
|
+
*/
|
|
473
|
+
function rankCapped(issueText, scanned, specs, topN, weight, gateThreshold, maxMatchedSpecs) {
|
|
474
|
+
const known = new Set(specs.map((s) => s.id));
|
|
475
|
+
const cited = new Set((0, localize_1.citationsIn)(issueText, known).cited);
|
|
476
|
+
const lexical = matchedSpecsWeighted(issueText, specs, weight, gateThreshold)
|
|
477
|
+
.filter((id) => !cited.has(id));
|
|
478
|
+
if (lexical.length <= maxMatchedSpecs)
|
|
479
|
+
return rankWeighted(issueText, scanned, specs, topN, weight, gateThreshold);
|
|
480
|
+
// Over the cap: keep only the specs the request NAMED. Passing just those reproduces the citation
|
|
481
|
+
// path exactly, with no lexical spec matching left to admit anything else.
|
|
482
|
+
const citedSpecs = specs.filter((s) => cited.has(s.id));
|
|
483
|
+
return rankWeighted(issueText, scanned, citedSpecs, topN, weight, gateThreshold);
|
|
484
|
+
}
|
|
485
|
+
/**
|
|
486
|
+
* The cap, applied only to requests that named nothing.
|
|
487
|
+
*
|
|
488
|
+
* The two halves the measurements each found: a citation corroborates the specs that merely share
|
|
489
|
+
* vocabulary with the request, so with one present they help (dropping them cost Top-10 recall
|
|
490
|
+
* 0.770 → 0.743). Without one, a large number of matches is a fact about the request's wording
|
|
491
|
+
* rather than evidence, and their bonuses bury the lexical ranking (0.275 against a 0.357 baseline).
|
|
492
|
+
* A SMALL number of matches with no citation is still meaningful — that is the second corpus, at
|
|
493
|
+
* 0.87 matches per case, and capping it there threw away the modest lead it had.
|
|
494
|
+
*
|
|
495
|
+
* So: exempt cited requests, and cap only the uncited ones that matched more specs than a request
|
|
496
|
+
* can plausibly be about.
|
|
497
|
+
*/
|
|
498
|
+
function rankUncitedCapped(issueText, scanned, specs, topN, weight, gateThreshold, maxMatchedSpecs) {
|
|
499
|
+
const cited = (0, localize_1.citationsIn)(issueText, new Set(specs.map((s) => s.id))).cited.length > 0;
|
|
500
|
+
if (cited)
|
|
501
|
+
return rankWeighted(issueText, scanned, specs, topN, weight, gateThreshold);
|
|
502
|
+
return rankCapped(issueText, scanned, specs, topN, weight, gateThreshold, maxMatchedSpecs);
|
|
503
|
+
}
|
|
504
|
+
/**
|
|
505
|
+
* One `calls` hop out from the lexical hits, added at a discount.
|
|
506
|
+
*
|
|
507
|
+
* With no citation the spec layer is off and the ranking is exactly what grepping the request's
|
|
508
|
+
* words would produce. The call graph is the only structure left that could beat it, and A-SPEC-318
|
|
509
|
+
* measured the opportunity: 13 missed files one `calls` hop away, all 58 hop edges being calls, none
|
|
510
|
+
* imports or inherits. That was measured under cited requests, where the spec layer had already
|
|
511
|
+
* found most of the answer — uncited, the lexical hits are the only seed, so the hop has more to add.
|
|
512
|
+
*
|
|
513
|
+
* Half the seed's score, and never more: a callee is evidence about the seed's neighbourhood, not
|
|
514
|
+
* about itself. If a hop could outrank a direct hit, the ranking would be trading the answer for its
|
|
515
|
+
* surroundings, which is the failure the spec-graph expansion arm was rejected for.
|
|
516
|
+
*/
|
|
517
|
+
function rankWithCallHop(issueText, scanned, specs, topN, weight, gateThreshold, maxMatchedSpecs,
|
|
518
|
+
/** @implements A-SPEC-394 — use import edges to bind names the global map calls ambiguous. */
|
|
519
|
+
importAware = false) {
|
|
520
|
+
const base = rankUncitedCapped(issueText, scanned, specs, scanned.length, weight, gateThreshold, maxMatchedSpecs);
|
|
521
|
+
if (base.length === 0)
|
|
522
|
+
return [];
|
|
523
|
+
// Symbol -> the file declaring it. A name declared in two files is ambiguous and is not followed:
|
|
524
|
+
// a wrong hop is a confident false positive, a missing one is a disclosed gap.
|
|
525
|
+
const declaredIn = new Map();
|
|
526
|
+
for (const f of scanned) {
|
|
527
|
+
for (const sym of f.symbols) {
|
|
528
|
+
const bare = sym.qualifiedName.includes('.')
|
|
529
|
+
? sym.qualifiedName.slice(sym.qualifiedName.lastIndexOf('.') + 1) : sym.qualifiedName;
|
|
530
|
+
for (const key of new Set([sym.qualifiedName, bare])) {
|
|
531
|
+
declaredIn.set(key, declaredIn.has(key) && declaredIn.get(key) !== f.sourcePath ? null : f.sourcePath);
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
// @implements A-SPEC-394
|
|
536
|
+
// Per-file disambiguation from imports (A-SPEC-393). The global map above discards a name declared
|
|
537
|
+
// twice; here a file that imports exactly one of the declaring modules binds to that one. This is
|
|
538
|
+
// the SAME hop as before on a BETTER edge set — a re-test of a rejected mechanism under a changed
|
|
539
|
+
// premise, not a new mechanism.
|
|
540
|
+
const declaredEverywhere = new Map();
|
|
541
|
+
for (const f of scanned) {
|
|
542
|
+
for (const sym of f.symbols) {
|
|
543
|
+
const bare = sym.qualifiedName.includes('.')
|
|
544
|
+
? sym.qualifiedName.slice(sym.qualifiedName.lastIndexOf('.') + 1) : sym.qualifiedName;
|
|
545
|
+
for (const key of new Set([sym.qualifiedName, bare])) {
|
|
546
|
+
(declaredEverywhere.get(key) ?? declaredEverywhere.set(key, new Set()).get(key)).add(f.sourcePath);
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
const knownPaths = new Set(scanned.map((f) => f.sourcePath));
|
|
551
|
+
const importsOf = new Map();
|
|
552
|
+
if (importAware) {
|
|
553
|
+
for (const f of scanned) {
|
|
554
|
+
const set = new Set();
|
|
555
|
+
for (const e of f.edges ?? []) {
|
|
556
|
+
if (e.rel !== 'imports')
|
|
557
|
+
continue;
|
|
558
|
+
const target = (0, edge_quality_1.resolveImport)(f.sourcePath, e.to, knownPaths);
|
|
559
|
+
if (target)
|
|
560
|
+
set.add(target);
|
|
561
|
+
}
|
|
562
|
+
importsOf.set(f.sourcePath, set);
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
const resolveFrom = (fromFile, name) => {
|
|
566
|
+
const direct = declaredIn.get(name);
|
|
567
|
+
if (direct)
|
|
568
|
+
return direct;
|
|
569
|
+
if (!importAware)
|
|
570
|
+
return null;
|
|
571
|
+
const all = declaredEverywhere.get(name);
|
|
572
|
+
if (!all || all.size < 2)
|
|
573
|
+
return null;
|
|
574
|
+
const imported = [...all].filter((t) => importsOf.get(fromFile)?.has(t));
|
|
575
|
+
return imported.length === 1 ? imported[0] : null;
|
|
576
|
+
};
|
|
577
|
+
const score = new Map(base.map((h) => [h.file, h.score]));
|
|
578
|
+
const byPath = new Map(scanned.map((f) => [f.sourcePath, f]));
|
|
579
|
+
for (const hit of base) {
|
|
580
|
+
const f = byPath.get(hit.file);
|
|
581
|
+
for (const e of f?.edges ?? []) {
|
|
582
|
+
if (e.rel !== 'calls')
|
|
583
|
+
continue;
|
|
584
|
+
const target = resolveFrom(hit.file, e.to);
|
|
585
|
+
if (!target || target === hit.file)
|
|
586
|
+
continue;
|
|
587
|
+
const bonus = hit.score / 2;
|
|
588
|
+
if ((score.get(target) ?? 0) < bonus)
|
|
589
|
+
score.set(target, bonus);
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
return [...score.entries()]
|
|
593
|
+
.filter(([, v]) => v > 0)
|
|
594
|
+
.map(([file, s]) => ({ file, score: s }))
|
|
595
|
+
.sort((a, b) => Number(isVendorPath(a.file)) - Number(isVendorPath(b.file)) ||
|
|
596
|
+
b.score - a.score ||
|
|
597
|
+
a.file.localeCompare(b.file))
|
|
598
|
+
.slice(0, topN);
|
|
599
|
+
}
|
|
600
|
+
function ablate(issueText, scanned, specs, topN, opts) {
|
|
601
|
+
const weights = opts.idf ? normalisedIdf(scanned) : null;
|
|
602
|
+
const weight = (t) => (weights ? (weights.get(t) ?? 1) : 1);
|
|
603
|
+
const hits = rankWeighted(issueText, scanned, opts.specBonus ? specs : [], scanned.length, weight, 1);
|
|
604
|
+
const adjusted = opts.vendorDemotion
|
|
605
|
+
? hits
|
|
606
|
+
// The demotion is applied inside `rankWeighted`; undoing it is a doubling, which restores the
|
|
607
|
+
// pre-demotion score exactly because the demotion is a halving.
|
|
608
|
+
: hits.map((h) => ({ file: h.file, score: isVendorPath(h.file) ? h.score * 2 : h.score }));
|
|
609
|
+
return [...adjusted]
|
|
610
|
+
.sort((a, b) => (opts.vendorDemotion ? Number(isVendorPath(a.file)) - Number(isVendorPath(b.file)) : 0) ||
|
|
611
|
+
b.score - a.score ||
|
|
612
|
+
a.file.localeCompare(b.file))
|
|
613
|
+
.slice(0, topN);
|
|
614
|
+
}
|
|
615
|
+
/**
|
|
616
|
+
* @implements A-SPEC-396
|
|
617
|
+
* Limit how many files each matched spec may lift.
|
|
618
|
+
*
|
|
619
|
+
* The ablation isolated the cost: turning the spec bonus off restores the baseline exactly, so that
|
|
620
|
+
* layer buys Top-1 (0.244 → 0.267 here, 0.400 → 0.433 on jarvis) and sells precision (0.156 → 0.104).
|
|
621
|
+
*
|
|
622
|
+
* Three earlier attempts all changed WHICH specs are admitted — breadth damping, coverage scaling,
|
|
623
|
+
* a cap on matched specs — and all three were rejected. This changes something else: once a spec is
|
|
624
|
+
* admitted, every file anchoring it rises together, and that simultaneity is the shape of the
|
|
625
|
+
* precision loss. Ranking a spec's anchored files by their own lexical evidence and lifting only the
|
|
626
|
+
* strongest few keeps the top of the list while leaving the tail alone.
|
|
627
|
+
*
|
|
628
|
+
* The cost is explicit: a file with no lexical evidence can no longer be lifted by its spec alone,
|
|
629
|
+
* so the layer loses the files it uniquely contributed. Whether that trade is positive is the
|
|
630
|
+
* measurement, not the assumption.
|
|
631
|
+
*/
|
|
632
|
+
function rankSpecLiftLimited(issueText, scanned, specs, topN, weight, gateThreshold, liftPerSpec) {
|
|
633
|
+
const withSpecs = rankWeighted(issueText, scanned, specs, scanned.length, weight, gateThreshold);
|
|
634
|
+
const lexicalOnly = new Map(rankWeighted(issueText, scanned, [], scanned.length, weight, gateThreshold).map((h) => [h.file, h.score]));
|
|
635
|
+
const known = new Set(specs.map((s) => s.id));
|
|
636
|
+
const cited = new Set((0, localize_1.citationsIn)(issueText, known).cited);
|
|
637
|
+
const admitted = new Set([...cited, ...matchedSpecsWeighted(issueText, specs, weight, gateThreshold)]);
|
|
638
|
+
// For each admitted spec, keep only its strongest anchored files by LEXICAL score. Ties break on
|
|
639
|
+
// path so the choice is reproducible rather than dependent on scan order.
|
|
640
|
+
const allowed = new Set();
|
|
641
|
+
for (const id of admitted) {
|
|
642
|
+
const anchored = scanned
|
|
643
|
+
.filter((f) => (f.implementsSpecs ?? []).includes(id))
|
|
644
|
+
.map((f) => ({ file: f.sourcePath, lex: lexicalOnly.get(f.sourcePath) ?? 0 }))
|
|
645
|
+
.sort((a, b) => b.lex - a.lex || a.file.localeCompare(b.file))
|
|
646
|
+
.slice(0, Math.max(0, liftPerSpec));
|
|
647
|
+
for (const a of anchored)
|
|
648
|
+
allowed.add(a.file);
|
|
649
|
+
}
|
|
650
|
+
return withSpecs
|
|
651
|
+
.map((h) => ({ file: h.file, score: allowed.has(h.file) ? h.score : (lexicalOnly.get(h.file) ?? 0) }))
|
|
652
|
+
.filter((h) => h.score > 0)
|
|
653
|
+
.sort((a, b) => Number(isVendorPath(a.file)) - Number(isVendorPath(b.file)) ||
|
|
654
|
+
b.score - a.score ||
|
|
655
|
+
a.file.localeCompare(b.file))
|
|
656
|
+
.slice(0, topN);
|
|
657
|
+
}
|
|
658
|
+
/**
|
|
659
|
+
* @implements A-SPEC-397
|
|
660
|
+
* The spec bonus with its magnitude as a parameter.
|
|
661
|
+
*
|
|
662
|
+
* Four arms failed to recover the 0.052 of precision the layer costs, and each changed a different
|
|
663
|
+
* thing: which specs are admitted (breadth damping, coverage scaling, a cap on matches) and how far
|
|
664
|
+
* an admitted one reaches (the lift limit). Magnitude was never touched.
|
|
665
|
+
*
|
|
666
|
+
* The default is 4 per anchor against a lexical symbol hit worth 3 and a path hit worth 1, so a
|
|
667
|
+
* single matched spec outweighs a symbol match — a file whose only evidence is one anchor can
|
|
668
|
+
* outrank a file whose name actually contains the request's words. That is a plausible source of the
|
|
669
|
+
* tail pollution and it is the last untried one.
|
|
670
|
+
*
|
|
671
|
+
* Reducing it should cost Top-1 as it recovers precision, so both are reported: a setting that fixes
|
|
672
|
+
* one by destroying the other is not a fix.
|
|
673
|
+
*/
|
|
674
|
+
function rankWithBonusScale(issueText, scanned, specs, topN, weight, gateThreshold, bonusScale) {
|
|
675
|
+
const withSpecs = rankWeighted(issueText, scanned, specs, scanned.length, weight, gateThreshold);
|
|
676
|
+
if (bonusScale === 1)
|
|
677
|
+
return withSpecs.slice(0, topN);
|
|
678
|
+
const lexical = new Map(rankWeighted(issueText, scanned, [], scanned.length, weight, gateThreshold).map((h) => [h.file, h.score]));
|
|
679
|
+
// Scale only the spec-derived part: the difference between the two arms IS the bonus, so this
|
|
680
|
+
// needs no assumption about how the bonus was computed and cannot drift from it.
|
|
681
|
+
return withSpecs
|
|
682
|
+
.map((h) => {
|
|
683
|
+
const lex = lexical.get(h.file) ?? 0;
|
|
684
|
+
return { file: h.file, score: lex + (h.score - lex) * bonusScale };
|
|
685
|
+
})
|
|
686
|
+
.filter((h) => h.score > 0)
|
|
687
|
+
.sort((a, b) => Number(isVendorPath(a.file)) - Number(isVendorPath(b.file)) ||
|
|
688
|
+
b.score - a.score ||
|
|
689
|
+
a.file.localeCompare(b.file))
|
|
690
|
+
.slice(0, topN);
|
|
691
|
+
}
|
|
692
|
+
/**
|
|
693
|
+
* @implements A-SPEC-398
|
|
694
|
+
* The spec bonus may REORDER, but may not ADMIT.
|
|
695
|
+
*
|
|
696
|
+
* Five arms failed to recover the 0.052 of precision the spec layer costs, and counting the lists
|
|
697
|
+
* showed why none of them could: the baseline returns 7.09 files per case and the product returns
|
|
698
|
+
* 9.27, filling all ten slots in 36 of 45 cases against the baseline's 16. The layer is not ranking
|
|
699
|
+
* the same set differently — it is **adding about two files per case**, and those additions are
|
|
700
|
+
* mostly wrong. Every arm so far reordered or rescaled; none declined to add.
|
|
701
|
+
*
|
|
702
|
+
* So: a file with lexical evidence keeps its bonus and can move up. A file whose only evidence is an
|
|
703
|
+
* anchor does not enter the list at all.
|
|
704
|
+
*
|
|
705
|
+
* The cost is the one thing the spec layer uniquely did — reaching a file the request's words never
|
|
706
|
+
* touch. That is a real loss and the measurement has to weigh it, not assume it away.
|
|
707
|
+
*/
|
|
708
|
+
function rankSpecReorderOnly(issueText, scanned, specs, topN, weight, gateThreshold) {
|
|
709
|
+
const withSpecs = rankWeighted(issueText, scanned, specs, scanned.length, weight, gateThreshold);
|
|
710
|
+
const lexical = new Map(rankWeighted(issueText, scanned, [], scanned.length, weight, gateThreshold).map((h) => [h.file, h.score]));
|
|
711
|
+
return withSpecs
|
|
712
|
+
.filter((h) => (lexical.get(h.file) ?? 0) > 0) // admitted by words, reordered by specs
|
|
713
|
+
.sort((a, b) => Number(isVendorPath(a.file)) - Number(isVendorPath(b.file)) ||
|
|
714
|
+
b.score - a.score ||
|
|
715
|
+
a.file.localeCompare(b.file))
|
|
716
|
+
.slice(0, topN);
|
|
717
|
+
}
|