Skip to content

Commit fcb1ef2

Browse files
lecoursenCopilot
andauthored
Add script to measure the always-on Copilot instruction budget (#61973)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 23338c6 commit fcb1ef2

4 files changed

Lines changed: 307 additions & 0 deletions

File tree

package-lock.json

Lines changed: 8 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@
6464
"lint-content": "tsx src/content-linter/scripts/lint-content.ts",
6565
"lint-translation": "vitest src/content-linter/tests/lint-files.ts",
6666
"liquid-markdown-tables": "tsx src/tools/scripts/liquid-markdown-tables/index.ts",
67+
"measure-instruction-budget": "tsx src/workflows/measure-instruction-budget.ts",
6768
"generate-article-api-docs": "tsx src/article-api/scripts/generate-api-docs.ts",
6869
"generate-code-scanning-query-list": "tsx src/codeql-queries/scripts/generate-code-scanning-query-list.ts",
6970
"generate-code-quality-query-list": "tsx src/codeql-queries/scripts/generate-code-quality-query-list.ts",
@@ -324,6 +325,7 @@
324325
"eslint-plugin-primer-react": "^8.5.2",
325326
"event-to-promise": "^0.8.0",
326327
"globals": "^17.3.0",
328+
"gpt-tokenizer": "^3.4.0",
327329
"graphql": "^16.12.0",
328330
"http-status-code": "^2.1.0",
329331
"husky": "^9.1.7",
Lines changed: 232 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,232 @@
1+
/**
2+
* @purpose Writer tool
3+
* @description Measure the always-on Copilot instruction budget for a content interaction.
4+
*
5+
* Scans `.github/instructions/*.instructions.md`, reads each file's `applyTo`
6+
* frontmatter, works out which files load for a representative content path,
7+
* and reports the combined budget against soft guardrails.
8+
*
9+
* The primary number is the discrete **rule count** (instruction-following
10+
* degrades with the number of discrete instructions). The **token count** is a
11+
* mechanical backstop. Both guardrails are soft: the script warns, it does not
12+
* fail, unless you pass `--strict`.
13+
*
14+
* Usage:
15+
* npm run measure-instruction-budget
16+
* npm run measure-instruction-budget -- --path content/get-started/foo.md
17+
* npm run measure-instruction-budget -- --json
18+
* npm run measure-instruction-budget -- --strict # exit 1 if over budget
19+
*/
20+
import fs from 'fs'
21+
import path from 'path'
22+
23+
import { program } from 'commander'
24+
import { encode } from 'gpt-tokenizer/encoding/o200k_base'
25+
26+
import readFrontmatter from '@/frame/lib/read-frontmatter'
27+
28+
// Single source of truth for the guardrails. Keep these in sync with the
29+
// instruction architecture doc (github/docs-team) and any future CI check.
30+
// Derived in github/docs-team#6829: frontier models stay near-perfect to
31+
// ~150 discrete instructions; ~45 tokens/rule puts the token backstop at ~6,500.
32+
const RULE_BUDGET = 150
33+
const TOKEN_BUDGET = 6500
34+
35+
const INSTRUCTIONS_DIR = '.github/instructions'
36+
const DEFAULT_PATH = 'content/get-started/example.md'
37+
38+
type FileReport = {
39+
file: string
40+
applyTo: string
41+
tokens: number
42+
rules: number
43+
}
44+
45+
type Options = {
46+
dir: string
47+
path: string
48+
json?: boolean
49+
strict?: boolean
50+
}
51+
52+
program
53+
.description('Measure the always-on Copilot instruction budget for a content interaction')
54+
.option('--dir <path>', 'directory of instruction files', INSTRUCTIONS_DIR)
55+
.option('--path <path>', 'representative edited file path to simulate', DEFAULT_PATH)
56+
.option('--json', 'serialize output as JSON')
57+
.option('--strict', 'exit 1 when a guardrail is exceeded (default: warn only)')
58+
59+
const isEntryPoint =
60+
import.meta.url === `file://${process.argv[1]}` ||
61+
process.argv[1]?.endsWith('measure-instruction-budget.ts')
62+
63+
if (isEntryPoint) {
64+
program.parse(process.argv)
65+
main(program.opts<Options>())
66+
}
67+
68+
function main(options: Options): void {
69+
const dir = options.dir
70+
if (!fs.existsSync(dir)) {
71+
console.error(`No instructions directory found at ${dir}`)
72+
process.exit(2)
73+
}
74+
75+
const files = fs
76+
.readdirSync(dir)
77+
.filter((name) => name.endsWith('.instructions.md'))
78+
.sort()
79+
80+
if (files.length === 0) {
81+
console.error(`No *.instructions.md files found in ${dir}`)
82+
process.exit(2)
83+
}
84+
85+
// Normalize to POSIX separators so backslash paths (e.g. on Windows) still
86+
// match the forward-slash applyTo globs.
87+
const simulatedPath = options.path.replace(/\\/g, '/')
88+
89+
const loaded: FileReport[] = []
90+
for (const name of files) {
91+
const raw = fs.readFileSync(path.join(dir, name), 'utf-8')
92+
const { content, data, errors } = readFrontmatter(raw, { filepath: name })
93+
if (errors && errors.length > 0) {
94+
// Don't silently fall back to applyTo '**' (which matches everything) and
95+
// distort the budget. Skip the file and warn so the numbers stay trustworthy.
96+
console.warn(
97+
`Warning: skipping ${name} because its frontmatter could not be parsed ` +
98+
`(${errors.map((e) => e.reason).join('; ')}).`,
99+
)
100+
continue
101+
}
102+
const applyTo = typeof data?.applyTo === 'string' ? data.applyTo : '**'
103+
if (!matchesPath(applyTo, simulatedPath)) continue
104+
const body = content || ''
105+
loaded.push({
106+
file: name,
107+
applyTo,
108+
// Count the frontmatter-stripped body: the `applyTo` frontmatter is
109+
// metadata that governs when the file loads, not text injected into the
110+
// prompt, so including it would systematically overcount.
111+
tokens: encode(body).length,
112+
rules: countRules(body),
113+
})
114+
}
115+
116+
const totalTokens = loaded.reduce((sum, f) => sum + f.tokens, 0)
117+
const totalRules = loaded.reduce((sum, f) => sum + f.rules, 0)
118+
const overTokens = Math.max(0, totalTokens - TOKEN_BUDGET)
119+
const overRules = Math.max(0, totalRules - RULE_BUDGET)
120+
121+
if (options.json) {
122+
console.log(
123+
JSON.stringify(
124+
{
125+
path: simulatedPath,
126+
ruleBudget: RULE_BUDGET,
127+
tokenBudget: TOKEN_BUDGET,
128+
totalRules,
129+
totalTokens,
130+
overRules,
131+
overTokens,
132+
withinBudget: overRules === 0 && overTokens === 0,
133+
files: loaded,
134+
},
135+
null,
136+
2,
137+
),
138+
)
139+
} else {
140+
printReport(simulatedPath, loaded, totalRules, totalTokens, overRules, overTokens)
141+
}
142+
143+
if (options.strict && (overRules > 0 || overTokens > 0)) {
144+
process.exit(1)
145+
}
146+
}
147+
148+
// Count discrete list-item rules (a proxy for "number of instructions"),
149+
// ignoring fenced code blocks so example code is not counted as instructions.
150+
export function countRules(body: string): number {
151+
const withoutCode = body.replace(/```[\s\S]*?```/g, '')
152+
const matches = withoutCode.match(/^\s*([-*]|\d+\.)\s/gm)
153+
return matches ? matches.length : 0
154+
}
155+
156+
// True if any comma-separated glob in `applyTo` matches `filePath`. Both sides
157+
// are normalized to POSIX separators so backslash paths still match.
158+
export function matchesPath(applyTo: string, filePath: string): boolean {
159+
const normalizedPath = filePath.replace(/\\/g, '/')
160+
return applyTo
161+
.split(',')
162+
.map((pattern) => pattern.trim().replace(/\\/g, '/'))
163+
.filter(Boolean)
164+
.some((pattern) => globToRegExp(pattern).test(normalizedPath))
165+
}
166+
167+
// Convert a VS Code-style applyTo glob to a RegExp. `**/` matches zero or more
168+
// path segments (so `**/*.md` matches both `README.md` and `dir/README.md`),
169+
// a standalone `**` matches across segments, and `*` matches within a segment.
170+
export function globToRegExp(glob: string): RegExp {
171+
let out = ''
172+
let i = 0
173+
while (i < glob.length) {
174+
const c = glob[i]
175+
if (c === '*' && glob[i + 1] === '*') {
176+
if (glob[i + 2] === '/') {
177+
out += '(?:.*/)?'
178+
i += 3
179+
} else {
180+
out += '.*'
181+
i += 2
182+
}
183+
} else if (c === '*') {
184+
out += '[^/]*'
185+
i += 1
186+
} else if ('.+()[]{}^$|\\/'.includes(c)) {
187+
out += `\\${c}`
188+
i += 1
189+
} else {
190+
out += c
191+
i += 1
192+
}
193+
}
194+
return new RegExp(`^${out}$`)
195+
}
196+
197+
function printReport(
198+
simulatedPath: string,
199+
loaded: FileReport[],
200+
totalRules: number,
201+
totalTokens: number,
202+
overRules: number,
203+
overTokens: number,
204+
): void {
205+
const width = Math.max('TOTAL'.length, ...loaded.map((f) => f.file.length))
206+
207+
console.log(`Always-on instruction budget for a content interaction (${simulatedPath})\n`)
208+
for (const f of loaded) {
209+
console.log(
210+
` ${f.file.padEnd(width)} ${String(f.rules).padStart(3)} rules ` +
211+
`${String(f.tokens).padStart(5)} tok (${f.applyTo})`,
212+
)
213+
}
214+
console.log(
215+
` ${'TOTAL'.padEnd(width)} ${String(totalRules).padStart(3)} rules ` +
216+
`${String(totalTokens).padStart(5)} tok`,
217+
)
218+
219+
const ruleStatus =
220+
overRules > 0 ? `OVER by ${overRules}` : `within budget, ${RULE_BUDGET - totalRules} headroom`
221+
const tokenStatus =
222+
overTokens > 0
223+
? `OVER by ${overTokens}`
224+
: `within budget, ${TOKEN_BUDGET - totalTokens} headroom`
225+
226+
console.log(`\nRules (primary): ${totalRules} / ~${RULE_BUDGET} ${ruleStatus}`)
227+
console.log(`Tokens (backstop): ${totalTokens} / ~${TOKEN_BUDGET} ${tokenStatus}`)
228+
console.log(
229+
'\nInstruction-following degrades with the number of discrete rules, ' +
230+
'so fewer, higher-value rules are always better.',
231+
)
232+
}
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { describe, expect, test } from 'vitest'
2+
3+
import { countRules, matchesPath, globToRegExp } from '../measure-instruction-budget'
4+
5+
describe('globToRegExp', () => {
6+
test('** matches across path segments', () => {
7+
expect(globToRegExp('**').test('content/a/b.md')).toBe(true)
8+
expect(globToRegExp('content/**').test('content/a/b.md')).toBe(true)
9+
expect(globToRegExp('content/**').test('data/a/b.md')).toBe(false)
10+
})
11+
12+
test('* matches within a single segment only', () => {
13+
expect(globToRegExp('package*.json').test('package-lock.json')).toBe(true)
14+
expect(globToRegExp('package*.json').test('package.json')).toBe(true)
15+
expect(globToRegExp('src/*.ts').test('src/index.ts')).toBe(true)
16+
expect(globToRegExp('src/*.ts').test('src/lib/index.ts')).toBe(false)
17+
})
18+
19+
test('**/*.md matches markdown at any depth, including the repo root', () => {
20+
expect(globToRegExp('**/*.md').test('content/get-started/foo.md')).toBe(true)
21+
expect(globToRegExp('**/*.md').test('README.md')).toBe(true)
22+
expect(globToRegExp('**/*.md').test('content/foo.ts')).toBe(false)
23+
})
24+
})
25+
26+
describe('matchesPath', () => {
27+
test('matches when any comma-separated glob matches', () => {
28+
expect(matchesPath('content/**,data/**,**/*.md', 'content/get-started/foo.md')).toBe(true)
29+
expect(matchesPath('content/**,data/**', 'data/reusables/x.md')).toBe(true)
30+
expect(matchesPath('src/**,.github/**', 'content/foo.md')).toBe(false)
31+
})
32+
33+
test('normalizes backslash paths to POSIX separators', () => {
34+
expect(matchesPath('content/**', 'content\\get-started\\foo.md')).toBe(true)
35+
})
36+
})
37+
38+
describe('countRules', () => {
39+
test('counts bullet and numbered list items', () => {
40+
const body = ['- first', '* second', '1. third', '2. fourth'].join('\n')
41+
expect(countRules(body)).toBe(4)
42+
})
43+
44+
test('counts indented (nested) list items', () => {
45+
const body = ['- top', ' - nested', ' * deeper'].join('\n')
46+
expect(countRules(body)).toBe(3)
47+
})
48+
49+
test('ignores list-like lines inside fenced code blocks', () => {
50+
const body = [
51+
'- a real rule',
52+
'```',
53+
'- not a rule',
54+
'1. also not a rule',
55+
'```',
56+
'- another rule',
57+
].join('\n')
58+
expect(countRules(body)).toBe(2)
59+
})
60+
61+
test('does not count headings or prose', () => {
62+
const body = ['# Heading', 'Some prose with - a dash mid-sentence.', '- one rule'].join('\n')
63+
expect(countRules(body)).toBe(1)
64+
})
65+
})

0 commit comments

Comments
 (0)