@vzn/vx-dte 0.0.651 → 0.0.653

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/planner.ts CHANGED
@@ -18,6 +18,9 @@ import {
18
18
  } from './dispatch.js'
19
19
  import { DEFAULT_STRATEGY, type Strategy } from './strategy.js'
20
20
 
21
+ /** Where a task naming no platform runs, or (several worker types) where each task runs. */
22
+ type Home = string | ((node: TaskNode) => string | undefined)
23
+
21
24
  /**
22
25
  * A task's duration when history has none: a test suite's, so a run with
23
26
  * no history starts workers for its work rather than one for all of it.
@@ -50,8 +53,34 @@ export interface PlanTask {
50
53
  readonly deps: readonly string[]
51
54
  /** The platform core placed it on (`TaskNode.platform`): only a worker of it runs it. */
52
55
  readonly platform?: string | undefined
56
+ /** Its number in the run's graph and its deps' (`planTasks`): a plan's shape reads them, not ids. */
57
+ readonly n?: number
58
+ readonly depsN?: Int32Array
59
+ /** Its servers' bases (`Need.servers`): booked once on each worker. */
60
+ readonly servers?: Need['servers']
61
+ }
62
+
63
+ /** The run's tasks numbered once, and each one's deps by number. */
64
+ interface Numbered {
65
+ readonly of: Map<string, Node>
66
+ readonly ids: readonly string[]
67
+ }
68
+
69
+ /** A task of the run's graph: its number, whether it runs, the tasks that run it waits on. */
70
+ interface Node {
71
+ readonly n: number
72
+ readonly node: TaskNode
73
+ readonly runs: boolean
74
+ deps?: string[]
75
+ depsN?: Int32Array
53
76
  }
54
77
 
78
+ /** The numbering each `depsN` is of. */
79
+ const numberedOf = new WeakMap<Int32Array, Numbered>()
80
+
81
+ /** A number for each of `nodes`, once a run. */
82
+ const numbers = new WeakMap<ReadonlyMap<string, TaskNode>, Numbered>()
83
+
55
84
  /** A worker the plan can place on: from `at` ms on, with what it runs until each ends. */
56
85
  export interface PlanWorker {
57
86
  readonly id: string
@@ -63,6 +92,7 @@ export interface PlanWorker {
63
92
  readonly id?: string
64
93
  readonly cores: number
65
94
  readonly memory: number
95
+ /** Below 0: past its time by as much. */
66
96
  readonly endsIn: number
67
97
  }[]
68
98
  /** Holding no task: the run may let it go. */
@@ -72,6 +102,8 @@ export interface PlanWorker {
72
102
  readonly platform?: string | undefined
73
103
  /** The size it was started as (`Size.name`): what its warm-up is learned by. */
74
104
  readonly size?: string | undefined
105
+ /** The servers it runs, each with the base booked there. */
106
+ readonly serving?: ReadonlyMap<string, number> | undefined
75
107
  }
76
108
 
77
109
  /** A worker's warm-up, one for all, or by its size (`Size.name`; none for one that joined on its own). */
@@ -96,6 +128,33 @@ export interface Plan {
96
128
  readonly againInMs: number | undefined
97
129
  }
98
130
 
131
+ /**
132
+ * What a plan foresees past its decisions, so the run follows it without
133
+ * playing it again: when each worker's last task in it ends (one idle
134
+ * before that is between tasks), when each start it wants later is due,
135
+ * and when each started now ends its work.
136
+ */
137
+ interface Ahead {
138
+ /** When the window it played moves on, from now: plan again then. */
139
+ readonly untilInMs: number | undefined
140
+ readonly freeInMs: ReadonlyMap<string, number>
141
+ /** The tasks it gives each worker. */
142
+ readonly given: ReadonlyMap<string, readonly string[]>
143
+ readonly later: readonly {
144
+ readonly size: Size
145
+ readonly inMs: number
146
+ readonly freeInMs: number
147
+ readonly joinInMs: number
148
+ readonly given: readonly string[]
149
+ }[]
150
+ /** For each started now, when its work ends, when it joins and what it runs. */
151
+ readonly started: readonly {
152
+ readonly freeInMs: number
153
+ readonly joinInMs: number
154
+ readonly given: readonly string[]
155
+ }[]
156
+ }
157
+
99
158
  /** History the run's finished tasks must have had before `slowdown` says anything. */
100
159
  const SLOWDOWN_MS = 20_000
101
160
 
@@ -157,30 +216,58 @@ export function planTasks(
157
216
  nodes: ReadonlyMap<string, TaskNode>,
158
217
  need: (id: string) => Need,
159
218
  durationMs: (id: string) => number | undefined,
160
- home?: string,
219
+ home?: Home,
161
220
  ): PlanTask[] {
162
- const runs = (id: string): boolean => nodes.get(id)?.config.exec?.command !== undefined
163
- const ran = new Map<string, string[]>()
164
- const depsOf = (id: string): string[] => {
165
- const known = ran.get(id)
166
- if (known !== undefined) return known
167
- ran.set(id, [])
168
- const out = (nodes.get(id)?.deps ?? []).flatMap((d) => (runs(d) ? [d] : depsOf(d)))
169
- ran.set(id, out)
170
- return out
221
+ // The graph is the run's, fixed: each task is numbered and its deps
222
+ // walked once a run, not at each plan (2026-11-05: a 5000-task sim
223
+ // spent a quarter here), and read by one lookup (2026-11-06).
224
+ let num = numbers.get(nodes)
225
+ if (num === undefined) {
226
+ const of = new Map<string, Node>()
227
+ const all: string[] = []
228
+ for (const [id, node] of nodes) {
229
+ of.set(id, { n: all.length, node, runs: node.config.exec?.command !== undefined })
230
+ all.push(id)
231
+ }
232
+ numbers.set(nodes, (num = { of, ids: all }))
233
+ }
234
+ const { of } = num
235
+ const depsOf = (e: Node): string[] => {
236
+ if (e.deps !== undefined) return e.deps
237
+ e.deps = []
238
+ const out: string[] = []
239
+ for (const d of e.node.deps) {
240
+ const x = of.get(d)
241
+ if (x === undefined) continue
242
+ if (x.runs) out.push(d)
243
+ else for (const dd of depsOf(x)) out.push(dd)
244
+ }
245
+ return (e.deps = out)
171
246
  }
172
- return [...ids].filter(runs).map((id) => {
247
+ const out: PlanTask[] = []
248
+ for (const id of ids) {
249
+ const e = of.get(id)
250
+ if (e === undefined || !e.runs) continue
173
251
  const n = need(id)
174
- return {
252
+ const deps = depsOf(e)
253
+ if (e.depsN === undefined) {
254
+ e.depsN = Int32Array.from(deps, (d) => of.get(d)!.n)
255
+ numberedOf.set(e.depsN, num)
256
+ }
257
+ out.push({
175
258
  id,
176
259
  cores: n.ask?.cores ?? n.cores,
177
260
  memory: n.ask?.memory ?? n.memory,
178
261
  above: n.above,
179
262
  ms: durationMs(id),
180
- deps: depsOf(id),
181
- platform: nodes.get(id)?.platform ?? home,
182
- }
183
- })
263
+ deps,
264
+ platform: typeof home === 'function' ? home(e.node) : (e.node.platform ?? home),
265
+ n: e.n,
266
+ depsN: e.depsN,
267
+ ...(n.servers === undefined ? {} : { servers: n.servers }),
268
+ })
269
+ }
270
+ return out
184
271
  }
185
272
 
186
273
  /**
@@ -200,7 +287,7 @@ export function missedWork(
200
287
  executed: ReadonlySet<string>,
201
288
  need: (id: string) => Need,
202
289
  durationMs: (id: string) => number | undefined,
203
- home?: string,
290
+ home?: Home,
204
291
  ): PlanTask[] {
205
292
  const changed = new Map<string, boolean>()
206
293
  // Whether `id`'s key changed: a cached task that missed, or one after it.
@@ -209,20 +296,56 @@ export function missedWork(
209
296
  if (known !== undefined) return known
210
297
  changed.set(id, false)
211
298
  const n = nodes.get(id)
212
- const c = (executed.has(id) && n?.config.cache !== undefined) || (n?.deps ?? []).some(changes)
299
+ const c = (executed.has(id) && n?.config.cache === true) || (n?.deps ?? []).some(changes)
213
300
  changed.set(id, c)
214
301
  return c
215
302
  }
216
303
  const missed = (id: string): boolean => {
217
304
  const n = nodes.get(id)
218
305
  return (
219
- executed.has(id) ||
220
- (n !== undefined && (n.config.cache === undefined || n.deps.some(changes)))
306
+ executed.has(id) || (n !== undefined && (n.config.cache !== true || n.deps.some(changes)))
221
307
  )
222
308
  }
223
309
  return planTasks([...remaining].filter(missed), nodes, need, durationMs, home)
224
310
  }
225
311
 
312
+ /**
313
+ * `workers` as a plan may let them go: of a platform whose sizes can start
314
+ * no other (`max` spent; one let go still counts), the last idle one stays
315
+ * while a task of that platform has not ended (`left`). A hit core's probe
316
+ * found can still miss when its restore fails, and only a worker of its
317
+ * platform runs it: main let its one arm worker go, check.binary.linux-arm64
318
+ * came back as a miss, and the run waited out its timeout (2026-11-06).
319
+ */
320
+ export function keepLast(
321
+ workers: readonly PlanWorker[],
322
+ sizes: readonly Size[],
323
+ started: ReadonlyMap<string, number>,
324
+ left: (platform: string) => boolean,
325
+ ): readonly PlanWorker[] {
326
+ const of = new Map<string, PlanWorker[]>()
327
+ for (const w of workers) {
328
+ if (w.platform === undefined) continue
329
+ const all = of.get(w.platform)
330
+ if (all === undefined) of.set(w.platform, [w])
331
+ else all.push(w)
332
+ }
333
+ let kept: Set<PlanWorker> | undefined
334
+ for (const [platform, all] of of) {
335
+ // A busy one is not let go; one starting, cancelled, frees its start.
336
+ if (all.some((w) => !w.idle && w.starting !== true)) continue
337
+ const idle = all.find((w) => w.idle && w.starting !== true)
338
+ if (idle === undefined) continue
339
+ const own = sizes.filter((s) => s.platform === platform)
340
+ if (own.length === 0 || own.some((s) => (started.get(s.name) ?? 0) < s.max)) continue
341
+ if (!left(platform)) continue
342
+ ;(kept ??= new Set()).add(idle)
343
+ }
344
+ return kept === undefined
345
+ ? workers
346
+ : workers.map((w) => (kept!.has(w) ? { ...w, idle: false } : w))
347
+ }
348
+
226
349
  /**
227
350
  * The tasks a plan counts as missed: those handed over to run, the
228
351
  * misses core's probe found, and of the commit's forecast (`likely`) only
@@ -244,6 +367,12 @@ interface Slot extends Bin {
244
367
  readonly at: number
245
368
  /** When it is first given a task. */
246
369
  first?: number
370
+ /** When its last task ends. */
371
+ free?: number
372
+ /** When its last task given in the play ends. */
373
+ last?: number
374
+ /** The tasks given it in the play. */
375
+ given?: string[]
247
376
  }
248
377
 
249
378
  /**
@@ -264,61 +393,55 @@ function play(
264
393
  tasks: readonly PlanTask[],
265
394
  workers: readonly PlanWorker[],
266
395
  how: Strategy,
267
- ): { end: number; first: Map<string, number>; ends: Map<string | undefined, number> } {
268
- const byId = new Map(tasks.map((t) => [t.id, t]))
269
- const dependants = new Map<string, string[]>()
270
- const waits = new Map<string, number>()
396
+ // Played no further: past it the rest ends no sooner than its longest
397
+ // chain from there (2026-11-05: "We can plan as we go till next level";
398
+ // "it can look ahead 2 warmups").
399
+ horizon = Infinity,
400
+ ): {
401
+ end: number
402
+ /** It stopped at `horizon` with work left. */
403
+ cut?: boolean
404
+ first: Map<string, number>
405
+ ends: Map<string | undefined, number>
406
+ free: () => Map<string, number>
407
+ given: () => Map<string, readonly string[]>
408
+ } {
271
409
  // A dependency running now ends when it does; one ended or a hit, now.
272
410
  const runningIds = new Set<string>()
273
411
  for (const w of workers) for (const r of w.running) if (r.id !== undefined) runningIds.add(r.id)
274
- for (const t of tasks) {
275
- let n = 0
276
- for (const d of t.deps)
277
- if (byId.has(d) || runningIds.has(d)) {
278
- n++
279
- // Pushed, not copied: copying made one install under 9000 tasks
280
- // 40 million copies a play (2026-10-25).
281
- const list = dependants.get(d)
282
- if (list === undefined) dependants.set(d, [t.id])
283
- else list.push(t.id)
284
- }
285
- waits.set(t.id, n)
286
- }
287
- const tail = new Map<string, number>()
288
- const tailOf = (id: string): number => {
289
- const known = tail.get(id)
290
- if (known !== undefined) return known
291
- let after = 0
292
- for (const d of dependants.get(id) ?? []) after = Math.max(after, tailOf(d))
293
- const t = after + (byId.get(id)!.ms ?? UNKNOWN_MS)
294
- tail.set(id, t)
295
- return t
296
- }
412
+ const { byId, after, afterAt, indexOf, waitedAt, tailOf, tailAt, kinds } = shapeOf(
413
+ tasks,
414
+ runningIds,
415
+ )
416
+ // By place, not id: maps keyed by id were a 5000-task play's most time (2026-11-05).
417
+ const waits = waitedAt.slice()
297
418
  const slots: Slot[] = workers.map((w) => ({
298
419
  id: w.id,
299
420
  cores: w.cores,
300
421
  memory: w.memory,
301
422
  platform: w.platform,
302
423
  at: w.at,
303
- used: { cores: 0, memory: 0 },
424
+ used: { cores: [...(w.serving?.values() ?? [])].reduce((a, b) => a + b, 0), memory: 0 },
304
425
  running: 0,
426
+ serving: new Set(w.serving?.keys()),
305
427
  }))
306
428
  // Ends to come: [at, slot, cores, memory, task or undefined].
307
- const ends: [number, Slot, number, number, string | undefined][] = []
429
+ const ends = new Ends()
308
430
  workers.forEach((w, i) => {
309
431
  const s = slots[i]!
310
432
  for (const r of w.running) {
311
433
  s.used.cores += r.cores
312
434
  s.used.memory += r.memory
313
435
  s.running++
314
- ends.push([Math.max(0, r.endsIn), s, r.cores, r.memory, r.id])
436
+ ends.push(Math.max(0, r.endsIn), s, r.cores, r.memory, -1, r.id)
437
+ s.free = Math.max(s.free ?? 0, r.endsIn)
315
438
  }
316
439
  })
317
440
  // Ready tasks, ordered again only once one joins (`graphene`: or
318
441
  // leaves): each step sorted every ready task, and a run of 9000 spent
319
442
  // its orchestrator planning (2026-10-25).
320
- const ready = new Ready(tasks)
321
- let joining = tasks.filter((t) => waits.get(t.id) === 0)
443
+ const ready = new Ready(tasks, kinds)
444
+ let joining = tasks.filter((_, i) => waits[i] === 0)
322
445
  let readyCount = joining.length
323
446
  const spent = new Set<string>()
324
447
  const bounds: Bounds = { done: false, open: true, free: 0, cores: 0, memory: 0 }
@@ -345,9 +468,33 @@ function play(
345
468
  const widest = Math.max(1, ...workers.map((w) => w.cores))
346
469
  let left = tasks.length
347
470
  let now = 0
348
- let last = Math.max(0, ...ends.map((e) => e[0]))
471
+ let last = Math.max(0, ends.last())
472
+ // Core-ms of the tasks not yet given.
473
+ const workLeft = new Map<string | undefined, number>()
474
+ const spend = (t: PlanTask, by: number): void => {
475
+ const w = by * t.cores * (t.ms ?? UNKNOWN_MS)
476
+ workLeft.set(undefined, (workLeft.get(undefined) ?? 0) + w)
477
+ if (t.platform !== undefined) workLeft.set(t.platform, (workLeft.get(t.platform) ?? 0) + w)
478
+ }
479
+ if (horizon < Infinity) for (const t of tasks) spend(t, 1)
349
480
  // When each platform's last task ends.
350
- const byPlatform = new Map<string | undefined, number>()
481
+ const byPlatform = (): Map<string | undefined, number> => {
482
+ const out = new Map<string | undefined, number>()
483
+ for (const s of slots)
484
+ if (s.last !== undefined) out.set(s.platform, Math.max(out.get(s.platform) ?? 0, s.last))
485
+ return out
486
+ }
487
+ // The joined slots, read again once the next one joins.
488
+ let joined: Slot[] = []
489
+ let nextJoin = -Infinity
490
+ // A task that filled a size waits for a bigger one still to join.
491
+ const later = (above: { cores: number; memory: number }, platform?: string): boolean =>
492
+ slots.some(
493
+ (s) =>
494
+ s.at > now &&
495
+ (platform === undefined || s.platform === platform) &&
496
+ (s.cores > above.cores || s.memory > above.memory),
497
+ )
351
498
  for (;;) {
352
499
  if (joining.length > 0 || ready.stale) {
353
500
  // By the longest chain, those still ready keep their order and the
@@ -365,21 +512,21 @@ function play(
365
512
  else ready.reset(order)
366
513
  joining = []
367
514
  }
368
- const joined = slots.filter((s) => s.at <= now)
369
- // A task that filled a size waits for a bigger one still to join.
370
- const later = (above: { cores: number; memory: number }, platform?: string): boolean =>
371
- slots.some(
372
- (s) =>
373
- s.at > now &&
374
- (platform === undefined || s.platform === platform) &&
375
- (s.cores > above.cores || s.memory > above.memory),
376
- )
377
- const to = assign(ready.asks(spent, bounds, needs.size), joined, later, how.place, {
378
- floor: (floor ??= floorOf(needs.values())),
379
- kinds: needs.size,
380
- spent,
381
- bounds,
382
- })
515
+ if (now >= nextJoin) {
516
+ joined = slots.filter((s) => s.at <= now)
517
+ nextJoin = Infinity
518
+ for (const s of slots) if (s.at > now) nextJoin = Math.min(nextJoin, s.at)
519
+ }
520
+ // With none ready nothing is given: a third of a sim's steps (2026-11-05).
521
+ const to =
522
+ readyCount === 0
523
+ ? NONE
524
+ : assign(ready.asks(spent, bounds, needs.size), joined, later, how.place, {
525
+ floor: (floor ??= floorOf(needs.values())),
526
+ kinds: needs.size,
527
+ spent,
528
+ bounds,
529
+ })
383
530
  // `graphene` ranks against the longest chain still ready.
384
531
  if (to.size > 0 && how.order === 'graphene') ready.stale = true
385
532
  for (const [id, best] of to) {
@@ -388,46 +535,385 @@ function play(
388
535
  readyCount--
389
536
  count(t, -1)
390
537
  best.first ??= now
538
+ ;(best.given ??= []).push(id)
539
+ best.free = Math.max(best.free ?? 0, now + (t.ms ?? UNKNOWN_MS))
391
540
  const end = now + (t.ms ?? UNKNOWN_MS)
392
- ends.push([end, best, Math.min(t.cores, best.cores), Math.min(t.memory, best.memory), t.id])
541
+ ends.push(
542
+ end,
543
+ best,
544
+ Math.min(t.cores, best.cores),
545
+ Math.min(t.memory, best.memory),
546
+ indexOf.get(id)!,
547
+ undefined,
548
+ )
393
549
  last = Math.max(last, end)
394
- byPlatform.set(best.platform, Math.max(byPlatform.get(best.platform) ?? 0, end))
550
+ best.last = Math.max(best.last ?? 0, end)
551
+ if (horizon < Infinity) spend(t, -1)
395
552
  }
396
553
  const first = (): Map<string, number> =>
397
554
  new Map(slots.flatMap((s) => (s.first === undefined ? [] : [[s.id, s.first] as const])))
398
- if (left === 0 && readyCount === 0) return { end: last, first: first(), ends: byPlatform }
555
+ const free = (): Map<string, number> =>
556
+ new Map(slots.flatMap((s) => (s.free === undefined ? [] : [[s.id, s.free] as const])))
557
+ const given = (): Map<string, readonly string[]> =>
558
+ new Map(slots.flatMap((s) => (s.given === undefined ? [] : [[s.id, s.given] as const])))
559
+ if (left === 0 && readyCount === 0)
560
+ return { end: last, first: first(), ends: byPlatform(), free, given }
399
561
  // The next moment anything changes: a task ends or a worker joins.
400
- let next = Infinity
401
- for (const e of ends) next = Math.min(next, e[0])
562
+ let next = ends.next()
402
563
  for (const s of slots) if (s.at > now) next = Math.min(next, s.at)
564
+ if (next >= horizon && next < Infinity) {
565
+ // No sooner than its longest chain, nor than its work spread on
566
+ // every core it has: the workers it asks later are the next plan's.
567
+ // Of each platform on its own workers, and all of it on all.
568
+ const work = new Map(workLeft)
569
+ const cores = new Map<string | undefined, number>()
570
+ for (const s of slots) {
571
+ cores.set(undefined, (cores.get(undefined) ?? 0) + s.cores)
572
+ if (s.platform !== undefined) cores.set(s.platform, (cores.get(s.platform) ?? 0) + s.cores)
573
+ }
574
+ // A running task's rest on its own platform too: counted on all
575
+ // alone, a Mac fewer read the Linux end later (2026-11-07).
576
+ for (const e of ends.all()) {
577
+ const w = e.cores * (e.at - now)
578
+ work.set(undefined, (work.get(undefined) ?? 0) + w)
579
+ const p = e.s.platform
580
+ if (p !== undefined) work.set(p, (work.get(p) ?? 0) + w)
581
+ }
582
+ let end = last
583
+ for (const [p, w] of work) end = Math.max(end, now + w / Math.max(cores.get(p) ?? 0, 1e-9))
584
+ for (const t of ready.live()) end = Math.max(end, now + tailOf(t.id))
585
+ for (const e of ends.all())
586
+ for (const d of (e.place >= 0 ? afterAt(e.place) : after.get(e.id!)) ?? [])
587
+ end = Math.max(end, e.at + tailAt(d))
588
+ return { end, cut: true, first: first(), ends: byPlatform(), free, given }
589
+ }
403
590
  if (next === Infinity)
404
- return { end: left === 0 ? last : Infinity, first: first(), ends: byPlatform }
591
+ return { end: left === 0 ? last : Infinity, first: first(), ends: byPlatform(), free, given }
405
592
  now = next
406
- for (let i = 0; i < ends.length;) {
407
- const [at, s, cores, memory, id] = ends[i]!
408
- if (at > now) {
409
- i++
410
- continue
411
- }
412
- ends.splice(i, 1)
593
+ while (ends.next() <= now) {
594
+ const { s, cores, memory, place, id } = ends.pop()
413
595
  s.used.cores -= cores
414
596
  s.used.memory -= memory
415
597
  s.running--
416
- if (id === undefined) continue
417
- if (byId.has(id)) left--
418
- for (const d of dependants.get(id) ?? []) {
419
- const n = waits.get(d)! - 1
420
- waits.set(d, n)
421
- if (n === 0) {
422
- joining.push(byId.get(d)!)
598
+ if (place >= 0) left--
599
+ else if (id === undefined) continue
600
+ for (const d of (place >= 0 ? afterAt(place) : after.get(id!)) ?? []) {
601
+ if (--waits[d]! === 0) {
602
+ joining.push(tasks[d]!)
423
603
  readyCount++
424
- count(byId.get(d)!, 1)
604
+ count(tasks[d]!, 1)
425
605
  }
426
606
  }
427
607
  }
428
608
  }
429
609
  }
430
610
 
611
+ const NONE: ReadonlyMap<string, Slot> = new Map()
612
+
613
+ interface End {
614
+ at: number
615
+ seq: number
616
+ s: Slot
617
+ cores: number
618
+ memory: number
619
+ /** The task's place in the play, or -1 for one running when it began (`id`). */
620
+ place: number
621
+ id: string | undefined
622
+ }
623
+
624
+ /**
625
+ * A play's ends to come, soonest first, ties in the order given: a scan
626
+ * of every running task at each step was most of a 20 000-task play
627
+ * (2026-11-05).
628
+ */
629
+ class Ends {
630
+ private readonly heap: End[] = []
631
+ private seq = 0
632
+ private readonly before = (a: End, b: End): boolean =>
633
+ a.at < b.at || (a.at === b.at && a.seq < b.seq)
634
+ push(
635
+ at: number,
636
+ s: Slot,
637
+ cores: number,
638
+ memory: number,
639
+ place: number,
640
+ id: string | undefined,
641
+ ): void {
642
+ const h = this.heap
643
+ const e: End = { at, seq: this.seq++, s, cores, memory, place, id }
644
+ let i = h.length
645
+ h.push(e)
646
+ while (i > 0) {
647
+ const p = (i - 1) >> 1
648
+ if (!this.before(e, h[p]!)) break
649
+ h[i] = h[p]!
650
+ i = p
651
+ }
652
+ h[i] = e
653
+ }
654
+ next(): number {
655
+ return this.heap[0]?.at ?? Infinity
656
+ }
657
+ all(): readonly End[] {
658
+ return this.heap
659
+ }
660
+ last(): number {
661
+ let most = 0
662
+ for (const e of this.heap) most = Math.max(most, e.at)
663
+ return most
664
+ }
665
+ pop(): End {
666
+ const h = this.heap
667
+ const top = h[0]!
668
+ const e = h.pop()!
669
+ if (h.length > 0) {
670
+ let i = 0
671
+ for (;;) {
672
+ const l = 2 * i + 1
673
+ if (l >= h.length) break
674
+ const c = l + 1 < h.length && this.before(h[l + 1]!, h[l]!) ? l + 1 : l
675
+ if (!this.before(h[c]!, e)) break
676
+ h[i] = h[c]!
677
+ i = c
678
+ }
679
+ h[i] = e
680
+ }
681
+ return top
682
+ }
683
+ }
684
+
685
+ /** What `play` reads of `tasks` alone, for each play of one plan. */
686
+ interface Shape {
687
+ readonly byId: { get(id: string): PlanTask | undefined }
688
+ /** Each running task's dependants, by their place in the tasks. */
689
+ readonly after: ReadonlyMap<string, readonly number[]>
690
+ /** Each task's dependants by its place, and its place by id: a lookup by id at each end was most of a play. */
691
+ readonly afterAt: (place: number) => Int32Array
692
+ readonly indexOf: { get(id: string): number | undefined }
693
+ /** How many dependencies each task, by its place, waits on before the play begins. */
694
+ readonly waitedAt: Int32Array
695
+ readonly tailOf: (id: string) => number
696
+ readonly tailAt: (place: number) => number
697
+ readonly kinds: Kinds
698
+ }
699
+
700
+ /**
701
+ * `tasks`' shape, made once while what runs stays the same: a plan plays
702
+ * them 20 to 40 times, and each play made it again, its tails and kinds
703
+ * too (2026-11-05).
704
+ */
705
+ function shapeOf(tasks: readonly PlanTask[], running: ReadonlySet<string>): Shape {
706
+ const runs = [...running].sort().join('\0')
707
+ const had = shapes.get(tasks)
708
+ if (had !== undefined && had.runs === runs) return had.shape
709
+ // Of a running task only, by id: one in the work is by its place.
710
+ const after = new Map<string, number[]>()
711
+ const waitedAt = new Int32Array(tasks.length)
712
+ const first = tasks[0]?.depsN
713
+ const num =
714
+ first !== undefined && tasks.every((t) => t.depsN !== undefined)
715
+ ? numberedOf.get(first)
716
+ : undefined
717
+ let edges = 0
718
+ for (let i = 0; i < tasks.length; i++) edges += tasks[i]!.deps.length
719
+ // Each dependency's place in the work, -1 when not in it.
720
+ const depAt = new Int32Array(edges)
721
+ let indexOf: { get(id: string): number | undefined }
722
+ if (num !== undefined) {
723
+ // By the run's numbers, not ids: a map of a million ids, made and
724
+ // read at each plan, was most of its time (2026-11-06).
725
+ const place = new Int32Array(num.ids.length).fill(-1)
726
+ for (let i = 0; i < tasks.length; i++) place[tasks[i]!.n!] = i
727
+ // Indexed: iterators over a million tasks' deps were a third of the shape.
728
+ for (let e = 0, i = 0; i < tasks.length; i++) {
729
+ const ds = tasks[i]!.depsN!
730
+ for (let k = 0; k < ds.length; k++) depAt[e++] = place[ds[k]!]!
731
+ }
732
+ indexOf = {
733
+ get: (id) => {
734
+ const n = num.of.get(id)?.n
735
+ const j = n === undefined ? -1 : place[n]!
736
+ return j < 0 ? undefined : j
737
+ },
738
+ }
739
+ } else {
740
+ // One map by id, the rest by place: a map of each by id made a
741
+ // 10 000-task plan's shape a third of its time (2026-11-05).
742
+ const of = new Map<string, number>()
743
+ for (let i = 0; i < tasks.length; i++) of.set(tasks[i]!.id, i)
744
+ let e = 0
745
+ for (const t of tasks) for (const d of t.deps) depAt[e++] = of.get(d) ?? -1
746
+ indexOf = of
747
+ }
748
+ // Each task's dependants in one array, by place: an array each, pushed
749
+ // to, was most of a million-task shape (2026-11-06).
750
+ const start = new Int32Array(tasks.length + 1)
751
+ for (let e = 0, i = 0; i < tasks.length; i++) {
752
+ const deps = tasks[i]!.deps
753
+ let n = 0
754
+ for (let k = 0; k < deps.length; k++, e++) {
755
+ const j = depAt[e]!
756
+ if (j >= 0) {
757
+ start[j + 1]!++
758
+ n++
759
+ } else if (running.has(deps[k]!)) {
760
+ n++
761
+ const at = after.get(deps[k]!)
762
+ if (at === undefined) after.set(deps[k]!, [i])
763
+ else at.push(i)
764
+ }
765
+ }
766
+ waitedAt[i] = n
767
+ }
768
+ for (let i = 0; i < tasks.length; i++) start[i + 1]! += start[i]!
769
+ const list = new Int32Array(start[tasks.length]!)
770
+ const fill = start.slice(0, tasks.length)
771
+ for (let e = 0, i = 0; i < tasks.length; i++)
772
+ for (let k = tasks[i]!.deps.length; k > 0; k--, e++) {
773
+ const j = depAt[e]!
774
+ if (j >= 0) list[fill[j]!++] = i
775
+ }
776
+ const afterAt = (i: number): Int32Array => list.subarray(start[i]!, start[i + 1]!)
777
+ const tail = new Float64Array(tasks.length).fill(-1)
778
+ const tailAt = (i: number): number => {
779
+ if (tail[i]! >= 0) return tail[i]!
780
+ let most = 0
781
+ for (let k = start[i]!; k < start[i + 1]!; k++) most = Math.max(most, tailAt(list[k]!))
782
+ return (tail[i] = most + (tasks[i]!.ms ?? UNKNOWN_MS))
783
+ }
784
+ const tailOf = (id: string): number => tailAt(indexOf.get(id)!)
785
+ const byId = { get: (id: string) => tasks[indexOf.get(id)!] }
786
+ const shape = {
787
+ byId,
788
+ after,
789
+ afterAt,
790
+ indexOf,
791
+ waitedAt,
792
+ tailOf,
793
+ tailAt,
794
+ kinds: { at: indexOf, kindAt: lazyKinds(tasks) },
795
+ }
796
+ shapes.set(tasks, { runs, shape })
797
+ return shape
798
+ }
799
+ const shapes = new WeakMap<readonly PlanTask[], { runs: string; shape: Shape }>()
800
+
801
+ /** Each task's place in `tasks` and its kind (`kindOf`). */
802
+ interface Kinds {
803
+ readonly at: { get(id: string): number | undefined }
804
+ readonly kindAt: (place: number) => string
805
+ }
806
+
807
+ /**
808
+ * Each task's kind, made as a play first reads it: made for every task,
809
+ * a million float-to-string keys were a fifth of a plan, and a play
810
+ * windowed to two warm-ups reads few of them (2026-11-06).
811
+ */
812
+ function lazyKinds(tasks: readonly PlanTask[]): (place: number) => string {
813
+ const kinds: (string | undefined)[] = []
814
+ return (i) => (kinds[i] ??= kindOf(tasks[i]!))
815
+ }
816
+
817
+ function kindsOf(tasks: readonly PlanTask[]): Kinds {
818
+ return { at: new Map(tasks.map((t, i) => [t.id, i])), kindAt: lazyKinds(tasks) }
819
+ }
820
+
821
+ /**
822
+ * No play of `tasks` ends sooner: each chain of them in a row, from when
823
+ * the running tasks it waits on end, and each running task's end.
824
+ */
825
+ function chainMs(tasks: readonly PlanTask[], workers: readonly PlanWorker[]): number {
826
+ const runningIds = new Set<string>()
827
+ const endsAt = new Map<string, number>()
828
+ for (const w of workers)
829
+ for (const r of w.running)
830
+ if (r.id !== undefined) {
831
+ runningIds.add(r.id)
832
+ endsAt.set(r.id, Math.max(endsAt.get(r.id) ?? 0, r.endsIn))
833
+ }
834
+ const { tailAt, after } = shapeOf(tasks, runningIds)
835
+ let most = 0
836
+ for (const w of workers) for (const r of w.running) most = Math.max(most, r.endsIn)
837
+ // From the running tasks' dependants: each task's deps read by id was
838
+ // most of a million-task plan's bound (2026-11-06).
839
+ const from = new Float64Array(tasks.length)
840
+ for (const [id, ds] of after)
841
+ for (const i of ds) from[i] = Math.max(from[i]!, endsAt.get(id) ?? 0)
842
+ for (let i = 0; i < tasks.length; i++) most = Math.max(most, from[i]! + tailAt(i))
843
+ return most
844
+ }
845
+
846
+ /** Two workers a play cannot tell apart, but by their place. */
847
+ const twins = (a: PlanWorker, b: PlanWorker): boolean =>
848
+ a.cores === b.cores && a.memory === b.memory && a.platform === b.platform && a.at === b.at
849
+
850
+ type Played = ReturnType<typeof play>
851
+
852
+ /**
853
+ * `kept` less each of `added` (its last ones, in the order the run would
854
+ * start them) the run ends as soon without, tried last first. Sizes come in
855
+ * turns, so a prefix holds ones it ends as soon without: a second Mac the
856
+ * plan needed came after a second Linux worker it did not, and main e118ccf
857
+ * cancelled that one unjoined (2026-10-24). Dropped when the end moves no
858
+ * later and its platform's moves no further than the margin, or still ends
859
+ * two warm-ups before the run: room for runners the CI queues. To the run's
860
+ * end alone, a 110 s guess of an unmeasured chain cut every other platform
861
+ * to it; to its platform's alone, 4 Macs started for work 2 ended 40 s
862
+ * before the Linux work (main a1950a2, 2026-11-07: "we optimize for total
863
+ * wall time"). The live plan and the footer's advice both trim by it.
864
+ */
865
+ function dropSpare(
866
+ added: readonly PlanWorker[],
867
+ kept: readonly PlanWorker[],
868
+ before: Played,
869
+ run: (ws: readonly PlanWorker[]) => Played,
870
+ ): { kept: PlanWorker[]; before: Played } {
871
+ let out = [...kept]
872
+ for (let i = added.length - 1, dropped = false; i >= 0; i--) {
873
+ const w = added[i]!
874
+ // Its twin after it stayed, and nothing moved since: without this
875
+ // one the play is that one's again, so it stays too. Each was a
876
+ // play of the whole run (2026-11-05).
877
+ if (i < added.length - 1 && !dropped && twins(w, added[i + 1]!)) continue
878
+ const without = out.filter((k) => k !== w)
879
+ // Given no task in a whole play, the play without it is the same.
880
+ if (!before.first.has(w.id) && before.cut !== true) {
881
+ out = without
882
+ dropped = true
883
+ continue
884
+ }
885
+ const after = run(without)
886
+ const ends = after.ends.get(w.platform) ?? 0
887
+ dropped =
888
+ after.end <= before.end &&
889
+ (ends <= (before.ends.get(w.platform) ?? 0) + MARGIN_MS ||
890
+ ends <= before.end - Math.max(MARGIN_MS, 2 * w.at))
891
+ if (dropped) {
892
+ out = without
893
+ before = after
894
+ }
895
+ }
896
+ return { kept: out, before }
897
+ }
898
+
899
+ /** A worker of `s` that joins `at`. */
900
+ function fresh(
901
+ s: { cpu: number; memory: number; platform?: string | undefined; size?: string | undefined },
902
+ id: string,
903
+ at: number,
904
+ ): PlanWorker {
905
+ return {
906
+ id,
907
+ cores: s.cpu,
908
+ memory: s.memory,
909
+ platform: s.platform,
910
+ ...(s.size === undefined ? {} : { size: s.size }),
911
+ at,
912
+ running: [],
913
+ idle: false,
914
+ }
915
+ }
916
+
431
917
  /**
432
918
  * The workers to start and to let go. The fewest new ones (`candidates`,
433
919
  * in the order the run would start them, each joining after its size's `warmUpMs`)
@@ -449,30 +935,43 @@ export function plan(
449
935
  // The latest an earlier plan of the same work allowed, from now.
450
936
  limitMs = Infinity,
451
937
  how: Strategy = DEFAULT_STRATEGY,
452
- ): Plan {
938
+ ): Plan & Ahead {
453
939
  const warm = warmOf(warmUpMs)
454
- const fresh = (
455
- s: { cpu: number; memory: number; platform?: string | undefined; size?: string | undefined },
456
- id: string,
457
- at: number,
458
- ): PlanWorker => ({
459
- id,
460
- cores: s.cpu,
461
- memory: s.memory,
462
- platform: s.platform,
463
- ...(s.size === undefined ? {} : { size: s.size }),
464
- at,
465
- running: [],
466
- idle: false,
467
- })
468
940
  const added = candidates.map((s, i) => fresh({ ...s, size: s.name }, `\0new${i}`, warm(s.name)))
469
- const ends: number[] = []
470
- for (let n = 0; n <= candidates.length; n++) {
471
- ends.push(simulate(tasks, [...workers, ...added.slice(0, n)], how))
472
- // Past the plan's own length more cannot end it sooner.
473
- if (n > 0 && ends[n] === ends[n - 1] && ends[n]! <= added[n - 1]!.at) break
941
+ // The fewest new ones ending within the margin of the best, found by
942
+ // halving: more workers end the run no later, so the best is all of
943
+ // them. Trying each count in turn played the whole run some 50 times a
944
+ // plan (2026-11-05: "Why it replays 64 times to pick workers").
945
+ // Two warm-ups ahead: a worker asked now joins after one, and the next
946
+ // plan, a warm-up on, sees the one after.
947
+ const horizon =
948
+ 2 *
949
+ Math.max(
950
+ warm(undefined),
951
+ ...candidates.map((c) => warm(c.name)),
952
+ ...workers.map((w) => w.at),
953
+ ) +
954
+ MARGIN_MS +
955
+ // And a task given at its end runs through: work counted as cores
956
+ // spread over the rest asked too few workers for a few long tasks.
957
+ tasks.reduce((most, t) => Math.max(most, t.ms ?? UNKNOWN_MS), 0)
958
+ // Each set of workers played once a plan: the count search, the drop
959
+ // pass, the releases and the final play each played the same set again.
960
+ const plays = new Map<string, ReturnType<typeof play>>()
961
+ const run = (ws: readonly PlanWorker[]): ReturnType<typeof play> => {
962
+ const key = ws.map((w) => `${w.id}\0${w.at}`).join('\n')
963
+ let p = plays.get(key)
964
+ if (p === undefined) plays.set(key, (p = play(tasks, ws, how, horizon)))
965
+ return p
474
966
  }
475
- const best = Math.min(...ends)
967
+ // Given no task in the play, and none past its window.
968
+ const unused = (p: ReturnType<typeof play>, id: string): boolean =>
969
+ !p.first.has(id) && p.cut !== true
970
+ const playOf = (n: number): ReturnType<typeof play> => run([...workers, ...added.slice(0, n)])
971
+ const endOf = (n: number): number => playOf(n).end
972
+ const floor = chainMs(tasks, workers)
973
+ const none = endOf(0)
974
+ const best = none <= floor ? none : Math.min(none, endOf(candidates.length))
476
975
  // Margin spent at each decision adds up: releases 4 s apart each lost
477
976
  // 4 s of 5 (tests/sim.test.ts, a growing graph). Spent once, unless the
478
977
  // rest can no longer end by then.
@@ -480,31 +979,19 @@ export function plan(
480
979
  best === Infinity
481
980
  ? Infinity
482
981
  : Math.max(best, Math.min(best + Math.max(MARGIN_MS, best * MARGIN), limitMs))
483
- const n = Math.max(
484
- 0,
485
- ends.findIndex((e) => e <= bound),
486
- )
982
+ let lo = 0
983
+ let hi = candidates.length
984
+ if (none <= bound) hi = 0
985
+ while (lo < hi) {
986
+ const mid = (lo + hi) >> 1
987
+ if (endOf(mid) <= bound) hi = mid
988
+ else lo = mid + 1
989
+ }
990
+ const n = lo
487
991
  let kept = [...workers, ...added.slice(0, n)]
488
- // Sizes come in turns, so the prefix can hold one the plan ends as soon
489
- // without: a second Mac the plan needed came after a second Linux worker
490
- // it did not, and main e118ccf cancelled that one unjoined (2026-10-24).
491
- // Dropped only when neither the end nor its platform's moves past the
492
- // margin: to the run's end alone, a 110 s guess of an unmeasured chain
493
- // cut every other platform to it.
494
- let before = best < Infinity && n > 0 ? play(tasks, kept, how) : undefined
495
- if (before !== undefined)
496
- for (let i = n - 1; i >= 0; i--) {
497
- const platform = added[i]!.platform
498
- const without = kept.filter((k) => k !== added[i])
499
- const after = play(tasks, without, how)
500
- if (
501
- after.end <= before.end &&
502
- (after.ends.get(platform) ?? 0) <= (before.ends.get(platform) ?? 0) + MARGIN_MS
503
- ) {
504
- kept = without
505
- before = after
506
- }
507
- }
992
+ // The ones the run ends as soon without go (`dropSpare`).
993
+ let before = best < Infinity && n > 0 ? playOf(n) : undefined
994
+ if (before !== undefined) ({ kept, before } = dropSpare(added.slice(0, n), kept, before, run))
508
995
  const release: string[] = []
509
996
  // Played with the rest: left out, the next idle one and the plan's end
510
997
  // counted on a worker the run lets go.
@@ -528,11 +1015,7 @@ export function plan(
528
1015
  // margin, the next plan's estimates moved a second later and
529
1016
  // cancelled 7 asked one at a time (PR #4214); ending the run no
530
1017
  // sooner, main 005e8f2 cancelled a mac and asked one again 18 s on.
531
- if (
532
- twin === undefined
533
- ? !play(tasks, kept, how).first.has(w.id)
534
- : simulate(tasks, without, how) <= bound
535
- ) {
1018
+ if (twin === undefined ? unused(run(kept), w.id) : run(without).end <= bound) {
536
1019
  kept = without
537
1020
  if (twin === undefined) release.push(w.id)
538
1021
  }
@@ -552,7 +1035,9 @@ export function plan(
552
1035
  })
553
1036
  for (const w of idle) {
554
1037
  const without = kept.filter((k) => k !== w)
555
- const next = play(tasks, [...kept, ...standIns], how).first.get(w.id)
1038
+ const played = run([...kept, ...standIns])
1039
+ // Given nothing in a window with work past it: needed past it, maybe.
1040
+ const next = played.first.get(w.id) ?? (played.cut === true ? horizon : undefined)
556
1041
  if (next === undefined) {
557
1042
  kept = without
558
1043
  release.push(w.id)
@@ -571,7 +1056,7 @@ export function plan(
571
1056
  w.id,
572
1057
  next,
573
1058
  )
574
- if (simulate(tasks, [...without, ...standIns, instead], how) <= bound) {
1059
+ if (run([...without, ...standIns, instead]).end <= bound) {
575
1060
  kept = without
576
1061
  release.push(w.id)
577
1062
  standIns.push(instead)
@@ -579,20 +1064,51 @@ export function plan(
579
1064
  }
580
1065
  }
581
1066
  }
582
- const { end, first } = play(tasks, [...kept, ...standIns], how)
1067
+ const { end, first, free, cut, given } = run([...kept, ...standIns])
583
1068
  const start: Size[] = []
1069
+ const frees = free()
1070
+ const gave = given()
1071
+ const started: Ahead['started'][number][] = []
1072
+ const later: Ahead['later'][number][] = []
584
1073
  let againInMs: number | undefined
585
1074
  for (let i = 0; i < n; i++) {
586
- const at = first.get(added[i]!.id)
1075
+ const id = added[i]!.id
1076
+ const at = first.get(id)
587
1077
  if (at === undefined) continue
588
1078
  // Given work within its warm-up and a margin: it starts now.
589
1079
  const warmUp = added[i]!.at
590
1080
  // Within a millisecond counts: `againInMs` of 1e-10 replanned the
591
1081
  // same plan 100,000 times at one instant (tests/sim.test.ts).
592
- if (at < warmUp + MARGIN_MS + 1) start.push(candidates[i]!)
593
- else againInMs = Math.min(againInMs ?? Infinity, at - warmUp - MARGIN_MS)
1082
+ if (at < warmUp + MARGIN_MS + 1) {
1083
+ start.push(candidates[i]!)
1084
+ started.push({ freeInMs: frees.get(id)!, joinInMs: warmUp, given: gave.get(id)! })
1085
+ } else {
1086
+ const inMs = at - warmUp - MARGIN_MS
1087
+ againInMs = Math.min(againInMs ?? Infinity, inMs)
1088
+ later.push({
1089
+ size: candidates[i]!,
1090
+ inMs,
1091
+ freeInMs: frees.get(id)!,
1092
+ joinInMs: warmUp,
1093
+ given: gave.get(id)!,
1094
+ })
1095
+ }
1096
+ }
1097
+ later.sort((a, b) => a.inMs - b.inMs)
1098
+ // Work past the window: plan again a warm-up on, the window moved.
1099
+ const untilInMs = cut === true ? (horizon - MARGIN_MS) / 2 : undefined
1100
+ return {
1101
+ start,
1102
+ release,
1103
+ endsInMs: end,
1104
+ boundMs: bound,
1105
+ againInMs: untilInMs === undefined ? againInMs : Math.min(againInMs ?? Infinity, untilInMs),
1106
+ untilInMs,
1107
+ freeInMs: frees,
1108
+ given: gave,
1109
+ later,
1110
+ started,
594
1111
  }
595
- return { start, release, endsInMs: end, boundMs: bound, againInMs }
596
1112
  }
597
1113
 
598
1114
  /**
@@ -669,15 +1185,42 @@ export function decide(input: {
669
1185
  readonly known?: ReadonlySet<string>
670
1186
  /** How it orders, places and scales (`need` books already). */
671
1187
  readonly how?: Strategy
672
- /** The platform a task naming none runs on (`homeOf`). */
673
- readonly home?: string | undefined
1188
+ /** The platform a task naming none runs on (`homeOf`), or where each task runs (several worker types). */
1189
+ readonly home?: Home | undefined
674
1190
  /**
675
1191
  * Sizes with a worker asked and not joined past its warm-up: the CI
676
1192
  * queued it, and one asked now queues behind it. None of them starts.
677
1193
  */
678
1194
  readonly queued?: ReadonlySet<string>
1195
+ /**
1196
+ * The run's last plan, kept here: while what it saw holds, the next is
1197
+ * that plan again, not played anew, and hands back no `tasks` when it
1198
+ * read none. With `now`, the run's clock.
1199
+ */
1200
+ readonly last?: { forecast?: Forecast }
1201
+ readonly now?: number
679
1202
  }): Plan & { readonly tasks: readonly PlanTask[] } {
680
1203
  const how = input.how ?? DEFAULT_STRATEGY
1204
+ const now = input.now ?? 0
1205
+ const had = input.last?.forecast
1206
+ // Following a plan reads only what events move: the workers, their
1207
+ // joins, each task as it starts and ends. Every task read at each of a
1208
+ // 5000-task run's events made it quadratic (2026-11-05: "Why do we
1209
+ // recheck every task against plan? There is no point").
1210
+ if (how.scale !== 'backlog' && had !== undefined) {
1211
+ const step = follow(had, undefined, input, undefined, now)
1212
+ // No work read, none to hand back: each step handed back the plan's
1213
+ // whole work, and the run read it all at each event.
1214
+ // A start or a release hands back the work it plans for, as the run reports it.
1215
+ if (step !== undefined && step !== 'full')
1216
+ return shown(
1217
+ input.last!,
1218
+ had,
1219
+ step,
1220
+ now,
1221
+ step.start.length > 0 || step.release.length > 0 ? had.work : [],
1222
+ )
1223
+ }
681
1224
  const tasks = missedWork(
682
1225
  input.remaining,
683
1226
  input.nodes,
@@ -702,7 +1245,9 @@ export function decide(input: {
702
1245
  )
703
1246
  if (how.scale === 'backlog')
704
1247
  return { ...backlog(tasks, input.workers, sizes, input.warmUpMs, how), tasks }
705
- const p = plan(
1248
+ const step = had === undefined ? undefined : follow(had, tasks, input, sizes, now)
1249
+ if (step !== undefined && step !== 'full') return shown(input.last!, had!, step, now, tasks)
1250
+ const { freeInMs, given, later, started, untilInMs, ...p } = plan(
706
1251
  tasks,
707
1252
  input.workers,
708
1253
  sizes,
@@ -712,9 +1257,368 @@ export function decide(input: {
712
1257
  tasks.every((t) => input.known?.has(t.id) === true) ? input.limitMs : undefined,
713
1258
  how,
714
1259
  )
1260
+ if (input.last !== undefined)
1261
+ input.last.forecast = forecastOf(
1262
+ p,
1263
+ { freeInMs, given, later, started, untilInMs },
1264
+ tasks,
1265
+ input,
1266
+ now,
1267
+ )
715
1268
  return { ...p, tasks }
716
1269
  }
717
1270
 
1271
+ /**
1272
+ * What a plan saw, to tell whether the next would differ: its work and
1273
+ * each task's time, its workers as starting, idle or busy, the tasks
1274
+ * already past their time, the sizes queued, when it wanted to look again.
1275
+ */
1276
+ export interface Forecast {
1277
+ readonly at: number
1278
+ readonly plan: Plan
1279
+ /** The work it played. */
1280
+ readonly work: readonly PlanTask[]
1281
+ /** Each waiting task's time, and each waiting or running task's cores and memory. */
1282
+ readonly ms: ReadonlyMap<string, number | undefined>
1283
+ readonly need: ReadonlyMap<string, readonly [cores: number, memory: number]>
1284
+ /** The workers it saw, and those it started as they show. */
1285
+ readonly workers: ReadonlySet<string>
1286
+ /** How many workers the run has as planned. */
1287
+ readonly count: number
1288
+ /** When each worker's last planned task ends, on the run's clock. */
1289
+ readonly free: ReadonlyMap<string, number>
1290
+ /** The tasks planned on each worker. */
1291
+ readonly given: ReadonlyMap<string, readonly string[]>
1292
+ /** When each worker still starting joins, on the run's clock. */
1293
+ readonly join: ReadonlyMap<string, number>
1294
+ /** Those of each worker started and not seen yet, in the order started. */
1295
+ readonly fresh: readonly {
1296
+ readonly free: number
1297
+ readonly join: number
1298
+ readonly given: readonly string[]
1299
+ }[]
1300
+ /** Starts the plan wants later, each when due, on the run's clock. */
1301
+ readonly later: readonly {
1302
+ readonly size: Size
1303
+ readonly at: number
1304
+ readonly free: number
1305
+ readonly join: number
1306
+ readonly given: readonly string[]
1307
+ }[]
1308
+ /** Work it saw that has since been seen running: work that left otherwise moves the plan. */
1309
+ readonly seen: Set<string>
1310
+ /** When each task seen running ends, on the run's clock. */
1311
+ readonly ends: ReadonlyMap<string, number>
1312
+ /** How many tasks the run had handed over, and those it had when planned. */
1313
+ readonly executed: number
1314
+ readonly before: ReadonlySet<string>
1315
+ /**
1316
+ * How far it read the handed-over set, kept across its steps: a Set's
1317
+ * iterator sees what is added after it, so each id is read once. Each
1318
+ * step read the whole set, O(n) an event (2026-11-05).
1319
+ */
1320
+ readonly scan: { of?: ReadonlySet<string> | undefined; it?: Iterator<string>; read: number }
1321
+ /** Each size's warm-up as it saw it: a join tells the run's own. */
1322
+ readonly warm: readonly number[]
1323
+ /** When its window moves on, on the run's clock. */
1324
+ readonly until: number
1325
+ readonly late: ReadonlySet<string>
1326
+ readonly queued: string
1327
+ readonly final: boolean
1328
+ }
1329
+
1330
+ type Seen = Pick<
1331
+ Parameters<typeof decide>[0],
1332
+ 'workers' | 'queued' | 'final' | 'sizes' | 'started' | 'warmUpMs' | 'executed' | 'nodes'
1333
+ >
1334
+
1335
+ /**
1336
+ * Running tasks past their time by more than `slack`: one that runs that
1337
+ * long moves the plan (2026-11-05: "Reality changes by leave some
1338
+ * margin. 5% or sth").
1339
+ */
1340
+ function lateOf(workers: readonly PlanWorker[], slack: number): Set<string> {
1341
+ const out = new Set<string>()
1342
+ for (const w of workers)
1343
+ for (const r of w.running) if (r.endsIn < -slack && r.id !== undefined) out.add(r.id)
1344
+ return out
1345
+ }
1346
+
1347
+ /** Each waiting and running task's cores and memory, as a plan saw them. */
1348
+ function needsOf(
1349
+ tasks: readonly PlanTask[],
1350
+ workers: readonly PlanWorker[],
1351
+ ): Map<string, readonly [number, number]> {
1352
+ const out = new Map<string, readonly [number, number]>(
1353
+ tasks.map((t) => [t.id, [t.cores, t.memory] as const]),
1354
+ )
1355
+ for (const w of workers)
1356
+ for (const r of w.running) if (r.id !== undefined) out.set(r.id, [r.cores, r.memory])
1357
+ return out
1358
+ }
1359
+
1360
+ /** Whether `is` moved from `was` past 5%, or past `least` when they are small. */
1361
+ const moved = (was: number, is: number, least: number): boolean =>
1362
+ Math.abs(is - was) > Math.max(least, Math.abs(was) * MARGIN)
1363
+
1364
+ /** How late a task may run, or how far its time may move, before the plan is made again: 5% of the run's. */
1365
+ const slackOf = (p: Plan): number => Math.max(MARGIN_MS / 5, p.endsInMs * MARGIN)
1366
+
1367
+ const warmsOf = (input: Seen): number[] => {
1368
+ const warm = warmOf(input.warmUpMs)
1369
+ return [warm(undefined), ...input.sizes.map((s) => warm(s.name))]
1370
+ }
1371
+
1372
+ function forecastOf(
1373
+ p: Plan,
1374
+ ahead: Ahead,
1375
+ tasks: readonly PlanTask[],
1376
+ input: Seen,
1377
+ now: number,
1378
+ ): Forecast {
1379
+ return {
1380
+ at: now,
1381
+ plan: p,
1382
+ work: tasks,
1383
+ ms: new Map(tasks.map((t) => [t.id, t.ms])),
1384
+ need: needsOf(tasks, input.workers),
1385
+ workers: new Set(input.workers.map((w) => w.id).filter((id) => !p.release.includes(id))),
1386
+ count: input.workers.length + p.start.length - p.release.length,
1387
+ free: new Map([...ahead.freeInMs].map(([id, ms]) => [id, now + ms])),
1388
+ join: new Map(
1389
+ input.workers.flatMap((w) => (w.starting === true ? [[w.id, now + w.at] as const] : [])),
1390
+ ),
1391
+ given: ahead.given,
1392
+ fresh: ahead.started.map((a) => ({
1393
+ free: now + a.freeInMs,
1394
+ join: now + a.joinInMs,
1395
+ given: a.given,
1396
+ })),
1397
+ later: ahead.later.map((l) => ({
1398
+ size: l.size,
1399
+ at: now + l.inMs,
1400
+ free: now + l.freeInMs,
1401
+ join: now + l.inMs + l.joinInMs,
1402
+ given: l.given,
1403
+ })),
1404
+ seen: new Set(),
1405
+ ends: new Map(
1406
+ input.workers.flatMap((w) =>
1407
+ w.running.flatMap((r) => (r.id === undefined ? [] : [[r.id, now + r.endsIn] as const])),
1408
+ ),
1409
+ ),
1410
+ executed: input.executed.size,
1411
+ before: new Set(input.executed),
1412
+ scan: { read: 0 },
1413
+ warm: warmsOf(input),
1414
+ until: ahead.untilInMs === undefined ? Infinity : now + ahead.untilInMs,
1415
+ late: lateOf(input.workers, slackOf(p)),
1416
+ queued: [...(input.queued ?? [])].sort().join('\0'),
1417
+ final: input.final,
1418
+ }
1419
+ }
1420
+
1421
+ /** The plan `had` made, followed a step: its decisions now, its times less the time since. */
1422
+ function shown(
1423
+ last: { forecast?: Forecast },
1424
+ had: Forecast,
1425
+ step: Step,
1426
+ now: number,
1427
+ tasks: readonly PlanTask[],
1428
+ ): Plan & { readonly tasks: readonly PlanTask[] } {
1429
+ last.forecast = step.next
1430
+ const again = Math.min(step.next.later[0]?.at ?? Infinity, step.next.until)
1431
+ const since = now - had.at
1432
+ return {
1433
+ start: step.start,
1434
+ release: step.release,
1435
+ endsInMs: Math.max(0, had.plan.endsInMs - since),
1436
+ boundMs: had.plan.boundMs - since,
1437
+ againInMs: Number.isFinite(again) ? again - now : undefined,
1438
+ tasks,
1439
+ }
1440
+ }
1441
+
1442
+ type Step = { start: Size[]; release: string[]; next: Forecast }
1443
+
1444
+ /**
1445
+ * The plan `f` made, followed one step on when it still stands: no new
1446
+ * work, no task's time, cores or memory moved past 5%, no worker joined
1447
+ * or left but as planned, no task ran past its time. Its starts come due
1448
+ * and its workers with their work done go, as it foresaw; undefined
1449
+ * plays it anew. Planned at every task's end, a run of 1000 tasks
1450
+ * replayed itself 10 000 times (2026-11-05: "It's just about filling
1451
+ * boxes to full"; "If simulated all goes with plan so there should be no
1452
+ * replans").
1453
+ *
1454
+ * Without `tasks` (and `sizes`), only what events move is read: work it
1455
+ * did not see is `full`, read every task first.
1456
+ */
1457
+ function follow(
1458
+ f: Forecast,
1459
+ tasks: readonly PlanTask[] | undefined,
1460
+ input: Seen,
1461
+ sizes: readonly Size[] | undefined,
1462
+ now: number,
1463
+ ): Step | 'full' | undefined {
1464
+ if (now >= f.until) return undefined
1465
+ if (f.final !== input.final) return undefined
1466
+ if ([...(input.queued ?? [])].sort().join('\0') !== f.queued) return undefined
1467
+ if (input.workers.length !== f.count) return undefined
1468
+ const warm = warmsOf(input)
1469
+ for (let i = 0; i < warm.length; i++)
1470
+ if (moved(f.warm[i]!, warm[i]!, MARGIN_MS / 5)) return undefined
1471
+ const slack = slackOf(f.plan)
1472
+ // Only what moved since is read: each task as it starts, runs and ends.
1473
+ // A waiting one's time moves with the run's, which those show.
1474
+ const ends = new Map<string, number>()
1475
+ let began = 0
1476
+ for (const w of input.workers)
1477
+ for (const r of w.running) {
1478
+ if (r.id === undefined) continue
1479
+ const was = f.need.get(r.id)
1480
+ // As for time, so for resources (2026-11-05: "And resource usage").
1481
+ if (
1482
+ was !== undefined &&
1483
+ (moved(was[0], r.cores, 0.05) || moved(was[1], r.memory, 64 * 2 ** 20))
1484
+ )
1485
+ return undefined
1486
+ const end = f.ends.get(r.id)
1487
+ if (end !== undefined) {
1488
+ ends.set(r.id, end)
1489
+ continue
1490
+ }
1491
+ // Started since: work the plan saw, as long as it played it.
1492
+ if (!f.ms.has(r.id)) return undefined
1493
+ const ms = f.ms.get(r.id)
1494
+ if (ms !== undefined && r.endsIn > ms + Math.max(MARGIN_MS / 5, ms * MARGIN)) return undefined
1495
+ ends.set(r.id, now + r.endsIn)
1496
+ f.seen.add(r.id)
1497
+ began++
1498
+ }
1499
+ // Ended since: sooner than played moves the plan, and so does one of
1500
+ // no known time ending.
1501
+ for (const [id, at] of f.ends)
1502
+ if (!ends.has(id) && (now < at - slack || (f.ms.has(id) && f.ms.get(id) === undefined)))
1503
+ return undefined
1504
+ for (const id of lateOf(input.workers, slack)) if (!f.late.has(id)) return undefined
1505
+ // More handed over than started, or fewer known: work it did not see,
1506
+ // maybe. Read it.
1507
+ if (input.executed.size > f.executed + began || input.executed.size < f.executed) {
1508
+ // Handed over and not started yet is work it saw: only one it did not
1509
+ // see sends the plan to read every task.
1510
+ if (tasks === undefined) {
1511
+ if (input.executed.size < f.executed) return 'full'
1512
+ const scan = f.scan
1513
+ if (scan.of !== input.executed) {
1514
+ scan.of = input.executed
1515
+ scan.it = input.executed.values()
1516
+ scan.read = 0
1517
+ }
1518
+ for (; scan.read < input.executed.size; scan.read++) {
1519
+ const r = scan.it!.next()
1520
+ // One left the set: the count no longer says where it read to.
1521
+ if (r.done === true) {
1522
+ scan.of = undefined
1523
+ return 'full'
1524
+ }
1525
+ const id = r.value
1526
+ if (
1527
+ !f.ms.has(id) &&
1528
+ !f.before.has(id) &&
1529
+ input.nodes.get(id)?.config.exec?.command !== undefined
1530
+ ) {
1531
+ scan.read++
1532
+ return 'full'
1533
+ }
1534
+ }
1535
+ } else {
1536
+ for (const t of tasks) if (!f.ms.has(t.id)) return undefined
1537
+ // Work that left without running (a hit, or no longer asked) frees
1538
+ // what the plan gave it.
1539
+ const waiting = new Set(tasks.map((t) => t.id))
1540
+ for (const id of f.ms.keys())
1541
+ if (!waiting.has(id) && !f.seen.has(id) && !ends.has(id)) return undefined
1542
+ }
1543
+ }
1544
+ const workers = new Set(f.workers)
1545
+ const free = new Map(f.free)
1546
+ const given = new Map(f.given)
1547
+ const join = new Map(f.join)
1548
+ const fresh = [...f.fresh]
1549
+ const release: string[] = []
1550
+ for (const w of input.workers) {
1551
+ if (!workers.has(w.id)) {
1552
+ // One the plan started, shown: it takes the next started's work.
1553
+ const next = fresh.shift()
1554
+ if (next === undefined) return undefined
1555
+ workers.add(w.id)
1556
+ free.set(w.id, next.free)
1557
+ given.set(w.id, next.given)
1558
+ join.set(w.id, next.join)
1559
+ }
1560
+ // Joined sooner or later than played, past the margin.
1561
+ const joins = join.get(w.id)
1562
+ if (joins !== undefined) {
1563
+ if (w.starting === true) {
1564
+ if (now > joins + slack) return undefined
1565
+ } else {
1566
+ if (now < joins - slack) return undefined
1567
+ join.delete(w.id)
1568
+ }
1569
+ }
1570
+ // Idle between its tasks, as played; with its work done, it goes.
1571
+ if (!w.idle || w.starting === true) continue
1572
+ const at = free.get(w.id)
1573
+ if (at === undefined || now >= at - slack) {
1574
+ // Its work done in a window with work past it: maybe needed there,
1575
+ // and one let go still counts against its size's max. A Mac let go
1576
+ // so left a Mac task no worker, and the run never ended (#4989).
1577
+ if (f.until < Infinity) return undefined
1578
+ // A task planned on it not started: its last one ended and the next
1579
+ // not handed to it yet. Let go, that one had no worker and the run
1580
+ // never ended (a two-task chain on a max-1 size, its rest within the
1581
+ // slack). Played again, the plan sees what waits for it.
1582
+ if (given.get(w.id)?.some((id) => !f.seen.has(id)) === true) return undefined
1583
+ release.push(w.id)
1584
+ workers.delete(w.id)
1585
+ }
1586
+ }
1587
+ const start: Size[] = []
1588
+ const later = [...f.later]
1589
+ while (later.length > 0 && now >= later[0]!.at) {
1590
+ const { size, free: ends, join: joins, given: runs } = later.shift()!
1591
+ // Its size taken up since: the plan no longer fits the run. Read
1592
+ // from what the run started, not from every task: each start read
1593
+ // them all (2026-11-05).
1594
+ if (sizes === undefined) {
1595
+ const kind = input.sizes.find((s) => s.name === size.name)
1596
+ if (
1597
+ input.final ||
1598
+ kind === undefined ||
1599
+ input.queued?.has(size.name) === true ||
1600
+ (input.started.get(size.name) ?? 0) >= kind.max
1601
+ )
1602
+ return undefined
1603
+ } else if (!sizes.some((s) => s.name === size.name)) return undefined
1604
+ start.push(size)
1605
+ fresh.push({ free: ends, join: joins, given: runs })
1606
+ }
1607
+ const next: Forecast = {
1608
+ ...f,
1609
+ workers,
1610
+ count: f.count + start.length - release.length,
1611
+ free,
1612
+ given,
1613
+ join,
1614
+ fresh,
1615
+ later,
1616
+ ends,
1617
+ executed: input.executed.size,
1618
+ }
1619
+ return { start, release, next }
1620
+ }
1621
+
718
1622
  /**
719
1623
  * Spark's dynamic allocation: workers enough for the cores that what is
720
1624
  * ready or running asks, started at once; an idle one past that leaves.
@@ -794,8 +1698,17 @@ function backlog(
794
1698
  return { start, release, endsInMs: end, boundMs: end, againInMs: undefined }
795
1699
  }
796
1700
 
797
- /** The most kinds `Ready.asks` jumps across; past it, it reads every task. */
1701
+ /** The most kinds `Ready.asks` jumps across; past it, it jumps across the cores' classes. */
798
1702
  const MAX_JUMP_KINDS = 32
1703
+ /** Classes a doubling of cores spans (`classOf`). */
1704
+ const CLASSES = 4
1705
+ /** The class of a task that names a platform or a size to beat: never passed whole. */
1706
+ const ODD = -Infinity
1707
+ /** A task's cores' class: about 2^(c / CLASSES) to 2^((c + 1) / CLASSES), its span kept as joined. */
1708
+ const classOf = (t: PlanTask): number =>
1709
+ t.platform !== undefined || t.above !== undefined
1710
+ ? ODD
1711
+ : Math.floor(Math.log2(Math.max(t.cores, 1e-6)) * CLASSES)
799
1712
 
800
1713
  /**
801
1714
  * Ready tasks in the order `ordered` gave, by kind (`kindOf`): `assign`
@@ -806,9 +1719,8 @@ const MAX_JUMP_KINDS = 32
806
1719
  * task 17 million times a plan (2026-10-25).
807
1720
  */
808
1721
  class Ready {
809
- private readonly ids: readonly string[]
810
- private readonly at: Map<string, number>
811
- private readonly kindAt: readonly string[]
1722
+ private readonly at: Kinds['at']
1723
+ private readonly kindAt: (place: number) => string
812
1724
  private readonly taken: Uint8Array
813
1725
  /** The rank: the lower `primary` first, then the lower `seq`. */
814
1726
  private readonly primary: Float64Array
@@ -817,28 +1729,53 @@ class Ready {
817
1729
  /** In rank order; taken ones linger until the next join. */
818
1730
  private order: number[] = []
819
1731
  private kinds = new Map<string, number[]>()
1732
+ /**
1733
+ * Ready tasks by their cores' class (`classOf`), in rank order, each
1734
+ * class's first not taken and the most memory one asked: what `asks`
1735
+ * jumps across when kinds are too many.
1736
+ */
1737
+ private classes = new Map<number, number[]>()
1738
+ private readonly classHead = new Map<number, number>()
1739
+ /** The fewest and most cores and the most memory a task of each class asked. */
1740
+ private readonly classSpan = new Map<number, [number, number, number]>()
820
1741
  /** Each kind's first task not taken. */
821
1742
  private readonly head = new Map<string, number>()
822
1743
  /** No task before this place in `order` is left: a pass began at 0 and read 9000 taken ones (2026-10-25). */
823
1744
  private lo = 0
824
1745
  /** Order it again before the next step. */
825
1746
  stale = false
1747
+ /** Taken ones still in `order`: past its live ones, it is read again without them. */
1748
+ private dead = 0
1749
+ /** Each kind's tasks not taken: a kind with none leaves `kinds`. */
1750
+ private readonly liveOf = new Map<string, number>()
826
1751
 
827
- constructor(private readonly tasks: readonly PlanTask[]) {
828
- this.ids = tasks.map((t) => t.id)
829
- this.at = new Map(this.ids.map((id, i) => [id, i]))
830
- this.kindAt = tasks.map(kindOf)
1752
+ constructor(
1753
+ private readonly tasks: readonly PlanTask[],
1754
+ kinds: Kinds = kindsOf(tasks),
1755
+ ) {
1756
+ this.at = kinds.at
1757
+ this.kindAt = kinds.kindAt
831
1758
  this.taken = new Uint8Array(tasks.length)
832
1759
  this.primary = new Float64Array(tasks.length)
833
1760
  this.seq = new Float64Array(tasks.length)
834
1761
  }
835
1762
 
836
1763
  kindOf(id: string): string {
837
- return this.kindAt[this.at.get(id)!]!
1764
+ return this.kindAt(this.at.get(id)!)
838
1765
  }
839
1766
 
840
1767
  take(id: string): void {
841
- this.taken[this.at.get(id)!] = 1
1768
+ const i = this.at.get(id)!
1769
+ this.taken[i] = 1
1770
+ this.dead++
1771
+ const k = this.kindAt(i)
1772
+ const n = this.liveOf.get(k)! - 1
1773
+ if (n > 0) this.liveOf.set(k, n)
1774
+ else {
1775
+ this.liveOf.delete(k)
1776
+ this.kinds.delete(k)
1777
+ this.head.delete(k)
1778
+ }
842
1779
  }
843
1780
 
844
1781
  live(): PlanTask[] {
@@ -861,6 +1798,11 @@ class Ready {
861
1798
  this.order = []
862
1799
  this.kinds = new Map()
863
1800
  this.head.clear()
1801
+ this.classes = new Map()
1802
+ this.classHead.clear()
1803
+ this.classSpan.clear()
1804
+ this.liveOf.clear()
1805
+ this.dead = 0
864
1806
  this.stale = false
865
1807
  const batch = ids.map((id) => {
866
1808
  const i = this.at.get(id)!
@@ -891,26 +1833,99 @@ class Ready {
891
1833
  }
892
1834
 
893
1835
  private join(batch: readonly number[]): void {
894
- this.order = this.merge(this.order, batch)
895
- this.lo = 0
1836
+ // Taken ones outnumber the rest: read without them once.
1837
+ if (this.dead > this.order.length / 2) {
1838
+ this.order = this.merge(this.order, [])
1839
+ for (const [k, q] of this.kinds) this.kinds.set(k, this.merge(q, []))
1840
+ this.head.clear()
1841
+ for (const [c, q] of this.classes) {
1842
+ const left = this.merge(q, [])
1843
+ if (left.length > 0) this.classes.set(c, left)
1844
+ else this.classes.delete(c)
1845
+ }
1846
+ this.classHead.clear()
1847
+ this.dead = 0
1848
+ this.lo = 0
1849
+ }
1850
+ const len = this.order.length + batch.length
1851
+ this.lo = Math.min(this.lo, this.insert(this.order, batch))
1852
+ // A merge left the taken ones out.
1853
+ this.dead -= len - this.order.length
896
1854
  const by = new Map<string, number[]>()
897
1855
  for (const i of batch) {
898
- const k = this.kindAt[i]!
1856
+ const k = this.kindAt(i)
1857
+ this.liveOf.set(k, (this.liveOf.get(k) ?? 0) + 1)
899
1858
  const q = by.get(k)
900
1859
  if (q === undefined) by.set(k, [i])
901
1860
  else q.push(i)
902
1861
  }
903
- // Kinds keep the order's: a kind left with no task is gone, as one
904
- // ordered again from scratch.
905
- const kinds = new Map<string, number[]>()
906
- for (const [k, q] of this.kinds) {
907
- const merged = this.merge(q, by.get(k) ?? [])
908
- by.delete(k)
909
- if (merged.length > 0) kinds.set(k, merged)
1862
+ const byClass = new Map<number, number[]>()
1863
+ for (const i of batch) {
1864
+ const t = this.tasks[i]!
1865
+ const c = classOf(t)
1866
+ const span = this.classSpan.get(c)
1867
+ if (span === undefined) this.classSpan.set(c, [t.cores, t.cores, t.memory])
1868
+ else {
1869
+ span[0] = Math.min(span[0], t.cores)
1870
+ span[1] = Math.max(span[1], t.cores)
1871
+ span[2] = Math.max(span[2], t.memory)
1872
+ }
1873
+ const q = byClass.get(c)
1874
+ if (q === undefined) byClass.set(c, [i])
1875
+ else q.push(i)
910
1876
  }
911
- for (const [k, q] of by) kinds.set(k, q)
912
- this.kinds = kinds
913
- this.head.clear()
1877
+ for (const [c, q] of byClass) {
1878
+ const had = this.classes.get(c)
1879
+ if (had === undefined) {
1880
+ this.classes.set(c, q)
1881
+ this.classHead.delete(c)
1882
+ } else {
1883
+ const first = this.insert(had, q)
1884
+ const h = this.classHead.get(c)
1885
+ if (h !== undefined && first < h) this.classHead.set(c, first)
1886
+ }
1887
+ }
1888
+ // Only the kinds it joins: each join read every kind and every ready
1889
+ // task, and a 6000-task tree spent 2 s of a 5 s plan on it (2026-11-05).
1890
+ for (const [k, q] of by) {
1891
+ const had = this.kinds.get(k)
1892
+ if (had === undefined) {
1893
+ this.kinds.set(k, q)
1894
+ this.head.delete(k)
1895
+ } else {
1896
+ const first = this.insert(had, q)
1897
+ const h = this.head.get(k)
1898
+ if (h !== undefined && first < h) this.head.set(k, first)
1899
+ }
1900
+ }
1901
+ }
1902
+
1903
+ /**
1904
+ * `batch`, in rank order, into `into`, in rank order: merged when it is
1905
+ * as big, each put in its place when it is few. The first place it took.
1906
+ */
1907
+ private insert(into: number[], batch: readonly number[]): number {
1908
+ if (batch.length === 0) return into.length
1909
+ if (batch.length * 8 > into.length) {
1910
+ const merged = this.merge(into, batch)
1911
+ into.length = 0
1912
+ for (const i of merged) into.push(i)
1913
+ return 0
1914
+ }
1915
+ let first = into.length
1916
+ let lo = 0
1917
+ for (const b of batch) {
1918
+ let hi = into.length
1919
+ while (lo < hi) {
1920
+ const mid = (lo + hi) >> 1
1921
+ if (this.before(into[mid]!, b)) lo = mid + 1
1922
+ else hi = mid
1923
+ }
1924
+ into.splice(lo, 0, b)
1925
+ first = Math.min(first, lo)
1926
+ lo++
1927
+ }
1928
+ return first
914
1929
  }
915
1930
 
916
1931
  /** In order, past each kind in `spent` as `assign` reads it then. */
@@ -918,43 +1933,116 @@ class Ready {
918
1933
  // Measured needs are unrounded, so kinds can be as many as tasks: a
919
1934
  // jump across every kind then cost more than a read of every task.
920
1935
  if (this.kinds.size > MAX_JUMP_KINDS) {
921
- while (this.lo < this.order.length && this.taken[this.order[this.lo]!] === 1) this.lo++
922
- for (let p = this.lo; p < this.order.length; p++) {
923
- const i = this.order[p]!
924
- const kind = this.kindAt[i]!
925
- if (this.taken[i] === 1 || spent.has(kind)) continue
926
- if (bounds?.done === true || (kinds !== undefined && spent.size >= kinds)) return
927
- const t = this.tasks[i]!
928
- if (bounds !== undefined && dead(t, bounds)) spent.add(kind)
929
- else yield { id: t.id, need: t, kind }
930
- }
1936
+ yield* this.byClass(spent, bounds, kinds)
931
1937
  return
932
1938
  }
933
- const at = new Map<string, number>()
1939
+ // Arrays, not the maps: the scan below is most of a sim's time (2026-11-05).
1940
+ const ks: string[] = []
1941
+ const qs: (readonly number[])[] = []
1942
+ for (const [k, q] of this.kinds) {
1943
+ ks.push(k)
1944
+ qs.push(q)
1945
+ }
1946
+ const at = new Int32Array(ks.length).fill(-1)
934
1947
  let cursor = -1
935
1948
  for (;;) {
936
1949
  let next = -1
937
- let nextKind = ''
938
- for (const [k, q] of this.kinds) {
1950
+ let nextAt = -1
1951
+ for (let x = 0; x < ks.length; x++) {
1952
+ const k = ks[x]!
939
1953
  if (spent.has(k)) continue
940
- let p = at.get(k) ?? this.headOf(k, q)
1954
+ const q = qs[x]!
1955
+ let p = at[x]! === -1 ? this.headOf(k, q) : at[x]!
941
1956
  if (p < q.length && cursor !== -1 && !this.before(cursor, q[p]!))
942
1957
  p = this.after(q, p, cursor)
943
1958
  while (p < q.length && this.taken[q[p]!] === 1) p++
944
- at.set(k, p)
945
- if (p < q.length && (next === -1 || this.before(q[p]!, next))) {
1959
+ at[x] = p
1960
+ if (p === q.length) continue
1961
+ // A kind no bin fits or holds for is spent here, as `assign` would
1962
+ // spend it once read: yielded, each cost a sim of 300 tasks a
1963
+ // pass over the bins (2026-11-05). Bounds only shrink until a
1964
+ // bin is given, which clears `spent`.
1965
+ if (bounds !== undefined && dead(this.tasks[q[p]!]!, bounds)) {
1966
+ spent.add(k)
1967
+ continue
1968
+ }
1969
+ if (next === -1 || this.before(q[p]!, next)) {
946
1970
  next = q[p]!
947
- nextKind = k
1971
+ nextAt = x
1972
+ }
1973
+ }
1974
+ if (next === -1) return
1975
+ at[nextAt]!++
1976
+ cursor = next
1977
+ const t = this.tasks[next]!
1978
+ yield { id: t.id, need: t, kind: ks[nextAt]! }
1979
+ }
1980
+ }
1981
+
1982
+ /**
1983
+ * As a read of every ready task in rank order, past each taken, spent
1984
+ * or `dead` one, but jumping across the cores' classes: a class whose
1985
+ * every task no bin fits, by the span its tasks asked, is passed whole. Read one by one, the ready
1986
+ * tasks of a 5000-task play were most of its time (2026-11-05). A task
1987
+ * found dead stays dead until a bin is given, and a give only shrinks
1988
+ * the bins' room, so the read goes past it for the rest of the pass.
1989
+ */
1990
+ private *byClass(spent: Set<string>, bounds?: Bounds, kinds?: number): Generator<Ask> {
1991
+ const cs: number[] = []
1992
+ const qs: (readonly number[])[] = []
1993
+ for (const [c, q] of this.classes) {
1994
+ cs.push(c)
1995
+ qs.push(q)
1996
+ }
1997
+ const at = new Int32Array(cs.length).fill(-1)
1998
+ let cursor = -1
1999
+ for (;;) {
2000
+ if (bounds?.done === true || (kinds !== undefined && spent.size >= kinds)) return
2001
+ let next = -1
2002
+ for (let x = 0; x < cs.length; x++) {
2003
+ const c = cs[x]!
2004
+ if (
2005
+ bounds !== undefined &&
2006
+ c !== ODD &&
2007
+ !bounds.open &&
2008
+ this.classSpan.get(c)![0] > bounds.free + 1e-9 &&
2009
+ this.classSpan.get(c)![1] < bounds.cores &&
2010
+ this.classSpan.get(c)![2] <= bounds.memory
2011
+ )
2012
+ continue
2013
+ const q = qs[x]!
2014
+ let p = at[x]! === -1 ? this.classHeadOf(c, q) : at[x]!
2015
+ if (p < q.length && cursor !== -1 && !this.before(cursor, q[p]!))
2016
+ p = this.after(q, p, cursor)
2017
+ for (; p < q.length; p++) {
2018
+ const i = q[p]!
2019
+ if (this.taken[i] === 1) continue
2020
+ const t = this.tasks[i]!
2021
+ if (bounds === undefined || !dead(t, bounds)) break
2022
+ spent.add(this.kindAt(i))
2023
+ }
2024
+ at[x] = p
2025
+ // A spent kind is passed here only: a give clears `spent`.
2026
+ let r = p
2027
+ while (r < q.length && (this.taken[q[r]!] === 1 || spent.has(this.kindAt(q[r]!)))) r++
2028
+ if (r < q.length && (next === -1 || this.before(q[r]!, next))) {
2029
+ next = q[r]!
948
2030
  }
949
2031
  }
950
2032
  if (next === -1) return
951
- at.set(nextKind, at.get(nextKind)! + 1)
952
2033
  cursor = next
953
2034
  const t = this.tasks[next]!
954
- yield { id: t.id, need: t, kind: nextKind }
2035
+ yield { id: t.id, need: t, kind: this.kindAt(next) }
955
2036
  }
956
2037
  }
957
2038
 
2039
+ private classHeadOf(c: number, q: readonly number[]): number {
2040
+ let h = this.classHead.get(c) ?? 0
2041
+ while (h < q.length && this.taken[q[h]!] === 1) h++
2042
+ this.classHead.set(c, h)
2043
+ return h
2044
+ }
2045
+
958
2046
  private headOf(k: string, q: readonly number[]): number {
959
2047
  let h = this.head.get(k) ?? 0
960
2048
  while (h < q.length && this.taken[q[h]!] === 1) h++
@@ -986,3 +2074,193 @@ function floorOf(needs: Iterable<{ cores: number; memory: number }>): {
986
2074
  }
987
2075
  return { cores, memory }
988
2076
  }
2077
+
2078
+ /** Workers of one shape. */
2079
+ export interface Fleet {
2080
+ readonly count: number
2081
+ readonly cores: number
2082
+ readonly memory: number
2083
+ readonly platform?: string | undefined
2084
+ /** The declared size, when it is one. */
2085
+ readonly size?: string | undefined
2086
+ }
2087
+
2088
+ /** Workers that end `tasks` soonest, and when. */
2089
+ export interface Fleets {
2090
+ readonly fleet: readonly Fleet[]
2091
+ readonly endMs: number
2092
+ }
2093
+
2094
+ /**
2095
+ * What would have run `tasks` best, played from an empty run (`advise`):
2096
+ * on the declared sizes, how many of each; past their `max`, how many it
2097
+ * would take; and of any cores, the shape that ends as soon for the
2098
+ * least core time.
2099
+ */
2100
+ export interface Advice {
2101
+ readonly declared?: Fleets | undefined
2102
+ /** Only when more than the sizes' `max` end it past the margin sooner. */
2103
+ readonly more?: Fleets | undefined
2104
+ readonly any?: Fleets | undefined
2105
+ }
2106
+
2107
+ /** The cores a shape of `Advice.any` may have. */
2108
+ const SHAPES = [2, 4, 8, 16, 32, 64]
2109
+ /** Memory a core of a shape has when nothing declares any: as GitHub's larger runners. */
2110
+ const MEMORY_PER_CORE = 4 * 2 ** 30
2111
+
2112
+ /**
2113
+ * The run's workers, judged after it: `tasks` as each took, played from
2114
+ * nothing on the fewest of `sizes`, in the order the live plan starts
2115
+ * them (`candidates`), that end within its margin of all of them; again
2116
+ * with each size's `max` lifted; and on one shape a platform of any
2117
+ * cores. Each worker joins after its platform's `warm` warm-up.
2118
+ */
2119
+ export function advise(
2120
+ tasks: readonly PlanTask[],
2121
+ sizes: readonly Size[],
2122
+ warm: (platform: string | undefined) => number,
2123
+ how: Strategy = DEFAULT_STRATEGY,
2124
+ ): Advice {
2125
+ if (tasks.length === 0) return {}
2126
+ const perPlatform = new Map<string | undefined, number>()
2127
+ for (const t of tasks) perPlatform.set(t.platform, (perPlatform.get(t.platform) ?? 0) + 1)
2128
+ const wants = new Map(
2129
+ [...perPlatform].flatMap(([p, n]) => (p === undefined ? [] : [[p, n] as const])),
2130
+ )
2131
+ const atOf = (s: Size): number => warm(s.platform)
2132
+ const fleetOf = (kept: readonly PlanWorker[], of: readonly Size[]): Fleet[] => {
2133
+ const by = new Map<string, number>()
2134
+ for (const k of kept) if (k.size !== undefined) by.set(k.size, (by.get(k.size) ?? 0) + 1)
2135
+ return of.flatMap((s) => {
2136
+ const count = by.get(s.name) ?? 0
2137
+ return count === 0
2138
+ ? []
2139
+ : [{ count, cores: s.cpu, memory: s.memory, platform: s.platform, size: s.name }]
2140
+ })
2141
+ }
2142
+ const byName = (w: readonly Size[]) => (size: string | undefined) =>
2143
+ atOf(w.find((s) => s.name === size)!)
2144
+ // More workers than run at once end it no sooner: of `cores` on a
2145
+ // platform, at most twice its work over the longest chain, a task each
2146
+ // at most. Without the cap 1000 tasks played 1000 workers (70 s).
2147
+ const chain = longestMs(tasks)
2148
+ const work = new Map<string | undefined, number>()
2149
+ for (const t of tasks)
2150
+ work.set(t.platform, (work.get(t.platform) ?? 0) + t.cores * (t.ms ?? UNKNOWN_MS))
2151
+ const capOf = (cores: number, platform: string | undefined): number => {
2152
+ let w = 0
2153
+ let n = 0
2154
+ for (const [p, c] of perPlatform)
2155
+ if (platform === undefined || p === platform) {
2156
+ w += work.get(p) ?? 0
2157
+ n += c
2158
+ }
2159
+ return Math.min(n, 2 * Math.ceil(w / cores / Math.max(1, chain)) + 1)
2160
+ }
2161
+ /** The fewest of `of`, each up to its `max`, ending within the margin of all of them: halving. */
2162
+ const halved = (of: readonly Size[]): { fleets: Fleets; kept: PlanWorker[] } | undefined => {
2163
+ const at = byName(of)
2164
+ const all = candidates(of, new Map(), [], Infinity, wants).map((s, i) =>
2165
+ fresh({ ...s, size: s.name }, `\0advice${i}`, at(s.name)),
2166
+ )
2167
+ const best = simulate(tasks, all, how)
2168
+ if (!Number.isFinite(best)) return undefined
2169
+ const bound = best + Math.max(MARGIN_MS, best * MARGIN)
2170
+ let lo = 1
2171
+ let hi = all.length
2172
+ while (lo < hi) {
2173
+ const mid = (lo + hi) >> 1
2174
+ if (simulate(tasks, all.slice(0, mid), how) <= bound) hi = mid
2175
+ else lo = mid + 1
2176
+ }
2177
+ // Then each the run ends as soon without, as the live plan trims.
2178
+ const { kept, before } = dropSpare(
2179
+ all.slice(0, lo),
2180
+ all.slice(0, lo),
2181
+ play(tasks, all.slice(0, lo), how),
2182
+ (ws) => play(tasks, ws, how),
2183
+ )
2184
+ return { fleets: { fleet: fleetOf(kept, of), endMs: before.end }, kept }
2185
+ }
2186
+ const declared = sizes.length === 0 ? undefined : halved(sizes)?.fleets
2187
+ const lifted = sizes.map((s) => ({ ...s, max: Math.max(s.max, capOf(s.cpu, s.platform)) }))
2188
+ const wider = lifted.some((s, i) => s.max > sizes[i]!.max) ? halved(lifted)?.fleets : undefined
2189
+ const more =
2190
+ wider !== undefined &&
2191
+ declared !== undefined &&
2192
+ wider.endMs + Math.max(MARGIN_MS, declared.endMs * MARGIN) < declared.endMs
2193
+ ? wider
2194
+ : undefined
2195
+ // Of any cores: each platform's memory a core as its sizes have most.
2196
+ const ratio = new Map<string | undefined, number>()
2197
+ for (const s of sizes)
2198
+ ratio.set(s.platform, Math.max(ratio.get(s.platform) ?? 0, s.memory / s.cpu))
2199
+ const most = new Map<string | undefined, number>()
2200
+ for (const t of tasks) most.set(t.platform, Math.max(most.get(t.platform) ?? 0, t.memory))
2201
+ const tried: { fleets: Fleets; coreMs: number; workers: number }[] = []
2202
+ for (const cores of SHAPES) {
2203
+ const shapes: Size[] = [...perPlatform.keys()].map((platform) => ({
2204
+ name: `${cores}#${platform ?? ''}`,
2205
+ max: capOf(cores, platform),
2206
+ cpu: cores,
2207
+ memory: Math.max(
2208
+ cores * (ratio.get(platform) ?? ratio.get(undefined) ?? MEMORY_PER_CORE),
2209
+ most.get(platform) ?? 0,
2210
+ ),
2211
+ platform,
2212
+ }))
2213
+ const got = halved(shapes)
2214
+ if (got === undefined) continue
2215
+ let coreMs = 0
2216
+ for (const k of got.kept) coreMs += k.cores * Math.max(0, got.fleets.endMs - k.at)
2217
+ tried.push({ fleets: got.fleets, coreMs, workers: got.kept.length })
2218
+ }
2219
+ let any: Fleets | undefined
2220
+ if (tried.length > 0) {
2221
+ const soonest = Math.min(...tried.map((t) => t.fleets.endMs))
2222
+ const bound = soonest + Math.max(MARGIN_MS, soonest * MARGIN)
2223
+ let cheapest: (typeof tried)[number] | undefined
2224
+ for (const t of tried)
2225
+ if (
2226
+ t.fleets.endMs <= bound &&
2227
+ (cheapest === undefined ||
2228
+ t.coreMs < cheapest.coreMs ||
2229
+ // As cheap, fewer machines.
2230
+ (t.coreMs === cheapest.coreMs && t.workers < cheapest.workers))
2231
+ )
2232
+ cheapest = t
2233
+ any = {
2234
+ fleet: cheapest!.fleets.fleet.map((f) => ({
2235
+ count: f.count,
2236
+ cores: f.cores,
2237
+ memory: f.memory,
2238
+ platform: f.platform,
2239
+ })),
2240
+ endMs: cheapest!.fleets.endMs,
2241
+ }
2242
+ }
2243
+ return { declared, more, any }
2244
+ }
2245
+
2246
+ /** The longest chain of `tasks` by their `ms`, through the deps among them. */
2247
+ function longestMs(tasks: readonly PlanTask[]): number {
2248
+ const byId = new Map(tasks.map((t) => [t.id, t]))
2249
+ const memo = new Map<string, number>()
2250
+ const of = (t: PlanTask): number => {
2251
+ const had = memo.get(t.id)
2252
+ if (had !== undefined) return had
2253
+ memo.set(t.id, 0)
2254
+ let up = 0
2255
+ for (const d of t.deps) {
2256
+ const u = byId.get(d)
2257
+ if (u !== undefined) up = Math.max(up, of(u))
2258
+ }
2259
+ const out = up + (t.ms ?? UNKNOWN_MS)
2260
+ memo.set(t.id, out)
2261
+ return out
2262
+ }
2263
+ let most = 0
2264
+ for (const t of tasks) most = Math.max(most, of(t))
2265
+ return most
2266
+ }