tsc-ts 0.1.0 → 0.1.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.
Files changed (60) hide show
  1. package/README.md +1 -1
  2. package/dist/api/compiler/ast.d.ts +9 -3
  3. package/dist/api/compiler/ast.js +17 -12
  4. package/dist/api/compiler/checker-classes.js +2 -1
  5. package/dist/api/compiler/checker-contextual.d.ts +20 -0
  6. package/dist/api/compiler/checker-contextual.js +111 -1
  7. package/dist/api/compiler/checker-expressions.js +14 -1
  8. package/dist/api/compiler/checker-jsx.js +2 -1
  9. package/dist/api/compiler/checker-type-factory.js +53 -12
  10. package/dist/api/compiler/checker.d.ts +5 -1
  11. package/dist/api/compiler/checker.js +10 -4
  12. package/dist/api/compiler/compile-program.d.ts +5 -0
  13. package/dist/api/compiler/compile-program.js +8 -0
  14. package/dist/api/compiler/expr-record.d.ts +11 -0
  15. package/dist/api/compiler/expr-record.js +53 -0
  16. package/dist/api/compiler/identifier-name.d.ts +9 -0
  17. package/dist/api/compiler/identifier-name.js +10 -0
  18. package/dist/api/compiler/jsdoc.js +4 -2
  19. package/dist/api/compiler/parser.d.ts +2 -1
  20. package/dist/api/compiler/parser.js +10 -8
  21. package/dist/api/compiler/printer/factory.js +3 -2
  22. package/dist/api/compiler/printer/printer.js +3 -2
  23. package/dist/api/compiler/reparser.js +4 -3
  24. package/dist/api/compiler/scanner.d.ts +8 -0
  25. package/dist/api/compiler/scanner.js +58 -42
  26. package/dist/api/compiler/shared-results.d.ts +376 -143
  27. package/dist/api/compiler/shared-results.js +1550 -773
  28. package/dist/api/compiler/transformers/declarations.js +2 -1
  29. package/package.json +9 -9
  30. package/src/compiler/ast.ts +19 -12
  31. package/src/compiler/cgroup.ts +157 -0
  32. package/src/compiler/checker-classes.ts +2 -1
  33. package/src/compiler/checker-contextual.ts +104 -0
  34. package/src/compiler/checker-expressions.ts +16 -1
  35. package/src/compiler/checker-jsx.ts +2 -1
  36. package/src/compiler/checker-type-factory.ts +53 -14
  37. package/src/compiler/checker.ts +11 -4
  38. package/src/compiler/compile-program.ts +8 -0
  39. package/src/compiler/decoder.ts +2 -1
  40. package/src/compiler/doctor-report.ts +133 -0
  41. package/src/compiler/expr-record.ts +59 -0
  42. package/src/compiler/identifier-name.ts +13 -0
  43. package/src/compiler/jsdoc.ts +4 -2
  44. package/src/compiler/parallel-check.ts +353 -146
  45. package/src/compiler/parallel-emit.ts +3 -3
  46. package/src/compiler/parser.ts +10 -8
  47. package/src/compiler/printer/factory.ts +3 -2
  48. package/src/compiler/printer/printer.ts +4 -2
  49. package/src/compiler/reparser.ts +7 -3
  50. package/src/compiler/run-config.ts +1383 -0
  51. package/src/compiler/run-record-read.ts +87 -0
  52. package/src/compiler/run-records.ts +575 -0
  53. package/src/compiler/scanner.ts +57 -41
  54. package/src/compiler/shared-results.ts +1626 -896
  55. package/src/compiler/thread-entry.ts +3 -1
  56. package/src/compiler/transformers/declarations.ts +2 -1
  57. package/src/compiler/tsc-build.ts +64 -6
  58. package/src/compiler/tsc-cli.ts +33 -4
  59. package/src/compiler/tsc-compile.ts +213 -8
  60. package/src/compiler/tsc-watch.ts +25 -4
@@ -0,0 +1,1383 @@
1
+ /**
2
+ * The run configuration: how many checker threads check a program, whether they share results with each
3
+ * other, and how many threads parse ahead of the program, with where each value came from. The values are
4
+ * pinned by `--checkers` / `"checkers"` and by the `TSC_TS_CHECKERS`, `TSC_TS_SHARE`, `TSC_TS_PARSE_THREADS`
5
+ * and `TSC_TS_AUTO` environment variables; a value that no pin gives is chosen by the program's signals
6
+ * (the static rules below) and, where the project has run before, by what those runs measured: a
7
+ * configuration the record shows to be faster is adopted, an adopted one is re-measured by control runs
8
+ * and dropped when it stops paying, and a configuration the signals leave uncertain is tried in a real run
9
+ * (`decideCheck`, `preParseConfig`).
10
+ *
11
+ * The parse threads must be known before any file is parsed (`preParseConfig`); the checker count and
12
+ * sharing are decided once the program is bound, from what it shows about the cost of checking it
13
+ * (`decideCheck`). `--extendedDiagnostics` prints the values, their sources and how to pin them after the
14
+ * statistics table (`reportRunConfig`).
15
+ */
16
+ import { availableCpus } from './cgroup.js';
17
+ import {
18
+ shapeChanged,
19
+ type RecordChoice,
20
+ type RecordRejection,
21
+ type RecordSample,
22
+ type RecordShape,
23
+ type RunRecord,
24
+ } from './run-records.js';
25
+
26
+ /** Where a setting's value came from. */
27
+ export enum Source {
28
+ /** An explicit pin: `--checkers`, `"checkers"`, or a `TSC_TS_*` environment variable. */
29
+ Pinned,
30
+ /** What the compiler used before anything chose, and what `TSC_TS_AUTO=0` keeps. */
31
+ Default,
32
+ /** A rule over the program's signals. */
33
+ Static,
34
+ /** What earlier runs of the project measured (run records). */
35
+ Learned,
36
+ /** A measurement taken during this run. */
37
+ Measured,
38
+ /** A run trying an alternative configuration, to learn from it. */
39
+ Explore,
40
+ /** A run re-measuring the default, to keep the learned choice honest. */
41
+ Control,
42
+ }
43
+
44
+ /** How much of the selection may run. */
45
+ export enum Automaticity {
46
+ /** Every source may choose: rules, records and in-run switches. */
47
+ On,
48
+ /** Rules and in-run guards only (`TSC_TS_AUTO=static`): no records are read or written. */
49
+ Static,
50
+ /** Nothing but the defaults the pins leave (`TSC_TS_AUTO=0`). */
51
+ Off,
52
+ /**
53
+ * As `On`, and a run may try an alternative the signals leave uncertain (`TSC_TS_AUTO=explore`).
54
+ * Experimental: a run that explores is slower than the configuration it would have used.
55
+ */
56
+ Explore,
57
+ }
58
+
59
+ /**
60
+ * Whether the record may decide any setting: `TSC_TS_AUTO` unset or `1`, and `explore`, which adds the
61
+ * exploration of the settings the signals leave uncertain.
62
+ */
63
+ export function learns(automaticity: Automaticity): boolean {
64
+ return automaticity === Automaticity.On || automaticity === Automaticity.Explore;
65
+ }
66
+
67
+ /** The values a run uses and where each came from. */
68
+ export class RunConfig {
69
+ /** The checker threads that check the program; 1 checks on the main thread. */
70
+ checkers = 1;
71
+ checkersSource: Source = Source.Default;
72
+ /** Whether checker threads share the results they compute (`shared-results.ts`). */
73
+ share = false;
74
+ shareSource: Source = Source.Default;
75
+ /**
76
+ * Whether a sharing run's posts are cost-bounded (`shared-results.ts`): it posts only results another checker
77
+ * demanded and budgets the records a post adds and a take builds. Off by default: a run that gains nothing by
78
+ * it pays for the demand bits and the budget, and no signal separates the projects that gain from those that
79
+ * do not, so a project turns it on by measuring it (`learn`).
80
+ */
81
+ bounded = false;
82
+ boundedSource: Source = Source.Default;
83
+ /** The threads that parse and bind the program's files ahead of it (`parallel-parse.ts`). */
84
+ parseThreads = 1;
85
+ parseSource: Source = Source.Default;
86
+ /** The pin a value came from, for the section (`--checkers`, `TSC_TS_SHARE`, `TSC_TS_PARSE_THREADS`). */
87
+ checkersPin: string | undefined = undefined;
88
+ sharePin: string | undefined = undefined;
89
+ boundedPin: string | undefined = undefined;
90
+ parsePin: string | undefined = undefined;
91
+ /** How much of the selection may run (`TSC_TS_AUTO`); `Off` leaves every value to the pins and defaults. */
92
+ automaticity = Automaticity.On;
93
+ /** Whether this run reports its signals to a doctor child's report (`TSC_TS_DOCTOR_REPORT`). */
94
+ measure = false;
95
+ /** Why each value was chosen, for the section's `automatic:` reason on that setting's line. */
96
+ readonly checkersReasons: string[] = [];
97
+ readonly shareReasons: string[] = [];
98
+ readonly boundedReasons: string[] = [];
99
+ readonly parseReasons: string[] = [];
100
+ /** Whether this run may write a run record (`run-records.ts`), and why not where it may not. */
101
+ records = true;
102
+ recordsReason: string | undefined = undefined;
103
+ /** What the bound program showed about the cost of checking it (`parallel-check.ts`), where computed. */
104
+ signals: CheckSignals | undefined = undefined;
105
+ /** The project's record of earlier runs, where it was read before this run's files were parsed. */
106
+ record: RunRecord | undefined = undefined;
107
+ /** Where that record lives: its directory and key, and the file they name (`run-records.ts`). */
108
+ recordDirectory: string | undefined = undefined;
109
+ recordKey: string | undefined = undefined;
110
+ recordFile: string | undefined = undefined;
111
+ /** The program's size, where this run computed it (`parallel-check.ts`): its record and the shape check. */
112
+ shape: RecordShape | undefined = undefined;
113
+ /** The role this run's sample gets in the record. */
114
+ role: 'chosen' | 'control' | 'explore' = 'chosen';
115
+ /**
116
+ * The configuration the record keeps as chosen after this run. Learning adopts a measured configuration
117
+ * and reverts to the static one; a run reading no record leaves it for the writer.
118
+ */
119
+ chosen: RecordChoice | undefined = undefined;
120
+ /**
121
+ * The configuration `fastRevert` rejected in this run, which the record keeps so a later run reads only
122
+ * the samples taken after the rejection as evidence for it (`run-records.ts`).
123
+ */
124
+ rejected: RecordRejection | undefined = undefined;
125
+ /** The window a sharing run reads its checkers' counters in: a tenth of the recorded check, or 300 ms. */
126
+ shareWindowMs = shareWindow;
127
+ /**
128
+ * Whether this run may try an alternative configuration: `TSC_TS_AUTO=explore` only, and never in CI,
129
+ * watch mode or a concurrent build. Off by default: a run adopts what the record's samples show faster,
130
+ * and tries nothing that would make the run itself slower.
131
+ */
132
+ explore = false;
133
+ }
134
+
135
+ /** The environment's pins, parsed once per run: `TSC_TS_AUTO`, `TSC_TS_SHARE` and `TSC_TS_PARSE_THREADS`. */
136
+ export interface Pins {
137
+ readonly automaticity: Automaticity;
138
+ /** `TSC_TS_SHARE`: sharing pinned on or off; `undefined` when the variable is not set. */
139
+ readonly share: boolean | undefined;
140
+ /** `TSC_TS_SHARE_BOUNDED`: bounded posting pinned on or off; `undefined` when the variable is not set. */
141
+ readonly bounded: boolean | undefined;
142
+ /** `TSC_TS_PARSE_THREADS`: the parse threads pinned; `undefined` when not set or not a whole number above 0. */
143
+ readonly parseThreads: number | undefined;
144
+ }
145
+
146
+ /** `TSC_TS_SHARE` parsed: `1` pins sharing on, `0` pins it off, and anything else pins nothing. */
147
+ export function sharePin(env: Record<string, string | undefined>): boolean | undefined {
148
+ return binaryPin(env.TSC_TS_SHARE);
149
+ }
150
+
151
+ /** `TSC_TS_SHARE_BOUNDED` parsed: `1` pins bounded posting on, `0` pins it off, anything else pins nothing. */
152
+ export function boundedPin(env: Record<string, string | undefined>): boolean | undefined {
153
+ return binaryPin(env.TSC_TS_SHARE_BOUNDED);
154
+ }
155
+
156
+ /** `1` as true, `0` as false, and anything else (including unset) as no pin. */
157
+ function binaryPin(value: string | undefined): boolean | undefined {
158
+ return value === '1' ? true : value === '0' ? false : undefined;
159
+ }
160
+
161
+ /** The pins of `env`: `TSC_TS_AUTO` (`0`, `static`, `explore` or unset), `TSC_TS_SHARE` and `TSC_TS_PARSE_THREADS`. */
162
+ export function pinsOf(env: Record<string, string | undefined>): Pins {
163
+ const automatic = env.TSC_TS_AUTO;
164
+ const threads = env.TSC_TS_PARSE_THREADS === undefined ? NaN : Number(env.TSC_TS_PARSE_THREADS);
165
+ return {
166
+ automaticity:
167
+ automatic === '0'
168
+ ? Automaticity.Off
169
+ : automatic === 'static'
170
+ ? Automaticity.Static
171
+ : automatic === 'explore'
172
+ ? Automaticity.Explore
173
+ : Automaticity.On,
174
+ share: sharePin(env),
175
+ bounded: boundedPin(env),
176
+ parseThreads: Number.isInteger(threads) && threads >= 1 ? threads : undefined,
177
+ };
178
+ }
179
+
180
+ /**
181
+ * How the number of checkers is chosen without `--checkers`: `auto` (the default) uses every CPU this
182
+ * process may run on (`availableCpus()`, at most `maxAutoCheckers`), and at least tsgo's four;
183
+ * fewer than the CPUs, but never fewer than four, when the estimated peak memory of more checkers would
184
+ * not fit. `fixed` (`TSC_TS_CHECKERS=fixed`) uses tsgo's four.
185
+ */
186
+ export function checkersPolicy(env: Record<string, string | undefined>): 'fixed' | 'auto' {
187
+ return env.TSC_TS_CHECKERS === 'fixed' ? 'fixed' : 'auto';
188
+ }
189
+
190
+ /** tsgo's number of checkers, and the fewest `auto` starts. */
191
+ export const tsgoCheckers = 4;
192
+
193
+ /** The most checkers `auto` starts (measured up to 16 CPUs). */
194
+ export const maxAutoCheckers = 16;
195
+
196
+ /** `auto` with `cpus` CPUs when the memory estimate fits `fit` checkers: tsgo's four at least. */
197
+ export function autoCheckers(cpus: number, fit: number): number {
198
+ return Math.max(tsgoCheckers, Math.min(cpus, fit));
199
+ }
200
+
201
+ /**
202
+ * When a checker's own files are a small part of what it resolves (`libraryBoundCount`): the least
203
+ * text, in characters, of the libraries the files of a checker's range reach, on average over the
204
+ * ranges, and the least multiple of the text of the range's own files it is.
205
+ */
206
+ export const libraryBoundText = 6_000_000;
207
+ export const libraryBoundShare = 5;
208
+
209
+ /** Whether ranges that reach `library` characters of libraries and check `own` are library-bound. */
210
+ export function isLibraryBound(library: number, own: number): boolean {
211
+ return library >= libraryBoundText && library >= libraryBoundShare * own;
212
+ }
213
+
214
+ /**
215
+ * The number of checkers for `count` checkers whose ranges reach `library` characters of libraries
216
+ * and check `own` characters each, on average: three quarters of them (tsgo's four at least) where
217
+ * every checker spends most of its time resolving the same large libraries (`libraryBoundText`,
218
+ * `libraryBoundShare`), else `count`.
219
+ *
220
+ * Each checker resolves the declarations its files reach, whatever else it checks. Where they are
221
+ * millions of characters, such as an Effect-based project's, and much more than the checker's own
222
+ * files, a checker costs seconds of CPU and hundreds of MB before its files get cheap, and the last
223
+ * quarter of the checkers (on 16 CPUs of 8 cores, the ones sharing a core with another) repeat that
224
+ * work rather than save time: T3 Code's web and server projects ran 3% and 1% faster with 12
225
+ * checkers than with 16, and used 16% and 8% less memory. Where checkers resolve less, or mostly
226
+ * check their own files, 12 of 16 checkers were slower: Excalidraw (1.4M library and 0.15M own
227
+ * characters per range) by 6%, Sentry (5.7M and 3.0M) and the tsc-ts sources by about 11%.
228
+ */
229
+ export function libraryBoundCount(count: number, library: number, own: number): number {
230
+ return isLibraryBound(library, own) ? Math.max(tsgoCheckers, Math.ceil((count * 3) / 4)) : count;
231
+ }
232
+
233
+ /**
234
+ * The least checkers sharing can pay with: 12, from T3 Code's two projects at 16 and 4 neutral to
235
+ * slightly worse (1.028). c8 and c12 are unmeasured, so 12 is where the measurements begin.
236
+ */
237
+ export const minSharingCheckers = 12;
238
+
239
+ /**
240
+ * The share of a program's files that must be declaration files or come from `node_modules` (F) for
241
+ * sharing to pay: the files every checker resolves again. T3 Code's projects are at 0.48 and 0.66;
242
+ * TypeORM, VS Code and Sentry at 0.08, 0.08 and 0.26, where sharing was neutral or slower.
243
+ */
244
+ export const sharingFileShare = 0.4;
245
+
246
+ /**
247
+ * How much sharing raises the estimated peak memory, for the fit a count must pass before sharing turns
248
+ * on: peak RSS grew by up to 11% at 16 checkers (T3 web), and each extra checker's declarations are
249
+ * resolved by one more checker.
250
+ */
251
+ export const sharingPeakShare = 1.12;
252
+
253
+ /**
254
+ * Whether `signals` say that sharing pays: at least `minSharingCheckers` checkers, `sharingFileShare`
255
+ * of the program declarations or from node_modules, and ranges that mostly resolve the same large
256
+ * libraries — the work sharing removes. The memory fit (`CheckFit`) is checked by `decideCheck`.
257
+ */
258
+ export function sharingCandidate(signals: CheckSignals): boolean {
259
+ return (
260
+ signals.count >= minSharingCheckers &&
261
+ signals.fileShare >= sharingFileShare &&
262
+ isLibraryBound(signals.library, signals.own)
263
+ );
264
+ }
265
+
266
+ /** The window a sharing run reads its counters in where the record has no check time: 300 ms. */
267
+ export const shareWindow = 300;
268
+
269
+ /**
270
+ * Learning (DESIGN 3, 4, 5). A run with a record of the project's earlier runs compares the configurations
271
+ * those samples measured with the ones the static rules choose. It needs three usable samples of each arm
272
+ * before it trusts a comparison (`comparisonSamples`), and a configuration beats another only when it is
273
+ * faster by the margin the runs around each sample leave (`beatsPaired`, DESIGN 4.1: 3% at least, twice the
274
+ * standard error of the paired gains), or, where the record has too few pairs, by `marginOf` (3% at least, twice the
275
+ * larger arm's relative MAD). Adopted configurations are re-measured by control runs and dropped by the
276
+ * fast-revert rule; a configuration the signals leave uncertain is tried in a real run, at most one in
277
+ * `exploreRuns` and never when the record shows it loses.
278
+ */
279
+
280
+ /** The fewest usable samples of each arm a comparison needs. */
281
+ export const comparisonSamples = 3;
282
+ /** The smallest margin one configuration must beat another by (DESIGN 4.1: T1's c1 noise band). */
283
+ export const marginFloor = 0.03;
284
+ /** The load above which a sample is kept in the record but not used for a decision (DESIGN 3.3). */
285
+ export const usableLoad = 0.5;
286
+ /** The runs between two control runs of an adopted configuration (DESIGN 4.2). */
287
+ export const controlRuns = 16;
288
+ /** The seconds after which an adopted configuration is re-measured, even without `controlRuns` runs. */
289
+ export const controlAfterSeconds = 14 * 86400;
290
+ /** The fewest runs between two exploring runs (DESIGN 4.4). */
291
+ export const exploreRuns = 8;
292
+ /** The least recorded check time exploration is worth its noise at, milliseconds (DESIGN 4.4). */
293
+ export const exploreCheckMs = 2000;
294
+ /** The fewest checkers a sharing exploration runs with (DESIGN 4.4). */
295
+ export const exploreSharingCheckers = 8;
296
+ /** The least file share at which sharing is uncertain (DESIGN 4.4). */
297
+ export const exploreSharingShare = 0.3;
298
+ /** The band of L / O within which the checker count is uncertain (DESIGN 4.4). */
299
+ export const exploreLibraryRatio = 2.5;
300
+ export const exploreLibraryRatioHigh = 10;
301
+ /** The least library text at which the checker count is uncertain (DESIGN 4.4). */
302
+ export const exploreLibraryText = 3_000_000;
303
+
304
+ /** The median of `values`, `undefined` where there are none. */
305
+ export function medianOf(values: readonly number[]): number | undefined {
306
+ if (values.length === 0) return undefined;
307
+ const sorted = [...values].sort((a, b) => a - b);
308
+ const middle = sorted.length >> 1;
309
+ return sorted.length % 2 === 1 ? sorted[middle]! : (sorted[middle - 1]! + sorted[middle]!) / 2;
310
+ }
311
+
312
+ /** An arm's relative noise: 1.4826 × its median absolute deviation / its median (DESIGN 4.1). */
313
+ export function noiseOf(values: readonly number[]): number {
314
+ const median = medianOf(values);
315
+ if (median === undefined || median <= 0) return 0;
316
+ const deviations = values.map((value) => Math.abs(value - median));
317
+ return (1.4826 * (medianOf(deviations) ?? 0)) / median;
318
+ }
319
+
320
+ /** The margin `a` and `b` must differ by: 3%, or twice the larger arm's noise (DESIGN 4.1). */
321
+ export function marginOf(a: readonly number[], b: readonly number[]): number {
322
+ return Math.max(marginFloor, 2 * Math.max(noiseOf(a), noiseOf(b)));
323
+ }
324
+
325
+ /**
326
+ * How much faster `better` is than `worse`, where it beats it by `marginOf`: `0.21` for 21% faster,
327
+ * `undefined` where there are fewer than `comparisonSamples` usable samples of either arm or no margin
328
+ * separates them.
329
+ */
330
+ export function beatsBy(better: readonly number[], worse: readonly number[]): number | undefined {
331
+ if (better.length < comparisonSamples || worse.length < comparisonSamples) return undefined;
332
+ const b = medianOf(better)!;
333
+ const w = medianOf(worse)!;
334
+ if (b <= 0 || w <= 0) return undefined;
335
+ return b <= w * (1 - marginOf(better, worse)) ? 1 - b / w : undefined;
336
+ }
337
+
338
+ /** The samples of `record` a decision may use: those taken while the machine was not busy. */
339
+ export function usableSamples(record: RunRecord): RecordSample[] {
340
+ return record.samples.filter((sample) => sample.load <= usableLoad);
341
+ }
342
+
343
+ /** A configuration a paired comparison names: the three settings a sample's check time depends on. */
344
+ export interface CheckArm {
345
+ readonly checkers: number;
346
+ readonly share: boolean;
347
+ readonly bounded: boolean;
348
+ }
349
+
350
+ /** Whether `sample` checked with `arm`. */
351
+ function checkedWithArm(sample: RecordSample, arm: CheckArm): boolean {
352
+ return sample.checkers === arm.checkers && sample.share === arm.share && (sample.bounded ?? false) === arm.bounded;
353
+ }
354
+
355
+ /**
356
+ * The relative gains of each sample of `better` against the sample of `worse` nearest to it in record order: `1 -
357
+ * better/worse` for every sample of the first arm that has one of the second to compare with. Runs minutes apart
358
+ * meet the same memory pressure — the page cache, the allocator, the garbage collector's footprint — and that
359
+ * pressure moves a whole session's runs together, so the spread of either arm's samples over-counts it
360
+ * (`noiseOf` reads that spread). A difference against the nearest run of the other arm cancels it, which is what
361
+ * this reads. A tie goes to the earlier sample, the run the alternative was tried after.
362
+ *
363
+ * The record may alternate the two arms or space them `exploreRuns` apart; either way a sample is compared with
364
+ * the record's nearest evidence of the other arm, not with a global median. Each sample of `worse` pairs once:
365
+ * three alternative samples must read three runs of the baseline, so a record with one baseline sample gives one
366
+ * gain however many alternative samples it has.
367
+ */
368
+ export function pairedGains(samples: readonly RecordSample[], better: CheckArm, worse: CheckArm): number[] {
369
+ const gains: number[] = [];
370
+ const paired: boolean[] = [];
371
+ for (let i = 0; i < samples.length; i++) {
372
+ const sample = samples[i]!;
373
+ if (!checkedWithArm(sample, better)) continue;
374
+ let near = -1;
375
+ for (let j = 0; j < samples.length; j++) {
376
+ if (j === i || paired[j] || !checkedWithArm(samples[j]!, worse)) continue;
377
+ const distance = Math.abs(j - i);
378
+ if (near < 0 || distance < Math.abs(near - i) || (distance === Math.abs(near - i) && j < near)) near = j;
379
+ }
380
+ if (near < 0) continue;
381
+ paired[near] = true;
382
+ const worseMs = samples[near]!.checkMs;
383
+ if (worseMs > 0) gains.push(1 - sample.checkMs / worseMs);
384
+ }
385
+ return gains;
386
+ }
387
+
388
+ /**
389
+ * How much faster `better` is than `worse`, from the gains of `pairedGains`: `0.11` for 11% faster, `undefined`
390
+ * where the record cannot show it. Two views must agree. The median paired gain must clear `marginFloor` and
391
+ * twice the standard error of the median of the gains, 2 × 1.2533 × 1.4826 × their median absolute deviation /
392
+ * √(the pairs): the spread two neighbouring runs do not share is what one pair is worth, and a median of several
393
+ * pairs is closer than that. The arms' medians must also differ by the floor, so a gain made up of a few lucky
394
+ * pairs cannot carry a comparison the record as a whole does not show. The comparison needs `comparisonSamples`
395
+ * pairs, and `pairedGains` pairs each baseline sample once, so that is three usable samples of each arm.
396
+ */
397
+ export function beatsPaired(samples: readonly RecordSample[], better: CheckArm, worse: CheckArm): number | undefined {
398
+ const gains = pairedGains(samples, better, worse);
399
+ if (gains.length < comparisonSamples) return undefined;
400
+ return pairedBeat(samples, better, worse, gains);
401
+ }
402
+
403
+ /** `beatsPaired` over `gains`, which the caller has already found to be `comparisonSamples` or more. */
404
+ function pairedBeat(
405
+ samples: readonly RecordSample[],
406
+ better: CheckArm,
407
+ worse: CheckArm,
408
+ gains: readonly number[],
409
+ ): number | undefined {
410
+ const median = medianOf(gains)!;
411
+ const spread = 1.4826 * (medianOf(gains.map((gain) => Math.abs(gain - median))) ?? 0);
412
+ if (median < Math.max(marginFloor, (2 * 1.2533 * spread) / Math.sqrt(gains.length))) return undefined;
413
+ const b = medianOf(checkTimes(samples, better.checkers, better.share, better.bounded));
414
+ const w = medianOf(checkTimes(samples, worse.checkers, worse.share, worse.bounded));
415
+ if (b === undefined || w === undefined || b <= 0 || w <= 0 || b > w * (1 - marginFloor)) return undefined;
416
+ return median;
417
+ }
418
+
419
+ /**
420
+ * How much faster `better` is than `worse` on the record's evidence: the median paired gain where the record has
421
+ * enough pairs of neighbouring samples (`beatsPaired`), and the arm medians by `marginOf` only where it has too
422
+ * few pairs to read one (`beatsBy`). A paired comparison that found the pairs and still could not show the gain
423
+ * is the record's verdict on the alternative, and the fallback does not overrule it.
424
+ *
425
+ * `considered` is the alternative's samples with the rejected configuration's left out; the static arm is
426
+ * compared over every sample the record kept.
427
+ */
428
+ function beats(
429
+ samples: readonly RecordSample[],
430
+ considered: readonly RecordSample[],
431
+ better: CheckArm,
432
+ worse: CheckArm,
433
+ ): number | undefined {
434
+ const gains = pairedGains(considered, better, worse);
435
+ if (gains.length >= comparisonSamples) return pairedBeat(considered, better, worse, gains);
436
+ return beatsBy(
437
+ checkTimes(considered, better.checkers, better.share, better.bounded),
438
+ checkTimes(samples, worse.checkers, worse.share, worse.bounded),
439
+ );
440
+ }
441
+
442
+ /**
443
+ * The check times of the samples that checked with `checkers` checkers, sharing `share`, and cost-bounded posting
444
+ * `bounded`. Every comparison names all three arms: a sample without the field is an unbounded one.
445
+ */
446
+ export function checkTimes(
447
+ samples: readonly RecordSample[],
448
+ checkers: number,
449
+ share: boolean,
450
+ bounded: boolean,
451
+ ): number[] {
452
+ const times: number[] = [];
453
+ for (const sample of samples)
454
+ if (sample.checkers === checkers && sample.share === share && (sample.bounded === true) === bounded)
455
+ times.push(sample.checkMs);
456
+ return times;
457
+ }
458
+
459
+ /** The parse times of the samples that parsed with `parseThreads` threads. */
460
+ export function parseTimes(samples: readonly RecordSample[], parseThreads: number): number[] {
461
+ const times: number[] = [];
462
+ for (const sample of samples) if (sample.parseThreads === parseThreads) times.push(sample.parseMs);
463
+ return times;
464
+ }
465
+
466
+ /**
467
+ * The least margin between masses of the two arms a fast revert needs: the three adopted samples against
468
+ * control samples that may be fewer than `comparisonSamples`.
469
+ */
470
+ function revertMargin(adopted: readonly number[], control: readonly number[]): number {
471
+ return Math.max(marginFloor, 2 * Math.max(noiseOf(adopted), noiseOf(control)));
472
+ }
473
+
474
+ /**
475
+ * Whether the last three usable samples of an adopted configuration all measured slower than the latest
476
+ * control samples by more than the margin: the adopted choice has gone bad and reverts now, without
477
+ * waiting for three new control samples (DESIGN 4.2).
478
+ */
479
+ export function fastRevert(
480
+ samples: readonly RecordSample[],
481
+ checkers: number,
482
+ share: boolean,
483
+ bounded: boolean,
484
+ ): boolean {
485
+ const adopted: number[] = [];
486
+ for (const sample of samples)
487
+ if (sample.checkers === checkers && sample.share === share && (sample.bounded === true) === bounded)
488
+ adopted.push(sample.checkMs);
489
+ if (adopted.length < comparisonSamples) return false;
490
+ const last = adopted.slice(-comparisonSamples);
491
+ const control: number[] = [];
492
+ for (const sample of samples)
493
+ if (sample.role === 'control' && sample.load <= usableLoad) control.push(sample.checkMs);
494
+ if (control.length === 0) return false;
495
+ const baseline = medianOf(control.slice(-comparisonSamples))!;
496
+ const margin = revertMargin(last, control.slice(-comparisonSamples));
497
+ return last.every((ms) => ms > baseline * (1 + margin));
498
+ }
499
+
500
+ /** The window a sharing run reads its counters in: a tenth of the recorded check, at least 200 ms. */
501
+ export function shareWindowOf(
502
+ record: RunRecord | undefined,
503
+ checkers: number,
504
+ share: boolean,
505
+ bounded: boolean,
506
+ ): number {
507
+ if (record === undefined) return shareWindow;
508
+ const samples = usableSamples(record);
509
+ const times =
510
+ checkTimes(samples, checkers, share, bounded).length > 0
511
+ ? checkTimes(samples, checkers, share, bounded)
512
+ : checkTimes(samples, checkers, share, !bounded);
513
+ const median = medianOf(times);
514
+ if (median === undefined) return shareWindow;
515
+ return Math.max(200, Math.round(median / 10));
516
+ }
517
+
518
+ /** `fraction` as a whole percentage, `21` for 0.213. */
519
+ function percent(fraction: number): number {
520
+ return Math.round(fraction * 100);
521
+ }
522
+
523
+ /**
524
+ * The number of threads that parse ahead of a program checked by `checkers` checkers: every CPU this
525
+ * process may run on, as tsgo parses on every core whatever its checker count (parse threads hand
526
+ * their files to the shared program; what else they allocate is reused by the checker threads), but
527
+ * one. The main thread hands out the chunks they load: with a thread on every CPU, it waits for a CPU
528
+ * each time a thread returns a chunk, and so does that thread for its next chunk (VS Code's parse took
529
+ * 18-57 ms longer with 16 threads on 16 CPUs than with 15).
530
+ */
531
+ export function defaultParseThreads(checkers: number): number {
532
+ const cpus = availableCpus();
533
+ return Math.max(1, Math.min(cpus - 1, Math.max(checkers, Math.min(cpus, maxAutoCheckers))));
534
+ }
535
+
536
+ /** The options the configuration reads: `"checkers"` or `--checkers` pins the count (`CompilerOptions`). */
537
+ export interface RunOptions {
538
+ readonly checkers?: number;
539
+ }
540
+
541
+ /**
542
+ * The run's configuration for a program with `options`, before its files are parsed: the parse threads,
543
+ * which are fixed before any file is known, and the pins of the checker count and sharing. `checkers` is
544
+ * the count for the options without the program (`checkerThreads`); the count and sharing are decided
545
+ * after bind, in `decideCheck`.
546
+ *
547
+ * `record` is the project's record of earlier runs where this run may read one; its chosen parse threads
548
+ * apply here, and where the record leaves them uncertain and the static rules have nothing to try, this
549
+ * run parses with half the default threads to measure them. `explore` is false where a run may not try an
550
+ * alternative (watch mode, a concurrent build); CI is read from `env`.
551
+ */
552
+ export function preParseConfig(
553
+ options: RunOptions,
554
+ checkers: number,
555
+ env: Record<string, string | undefined> = process.env,
556
+ record: RunRecord | undefined = undefined,
557
+ explore: boolean = true,
558
+ ): RunConfig {
559
+ const config = new RunConfig();
560
+ const pins = pinsOf(env);
561
+ config.automaticity = pins.automaticity;
562
+ config.checkers = checkers;
563
+ config.record = record;
564
+ const ci = env.CI;
565
+ config.explore =
566
+ explore && pins.automaticity === Automaticity.Explore && (ci === undefined || ci === '' || ci === 'false');
567
+ if (options.checkers !== undefined) {
568
+ config.checkersSource = Source.Pinned;
569
+ config.checkersPin = '--checkers';
570
+ } else if (checkersPolicy(env) === 'fixed') {
571
+ config.checkersSource = Source.Pinned;
572
+ config.checkersPin = 'TSC_TS_CHECKERS';
573
+ }
574
+ const parseDefault = defaultParseThreads(checkers);
575
+ if (pins.parseThreads !== undefined) {
576
+ config.parseThreads = pins.parseThreads;
577
+ config.parseSource = Source.Pinned;
578
+ config.parsePin = 'TSC_TS_PARSE_THREADS';
579
+ } else if (controlDue(record, pins.automaticity, Math.floor(Date.now() / 1000))) {
580
+ // A control run parses with the default threads too: it re-measures the static choice whole.
581
+ config.parseThreads = parseDefault;
582
+ config.parseSource = Source.Control;
583
+ config.role = 'control';
584
+ } else {
585
+ config.parseThreads = parseDefault;
586
+ const learned = learnedParseThreads(record, parseDefault, pins.automaticity);
587
+ const explored = learned === undefined ? exploreParseThreads(record, parseDefault, config.explore) : undefined;
588
+ if (learned !== undefined) {
589
+ config.parseThreads = learned.threads;
590
+ config.parseSource = Source.Learned;
591
+ config.parseReasons.push(learned.reason);
592
+ } else if (explored !== undefined) {
593
+ config.parseThreads = explored;
594
+ config.parseSource = Source.Explore;
595
+ config.role = 'explore';
596
+ }
597
+ }
598
+ if (pins.share !== undefined) {
599
+ config.share = pins.share;
600
+ config.shareSource = Source.Pinned;
601
+ config.sharePin = 'TSC_TS_SHARE';
602
+ }
603
+ if (pins.bounded !== undefined) {
604
+ config.bounded = pins.bounded;
605
+ config.boundedSource = Source.Pinned;
606
+ config.boundedPin = 'TSC_TS_SHARE_BOUNDED';
607
+ }
608
+ if (env.TSC_TS_CACHE === '0') {
609
+ config.records = false;
610
+ config.recordsReason = 'TSC_TS_CACHE=0';
611
+ } else if (!learns(pins.automaticity)) {
612
+ config.records = false;
613
+ config.recordsReason = 'TSC_TS_AUTO=' + (pins.automaticity === Automaticity.Off ? '0' : 'static');
614
+ }
615
+ // A doctor child reports the signals even though it keeps no record (`doctor-report.ts`).
616
+ const report = env.TSC_TS_DOCTOR_REPORT;
617
+ config.measure = report !== undefined && report !== '';
618
+ return config;
619
+ }
620
+
621
+ /**
622
+ * What a bound program shows about the cost of checking it, which the rules that choose the checker
623
+ * count and sharing read. The ranges are the checker threads' ranges of files (`planSchedule`).
624
+ */
625
+ export class CheckSignals {
626
+ /** The checkers the plan was made for, before any rule cut the count. */
627
+ count: number;
628
+ /** The statement ranges of the plan; 0 where every file is checked whole. */
629
+ ranges: number;
630
+ /** Characters of the libraries a checker's range reaches, on average over the ranges (L). */
631
+ library = 0;
632
+ /** Characters of the files a checker's range checks, on average over the ranges (O). */
633
+ own = 0;
634
+ /** The share of `library` + `own` that a second range reaches too (R). */
635
+ redundancy = 0;
636
+ /** The share of the program's files that are declaration files (F_d). */
637
+ declarationShare = 0;
638
+ /** The share of the program's files that come from `node_modules` (F_n). */
639
+ nodeModulesShare = 0;
640
+
641
+ constructor(count: number, ranges: number) {
642
+ this.count = count;
643
+ this.ranges = ranges;
644
+ }
645
+
646
+ /** F = max(F_d, F_n): the share of the program that is declarations every checker resolves again. */
647
+ get fileShare(): number {
648
+ return Math.max(this.declarationShare, this.nodeModulesShare);
649
+ }
650
+ }
651
+
652
+ /**
653
+ * The checkers whose estimated peak memory fits a program: without sharing, and with the peak sharing
654
+ * adds (`sharingPeakShare`). Computed by `parallel-check.ts`, which holds the estimate.
655
+ */
656
+ export interface CheckFit {
657
+ /** The checkers whose estimated peak memory fits with the peak sharing adds (`sharingPeakShare`). */
658
+ readonly sharing: number;
659
+ }
660
+
661
+ /** What `decideCheck` needs of the run besides the signals: whether it can share, and how many checkers fit. */
662
+ export interface CheckDecision {
663
+ /** Whether checking happens on checker threads that share the program (scriptc builds), more than one. */
664
+ readonly possible: boolean;
665
+ /** The memory fit, where the sharing rule needs it; `undefined` elsewhere. */
666
+ readonly fit: CheckFit | undefined;
667
+ /** The program's shape, where this run read a record: its samples describe this program only if it matches. */
668
+ readonly shape?: RecordShape;
669
+ /** The CPUs automatic selection may use (`min(availableCpus(), maxAutoCheckers)`). */
670
+ readonly cpus?: number;
671
+ /** The memory limit, in bytes: a configuration whose recorded peak exceeds `memoryShare` of it is not chosen. */
672
+ readonly memoryBytes?: number;
673
+ /** The time, seconds since the epoch, for the age of the last control sample (DESIGN 4.2). */
674
+ readonly now?: number;
675
+ }
676
+
677
+ /** The decision of a run that cannot share, made without the memory fit. */
678
+ export const noSharing: CheckDecision = { possible: false, fit: undefined };
679
+
680
+ /** The share of the memory limit a configuration may have reached at its peak (DESIGN 5.5). */
681
+ export const memoryShare = 0.85;
682
+
683
+ /**
684
+ * The checker count and sharing for a bound program, from `signals`; returns the count.
685
+ *
686
+ * The count is the one the plan was made for, cut to three quarters where every range mostly resolves
687
+ * the same large libraries and no pin gave it (`libraryBoundCount`, `tests/parallel-schedule` records
688
+ * the measurements).
689
+ *
690
+ * Sharing is turned on by the static rule where the run may choose it and can share, the signals say it
691
+ * pays (`sharingCandidate`), the count it leaves is one sharing was measured at (`minSharingCheckers`),
692
+ * and that count fits memory with the peak sharing adds. The cut stays on: 16 checkers with sharing are
693
+ * within noise of the cut count with it on T3 Code's two projects (Q2: 0.99 and 1.03 of the cut count,
694
+ * the intervals covering 1), and the cut uses 0.4 GB to 0.9 GB less at the peak, so the count is not
695
+ * raised for sharing.
696
+ *
697
+ * Where the run read the project's record, the samples it holds decide more: an adopted configuration
698
+ * stays until the static one beats it and reverts early when it stops paying (`learn`), a configuration
699
+ * the signals leave uncertain is tried, and a run in every `controlRuns` re-measures the static choice.
700
+ * That includes bounded posting (`RunConfig.bounded`), which no signal decides and which a project learns
701
+ * from its own runs: T3 web's checks were 7% faster with it at 16 checkers and T3 server's 8-9% slower.
702
+ *
703
+ * Sharing is what `TSC_TS_SHARE` pins, and bounded posting `TSC_TS_SHARE_BOUNDED`. The decisions are those
704
+ * of a run before this module existed, and what `TSC_TS_AUTO=0` keeps.
705
+ */
706
+ export function decideCheck(config: RunConfig, signals: CheckSignals, decision: CheckDecision = noSharing): number {
707
+ const count = signals.count;
708
+ const pinned = config.checkersSource === Source.Pinned;
709
+ const cut = pinned || count <= tsgoCheckers ? count : libraryBoundCount(count, signals.library, signals.own);
710
+ config.checkers = cut;
711
+ const fit = decision.fit;
712
+ if (
713
+ decision.possible &&
714
+ fit !== undefined &&
715
+ fit.sharing >= cut &&
716
+ cut >= minSharingCheckers &&
717
+ config.shareSource !== Source.Pinned &&
718
+ config.automaticity !== Automaticity.Off &&
719
+ sharingCandidate(signals)
720
+ ) {
721
+ config.share = true;
722
+ config.shareSource = Source.Static;
723
+ config.shareReasons.push(sharingReason(signals));
724
+ }
725
+ config.shape = decision.shape;
726
+ if (learns(config.automaticity) && config.record !== undefined) learn(config, signals, decision, cut);
727
+ config.shareWindowMs = shareWindowOf(config.record, config.checkers, config.share, config.bounded);
728
+ return config.checkers;
729
+ }
730
+
731
+ /**
732
+ * The static rules' choice and the record's evidence decide this run's checker count and sharing, and the
733
+ * configuration the record keeps as chosen (DESIGN 4.2-4.4):
734
+ *
735
+ * - an adopted configuration stays while the static one does not beat it, and reverts at once when its
736
+ * last three samples are slower than the last control samples (`fastRevert`);
737
+ * - one run in `controlRuns`, or the first after `controlAfterSeconds`, uses the static choice and is
738
+ * recorded with role `control`, so the baseline the keep rule reads stays current;
739
+ * - a configuration with `comparisonSamples` usable samples that beats the static choice by the margin
740
+ * (`beats`) is adopted, one setting at a time: sharing, the checker count, then bounded posting at the
741
+ * count and sharing adopted before it;
742
+ * - where the signals leave a setting uncertain and the record has fewer than `comparisonSamples` of the
743
+ * alternative, one run in `exploreRuns` tries it, with role `explore`.
744
+ *
745
+ * A pinned setting is never chosen by any of this. A run that explored its parse threads (`role`
746
+ * `explore` before binding) or is a control run keeps its static checkers and sharing, so that one
747
+ * dimension is measured at a time.
748
+ */
749
+ function learn(config: RunConfig, signals: CheckSignals, decision: CheckDecision, staticCheckers: number): void {
750
+ const record = config.record!;
751
+ const staticShare =
752
+ config.shareSource === Source.Pinned
753
+ ? config.share
754
+ : sharingCandidate(signals) &&
755
+ decision.possible &&
756
+ decision.fit !== undefined &&
757
+ decision.fit.sharing >= staticCheckers &&
758
+ staticCheckers >= minSharingCheckers;
759
+ const changed = decision.shape !== undefined && shapeChanged(record.shape, decision.shape);
760
+ const samples = changed ? [] : usableSamples(record);
761
+ const cpus = Math.min(decision.cpus ?? availableCpus(), maxAutoCheckers);
762
+ const parseDefault = defaultParseThreads(signals.count);
763
+ const checkersPinned = config.checkersSource === Source.Pinned;
764
+ const sharePinned = config.shareSource === Source.Pinned;
765
+ const boundedPinned = config.boundedSource === Source.Pinned;
766
+ // The static choice is unbounded posting: a run with no record, or with `TSC_TS_AUTO=static`, never bounds it.
767
+ const staticBounded = boundedPinned ? config.bounded : false;
768
+ const parsePinned = config.parseSource === Source.Pinned;
769
+ const before = config.role;
770
+ const exploredBefore = before === 'explore';
771
+ const controlBefore = before === 'control';
772
+
773
+ // Written out field by field rather than spread (`{ ...record.chosen }`): a spread copies properties
774
+ // the compiler cannot name at its call sites, which scriptc's native build reads as a dynamic store
775
+ // and answers by storing every class's number fields as doubles (`scripts/check-int32-fields.mjs`).
776
+ let chosen: RecordChoice = changed
777
+ ? {
778
+ checkers: staticCheckers,
779
+ share: staticShare,
780
+ bounded: staticBounded,
781
+ parseThreads: parseDefault,
782
+ source: 'default',
783
+ since: 0,
784
+ }
785
+ : {
786
+ checkers: record.chosen.checkers,
787
+ share: record.chosen.share,
788
+ bounded: record.chosen.bounded,
789
+ parseThreads: record.chosen.parseThreads,
790
+ source: record.chosen.source,
791
+ since: record.chosen.since,
792
+ };
793
+
794
+ let checkers = staticCheckers;
795
+ let share = staticShare;
796
+ let bounded = staticBounded;
797
+ let checkersLearned = false;
798
+ let shareLearned = false;
799
+ let boundedLearned = false;
800
+ let checkersGain: number | undefined = undefined;
801
+ let shareGain: number | undefined = undefined;
802
+ let boundedGain: number | undefined = undefined;
803
+ let parseChosen = parseDefault;
804
+ let adopted = false;
805
+ let role: 'chosen' | 'control' | 'explore' = before;
806
+ // The configuration `fastRevert` rejected, this run or an earlier one: its older samples can still
807
+ // dominate a median, and the last three runs are what the revert rule reads (`samplesFor`).
808
+ let rejection = record.rejected;
809
+
810
+ if (!exploredBefore && !controlBefore) {
811
+ // 1. An adopted configuration stays until the static choice beats it, and a bad one reverts at once.
812
+ const learned = chosen.source === 'learned';
813
+ const wantCheckers = checkersPinned ? staticCheckers : chosen.checkers;
814
+ const wantShare = sharePinned ? staticShare : chosen.share;
815
+ const wantBounded = boundedPinned ? staticBounded : chosen.bounded === true;
816
+ if (
817
+ learned &&
818
+ (wantCheckers !== staticCheckers || wantShare !== staticShare || wantBounded !== staticBounded) &&
819
+ fits(wantCheckers, wantShare, decision, cpus, samples, decision.memoryBytes)
820
+ ) {
821
+ const own = checkTimes(samples, wantCheckers, wantShare, wantBounded);
822
+ const baseline = checkTimes(samples, staticCheckers, staticShare, staticBounded);
823
+ const staticBeats = beatsBy(baseline, own) !== undefined;
824
+ const fellBehind = !staticBeats && fastRevert(samples, wantCheckers, wantShare, wantBounded);
825
+ if (!staticBeats && !fellBehind) {
826
+ checkers = wantCheckers;
827
+ share = wantShare;
828
+ bounded = wantBounded;
829
+ checkersLearned = wantCheckers !== staticCheckers;
830
+ shareLearned = wantShare !== staticShare;
831
+ boundedLearned = wantBounded !== staticBounded;
832
+ const gain = beatsBy(own, baseline);
833
+ if (checkersLearned) checkersGain = gain;
834
+ if (shareLearned) shareGain = gain;
835
+ if (boundedLearned) boundedGain = gain;
836
+ }
837
+ if (fellBehind) rejection = { checkers: wantCheckers, share: wantShare, bounded: wantBounded, run: record.runs };
838
+ }
839
+ // 2. A control run re-measures the static choice, and changes no choice.
840
+ const learnedBefore =
841
+ learned &&
842
+ (chosen.checkers !== staticCheckers ||
843
+ chosen.share !== staticShare ||
844
+ (chosen.bounded === true) !== staticBounded ||
845
+ chosen.parseThreads !== parseDefault);
846
+ if (learnedBefore && (record.runs - record.lastControl >= controlRuns || controlIsStale(record, decision.now))) {
847
+ checkers = staticCheckers;
848
+ share = staticShare;
849
+ bounded = staticBounded;
850
+ checkersLearned = false;
851
+ shareLearned = false;
852
+ boundedLearned = false;
853
+ parseChosen = parseDefault;
854
+ role = 'control';
855
+ } else {
856
+ // 3. A configuration the record shows faster than the static one is adopted.
857
+ if (!sharePinned && !shareLearned) {
858
+ const alternative = !staticShare;
859
+ if (fits(checkers, alternative, decision, cpus, samples, decision.memoryBytes)) {
860
+ const own = samplesFor(samples, rejection, checkers, alternative, bounded);
861
+ const gain = beats(
862
+ samples,
863
+ own,
864
+ { checkers, share: alternative, bounded },
865
+ { checkers, share: staticShare, bounded },
866
+ );
867
+ if (gain !== undefined) {
868
+ share = alternative;
869
+ shareLearned = true;
870
+ shareGain = gain;
871
+ adopted = true;
872
+ }
873
+ }
874
+ }
875
+ if (!checkersPinned && !checkersLearned) {
876
+ for (const candidate of countCandidates(staticCheckers, cpus)) {
877
+ if (candidate === checkers || !fits(candidate, share, decision, cpus, samples, decision.memoryBytes))
878
+ continue;
879
+ const own = samplesFor(samples, rejection, candidate, share, bounded);
880
+ const gain = beats(
881
+ samples,
882
+ own,
883
+ { checkers: candidate, share, bounded },
884
+ { checkers: staticCheckers, share: staticShare, bounded },
885
+ );
886
+ if (gain !== undefined) {
887
+ checkers = candidate;
888
+ checkersLearned = true;
889
+ checkersGain = gain;
890
+ adopted = true;
891
+ break;
892
+ }
893
+ }
894
+ }
895
+ // 3b. Bounded posting is adopted where the project's runs measured it faster than Phase 1's posting at the
896
+ // count and sharing this run uses. It is the one setting no signal separates: T3 web gained 7% by it and T3
897
+ // server lost 8-9%, and both projects are library-bound at 16 checkers. A record with no bounded samples, or
898
+ // with samples that lose, leaves it off.
899
+ if (!boundedPinned && !boundedLearned && share && decision.possible) {
900
+ const own = samplesFor(samples, rejection, checkers, share, true);
901
+ const gain = beats(samples, own, { checkers, share, bounded: true }, { checkers, share, bounded: false });
902
+ if (gain !== undefined) {
903
+ bounded = true;
904
+ boundedLearned = true;
905
+ boundedGain = gain;
906
+ adopted = true;
907
+ }
908
+ }
909
+ // 4. Parse threads are chosen for the next run here; this run parsed with the choice its record had.
910
+ // The recorded count is kept only while the comparison that adopted it still shows it faster: a
911
+ // record can name a count without one (a first run pinned it before any record existed), and the
912
+ // samples of one run are no comparison, so that count is not learned here either.
913
+ if (!parsePinned) {
914
+ if (learned && chosen.parseThreads !== parseDefault && parseWins(samples, chosen.parseThreads, parseDefault)) {
915
+ parseChosen = chosen.parseThreads;
916
+ } else {
917
+ for (const candidate of parseCandidates(parseDefault, cpus)) {
918
+ if (candidate === parseChosen) continue;
919
+ const own = parseTimes(samples, candidate);
920
+ const gain = beatsBy(own, parseTimes(samples, parseDefault));
921
+ if (gain !== undefined) {
922
+ parseChosen = candidate;
923
+ adopted = true;
924
+ break;
925
+ }
926
+ }
927
+ }
928
+ }
929
+ // 5. A setting the signals leave uncertain is tried in this run, one dimension at a time, unless
930
+ // a pin fixed it.
931
+ if (role === 'chosen' && !adopted && config.explore && !changed) {
932
+ const checkMs = medianOf(checkTimes(samples, checkers, share, bounded));
933
+ if (record.runs - record.lastExplore >= exploreRuns && checkMs !== undefined && checkMs >= exploreCheckMs) {
934
+ const shareAlt = !share;
935
+ // The dimensions are tried in order and the first with an alternative left takes the run: a band whose
936
+ // alternatives are all closed, such as a checker count whose candidates have measured, leaves the next
937
+ // one to be tried rather than ending exploration.
938
+ let exploring = false;
939
+ if (
940
+ !sharePinned &&
941
+ sharingBand(signals) &&
942
+ fits(checkers, shareAlt, decision, cpus, samples, decision.memoryBytes) &&
943
+ openCandidate(samples, checkers, shareAlt, bounded, checkers, share, bounded)
944
+ ) {
945
+ share = shareAlt;
946
+ role = 'explore';
947
+ exploring = true;
948
+ }
949
+ if (!exploring && !checkersPinned && checkersBand(signals)) {
950
+ for (const candidate of countCandidates(staticCheckers, cpus)) {
951
+ if (candidate === checkers || !fits(candidate, share, decision, cpus, samples, decision.memoryBytes))
952
+ continue;
953
+ if (openCandidate(samples, candidate, share, bounded, checkers, share, bounded)) {
954
+ checkers = candidate;
955
+ role = 'explore';
956
+ exploring = true;
957
+ break;
958
+ }
959
+ }
960
+ }
961
+ if (
962
+ !exploring &&
963
+ !boundedPinned &&
964
+ share &&
965
+ decision.possible &&
966
+ openCandidate(samples, checkers, share, true, checkers, share, false)
967
+ ) {
968
+ // Bounded posting is the last dimension exploration tries: it changes no diagnostics and no other
969
+ // setting, and a run that tries it pays a few percent when it does not pay back.
970
+ bounded = true;
971
+ role = 'explore';
972
+ }
973
+ }
974
+ }
975
+ }
976
+ }
977
+
978
+ // Apply what the run uses, and what the record keeps as chosen.
979
+ config.checkers = checkers;
980
+ config.share = share;
981
+ config.bounded = bounded;
982
+ config.role = role;
983
+ config.checkersSource = checkersLearned ? Source.Learned : config.checkersSource;
984
+ if (checkersLearned)
985
+ config.checkersReasons.push(
986
+ learnedReason('check', checkersGain, checkTimes(samples, checkers, share, bounded).length),
987
+ );
988
+ if (shareLearned) {
989
+ config.shareSource = Source.Learned;
990
+ config.shareReasons.length = 0;
991
+ config.shareReasons.push(learnedReason('check', shareGain, checkTimes(samples, checkers, share, bounded).length));
992
+ } else if (!share && config.shareSource === Source.Static) {
993
+ config.shareSource = Source.Default;
994
+ config.shareReasons.length = 0;
995
+ }
996
+ if (boundedLearned) {
997
+ config.boundedSource = Source.Learned;
998
+ config.boundedReasons.length = 0;
999
+ config.boundedReasons.push(
1000
+ learnedReason('bounded', boundedGain, checkTimes(samples, checkers, share, bounded).length),
1001
+ );
1002
+ }
1003
+ if (role === 'explore') {
1004
+ // The run explores the dimension it changed: the parse threads where the block above did not run.
1005
+ if (share !== staticShare) {
1006
+ config.shareSource = Source.Explore;
1007
+ config.shareReasons.length = 0;
1008
+ } else if (checkers !== staticCheckers) {
1009
+ config.checkersSource = Source.Explore;
1010
+ config.checkersReasons.length = 0;
1011
+ } else if (bounded !== staticBounded) {
1012
+ config.boundedSource = Source.Explore;
1013
+ config.boundedReasons.length = 0;
1014
+ }
1015
+ } else if (role === 'control') {
1016
+ // A control run uses the static checker count and sharing; the parse threads were fixed before the
1017
+ // program was bound, and a run that parsed with learned ones says so.
1018
+ config.checkersSource = Source.Control;
1019
+ config.shareSource = Source.Control;
1020
+ config.boundedSource = Source.Control;
1021
+ config.checkersReasons.length = 0;
1022
+ config.shareReasons.length = 0;
1023
+ config.boundedReasons.length = 0;
1024
+ }
1025
+ const chosenNow: RecordChoice = {
1026
+ checkers,
1027
+ share,
1028
+ bounded,
1029
+ parseThreads: parseChosen,
1030
+ source: checkersLearned || shareLearned || boundedLearned || parseChosen !== parseDefault ? 'learned' : 'default',
1031
+ since: 0,
1032
+ };
1033
+ config.chosen = role === 'chosen' ? chosenNow : chosen;
1034
+ config.rejected = rejection;
1035
+ }
1036
+
1037
+ /**
1038
+ * The samples an adoption may read for a configuration: where the record rejected it, only the runs after
1039
+ * the rejection. The older samples are the ones `fastRevert` found it had fallen behind on, and a median
1040
+ * over them would adopt the configuration the record just gave up again (`RecordRejection`).
1041
+ */
1042
+ function samplesFor(
1043
+ samples: readonly RecordSample[],
1044
+ rejection: RecordRejection | undefined,
1045
+ checkers: number,
1046
+ share: boolean,
1047
+ bounded: boolean,
1048
+ ): readonly RecordSample[] {
1049
+ if (
1050
+ rejection === undefined ||
1051
+ rejection.checkers !== checkers ||
1052
+ rejection.share !== share ||
1053
+ (rejection.bounded === true) !== bounded
1054
+ )
1055
+ return samples;
1056
+ return samples.filter((sample) => sample.run > rejection.run);
1057
+ }
1058
+
1059
+ /** Whether a configuration may be chosen or tried: the count automatic selection may use, and the memory it reaches. */
1060
+ function fits(
1061
+ checkers: number,
1062
+ share: boolean,
1063
+ decision: CheckDecision,
1064
+ cpus: number,
1065
+ samples: readonly RecordSample[],
1066
+ memoryBytes: number | undefined,
1067
+ ): boolean {
1068
+ if (checkers < 1 || checkers > cpus) return false;
1069
+ if (share && !(decision.possible && decision.fit !== undefined && decision.fit.sharing >= checkers)) return false;
1070
+ return peakFits(samples, checkers, share, memoryBytes);
1071
+ }
1072
+
1073
+ /** Whether every sample of a configuration stayed under `memoryShare` of the memory limit (DESIGN 5.5). */
1074
+ function peakFits(
1075
+ samples: readonly RecordSample[],
1076
+ checkers: number,
1077
+ share: boolean,
1078
+ memoryBytes: number | undefined,
1079
+ ): boolean {
1080
+ if (memoryBytes === undefined || memoryBytes <= 0) return true;
1081
+ const limit = (memoryBytes / 1048576) * memoryShare;
1082
+ for (const sample of samples)
1083
+ if (sample.checkers === checkers && sample.share === share && sample.peakMB > limit) return false;
1084
+ return true;
1085
+ }
1086
+
1087
+ /** Whether this run is a control run: the record holds a learned choice, and one is due (DESIGN 4.2). */
1088
+ function controlDue(record: RunRecord | undefined, automaticity: Automaticity, now: number): boolean {
1089
+ if (!learns(automaticity) || record === undefined || record.chosen.source !== 'learned') return false;
1090
+ if (record.runs - record.lastControl >= controlRuns) return true;
1091
+ return controlIsStale(record, now);
1092
+ }
1093
+
1094
+ /**
1095
+ * Whether a control run is due: none yet, or the last one is older than `controlAfterSeconds` (DESIGN 4.2).
1096
+ * This reads every sample the record kept, not only the usable ones: when the run adopted a choice or last
1097
+ * measured it is a fact about the record, and a busy run is still a run. Filtering by load here would date the
1098
+ * adoption from nothing whenever that run's load was above the gate, and every run after it would be a control
1099
+ * run.
1100
+ */
1101
+ function controlIsStale(record: RunRecord, now: number | undefined): boolean {
1102
+ if (now === undefined) return false;
1103
+ let last: number | undefined = undefined;
1104
+ for (const sample of record.samples)
1105
+ if (sample.role === 'control' && (last === undefined || sample.at > last)) last = sample.at;
1106
+ if (last === undefined) {
1107
+ for (const sample of record.samples)
1108
+ if (sample.run === record.chosen.since && (last === undefined || sample.at > last)) last = sample.at;
1109
+ }
1110
+ return last === undefined || now - last >= controlAfterSeconds;
1111
+ }
1112
+
1113
+ /**
1114
+ * Whether a configuration is still worth trying: it has fewer than `comparisonSamples` usable samples and
1115
+ * has not lost to `baseline` by the margin on two (DESIGN 4.4).
1116
+ */
1117
+ function openCandidate(
1118
+ samples: readonly RecordSample[],
1119
+ checkers: number,
1120
+ share: boolean,
1121
+ bounded: boolean,
1122
+ baselineCheckers: number,
1123
+ baselineShare: boolean,
1124
+ baselineBounded: boolean,
1125
+ ): boolean {
1126
+ const own = checkTimes(samples, checkers, share, bounded);
1127
+ if (own.length >= comparisonSamples) return false;
1128
+ return !loses(own, checkTimes(samples, baselineCheckers, baselineShare, baselineBounded));
1129
+ }
1130
+
1131
+ /** Whether an arm with at least two samples lost to `baseline` by the margin. */
1132
+ function loses(candidate: readonly number[], baseline: readonly number[]): boolean {
1133
+ if (candidate.length < 2 || baseline.length === 0) return false;
1134
+ const c = medianOf(candidate)!;
1135
+ const b = medianOf(baseline)!;
1136
+ if (c <= 0 || b <= 0) return false;
1137
+ return c > b * (1 + marginOf(candidate, baseline));
1138
+ }
1139
+
1140
+ /** The counts automatic selection may try instead of `count`: three quarters of it, and every CPU (DESIGN 4.4). */
1141
+ function countCandidates(count: number, cpus: number): number[] {
1142
+ const candidates = [Math.max(1, Math.ceil((count * 3) / 4)), cpus];
1143
+ const distinct: number[] = [];
1144
+ for (const candidate of candidates)
1145
+ if (candidate !== count && !distinct.includes(candidate)) distinct.push(candidate);
1146
+ return distinct;
1147
+ }
1148
+
1149
+ /** Whether `threads` parsed faster than `parseDefault` by the margin over `comparisonSamples` runs a side. */
1150
+ function parseWins(samples: readonly RecordSample[], threads: number, parseDefault: number): boolean {
1151
+ return beatsBy(parseTimes(samples, threads), parseTimes(samples, parseDefault)) !== undefined;
1152
+ }
1153
+
1154
+ /** The parse-thread counts automatic selection may try: half the default (DESIGN 4.4). */
1155
+ function parseCandidates(parseDefault: number, cpus: number): number[] {
1156
+ const candidate = Math.max(1, Math.ceil(parseDefault / 2));
1157
+ if (candidate >= parseDefault || candidate > Math.max(1, cpus - 1)) return [];
1158
+ return [candidate];
1159
+ }
1160
+
1161
+ /**
1162
+ * Whether sharing is uncertain (DESIGN 4.4): at least `exploreSharingCheckers` checkers, `exploreSharingShare`
1163
+ * of the files, and exactly one of the static rule's two tests holds.
1164
+ */
1165
+ export function sharingBand(signals: CheckSignals): boolean {
1166
+ return (
1167
+ signals.count >= exploreSharingCheckers &&
1168
+ signals.fileShare >= exploreSharingShare &&
1169
+ signals.fileShare >= sharingFileShare !== isLibraryBound(signals.library, signals.own)
1170
+ );
1171
+ }
1172
+
1173
+ /** Whether the checker count is uncertain (DESIGN 4.4): L / O between 2.5 and 10, and at least 3M library characters. */
1174
+ export function checkersBand(signals: CheckSignals): boolean {
1175
+ return bandOf(signals.library, signals.own);
1176
+ }
1177
+
1178
+ /** `bandOf` on a record's shape, for the parse threads, which are chosen before the program is bound. */
1179
+ function bandOf(library: number, own: number): boolean {
1180
+ if (own <= 0 || library < exploreLibraryText) return false;
1181
+ const ratio = library / own;
1182
+ return ratio >= exploreLibraryRatio && ratio <= exploreLibraryRatioHigh;
1183
+ }
1184
+
1185
+ /** Whether the record's signals leave sharing uncertain, for the parse threads' choice before binding. */
1186
+ function recordSharingBand(record: RunRecord): boolean {
1187
+ const shape = record.shape;
1188
+ const fileShare = shape.fileShare ?? 0;
1189
+ return (
1190
+ record.chosen.checkers >= exploreSharingCheckers &&
1191
+ fileShare >= exploreSharingShare &&
1192
+ fileShare >= sharingFileShare !== isLibraryBound(shape.library, shape.own)
1193
+ );
1194
+ }
1195
+
1196
+ /** How a learned choice reads in the section: `check 21% faster over 5 runs`, `parse 4% faster over 3 runs`. */
1197
+ function learnedReason(dimension: 'check' | 'parse' | 'bounded', gain: number | undefined, runs: number): string {
1198
+ const count = runs === 1 ? '1 run' : `${runs} runs`;
1199
+ const label = dimension === 'parse' ? 'parse' : dimension === 'bounded' ? 'bounded posting' : 'check';
1200
+ return gain === undefined ? `${label} measured over ${count}` : `${label} ${percent(gain)}% faster over ${count}`;
1201
+ }
1202
+
1203
+ /** The parse threads the record adopted, and why, where this run may use them (`learnedParseThreads`). */
1204
+ function learnedParseThreads(
1205
+ record: RunRecord | undefined,
1206
+ parseDefault: number,
1207
+ automaticity: Automaticity,
1208
+ ): { threads: number; reason: string } | undefined {
1209
+ if (!learns(automaticity) || record === undefined) return undefined;
1210
+ const threads = record.chosen.parseThreads;
1211
+ if (!Number.isInteger(threads) || threads < 1 || threads === parseDefault) return undefined;
1212
+ const samples = usableSamples(record);
1213
+ const gain = beatsBy(parseTimes(samples, threads), parseTimes(samples, parseDefault));
1214
+ // The record's chosen count can differ from the default without having won a comparison: a run that
1215
+ // pinned the parse threads before any record existed stores the pinned count, and the samples of that
1216
+ // one run are no comparison. Only the three-sample comparison makes a count learned.
1217
+ if (gain === undefined) return undefined;
1218
+ return { threads, reason: learnedReason('parse', gain, parseTimes(samples, threads).length) };
1219
+ }
1220
+
1221
+ /**
1222
+ * The parse threads this run tries where the record leaves them open: half the default, once in
1223
+ * `exploreRuns` runs of at least `exploreCheckMs`, when the settings the bound program decides have no
1224
+ * candidate left to try (DESIGN 4.4: one dimension at a time, sharing and checkers first).
1225
+ */
1226
+ function exploreParseThreads(
1227
+ record: RunRecord | undefined,
1228
+ parseDefault: number,
1229
+ explore: boolean,
1230
+ ): number | undefined {
1231
+ if (!explore || record === undefined || record.chosen.source === 'learned') return undefined;
1232
+ if (record.runs - record.lastExplore < exploreRuns) return undefined;
1233
+ const samples = usableSamples(record);
1234
+ const checkMs = medianOf(
1235
+ checkTimes(samples, record.chosen.checkers, record.chosen.share, record.chosen.bounded === true),
1236
+ );
1237
+ if (checkMs === undefined || checkMs < exploreCheckMs) return undefined;
1238
+ if (openSetting(record, samples)) return undefined;
1239
+ for (const candidate of parseCandidates(parseDefault, availableCpus())) {
1240
+ const own = parseTimes(samples, candidate);
1241
+ if (own.length >= comparisonSamples || loses(own, parseTimes(samples, parseDefault))) continue;
1242
+ return candidate;
1243
+ }
1244
+ return undefined;
1245
+ }
1246
+
1247
+ /**
1248
+ * Whether the checker count or sharing still has a candidate exploration would try now, which the run's
1249
+ * binding decides: the parse threads wait for it to close (DESIGN 4.4).
1250
+ */
1251
+ function openSetting(record: RunRecord, samples: readonly RecordSample[]): boolean {
1252
+ const chosen = record.chosen;
1253
+ const chosenBounded = chosen.bounded === true;
1254
+ const cpus = Math.min(availableCpus(), maxAutoCheckers);
1255
+ const open = (checkers: number, share: boolean) =>
1256
+ openCandidate(samples, checkers, share, chosenBounded, chosen.checkers, chosen.share, chosenBounded);
1257
+ if (recordSharingBand(record) && open(chosen.checkers, !chosen.share)) return true;
1258
+ if (!bandOf(record.shape.library, record.shape.own)) return false;
1259
+ for (const candidate of countCandidates(chosen.checkers, cpus)) if (open(candidate, chosen.share)) return true;
1260
+ return false;
1261
+ }
1262
+
1263
+ /**
1264
+ * Why sharing turned on, for the section: the share of the program that is declarations every checker
1265
+ * resolves again, and what the ranges reach, as `66% of files in node_modules; ranges reach 12.1M
1266
+ * library characters for 0.9M checked`.
1267
+ */
1268
+ function sharingReason(signals: CheckSignals): string {
1269
+ const declarations = signals.declarationShare > signals.nodeModulesShare;
1270
+ const share = declarations ? signals.declarationShare : signals.nodeModulesShare;
1271
+ return (
1272
+ `${Math.round(share * 100)}% of files ` +
1273
+ (declarations ? 'are declarations' : 'in node_modules') +
1274
+ `; ranges reach ${millions(signals.library)}M library characters for ${millions(signals.own)}M checked`
1275
+ );
1276
+ }
1277
+
1278
+ /** `chars` in millions, with one decimal: `12.1M`. */
1279
+ function millions(chars: number): string {
1280
+ return (chars / 1_000_000).toFixed(1);
1281
+ }
1282
+
1283
+ /** Where the section writes: the system of a compile, or anything else with `write`. */
1284
+ export interface RunConfigOutput {
1285
+ write(text: string): void;
1286
+ }
1287
+
1288
+ /** What the section's `Records:` line says (`run-records.ts`). */
1289
+ export interface RecordsReport {
1290
+ /** The record's path, as the section prints it (`~` for the home directory). */
1291
+ readonly path?: string;
1292
+ /** Why this run wrote no record. */
1293
+ readonly off?: string;
1294
+ /** Why the record could not be written, where writing was attempted. */
1295
+ readonly unavailable?: string;
1296
+ }
1297
+
1298
+ /**
1299
+ * The `--extendedDiagnostics` section, printed after the statistics table: a blank line, `Suggestions:`,
1300
+ * and one line per setting with its value, where the value came from in parentheses, and how to pin it
1301
+ * where no pin chose it. Never printed for `--diagnostics` alone (`emitStatusAndReportStatistics`).
1302
+ */
1303
+ export function reportRunConfig(out: RunConfigOutput, config: RunConfig, records?: RecordsReport): void {
1304
+ out.write(
1305
+ '\nSuggestions:\n' +
1306
+ suggestion(
1307
+ 'Checkers',
1308
+ config.checkers,
1309
+ config.checkersSource,
1310
+ config.checkersPin,
1311
+ `--checkers ${config.checkers}`,
1312
+ config.checkersReasons,
1313
+ ) +
1314
+ suggestion(
1315
+ 'Sharing',
1316
+ config.share ? 'on' : 'off',
1317
+ config.shareSource,
1318
+ config.sharePin,
1319
+ `TSC_TS_SHARE=${config.share ? '1' : '0'}`,
1320
+ config.shareReasons,
1321
+ ) +
1322
+ suggestion(
1323
+ 'Bounded posting',
1324
+ config.bounded ? 'on' : 'off',
1325
+ config.boundedSource,
1326
+ config.boundedPin,
1327
+ `TSC_TS_SHARE_BOUNDED=${config.bounded ? '1' : '0'}`,
1328
+ config.boundedReasons,
1329
+ ) +
1330
+ suggestion(
1331
+ 'Parse threads',
1332
+ config.parseThreads,
1333
+ config.parseSource,
1334
+ config.parsePin,
1335
+ `TSC_TS_PARSE_THREADS=${config.parseThreads}`,
1336
+ config.parseReasons,
1337
+ ) +
1338
+ recordsLine(records),
1339
+ );
1340
+ }
1341
+
1342
+ /** The `Records:` line: the record's path, why the run kept none, or why the write failed. */
1343
+ function recordsLine(records: RecordsReport | undefined): string {
1344
+ if (records === undefined) return '';
1345
+ if (records.path !== undefined) return ` Records: ${records.path}\n`;
1346
+ if (records.unavailable !== undefined) return ` Records: unavailable (${records.unavailable})\n`;
1347
+ return ` Records: off (${records.off ?? 'disabled'})\n`;
1348
+ }
1349
+
1350
+ /** One line of the section: the setting, its value, its source, and the pin for it where none chose it. */
1351
+ function suggestion(
1352
+ setting: string,
1353
+ value: number | string,
1354
+ source: Source,
1355
+ pin: string | undefined,
1356
+ pinHint: string,
1357
+ reasons: readonly string[],
1358
+ ): string {
1359
+ let line = ` ${setting}: ${value} (${sourceText(source, pin, reasons)})`;
1360
+ if (pin === undefined) line += `; pin with ${pinHint}`;
1361
+ return line + '\n';
1362
+ }
1363
+
1364
+ /** A source as the section prints it: `pinned: --checkers`, `default`, `automatic: <reason>`, … */
1365
+ function sourceText(source: Source, pin: string | undefined, reasons: readonly string[]): string {
1366
+ const reason = reasons.join('; ');
1367
+ switch (source) {
1368
+ case Source.Pinned:
1369
+ return `pinned: ${pin ?? 'environment'}`;
1370
+ case Source.Default:
1371
+ return reason === '' ? 'default' : `automatic: ${reason}`;
1372
+ case Source.Static:
1373
+ return reason === '' ? 'automatic' : `automatic: ${reason}`;
1374
+ case Source.Learned:
1375
+ return reason === '' ? 'learned' : `learned: ${reason}`;
1376
+ case Source.Measured:
1377
+ return reason === '' ? 'measured' : `measured: ${reason}`;
1378
+ case Source.Explore:
1379
+ return 'exploring';
1380
+ case Source.Control:
1381
+ return 'control run';
1382
+ }
1383
+ }