src/vs/platform/agentHost/node/shared/editSurvivalTracker.ts

236 LOC · 231 covered · 5 uncovered · 37 ranges · 2281 concepts · 21 introducers · 1079 tests

File neighbourhood

The centred file is linked to every concept that introduces one of its ranges, every test that runs code from the file, and the gray connector concepts standing between those tests and the file's own introducer concepts. Undirected links join concepts to every file where they introduce source and concepts to the tests they introduce; arrows show specialization between the displayed concepts and bridge only concepts omitted from this view. Concept colors match the source ranges below; connector concepts have no source color and are shown in gray.

Focused file, its introducer and connector concepts, their introduced files, and tests that run code from the file

In the embedded map, ordinary wheel input scrolls the page; use the visible controls to zoom and drag to pan. Open the full-screen map for canvas navigation: wheel pans, Ctrl/Command plus wheel zooms, and arrow keys pan when this region is focused. On touch screens, open the full-screen map to pan or pinch. If JavaScript or WebGL is unavailable, use the related-file, concept, and source links on this page.

Graph controls are ready.

Interactive rendering requires JavaScript and WebGL. Use the related-file, concept, and source links on this page while the interactive map is unavailable.

1 > /*--------------------------------------------------------------------------------------------- editSurvivalTracker.ts ×8
2 > * Copyright (c) Microsoft Corporation. All rights reserved.
3 > * Licensed under the MIT License. See License.txt in the project root for license information.
4 > *--------------------------------------------------------------------------------------------*/
5 >
6 > /*
7 > * Edit-survival math for agent-host file edits.
8 > *
9 > * Sister implementation of the chat extension's `EditSurvivalTracker`
10 > * (`extensions/copilot/src/platform/editSurvivalTracking/common/editSurvivalTracker.ts`).
11 > * The extension version operates on multi-range `StringEdit`s with a
12 > * live `TextModel`; here we only have whole-file snapshots and (when
13 > * the tool input is recognisable) the explicit text the AI wrote. The
14 > * whole-file path is the baseline; the chunked path uses asymmetric
15 > * "fraction of AI 4-grams still present in the file" scoring so an
16 > * edit's score doesn't decay as the file grows around it.
17 > *
18 > * Mostly carried over from the chat extension's version.
19 > */
20 >
21 > /**
22 > * Computes a number between 0 and 1 that reflects how similar the two
23 > * texts are by counting how many 4-grams are shared between them.
24 > */
25 > export function compute4GramTextSimilarity(text1: string, text2: string): number {
26 > const n = 4; editSurvivalTracker.ts ×1
27 >
28 > if (text1.length < n || text2.length < n) {
29 > return text1 === text2 ? 1 : 0; editSurvivalTracker.ts ×1
30 > }
32 > const nGramIdx = new Map<string, number>();
33 >
34 > for (let i = 0; i <= text1.length - n; i++) {
35 > const nGram = text1.substring(i, i + n);
36 > const count = nGramIdx.get(nGram) || 0;
37 > nGramIdx.set(nGram, count + 1);
38 > }
39 >
40 > for (let i = 0; i <= text2.length - n; i++) {
41 > const nGram = text2.substring(i, i + n);
42 > const count = nGramIdx.get(nGram) || 0;
43 > nGramIdx.set(nGram, count - 1);
44 > }
45 >
46 > const totalNGramCount = text1.length - n + 1 + text2.length - n + 1;
47 >
48 > let differentNGramCount = 0;
49 > for (const count of nGramIdx.values()) {
50 > differentNGramCount += Math.abs(count);
51 > }
52 >
53 > const equalNGramCount = totalNGramCount - differentNGramCount;
54 >
55 > return equalNGramCount / totalNGramCount;
56 > }
58 > /**
59 > * Computes the share of `chunk`'s 4-grams that appear anywhere in
60 > * `currentText`. Unlike {@link compute4GramTextSimilarity}, this is
61 > * asymmetric: the denominator is the chunk's n-gram count, not the
62 > * combined corpus. That makes the result stable as `currentText` grows
63 > * around the chunk — appending unrelated content does not drag the
64 > * score down. Returns a number in [0, 1].
65 > *
66 > * Used to ask "is the text the AI wrote still present in the file?"
67 > * when we have an explicit chunk (the `new_string` from `Edit`, each
68 > * entry of `MultiEdit.edits[*].new_string`, or `Write.content`) rather
69 > * than a whole-file before/after pair.
70 > *
71 > * For multi-chunk scoring against the same file, prefer building the
72 > * file n-gram set once via {@link buildNGramSet} and passing it to
73 > * {@link computeFractionPresentInSet} to avoid rebuilding the set per
74 > * chunk — see {@link computeChunkedFourGramSurvival}.
75 > */
76 > export function computeFractionPresentIn(chunk: string, currentText: string): number {
77 > const n = 4; editSurvivalTracker.ts ×1
78 > if (chunk.length === 0) {
80 > }
81 > if (chunk.length < n) { editSurvivalTracker.ts ×1
82 > return currentText.includes(chunk) ? 1 : 0; editSurvivalTracker.ts ×1
83 > }
84 > if (currentText.length < n) { editSurvivalTracker.ts ×2
85 return 0;
86 }
87 > return computeFractionPresentInSet(chunk, buildNGramSet(currentText, n), n); editSurvivalTracker.ts ×2
88 > }
90 > /** Builds the set of length-`n` substrings of `text`. */
91 > function buildNGramSet(text: string, n: number): Set<string> { editSurvivalTracker.ts ×3
92 > const set = new Set<string>();
93 > for (let i = 0; i <= text.length - n; i++) {
94 > set.add(text.substring(i, i + n));
95 > }
96 > return set;
97 > }
99 > /**
100 > * {@link computeFractionPresentIn} with a precomputed file n-gram set.
101 > * `chunk.length >= n` is the caller's responsibility; short/empty
102 > * chunks are handled by {@link computeFractionPresentIn}.
103 > */
104 > function computeFractionPresentInSet(chunk: string, fileNGrams: ReadonlySet<string>, n: number): number { editSurvivalTracker.ts ×3
105 > const total = chunk.length - n + 1;
106 > let present = 0;
107 > for (let i = 0; i < total; i++) {
108 > if (fileNGrams.has(chunk.substring(i, i + n))) {
109 > present++; editSurvivalTracker.ts ×1
110 > }
112 > return present / total;
113 > }
115 > /**
116 > * Length-weighted average of {@link computeFractionPresentIn} across
117 > * multiple AI-written chunks. The weight is the chunk's n-gram count
118 > * (approx its character length), so a 200-char chunk counts ~10x as much
119 > * as a 20-char chunk. Returns 0 when there are no chunks (callers
120 > * should branch on that and fall back to whole-file scoring).
121 > *
122 > * Builds the file n-gram set exactly once and reuses it for every
123 > * chunk, so cost is O(|currentText| + sum(|chunk|)) rather than
124 > * O(|chunks| × |currentText|).
125 > */
126 > export function computeChunkedFourGramSurvival(aiChunks: readonly string[], currentText: string): number {
127 > if (aiChunks.length === 0) { editSurvivalTracker.ts ×2
128 > return 0; editSurvivalTracker.ts ×1
129 > }
131 > const n = 4;
132 > const fileNGrams = currentText.length >= n ? buildNGramSet(currentText, n) : undefined; editSurvivalTracker.ts ×2
133 >
134 > let totalWeight = 0;
135 > let weightedSum = 0;
136 > for (const chunk of aiChunks) {
137 > // Use n-gram count as the weight, with a floor of 1 for tiny editSurvivalTracker.ts ×5
138 > // chunks (so they still contribute their full presence signal
139 > // rather than getting zero weight).
140 > const weight = Math.max(1, chunk.length - n + 1);
141 > let fraction: number;
142 > if (chunk.length === 0) {
143 fraction = 1;
144 > } else if (chunk.length < n) { editSurvivalTracker.ts ×5
145 fraction = currentText.includes(chunk) ? 1 : 0;
146 > } else if (!fileNGrams) { editSurvivalTracker.ts ×5
147 fraction = 0;
149 > fraction = computeFractionPresentInSet(chunk, fileNGrams, n);
150 > }
151 > weightedSum += fraction * weight;
152 > totalWeight += weight;
153 > }
154 > return weightedSum / totalWeight;
155 > }
157 > /**
158 > * Result of {@link computeWholeFileEditSurvival}.
159 > */
160 > export interface IEditSurvivalScore {
161 > /**
162 > * 4-gram similarity between the current file content and the
163 > * text the AI wrote. 1 = current text is identical to AI text,
164 > * 0 = nothing in common.
165 > */
166 > readonly fourGram: number;
167 > /**
168 > * 1 minus the fraction by which the user moved the text back
169 > * toward the original. 1 = no revert (user kept or refined AI
170 > * output), 0 = full revert to original.
171 > */
172 > readonly noRevert: number;
173 > }
174 >
175 > /**
176 > * Computes the whole-file revert score. 1 = file did not move back
177 > * toward the original, 0 = file is back to the original. Used by both
178 > * the whole-file and the chunked code paths, since revert detection is
179 > * intrinsically a whole-file question (we want to know whether the
180 > * user undid the change, not whether each AI-written region is still
181 > * present).
182 > */
183 > export function computeNoRevertScore(beforeText: string, afterText: string, currentText: string): number {
184 > const aiSimilarity = compute4GramTextSimilarity(afterText, beforeText); editSurvivalTracker.ts ×1
185 > if (aiSimilarity === 1) {
186 > // AI's edit produced text identical to the file before — there editSurvivalTracker.ts ×1
187 > // is nothing to revert. Guard so we don't divide by zero.
188 > return 1;
189 > }
190 > const userSimilarity = compute4GramTextSimilarity(currentText, beforeText); editSurvivalTracker.ts ×1
191 > return 1 - Math.max(userSimilarity - aiSimilarity, 0) / (1 - aiSimilarity);
192 > }
194 > /**
195 > * Computes survival scores for a whole-file edit.
196 > *
197 > * @param beforeText - File content before the AI edit was applied.
198 > * @param afterText - File content the AI wrote.
199 > * @param currentText - File content right now.
200 > */
201 > export function computeWholeFileEditSurvival(
202 > beforeText: string, editSurvivalTracker.ts ×1
203 > afterText: string,
204 > currentText: string,
205 > ): IEditSurvivalScore {
206 > return {
207 > fourGram: compute4GramTextSimilarity(currentText, afterText),
208 > noRevert: computeNoRevertScore(beforeText, afterText, currentText),
209 > };
210 > }
212 > /**
213 > * Computes survival scores for an edit when we know the explicit
214 > * AI-written chunks. `fourGram` uses the chunked, search-within scoring
215 > * so the denominator is bounded by the AI's written text (immune to
216 > * file-growth artifacts); `noRevert` continues to use the whole-file
217 > * comparison so reverts are still detectable.
218 > *
219 > * Falls back to whole-file scoring when `aiChunks` is empty (e.g. tool
220 > * input was unrecognised or malformed) so callers can pass through
221 > * uniformly.
222 > */
223 > export function computeChunkedEditSurvival(
224 > beforeText: string, editSurvivalTracker.ts ×2
225 > afterText: string,
226 > aiChunks: readonly string[],
227 > currentText: string,
228 > ): IEditSurvivalScore {
229 > const fourGram = aiChunks.length === 0
230 > ? compute4GramTextSimilarity(currentText, afterText) editSurvivalTracker.ts ×1
231 > : computeChunkedFourGramSurvival(aiChunks, currentText); editSurvivalTracker.ts ×1
233 > fourGram,
234 > noRevert: computeNoRevertScore(beforeText, afterText, currentText),
235 > };
236 > }