@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,55 @@
|
|
|
1
|
+
import type { Spec } from '../spec/spec-parser';
|
|
2
|
+
/**
|
|
3
|
+
* One token, reduced to the form two spellings of the same word share.
|
|
4
|
+
*
|
|
5
|
+
* Suffix stripping is conservative and applied once — an aggressive stemmer merges words that are
|
|
6
|
+
* genuinely different (`parse`/`parsing` should merge, `general`/`generate` must not), and a wrong
|
|
7
|
+
* merge is a false positive nobody can see in the output.
|
|
8
|
+
*/
|
|
9
|
+
export declare function normaliseTerm(term: string): string;
|
|
10
|
+
export declare const normalisedTerms: (text: string) => string[];
|
|
11
|
+
export interface RankedSpec {
|
|
12
|
+
id: string;
|
|
13
|
+
score: number;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* BM25 over A-SPEC documents.
|
|
17
|
+
*
|
|
18
|
+
* `k1` bounds how much repetition can add and `b` controls the length correction — the standard
|
|
19
|
+
* 1.2/0.75, not tuned here, because tuning two constants on sixty cases fits noise and this arm has
|
|
20
|
+
* to answer a yes-or-no question about the idea rather than squeeze the last point out of it.
|
|
21
|
+
*
|
|
22
|
+
* A-SPEC only, as everywhere else: files anchor A-SPECs, so no other kind can pull a file through.
|
|
23
|
+
*/
|
|
24
|
+
export declare function retrieveSpecs(issueText: string, specs: readonly Spec[], topK: number): RankedSpec[];
|
|
25
|
+
/**
|
|
26
|
+
* @implements A-SPEC-380
|
|
27
|
+
* The query, expanded with the vocabulary of the specs that the first search's best files anchor.
|
|
28
|
+
*
|
|
29
|
+
* Six mechanisms have been measured against uncited requests and none of them used the graph
|
|
30
|
+
* ACTIVELY: every one added a graph-derived score to a single retrieval pass. This is the other
|
|
31
|
+
* structure — retrieve, traverse, expand, retrieve again — which information retrieval calls
|
|
32
|
+
* pseudo-relevance feedback, and it is the only principled way a graph can close the gap between the
|
|
33
|
+
* words a reporter writes and the names the code uses. A bug report says "uploads are being rejected";
|
|
34
|
+
* the spec those files anchor says "quarantine of malformed payloads"; the code is named
|
|
35
|
+
* `sanitizeIngest`. No amount of matching the request against the code bridges that. Going through
|
|
36
|
+
* the spec does.
|
|
37
|
+
*
|
|
38
|
+
* Expansion terms are weighted BELOW the original query, and the reason is the failure mode: if the
|
|
39
|
+
* first pass ranked the wrong file, its spec's vocabulary is the wrong vocabulary, and an
|
|
40
|
+
* over-weighted expansion turns one mistake into a whole ranking of them.
|
|
41
|
+
*/
|
|
42
|
+
export interface ExpandedQuery {
|
|
43
|
+
/** Original request terms, normalised. */
|
|
44
|
+
base: string[];
|
|
45
|
+
/** Terms contributed by the specs reached through the top files, with their weight. */
|
|
46
|
+
expansion: Map<string, number>;
|
|
47
|
+
}
|
|
48
|
+
export declare function expandThroughGraph(issueText: string, topFiles: readonly {
|
|
49
|
+
file: string;
|
|
50
|
+
anchors: readonly string[];
|
|
51
|
+
}[], specs: readonly Spec[], opts: {
|
|
52
|
+
seedFiles: number;
|
|
53
|
+
termsPerSpec: number;
|
|
54
|
+
weight: number;
|
|
55
|
+
}): ExpandedQuery;
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.normalisedTerms = void 0;
|
|
4
|
+
exports.normaliseTerm = normaliseTerm;
|
|
5
|
+
exports.retrieveSpecs = retrieveSpecs;
|
|
6
|
+
exports.expandThroughGraph = expandThroughGraph;
|
|
7
|
+
const localize_1 = require("../rtm/localize");
|
|
8
|
+
/**
|
|
9
|
+
* Ranked retrieval of specs for a request, instead of counting exact token matches.
|
|
10
|
+
*
|
|
11
|
+
* Five mechanisms have been measured against uncited requests and all five were negative — and all
|
|
12
|
+
* five were about TRAVERSAL: which neighbours to follow, how much bonus to pay, when to stop. The
|
|
13
|
+
* step none of them touched is the one that decides whether the request and a spec are about the
|
|
14
|
+
* same thing at all, and that step is still exact string equality over a bag of words.
|
|
15
|
+
*
|
|
16
|
+
* So `quarantine` and `quarantining` are different subjects, `config` and `cfg` are different
|
|
17
|
+
* subjects, and a two-line spec is compared to a request on the same footing as a forty-line one.
|
|
18
|
+
* That is string matching wearing the word "semantic".
|
|
19
|
+
*
|
|
20
|
+
* What replaces it here is the strongest thing available with no model and no network: BM25 with
|
|
21
|
+
* morphological normalisation. It is not embeddings, and calling it semantic search would overstate
|
|
22
|
+
* it — but it does handle inflection, the abbreviations this corpus actually uses, and document
|
|
23
|
+
* length, none of which the previous rule could.
|
|
24
|
+
*/
|
|
25
|
+
/**
|
|
26
|
+
* Abbreviations that genuinely occur in these corpora, checked rather than imagined.
|
|
27
|
+
*
|
|
28
|
+
* Deliberately short: every entry is a claim that two different words mean the same thing, and a
|
|
29
|
+
* wrong one silently merges unrelated subjects. Anything not observed here stays out.
|
|
30
|
+
*/
|
|
31
|
+
const ABBREVIATIONS = new Map(Object.entries({
|
|
32
|
+
cfg: 'config', config: 'config', configuration: 'config',
|
|
33
|
+
auth: 'auth', authentication: 'auth', authorization: 'auth',
|
|
34
|
+
msg: 'message', message: 'message',
|
|
35
|
+
req: 'request', request: 'request', requirement: 'requirement',
|
|
36
|
+
res: 'response', resp: 'response', response: 'response',
|
|
37
|
+
err: 'error', error: 'error',
|
|
38
|
+
init: 'init', initialize: 'init', initialise: 'init', initialization: 'init',
|
|
39
|
+
impl: 'impl', implement: 'impl', implementation: 'impl', implements: 'impl',
|
|
40
|
+
spec: 'spec', specification: 'spec',
|
|
41
|
+
doc: 'doc', docs: 'doc', documentation: 'doc',
|
|
42
|
+
repo: 'repo', repository: 'repo',
|
|
43
|
+
db: 'db', database: 'db',
|
|
44
|
+
}));
|
|
45
|
+
/**
|
|
46
|
+
* One token, reduced to the form two spellings of the same word share.
|
|
47
|
+
*
|
|
48
|
+
* Suffix stripping is conservative and applied once — an aggressive stemmer merges words that are
|
|
49
|
+
* genuinely different (`parse`/`parsing` should merge, `general`/`generate` must not), and a wrong
|
|
50
|
+
* merge is a false positive nobody can see in the output.
|
|
51
|
+
*/
|
|
52
|
+
function normaliseTerm(term) {
|
|
53
|
+
const abbreviated = ABBREVIATIONS.get(term);
|
|
54
|
+
if (abbreviated)
|
|
55
|
+
return abbreviated;
|
|
56
|
+
let t = term;
|
|
57
|
+
// `ers` is deliberately absent: it would take `handlers` to `handl` while `handler` stays whole,
|
|
58
|
+
// splitting a word from its own plural — the opposite of what normalisation is for. Plain `s`
|
|
59
|
+
// handles that pair correctly.
|
|
60
|
+
for (const [suffix, min] of [['ings', 6], ['ing', 5], ['ies', 5], ['ed', 4], ['es', 4], ['s', 4]]) {
|
|
61
|
+
if (t.length >= min && t.endsWith(suffix)) {
|
|
62
|
+
t = t.slice(0, -suffix.length);
|
|
63
|
+
break;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
if (t.endsWith('i'))
|
|
67
|
+
t = `${t.slice(0, -1)}y`; // `ies` -> `i` -> `y`
|
|
68
|
+
// `quarantine` and `quarantining` must land on the same stem: stripping `ing` leaves `quarantin`
|
|
69
|
+
// while the base form keeps its silent `e`, so the `e` goes too. Length-guarded, or `file` and
|
|
70
|
+
// `code` would be mangled into stems that collide with unrelated short words.
|
|
71
|
+
if (t.length >= 5 && t.endsWith('e'))
|
|
72
|
+
t = t.slice(0, -1);
|
|
73
|
+
return ABBREVIATIONS.get(t) ?? t;
|
|
74
|
+
}
|
|
75
|
+
const normalisedTerms = (text) => (0, localize_1.significantTerms)(text).map(normaliseTerm);
|
|
76
|
+
exports.normalisedTerms = normalisedTerms;
|
|
77
|
+
/**
|
|
78
|
+
* BM25 over A-SPEC documents.
|
|
79
|
+
*
|
|
80
|
+
* `k1` bounds how much repetition can add and `b` controls the length correction — the standard
|
|
81
|
+
* 1.2/0.75, not tuned here, because tuning two constants on sixty cases fits noise and this arm has
|
|
82
|
+
* to answer a yes-or-no question about the idea rather than squeeze the last point out of it.
|
|
83
|
+
*
|
|
84
|
+
* A-SPEC only, as everywhere else: files anchor A-SPECs, so no other kind can pull a file through.
|
|
85
|
+
*/
|
|
86
|
+
function retrieveSpecs(issueText, specs, topK) {
|
|
87
|
+
const query = [...new Set((0, exports.normalisedTerms)(issueText))];
|
|
88
|
+
if (query.length === 0)
|
|
89
|
+
return [];
|
|
90
|
+
const docs = specs
|
|
91
|
+
.filter((s) => s.type === 'A-SPEC')
|
|
92
|
+
.map((s) => ({ id: s.id, terms: (0, exports.normalisedTerms)(`${s.title} ${Object.values(s.sections).join(' ')}`) }));
|
|
93
|
+
if (docs.length === 0)
|
|
94
|
+
return [];
|
|
95
|
+
const avgLen = docs.reduce((sum, d) => sum + d.terms.length, 0) / docs.length;
|
|
96
|
+
const df = new Map();
|
|
97
|
+
for (const d of docs)
|
|
98
|
+
for (const t of new Set(d.terms))
|
|
99
|
+
df.set(t, (df.get(t) ?? 0) + 1);
|
|
100
|
+
const k1 = 1.2, b = 0.75;
|
|
101
|
+
const scored = [];
|
|
102
|
+
for (const d of docs) {
|
|
103
|
+
const freq = new Map();
|
|
104
|
+
for (const t of d.terms)
|
|
105
|
+
freq.set(t, (freq.get(t) ?? 0) + 1);
|
|
106
|
+
let score = 0;
|
|
107
|
+
for (const q of query) {
|
|
108
|
+
const f = freq.get(q) ?? 0;
|
|
109
|
+
if (f === 0)
|
|
110
|
+
continue;
|
|
111
|
+
const n = df.get(q) ?? 0;
|
|
112
|
+
// Standard BM25 IDF, floored: a term in every document contributes nothing rather than
|
|
113
|
+
// subtracting, which would make a common word count AGAINST a spec that contains it.
|
|
114
|
+
const idf = Math.max(Math.log(1 + (docs.length - n + 0.5) / (n + 0.5)), 0);
|
|
115
|
+
score += idf * (f * (k1 + 1)) / (f + k1 * (1 - b + b * (d.terms.length / (avgLen || 1))));
|
|
116
|
+
}
|
|
117
|
+
if (score > 0)
|
|
118
|
+
scored.push({ id: d.id, score });
|
|
119
|
+
}
|
|
120
|
+
return scored.sort((a, b2) => b2.score - a.score || a.id.localeCompare(b2.id)).slice(0, topK);
|
|
121
|
+
}
|
|
122
|
+
function expandThroughGraph(issueText, topFiles, specs, opts) {
|
|
123
|
+
const base = [...new Set((0, exports.normalisedTerms)(issueText))];
|
|
124
|
+
const expansion = new Map();
|
|
125
|
+
if (opts.weight <= 0 || opts.seedFiles <= 0)
|
|
126
|
+
return { base, expansion };
|
|
127
|
+
const byId = new Map(specs.filter((s) => s.type === 'A-SPEC').map((s) => [s.id, s]));
|
|
128
|
+
const reached = new Set();
|
|
129
|
+
for (const f of topFiles.slice(0, opts.seedFiles))
|
|
130
|
+
for (const id of f.anchors)
|
|
131
|
+
if (byId.has(id))
|
|
132
|
+
reached.add(id);
|
|
133
|
+
if (reached.size === 0)
|
|
134
|
+
return { base, expansion };
|
|
135
|
+
// Rank the spec's own vocabulary by how distinctive it is within the reached set, so expansion
|
|
136
|
+
// contributes the words that make those specs different rather than the words every spec uses.
|
|
137
|
+
const df = new Map();
|
|
138
|
+
const perSpec = [];
|
|
139
|
+
for (const id of reached) {
|
|
140
|
+
const s = byId.get(id);
|
|
141
|
+
const terms = [...new Set((0, exports.normalisedTerms)(`${s.title} ${Object.values(s.sections).join(' ')}`))];
|
|
142
|
+
perSpec.push(terms);
|
|
143
|
+
for (const t of terms)
|
|
144
|
+
df.set(t, (df.get(t) ?? 0) + 1);
|
|
145
|
+
}
|
|
146
|
+
const baseSet = new Set(base);
|
|
147
|
+
for (const terms of perSpec) {
|
|
148
|
+
const ranked = terms
|
|
149
|
+
.filter((t) => !baseSet.has(t))
|
|
150
|
+
.sort((a, b) => (df.get(a) ?? 0) - (df.get(b) ?? 0) || a.localeCompare(b))
|
|
151
|
+
.slice(0, opts.termsPerSpec);
|
|
152
|
+
for (const t of ranked)
|
|
153
|
+
expansion.set(t, Math.max(expansion.get(t) ?? 0, opts.weight));
|
|
154
|
+
}
|
|
155
|
+
return { base, expansion };
|
|
156
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { ScannedFile } from '../cpg/cpg-scanner';
|
|
2
|
+
import type { Spec } from '../spec/spec-parser';
|
|
3
|
+
import type { LocalizationReport } from '../rtm/localize';
|
|
4
|
+
/**
|
|
5
|
+
* What the spec layer is actually doing on a corpus.
|
|
6
|
+
*
|
|
7
|
+
* All of the harness's measured advantage lives in this layer (A-SPEC-362), and on the second corpus
|
|
8
|
+
* it delivers nothing — near-parity with plain word matching, ahead on two axes and behind on two.
|
|
9
|
+
* The density explanation was tested and retracted: that corpus resolves an anchor for every scanned
|
|
10
|
+
* file. So the layer is present, connected, and not paying, and the next honest step is to count what
|
|
11
|
+
* it does rather than to guess again.
|
|
12
|
+
*
|
|
13
|
+
* Every field is a count or a share, deliberately: a single "spec layer health" score would hide
|
|
14
|
+
* which of these differs, and it is the difference between corpora that carries the answer.
|
|
15
|
+
*/
|
|
16
|
+
export interface SpecLayerStats {
|
|
17
|
+
cases: number;
|
|
18
|
+
/** A-SPECs the request was taken to match, per case. */
|
|
19
|
+
matchedSpecs: number;
|
|
20
|
+
/** Files that received a spec bonus, per case. */
|
|
21
|
+
filesViaSpec: number;
|
|
22
|
+
/** Of those, the share that the commit actually changed — the layer's own precision. */
|
|
23
|
+
viaSpecPrecision: number | null;
|
|
24
|
+
/** Truth files reached ONLY through a spec — what the layer uniquely contributes. */
|
|
25
|
+
truthOnlyViaSpec: number;
|
|
26
|
+
/** Files anchoring each spec, and specs anchored by each file: the shape of the mapping. */
|
|
27
|
+
filesPerSpec: number | null;
|
|
28
|
+
specsPerFile: number | null;
|
|
29
|
+
/** Share of cases whose request text cited a spec outright — the strongest signal available. */
|
|
30
|
+
citingCases: number | null;
|
|
31
|
+
}
|
|
32
|
+
export interface CaseObservation {
|
|
33
|
+
report: LocalizationReport;
|
|
34
|
+
scanned: readonly ScannedFile[];
|
|
35
|
+
specs: readonly Spec[];
|
|
36
|
+
truthFiles: readonly string[];
|
|
37
|
+
}
|
|
38
|
+
export declare function specLayerStats(observations: readonly CaseObservation[]): SpecLayerStats;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.specLayerStats = specLayerStats;
|
|
4
|
+
function specLayerStats(observations) {
|
|
5
|
+
if (observations.length === 0) {
|
|
6
|
+
return { cases: 0, matchedSpecs: 0, filesViaSpec: 0, viaSpecPrecision: null,
|
|
7
|
+
truthOnlyViaSpec: 0, filesPerSpec: null, specsPerFile: null, citingCases: null };
|
|
8
|
+
}
|
|
9
|
+
let matched = 0, viaFiles = 0, viaHits = 0, onlyVia = 0, citing = 0;
|
|
10
|
+
let fps = 0, spf = 0, shapeSamples = 0;
|
|
11
|
+
for (const o of observations) {
|
|
12
|
+
matched += o.report.matchedSpecs.length;
|
|
13
|
+
const truth = new Set(o.truthFiles);
|
|
14
|
+
const via = o.report.hits.filter((h) => h.viaSpecs.length > 0);
|
|
15
|
+
viaFiles += via.length;
|
|
16
|
+
viaHits += via.filter((h) => truth.has(h.file)).length;
|
|
17
|
+
// Reached only through a spec: no symbol matched and no path term did, so the lexical layer
|
|
18
|
+
// could not have produced it at any weighting.
|
|
19
|
+
onlyVia += via.filter((h) => truth.has(h.file) && h.matchedSymbols.length === 0).length;
|
|
20
|
+
if (o.report.hits.some((h) => h.why.some((w) => w.startsWith('request cites'))))
|
|
21
|
+
citing++;
|
|
22
|
+
const known = new Set(o.specs.map((s) => s.id));
|
|
23
|
+
const perSpec = new Map();
|
|
24
|
+
let anchorsTotal = 0, anchoredFiles = 0;
|
|
25
|
+
for (const f of o.scanned) {
|
|
26
|
+
const anchors = [...new Set(f.implementsSpecs ?? [])].filter((id) => known.has(id));
|
|
27
|
+
if (anchors.length === 0)
|
|
28
|
+
continue;
|
|
29
|
+
anchoredFiles++;
|
|
30
|
+
anchorsTotal += anchors.length;
|
|
31
|
+
for (const id of anchors)
|
|
32
|
+
perSpec.set(id, (perSpec.get(id) ?? 0) + 1);
|
|
33
|
+
}
|
|
34
|
+
if (anchoredFiles > 0) {
|
|
35
|
+
fps += [...perSpec.values()].reduce((a, b) => a + b, 0) / Math.max(1, perSpec.size);
|
|
36
|
+
spf += anchorsTotal / anchoredFiles;
|
|
37
|
+
shapeSamples++;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
const n = observations.length;
|
|
41
|
+
return {
|
|
42
|
+
cases: n,
|
|
43
|
+
matchedSpecs: matched / n,
|
|
44
|
+
filesViaSpec: viaFiles / n,
|
|
45
|
+
// null, not zero: if the layer scored no file at all there is no precision to report.
|
|
46
|
+
viaSpecPrecision: viaFiles === 0 ? null : viaHits / viaFiles,
|
|
47
|
+
truthOnlyViaSpec: onlyVia / n,
|
|
48
|
+
filesPerSpec: shapeSamples === 0 ? null : fps / shapeSamples,
|
|
49
|
+
specsPerFile: shapeSamples === 0 ? null : spf / shapeSamples,
|
|
50
|
+
citingCases: citing / n,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How recently and how often each file changed, as of a point in time.
|
|
3
|
+
*
|
|
4
|
+
* Every graph mechanism tried against uncited requests has been a spec-graph mechanism, and all five
|
|
5
|
+
* were negative. The Goal's Phase 3 names a second structure that has never been used for retrieval
|
|
6
|
+
* at all: the temporal graph — Commit nodes and `changed_by` edges. A file that changed recently and
|
|
7
|
+
* often is more likely to change again, which is one of the oldest findings in fault localization,
|
|
8
|
+
* and unlike everything tried so far it does not depend on the request naming anything.
|
|
9
|
+
*
|
|
10
|
+
* POINT-IN-TIME is the whole discipline here. Reading the answer commit, or anything after it, is
|
|
11
|
+
* reading the answer: the file the commit touched will look "recently changed" because it is about
|
|
12
|
+
* to be. The window ends at the parent, exactly as the rest of the replay does.
|
|
13
|
+
*/
|
|
14
|
+
export interface TemporalPrior {
|
|
15
|
+
/** Commits, within the window, that touched each file. */
|
|
16
|
+
frequency: Map<string, number>;
|
|
17
|
+
/** How many commits back the file was last touched; absent when it never was. */
|
|
18
|
+
recency: Map<string, number>;
|
|
19
|
+
/** Commits actually examined — 0 means the prior is UNMEASURED, not that nothing changed. */
|
|
20
|
+
commits: number;
|
|
21
|
+
}
|
|
22
|
+
export declare function temporalPrior(root: string, before: string, limit?: number): TemporalPrior;
|
|
23
|
+
/**
|
|
24
|
+
* The multiplier a file's history earns.
|
|
25
|
+
*
|
|
26
|
+
* Multiplicative rather than additive: the prior says which of several plausible files is likelier,
|
|
27
|
+
* and it must not promote a file the request has no lexical connection to at all. A file with no
|
|
28
|
+
* history in the window scores 1 — unknown is not evidence against.
|
|
29
|
+
*
|
|
30
|
+
* `halfLife` is in commits. Recency decays geometrically; frequency enters through a log so that a
|
|
31
|
+
* file touched fifty times is not fifty times likelier than one touched once.
|
|
32
|
+
*/
|
|
33
|
+
export declare function priorFor(prior: TemporalPrior, file: string, halfLife: number, freqWeight: number): number;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.temporalPrior = temporalPrior;
|
|
4
|
+
exports.priorFor = priorFor;
|
|
5
|
+
// @implements A-SPEC-377
|
|
6
|
+
const node_child_process_1 = require("node:child_process");
|
|
7
|
+
const maintenance_analyze_1 = require("../mcp/maintenance-analyze");
|
|
8
|
+
function temporalPrior(root, before, limit = 200) {
|
|
9
|
+
let raw;
|
|
10
|
+
try {
|
|
11
|
+
raw = (0, node_child_process_1.execFileSync)('git', ['-C', root, 'log', '--no-merges', `-${limit}`, '--format=%H', '--name-only', before], { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
// A repository or revision this machine does not have is unmeasured, not empty.
|
|
15
|
+
return { frequency: new Map(), recency: new Map(), commits: 0 };
|
|
16
|
+
}
|
|
17
|
+
const frequency = new Map();
|
|
18
|
+
const recency = new Map();
|
|
19
|
+
let index = -1;
|
|
20
|
+
for (const line of raw.split('\n')) {
|
|
21
|
+
const t = line.trim();
|
|
22
|
+
if (t.length === 0)
|
|
23
|
+
continue;
|
|
24
|
+
if (/^[0-9a-f]{40}$/.test(t)) {
|
|
25
|
+
index++;
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
const file = (0, maintenance_analyze_1.unquoteGitPath)(t);
|
|
29
|
+
frequency.set(file, (frequency.get(file) ?? 0) + 1);
|
|
30
|
+
if (!recency.has(file))
|
|
31
|
+
recency.set(file, index); // first sighting is the most recent commit
|
|
32
|
+
}
|
|
33
|
+
return { frequency, recency, commits: index + 1 };
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* The multiplier a file's history earns.
|
|
37
|
+
*
|
|
38
|
+
* Multiplicative rather than additive: the prior says which of several plausible files is likelier,
|
|
39
|
+
* and it must not promote a file the request has no lexical connection to at all. A file with no
|
|
40
|
+
* history in the window scores 1 — unknown is not evidence against.
|
|
41
|
+
*
|
|
42
|
+
* `halfLife` is in commits. Recency decays geometrically; frequency enters through a log so that a
|
|
43
|
+
* file touched fifty times is not fifty times likelier than one touched once.
|
|
44
|
+
*/
|
|
45
|
+
function priorFor(prior, file, halfLife, freqWeight) {
|
|
46
|
+
if (prior.commits === 0)
|
|
47
|
+
return 1;
|
|
48
|
+
const age = prior.recency.get(file);
|
|
49
|
+
const freq = prior.frequency.get(file) ?? 0;
|
|
50
|
+
const recencyTerm = age === undefined ? 0 : Math.pow(0.5, age / halfLife);
|
|
51
|
+
const freqTerm = Math.log1p(freq) / Math.log1p(10);
|
|
52
|
+
return 1 + recencyTerm + freqWeight * freqTerm;
|
|
53
|
+
}
|
|
@@ -60,6 +60,21 @@ export declare function parseExecutedCounts(stdout: string, cwd: string): Record
|
|
|
60
60
|
* pending/skipped assertions are excluded. Errors and failures DO count: they executed.
|
|
61
61
|
*/
|
|
62
62
|
export declare function parseJUnitXmlCounts(xml: string, ext?: string): Record<string, number>;
|
|
63
|
+
/**
|
|
64
|
+
* @implements A-SPEC-466 — W-4. The TARGET repo's own jest entry, as its package declares it.
|
|
65
|
+
*
|
|
66
|
+
* Windows npm/npx are `.cmd` shims: execFileSync('npx') is ENOENT and naming the .cmd is EINVAL
|
|
67
|
+
* (Node's batch-file mitigation), so `test_run` could never produce ART-4 execution evidence there
|
|
68
|
+
* — measured by the Windows review with a suite that passes standalone. Running the resolved JS
|
|
69
|
+
* entry with THIS node binary (process.execPath) removes the shim from the path entirely.
|
|
70
|
+
*
|
|
71
|
+
* `require.resolve('jest/bin/jest.js')` is blocked by jest's `exports` (measured:
|
|
72
|
+
* ERR_PACKAGE_PATH_NOT_EXPORTED), so the package.json is resolved instead and its own `bin`
|
|
73
|
+
* declaration is read — the package names its entry, this code does not guess it. Any failure
|
|
74
|
+
* returns null and the caller keeps the npx spelling: POSIX behavior is unchanged (no .cmd problem
|
|
75
|
+
* there), and a repo with no local jest could never yield meaningful suite evidence anyway.
|
|
76
|
+
*/
|
|
77
|
+
export declare function jestEntry(cwd: string): string | null;
|
|
63
78
|
/**
|
|
64
79
|
* @implements A-SPEC-137.2
|
|
65
80
|
* Executed-test count from `cargo test` text output. cargo prints one `test <name> ... ok|FAILED|
|
|
@@ -38,6 +38,7 @@ exports.ecosystemOf = ecosystemOf;
|
|
|
38
38
|
exports.parseGoTestJson = parseGoTestJson;
|
|
39
39
|
exports.parseExecutedCounts = parseExecutedCounts;
|
|
40
40
|
exports.parseJUnitXmlCounts = parseJUnitXmlCounts;
|
|
41
|
+
exports.jestEntry = jestEntry;
|
|
41
42
|
exports.parseCargoTest = parseCargoTest;
|
|
42
43
|
exports.runCargo = runCargo;
|
|
43
44
|
exports.runGradle = runGradle;
|
|
@@ -75,7 +76,11 @@ function ecosystemOf(file) {
|
|
|
75
76
|
// @implements A-SPEC-137.2 — Rust test files, matching the scanner's isTestFile convention:
|
|
76
77
|
// tests/<x>.rs (integration), <x>_test(s).rs, <x>_unittest.rs, test_<x>.rs. A plain src/x.rs may
|
|
77
78
|
// carry inline #[cfg(test)] unit tests but is a production source, not a selected test file.
|
|
78
|
-
|
|
79
|
+
// @implements A-SPEC-466 — both separators. The Windows review (W-4 section) measured
|
|
80
|
+
// ecosystemOf('tests\it.rs') = null: this was the only ecosystem whose pattern names a DIRECTORY,
|
|
81
|
+
// and the directory separator was spelled '/' only. java/dotnet were unaffected — their `[^/]*`
|
|
82
|
+
// happens to accept a backslash — so only this line changes.
|
|
83
|
+
if (/(^|[\\/])(tests[\\/][^\\/]*|[^\\/]*_(tests?|unittest)|test_[^\\/]*)\.rs$/i.test(file))
|
|
79
84
|
return 'rust';
|
|
80
85
|
// @implements A-SPEC-137.3 — JUnit/xUnit naming (case-sensitive `Test` so Manifest.java is not a
|
|
81
86
|
// test): Java `…Test(s|Case|Cases).java`, C# `…Test(s).cs`.
|
|
@@ -181,6 +186,34 @@ function parseJUnitXmlCounts(xml, ext = 'py') {
|
|
|
181
186
|
}
|
|
182
187
|
return out;
|
|
183
188
|
}
|
|
189
|
+
/**
|
|
190
|
+
* @implements A-SPEC-466 — W-4. The TARGET repo's own jest entry, as its package declares it.
|
|
191
|
+
*
|
|
192
|
+
* Windows npm/npx are `.cmd` shims: execFileSync('npx') is ENOENT and naming the .cmd is EINVAL
|
|
193
|
+
* (Node's batch-file mitigation), so `test_run` could never produce ART-4 execution evidence there
|
|
194
|
+
* — measured by the Windows review with a suite that passes standalone. Running the resolved JS
|
|
195
|
+
* entry with THIS node binary (process.execPath) removes the shim from the path entirely.
|
|
196
|
+
*
|
|
197
|
+
* `require.resolve('jest/bin/jest.js')` is blocked by jest's `exports` (measured:
|
|
198
|
+
* ERR_PACKAGE_PATH_NOT_EXPORTED), so the package.json is resolved instead and its own `bin`
|
|
199
|
+
* declaration is read — the package names its entry, this code does not guess it. Any failure
|
|
200
|
+
* returns null and the caller keeps the npx spelling: POSIX behavior is unchanged (no .cmd problem
|
|
201
|
+
* there), and a repo with no local jest could never yield meaningful suite evidence anyway.
|
|
202
|
+
*/
|
|
203
|
+
function jestEntry(cwd) {
|
|
204
|
+
try {
|
|
205
|
+
const pkgPath = require.resolve('jest/package.json', { paths: [cwd] });
|
|
206
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
|
|
207
|
+
const bin = typeof pkg.bin === 'string' ? pkg.bin : pkg.bin?.jest;
|
|
208
|
+
if (!bin)
|
|
209
|
+
return null;
|
|
210
|
+
const entry = path.join(path.dirname(pkgPath), bin);
|
|
211
|
+
return fs.existsSync(entry) ? entry : null;
|
|
212
|
+
}
|
|
213
|
+
catch {
|
|
214
|
+
return null;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
184
217
|
/** Run the jest half of a plan. */
|
|
185
218
|
function runJest(files, mode, cwd) {
|
|
186
219
|
// --runTestsByPath treats args as EXACT files (not testPathPattern REGEXes, which would over/mis-match
|
|
@@ -190,11 +223,15 @@ function runJest(files, mode, cwd) {
|
|
|
190
223
|
// nothing has verified nothing. `full` mode runs the whole suite.
|
|
191
224
|
const safeFiles = files.map((p) => (p.startsWith('-') ? `./${p}` : p));
|
|
192
225
|
// --json yields per-file assertion results = EXECUTION evidence (see parseExecutedCounts).
|
|
226
|
+
// @implements A-SPEC-466 — resolved local jest via process.execPath when the target repo has one
|
|
227
|
+
// (structurally no .cmd shim to ENOENT on); the npx spelling only as the fallback.
|
|
228
|
+
const entry = jestEntry(cwd);
|
|
229
|
+
const [cmd, ...pre] = entry !== null ? [process.execPath, entry] : ['npx', 'jest'];
|
|
193
230
|
const args = mode === 'scoped'
|
|
194
|
-
? [
|
|
195
|
-
: [
|
|
231
|
+
? [...pre, '--silent', '--json', '--runTestsByPath', '--', ...safeFiles]
|
|
232
|
+
: [...pre, '--silent', '--json'];
|
|
196
233
|
try {
|
|
197
|
-
const out = (0, node_child_process_1.execFileSync)(
|
|
234
|
+
const out = (0, node_child_process_1.execFileSync)(cmd, args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
|
|
198
235
|
return { passed: true, tail: tailOf(out), executed: parseExecutedCounts(out, cwd) };
|
|
199
236
|
}
|
|
200
237
|
catch (e) {
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the harness's test-selection recall is actually made of.
|
|
3
|
+
*
|
|
4
|
+
* The replay reports `testRecall` — 0.883 on this repository, 0.867 on the second — and this
|
|
5
|
+
* document has quoted those numbers repeatedly. What was never measured is HOW they are reached.
|
|
6
|
+
*
|
|
7
|
+
* They are reached by selecting most of the suite. A commit changes 2.2 test files on average here
|
|
8
|
+
* and 1.2 there; the harness names 131.0 and 89.9, which is 63.6% and 81.3% of every anchored test.
|
|
9
|
+
* Precision is 0.015 and 0.012. A recall figure computed over a selection that size is not evidence
|
|
10
|
+
* of discrimination — "run almost everything" scores well on recall by construction, and the whole
|
|
11
|
+
* value of intelligent test selection is running FEWER tests without losing the signal.
|
|
12
|
+
*
|
|
13
|
+
* For contrast, the crudest rule a person would use — run the test whose filename contains the
|
|
14
|
+
* changed file's stem — names 6.6 and 2.6 tests and still catches 49 of 67 and 14 of 36 answers.
|
|
15
|
+
* Lower recall, twenty to thirty times fewer tests.
|
|
16
|
+
*
|
|
17
|
+
* The selection is NOT narrowed here. Narrowing costs recall, where the right trade sits needs more
|
|
18
|
+
* measurement than this slice has, and tightening a gate on a hunch is a mistake this repository has
|
|
19
|
+
* already had to undo more than once. What this slice fixes is the reading: `testRecall` quoted
|
|
20
|
+
* alone reads as discrimination, and it is not.
|
|
21
|
+
*/
|
|
22
|
+
export interface TestSelectionSlice {
|
|
23
|
+
corpus: string;
|
|
24
|
+
cases: number;
|
|
25
|
+
/** Tests the harness named, averaged over cases. */
|
|
26
|
+
avgSelected: number;
|
|
27
|
+
/** Anchored tests that existed to choose from. */
|
|
28
|
+
avgUniverse: number;
|
|
29
|
+
/** Tests the commit actually changed. */
|
|
30
|
+
avgTruth: number;
|
|
31
|
+
recall: number;
|
|
32
|
+
precision: number;
|
|
33
|
+
/** The filename-stem rule, for contrast. */
|
|
34
|
+
naiveAvgSelected: number;
|
|
35
|
+
naiveHits: number;
|
|
36
|
+
truthTotal: number;
|
|
37
|
+
}
|
|
38
|
+
/** Measured 2026-08-29 through analyzeMaintenance on the newest 30 cases of each corpus. */
|
|
39
|
+
export declare const TEST_SELECTION: readonly TestSelectionSlice[];
|
|
40
|
+
/** How much of the available suite the selection covers. Near 1 means recall is uninformative. */
|
|
41
|
+
export declare function selectionShare(s: TestSelectionSlice): number;
|
|
42
|
+
/**
|
|
43
|
+
* Recall bought per test selected — the figure that separates discrimination from breadth. Selecting
|
|
44
|
+
* the whole suite drives this to its floor while `recall` reads 1.0.
|
|
45
|
+
*/
|
|
46
|
+
export declare function recallPerSelectedTest(s: TestSelectionSlice): number;
|
|
47
|
+
/**
|
|
48
|
+
* How far the selection could be narrowed — asked because A-SPEC-424 left it open.
|
|
49
|
+
*
|
|
50
|
+
* The harness's own selection, reordered by how well each test's filename matches the stems of the
|
|
51
|
+
* ranked candidate files, then cut at K. On this repository that ordering works: twenty tests
|
|
52
|
+
* instead of a hundred and thirty-one keep 89% of the recall, forty keep 94%. On the second corpus
|
|
53
|
+
* the same ordering does almost nothing until eighty.
|
|
54
|
+
*
|
|
55
|
+
* So the headroom is real and the cut is not safe. Introducing K=40 would cost 0.05 of recall here
|
|
56
|
+
* and 0.53 there. Recorded as both facts at once, because either one alone is misleading.
|
|
57
|
+
*/
|
|
58
|
+
export interface NarrowingCurve {
|
|
59
|
+
corpus: string;
|
|
60
|
+
avgSelected: number;
|
|
61
|
+
/** recall at each K, and `full` when nothing is cut. */
|
|
62
|
+
at: ReadonlyArray<{
|
|
63
|
+
k: number;
|
|
64
|
+
recall: number;
|
|
65
|
+
}>;
|
|
66
|
+
full: number;
|
|
67
|
+
}
|
|
68
|
+
/** Measured 2026-08-29, newest 30 cases per corpus, through analyzeMaintenance. */
|
|
69
|
+
export declare const NARROWING_CURVE: readonly NarrowingCurve[];
|
|
70
|
+
export declare function recallAt(curve: NarrowingCurve, k: number): number;
|
|
71
|
+
/**
|
|
72
|
+
* Whether cutting at K keeps most of the recall on EVERY corpus measured. One corpus agreeing is
|
|
73
|
+
* not evidence — that is the shape of mistake this repository keeps having to undo.
|
|
74
|
+
*/
|
|
75
|
+
export declare function narrowingIsSafe(curves: readonly NarrowingCurve[], k: number, keep?: number): boolean;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.NARROWING_CURVE = exports.TEST_SELECTION = void 0;
|
|
4
|
+
exports.selectionShare = selectionShare;
|
|
5
|
+
exports.recallPerSelectedTest = recallPerSelectedTest;
|
|
6
|
+
exports.recallAt = recallAt;
|
|
7
|
+
exports.narrowingIsSafe = narrowingIsSafe;
|
|
8
|
+
/** Measured 2026-08-29 through analyzeMaintenance on the newest 30 cases of each corpus. */
|
|
9
|
+
exports.TEST_SELECTION = [
|
|
10
|
+
{
|
|
11
|
+
corpus: 'holmes newest-30', cases: 30,
|
|
12
|
+
avgSelected: 131.0, avgUniverse: 206.1, avgTruth: 2.2,
|
|
13
|
+
recall: 0.883, precision: 0.015,
|
|
14
|
+
naiveAvgSelected: 6.6, naiveHits: 49, truthTotal: 67,
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
corpus: 'jarvis newest-30', cases: 30,
|
|
18
|
+
avgSelected: 89.9, avgUniverse: 110.6, avgTruth: 1.2,
|
|
19
|
+
recall: 0.867, precision: 0.012,
|
|
20
|
+
naiveAvgSelected: 2.6, naiveHits: 14, truthTotal: 36,
|
|
21
|
+
},
|
|
22
|
+
];
|
|
23
|
+
/** How much of the available suite the selection covers. Near 1 means recall is uninformative. */
|
|
24
|
+
function selectionShare(s) {
|
|
25
|
+
return s.avgUniverse === 0 ? 0 : s.avgSelected / s.avgUniverse;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Recall bought per test selected — the figure that separates discrimination from breadth. Selecting
|
|
29
|
+
* the whole suite drives this to its floor while `recall` reads 1.0.
|
|
30
|
+
*/
|
|
31
|
+
function recallPerSelectedTest(s) {
|
|
32
|
+
return s.avgSelected === 0 ? 0 : s.recall / s.avgSelected;
|
|
33
|
+
}
|
|
34
|
+
/** Measured 2026-08-29, newest 30 cases per corpus, through analyzeMaintenance. */
|
|
35
|
+
exports.NARROWING_CURVE = [
|
|
36
|
+
{
|
|
37
|
+
corpus: 'holmes newest-30', avgSelected: 131.0, full: 0.883,
|
|
38
|
+
at: [{ k: 1, recall: 0.318 }, { k: 3, recall: 0.434 }, { k: 5, recall: 0.451 },
|
|
39
|
+
{ k: 10, recall: 0.668 }, { k: 20, recall: 0.789 }, { k: 40, recall: 0.833 }, { k: 80, recall: 0.883 }],
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
corpus: 'jarvis newest-30', avgSelected: 89.9, full: 0.867,
|
|
43
|
+
at: [{ k: 1, recall: 0.067 }, { k: 3, recall: 0.150 }, { k: 5, recall: 0.150 },
|
|
44
|
+
{ k: 10, recall: 0.283 }, { k: 20, recall: 0.283 }, { k: 40, recall: 0.333 }, { k: 80, recall: 0.767 }],
|
|
45
|
+
},
|
|
46
|
+
];
|
|
47
|
+
function recallAt(curve, k) {
|
|
48
|
+
const point = curve.at.find((p) => p.k === k);
|
|
49
|
+
return point?.recall ?? curve.full;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Whether cutting at K keeps most of the recall on EVERY corpus measured. One corpus agreeing is
|
|
53
|
+
* not evidence — that is the shape of mistake this repository keeps having to undo.
|
|
54
|
+
*/
|
|
55
|
+
function narrowingIsSafe(curves, k, keep = 0.9) {
|
|
56
|
+
return curves.every((c) => recallAt(c, k) / c.full >= keep);
|
|
57
|
+
}
|