@vzn/vx-dte 0.0.651 → 0.0.652

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
@@ -50,8 +50,32 @@ export interface PlanTask {
50
50
  readonly deps: readonly string[]
51
51
  /** The platform core placed it on (`TaskNode.platform`): only a worker of it runs it. */
52
52
  readonly platform?: string | undefined
53
+ /** Its number in the run's graph and its deps' (`planTasks`): a plan's shape reads them, not ids. */
54
+ readonly n?: number
55
+ readonly depsN?: Int32Array
53
56
  }
54
57
 
58
+ /** The run's tasks numbered once, and each one's deps by number. */
59
+ interface Numbered {
60
+ readonly of: Map<string, Node>
61
+ readonly ids: readonly string[]
62
+ }
63
+
64
+ /** A task of the run's graph: its number, whether it runs, the tasks that run it waits on. */
65
+ interface Node {
66
+ readonly n: number
67
+ readonly node: TaskNode
68
+ readonly runs: boolean
69
+ deps?: string[]
70
+ depsN?: Int32Array
71
+ }
72
+
73
+ /** The numbering each `depsN` is of. */
74
+ const numberedOf = new WeakMap<Int32Array, Numbered>()
75
+
76
+ /** A number for each of `nodes`, once a run. */
77
+ const numbers = new WeakMap<ReadonlyMap<string, TaskNode>, Numbered>()
78
+
55
79
  /** A worker the plan can place on: from `at` ms on, with what it runs until each ends. */
56
80
  export interface PlanWorker {
57
81
  readonly id: string
@@ -63,6 +87,7 @@ export interface PlanWorker {
63
87
  readonly id?: string
64
88
  readonly cores: number
65
89
  readonly memory: number
90
+ /** Below 0: past its time by as much. */
66
91
  readonly endsIn: number
67
92
  }[]
68
93
  /** Holding no task: the run may let it go. */
@@ -96,6 +121,26 @@ export interface Plan {
96
121
  readonly againInMs: number | undefined
97
122
  }
98
123
 
124
+ /**
125
+ * What a plan foresees past its decisions, so the run follows it without
126
+ * playing it again: when each worker's last task in it ends (one idle
127
+ * before that is between tasks), when each start it wants later is due,
128
+ * and when each started now ends its work.
129
+ */
130
+ interface Ahead {
131
+ /** When the window it played moves on, from now: plan again then. */
132
+ readonly untilInMs: number | undefined
133
+ readonly freeInMs: ReadonlyMap<string, number>
134
+ readonly later: readonly {
135
+ readonly size: Size
136
+ readonly inMs: number
137
+ readonly freeInMs: number
138
+ readonly joinInMs: number
139
+ }[]
140
+ /** For each started now, when its work ends and when it joins. */
141
+ readonly started: readonly { readonly freeInMs: number; readonly joinInMs: number }[]
142
+ }
143
+
99
144
  /** History the run's finished tasks must have had before `slowdown` says anything. */
100
145
  const SLOWDOWN_MS = 20_000
101
146
 
@@ -159,28 +204,55 @@ export function planTasks(
159
204
  durationMs: (id: string) => number | undefined,
160
205
  home?: string,
161
206
  ): 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
207
+ // The graph is the run's, fixed: each task is numbered and its deps
208
+ // walked once a run, not at each plan (2026-11-05: a 5000-task sim
209
+ // spent a quarter here), and read by one lookup (2026-11-06).
210
+ let num = numbers.get(nodes)
211
+ if (num === undefined) {
212
+ const of = new Map<string, Node>()
213
+ const all: string[] = []
214
+ for (const [id, node] of nodes) {
215
+ of.set(id, { n: all.length, node, runs: node.config.exec?.command !== undefined })
216
+ all.push(id)
217
+ }
218
+ numbers.set(nodes, (num = { of, ids: all }))
171
219
  }
172
- return [...ids].filter(runs).map((id) => {
220
+ const { of } = num
221
+ const depsOf = (e: Node): string[] => {
222
+ if (e.deps !== undefined) return e.deps
223
+ e.deps = []
224
+ const out: string[] = []
225
+ for (const d of e.node.deps) {
226
+ const x = of.get(d)
227
+ if (x === undefined) continue
228
+ if (x.runs) out.push(d)
229
+ else for (const dd of depsOf(x)) out.push(dd)
230
+ }
231
+ return (e.deps = out)
232
+ }
233
+ const out: PlanTask[] = []
234
+ for (const id of ids) {
235
+ const e = of.get(id)
236
+ if (e === undefined || !e.runs) continue
173
237
  const n = need(id)
174
- return {
238
+ const deps = depsOf(e)
239
+ if (e.depsN === undefined) {
240
+ e.depsN = Int32Array.from(deps, (d) => of.get(d)!.n)
241
+ numberedOf.set(e.depsN, num)
242
+ }
243
+ out.push({
175
244
  id,
176
245
  cores: n.ask?.cores ?? n.cores,
177
246
  memory: n.ask?.memory ?? n.memory,
178
247
  above: n.above,
179
248
  ms: durationMs(id),
180
- deps: depsOf(id),
181
- platform: nodes.get(id)?.platform ?? home,
182
- }
183
- })
249
+ deps,
250
+ platform: e.node.platform ?? home,
251
+ n: e.n,
252
+ depsN: e.depsN,
253
+ })
254
+ }
255
+ return out
184
256
  }
185
257
 
186
258
  /**
@@ -209,15 +281,14 @@ export function missedWork(
209
281
  if (known !== undefined) return known
210
282
  changed.set(id, false)
211
283
  const n = nodes.get(id)
212
- const c = (executed.has(id) && n?.config.cache !== undefined) || (n?.deps ?? []).some(changes)
284
+ const c = (executed.has(id) && n?.config.cache === true) || (n?.deps ?? []).some(changes)
213
285
  changed.set(id, c)
214
286
  return c
215
287
  }
216
288
  const missed = (id: string): boolean => {
217
289
  const n = nodes.get(id)
218
290
  return (
219
- executed.has(id) ||
220
- (n !== undefined && (n.config.cache === undefined || n.deps.some(changes)))
291
+ executed.has(id) || (n !== undefined && (n.config.cache !== true || n.deps.some(changes)))
221
292
  )
222
293
  }
223
294
  return planTasks([...remaining].filter(missed), nodes, need, durationMs, home)
@@ -244,6 +315,10 @@ interface Slot extends Bin {
244
315
  readonly at: number
245
316
  /** When it is first given a task. */
246
317
  first?: number
318
+ /** When its last task ends. */
319
+ free?: number
320
+ /** When its last task given in the play ends. */
321
+ last?: number
247
322
  }
248
323
 
249
324
  /**
@@ -264,36 +339,27 @@ function play(
264
339
  tasks: readonly PlanTask[],
265
340
  workers: readonly PlanWorker[],
266
341
  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>()
342
+ // Played no further: past it the rest ends no sooner than its longest
343
+ // chain from there (2026-11-05: "We can plan as we go till next level";
344
+ // "it can look ahead 2 warmups").
345
+ horizon = Infinity,
346
+ ): {
347
+ end: number
348
+ /** It stopped at `horizon` with work left. */
349
+ cut?: boolean
350
+ first: Map<string, number>
351
+ ends: Map<string | undefined, number>
352
+ free: () => Map<string, number>
353
+ } {
271
354
  // A dependency running now ends when it does; one ended or a hit, now.
272
355
  const runningIds = new Set<string>()
273
356
  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
- }
357
+ const { byId, after, afterAt, indexOf, waitedAt, tailOf, tailAt, kinds } = shapeOf(
358
+ tasks,
359
+ runningIds,
360
+ )
361
+ // By place, not id: maps keyed by id were a 5000-task play's most time (2026-11-05).
362
+ const waits = waitedAt.slice()
297
363
  const slots: Slot[] = workers.map((w) => ({
298
364
  id: w.id,
299
365
  cores: w.cores,
@@ -304,21 +370,22 @@ function play(
304
370
  running: 0,
305
371
  }))
306
372
  // Ends to come: [at, slot, cores, memory, task or undefined].
307
- const ends: [number, Slot, number, number, string | undefined][] = []
373
+ const ends = new Ends()
308
374
  workers.forEach((w, i) => {
309
375
  const s = slots[i]!
310
376
  for (const r of w.running) {
311
377
  s.used.cores += r.cores
312
378
  s.used.memory += r.memory
313
379
  s.running++
314
- ends.push([Math.max(0, r.endsIn), s, r.cores, r.memory, r.id])
380
+ ends.push(Math.max(0, r.endsIn), s, r.cores, r.memory, -1, r.id)
381
+ s.free = Math.max(s.free ?? 0, r.endsIn)
315
382
  }
316
383
  })
317
384
  // Ready tasks, ordered again only once one joins (`graphene`: or
318
385
  // leaves): each step sorted every ready task, and a run of 9000 spent
319
386
  // its orchestrator planning (2026-10-25).
320
- const ready = new Ready(tasks)
321
- let joining = tasks.filter((t) => waits.get(t.id) === 0)
387
+ const ready = new Ready(tasks, kinds)
388
+ let joining = tasks.filter((_, i) => waits[i] === 0)
322
389
  let readyCount = joining.length
323
390
  const spent = new Set<string>()
324
391
  const bounds: Bounds = { done: false, open: true, free: 0, cores: 0, memory: 0 }
@@ -345,9 +412,33 @@ function play(
345
412
  const widest = Math.max(1, ...workers.map((w) => w.cores))
346
413
  let left = tasks.length
347
414
  let now = 0
348
- let last = Math.max(0, ...ends.map((e) => e[0]))
415
+ let last = Math.max(0, ends.last())
416
+ // Core-ms of the tasks not yet given.
417
+ const workLeft = new Map<string | undefined, number>()
418
+ const spend = (t: PlanTask, by: number): void => {
419
+ const w = by * t.cores * (t.ms ?? UNKNOWN_MS)
420
+ workLeft.set(undefined, (workLeft.get(undefined) ?? 0) + w)
421
+ if (t.platform !== undefined) workLeft.set(t.platform, (workLeft.get(t.platform) ?? 0) + w)
422
+ }
423
+ if (horizon < Infinity) for (const t of tasks) spend(t, 1)
349
424
  // When each platform's last task ends.
350
- const byPlatform = new Map<string | undefined, number>()
425
+ const byPlatform = (): Map<string | undefined, number> => {
426
+ const out = new Map<string | undefined, number>()
427
+ for (const s of slots)
428
+ if (s.last !== undefined) out.set(s.platform, Math.max(out.get(s.platform) ?? 0, s.last))
429
+ return out
430
+ }
431
+ // The joined slots, read again once the next one joins.
432
+ let joined: Slot[] = []
433
+ let nextJoin = -Infinity
434
+ // A task that filled a size waits for a bigger one still to join.
435
+ const later = (above: { cores: number; memory: number }, platform?: string): boolean =>
436
+ slots.some(
437
+ (s) =>
438
+ s.at > now &&
439
+ (platform === undefined || s.platform === platform) &&
440
+ (s.cores > above.cores || s.memory > above.memory),
441
+ )
351
442
  for (;;) {
352
443
  if (joining.length > 0 || ready.stale) {
353
444
  // By the longest chain, those still ready keep their order and the
@@ -365,21 +456,21 @@ function play(
365
456
  else ready.reset(order)
366
457
  joining = []
367
458
  }
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
- })
459
+ if (now >= nextJoin) {
460
+ joined = slots.filter((s) => s.at <= now)
461
+ nextJoin = Infinity
462
+ for (const s of slots) if (s.at > now) nextJoin = Math.min(nextJoin, s.at)
463
+ }
464
+ // With none ready nothing is given: a third of a sim's steps (2026-11-05).
465
+ const to =
466
+ readyCount === 0
467
+ ? NONE
468
+ : assign(ready.asks(spent, bounds, needs.size), joined, later, how.place, {
469
+ floor: (floor ??= floorOf(needs.values())),
470
+ kinds: needs.size,
471
+ spent,
472
+ bounds,
473
+ })
383
474
  // `graphene` ranks against the longest chain still ready.
384
475
  if (to.size > 0 && how.order === 'graphene') ready.stale = true
385
476
  for (const [id, best] of to) {
@@ -388,46 +479,327 @@ function play(
388
479
  readyCount--
389
480
  count(t, -1)
390
481
  best.first ??= now
482
+ best.free = Math.max(best.free ?? 0, now + (t.ms ?? UNKNOWN_MS))
391
483
  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])
484
+ ends.push(
485
+ end,
486
+ best,
487
+ Math.min(t.cores, best.cores),
488
+ Math.min(t.memory, best.memory),
489
+ indexOf.get(id)!,
490
+ undefined,
491
+ )
393
492
  last = Math.max(last, end)
394
- byPlatform.set(best.platform, Math.max(byPlatform.get(best.platform) ?? 0, end))
493
+ best.last = Math.max(best.last ?? 0, end)
494
+ if (horizon < Infinity) spend(t, -1)
395
495
  }
396
496
  const first = (): Map<string, number> =>
397
497
  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 }
498
+ const free = (): Map<string, number> =>
499
+ new Map(slots.flatMap((s) => (s.free === undefined ? [] : [[s.id, s.free] as const])))
500
+ if (left === 0 && readyCount === 0)
501
+ return { end: last, first: first(), ends: byPlatform(), free }
399
502
  // 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])
503
+ let next = ends.next()
402
504
  for (const s of slots) if (s.at > now) next = Math.min(next, s.at)
505
+ if (next >= horizon && next < Infinity) {
506
+ // No sooner than its longest chain, nor than its work spread on
507
+ // every core it has: the workers it asks later are the next plan's.
508
+ // Of each platform on its own workers, and all of it on all.
509
+ const work = new Map(workLeft)
510
+ const cores = new Map<string | undefined, number>()
511
+ for (const s of slots) {
512
+ cores.set(undefined, (cores.get(undefined) ?? 0) + s.cores)
513
+ if (s.platform !== undefined) cores.set(s.platform, (cores.get(s.platform) ?? 0) + s.cores)
514
+ }
515
+ for (const e of ends.all())
516
+ work.set(undefined, (work.get(undefined) ?? 0) + e.cores * (e.at - now))
517
+ let end = last
518
+ for (const [p, w] of work) end = Math.max(end, now + w / Math.max(cores.get(p) ?? 0, 1e-9))
519
+ for (const t of ready.live()) end = Math.max(end, now + tailOf(t.id))
520
+ for (const e of ends.all())
521
+ for (const d of (e.place >= 0 ? afterAt(e.place) : after.get(e.id!)) ?? [])
522
+ end = Math.max(end, e.at + tailAt(d))
523
+ return { end, cut: true, first: first(), ends: byPlatform(), free }
524
+ }
403
525
  if (next === Infinity)
404
- return { end: left === 0 ? last : Infinity, first: first(), ends: byPlatform }
526
+ return { end: left === 0 ? last : Infinity, first: first(), ends: byPlatform(), free }
405
527
  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)
528
+ while (ends.next() <= now) {
529
+ const { s, cores, memory, place, id } = ends.pop()
413
530
  s.used.cores -= cores
414
531
  s.used.memory -= memory
415
532
  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)!)
533
+ if (place >= 0) left--
534
+ else if (id === undefined) continue
535
+ for (const d of (place >= 0 ? afterAt(place) : after.get(id!)) ?? []) {
536
+ if (--waits[d]! === 0) {
537
+ joining.push(tasks[d]!)
423
538
  readyCount++
424
- count(byId.get(d)!, 1)
539
+ count(tasks[d]!, 1)
425
540
  }
426
541
  }
427
542
  }
428
543
  }
429
544
  }
430
545
 
546
+ const NONE: ReadonlyMap<string, Slot> = new Map()
547
+
548
+ interface End {
549
+ at: number
550
+ seq: number
551
+ s: Slot
552
+ cores: number
553
+ memory: number
554
+ /** The task's place in the play, or -1 for one running when it began (`id`). */
555
+ place: number
556
+ id: string | undefined
557
+ }
558
+
559
+ /**
560
+ * A play's ends to come, soonest first, ties in the order given: a scan
561
+ * of every running task at each step was most of a 20 000-task play
562
+ * (2026-11-05).
563
+ */
564
+ class Ends {
565
+ private readonly heap: End[] = []
566
+ private seq = 0
567
+ private readonly before = (a: End, b: End): boolean =>
568
+ a.at < b.at || (a.at === b.at && a.seq < b.seq)
569
+ push(
570
+ at: number,
571
+ s: Slot,
572
+ cores: number,
573
+ memory: number,
574
+ place: number,
575
+ id: string | undefined,
576
+ ): void {
577
+ const h = this.heap
578
+ const e: End = { at, seq: this.seq++, s, cores, memory, place, id }
579
+ let i = h.length
580
+ h.push(e)
581
+ while (i > 0) {
582
+ const p = (i - 1) >> 1
583
+ if (!this.before(e, h[p]!)) break
584
+ h[i] = h[p]!
585
+ i = p
586
+ }
587
+ h[i] = e
588
+ }
589
+ next(): number {
590
+ return this.heap[0]?.at ?? Infinity
591
+ }
592
+ all(): readonly End[] {
593
+ return this.heap
594
+ }
595
+ last(): number {
596
+ let most = 0
597
+ for (const e of this.heap) most = Math.max(most, e.at)
598
+ return most
599
+ }
600
+ pop(): End {
601
+ const h = this.heap
602
+ const top = h[0]!
603
+ const e = h.pop()!
604
+ if (h.length > 0) {
605
+ let i = 0
606
+ for (;;) {
607
+ const l = 2 * i + 1
608
+ if (l >= h.length) break
609
+ const c = l + 1 < h.length && this.before(h[l + 1]!, h[l]!) ? l + 1 : l
610
+ if (!this.before(h[c]!, e)) break
611
+ h[i] = h[c]!
612
+ i = c
613
+ }
614
+ h[i] = e
615
+ }
616
+ return top
617
+ }
618
+ }
619
+
620
+ /** What `play` reads of `tasks` alone, for each play of one plan. */
621
+ interface Shape {
622
+ readonly byId: { get(id: string): PlanTask | undefined }
623
+ /** Each running task's dependants, by their place in the tasks. */
624
+ readonly after: ReadonlyMap<string, readonly number[]>
625
+ /** Each task's dependants by its place, and its place by id: a lookup by id at each end was most of a play. */
626
+ readonly afterAt: (place: number) => Int32Array
627
+ readonly indexOf: { get(id: string): number | undefined }
628
+ /** How many dependencies each task, by its place, waits on before the play begins. */
629
+ readonly waitedAt: Int32Array
630
+ readonly tailOf: (id: string) => number
631
+ readonly tailAt: (place: number) => number
632
+ readonly kinds: Kinds
633
+ }
634
+
635
+ /**
636
+ * `tasks`' shape, made once while what runs stays the same: a plan plays
637
+ * them 20 to 40 times, and each play made it again, its tails and kinds
638
+ * too (2026-11-05).
639
+ */
640
+ function shapeOf(tasks: readonly PlanTask[], running: ReadonlySet<string>): Shape {
641
+ const runs = [...running].sort().join('\0')
642
+ const had = shapes.get(tasks)
643
+ if (had !== undefined && had.runs === runs) return had.shape
644
+ // Of a running task only, by id: one in the work is by its place.
645
+ const after = new Map<string, number[]>()
646
+ const waitedAt = new Int32Array(tasks.length)
647
+ const first = tasks[0]?.depsN
648
+ const num =
649
+ first !== undefined && tasks.every((t) => t.depsN !== undefined)
650
+ ? numberedOf.get(first)
651
+ : undefined
652
+ let edges = 0
653
+ for (let i = 0; i < tasks.length; i++) edges += tasks[i]!.deps.length
654
+ // Each dependency's place in the work, -1 when not in it.
655
+ const depAt = new Int32Array(edges)
656
+ let indexOf: { get(id: string): number | undefined }
657
+ if (num !== undefined) {
658
+ // By the run's numbers, not ids: a map of a million ids, made and
659
+ // read at each plan, was most of its time (2026-11-06).
660
+ const place = new Int32Array(num.ids.length).fill(-1)
661
+ for (let i = 0; i < tasks.length; i++) place[tasks[i]!.n!] = i
662
+ // Indexed: iterators over a million tasks' deps were a third of the shape.
663
+ for (let e = 0, i = 0; i < tasks.length; i++) {
664
+ const ds = tasks[i]!.depsN!
665
+ for (let k = 0; k < ds.length; k++) depAt[e++] = place[ds[k]!]!
666
+ }
667
+ indexOf = {
668
+ get: (id) => {
669
+ const n = num.of.get(id)?.n
670
+ const j = n === undefined ? -1 : place[n]!
671
+ return j < 0 ? undefined : j
672
+ },
673
+ }
674
+ } else {
675
+ // One map by id, the rest by place: a map of each by id made a
676
+ // 10 000-task plan's shape a third of its time (2026-11-05).
677
+ const of = new Map<string, number>()
678
+ for (let i = 0; i < tasks.length; i++) of.set(tasks[i]!.id, i)
679
+ let e = 0
680
+ for (const t of tasks) for (const d of t.deps) depAt[e++] = of.get(d) ?? -1
681
+ indexOf = of
682
+ }
683
+ // Each task's dependants in one array, by place: an array each, pushed
684
+ // to, was most of a million-task shape (2026-11-06).
685
+ const start = new Int32Array(tasks.length + 1)
686
+ for (let e = 0, i = 0; i < tasks.length; i++) {
687
+ const deps = tasks[i]!.deps
688
+ let n = 0
689
+ for (let k = 0; k < deps.length; k++, e++) {
690
+ const j = depAt[e]!
691
+ if (j >= 0) {
692
+ start[j + 1]!++
693
+ n++
694
+ } else if (running.has(deps[k]!)) {
695
+ n++
696
+ const at = after.get(deps[k]!)
697
+ if (at === undefined) after.set(deps[k]!, [i])
698
+ else at.push(i)
699
+ }
700
+ }
701
+ waitedAt[i] = n
702
+ }
703
+ for (let i = 0; i < tasks.length; i++) start[i + 1]! += start[i]!
704
+ const list = new Int32Array(start[tasks.length]!)
705
+ const fill = start.slice(0, tasks.length)
706
+ for (let e = 0, i = 0; i < tasks.length; i++)
707
+ for (let k = tasks[i]!.deps.length; k > 0; k--, e++) {
708
+ const j = depAt[e]!
709
+ if (j >= 0) list[fill[j]!++] = i
710
+ }
711
+ const afterAt = (i: number): Int32Array => list.subarray(start[i]!, start[i + 1]!)
712
+ const tail = new Float64Array(tasks.length).fill(-1)
713
+ const tailAt = (i: number): number => {
714
+ if (tail[i]! >= 0) return tail[i]!
715
+ let most = 0
716
+ for (let k = start[i]!; k < start[i + 1]!; k++) most = Math.max(most, tailAt(list[k]!))
717
+ return (tail[i] = most + (tasks[i]!.ms ?? UNKNOWN_MS))
718
+ }
719
+ const tailOf = (id: string): number => tailAt(indexOf.get(id)!)
720
+ const byId = { get: (id: string) => tasks[indexOf.get(id)!] }
721
+ const shape = {
722
+ byId,
723
+ after,
724
+ afterAt,
725
+ indexOf,
726
+ waitedAt,
727
+ tailOf,
728
+ tailAt,
729
+ kinds: { at: indexOf, kindAt: lazyKinds(tasks) },
730
+ }
731
+ shapes.set(tasks, { runs, shape })
732
+ return shape
733
+ }
734
+ const shapes = new WeakMap<readonly PlanTask[], { runs: string; shape: Shape }>()
735
+
736
+ /** Each task's place in `tasks` and its kind (`kindOf`). */
737
+ interface Kinds {
738
+ readonly at: { get(id: string): number | undefined }
739
+ readonly kindAt: (place: number) => string
740
+ }
741
+
742
+ /**
743
+ * Each task's kind, made as a play first reads it: made for every task,
744
+ * a million float-to-string keys were a fifth of a plan, and a play
745
+ * windowed to two warm-ups reads few of them (2026-11-06).
746
+ */
747
+ function lazyKinds(tasks: readonly PlanTask[]): (place: number) => string {
748
+ const kinds: (string | undefined)[] = []
749
+ return (i) => (kinds[i] ??= kindOf(tasks[i]!))
750
+ }
751
+
752
+ function kindsOf(tasks: readonly PlanTask[]): Kinds {
753
+ return { at: new Map(tasks.map((t, i) => [t.id, i])), kindAt: lazyKinds(tasks) }
754
+ }
755
+
756
+ /**
757
+ * No play of `tasks` ends sooner: each chain of them in a row, from when
758
+ * the running tasks it waits on end, and each running task's end.
759
+ */
760
+ function chainMs(tasks: readonly PlanTask[], workers: readonly PlanWorker[]): number {
761
+ const runningIds = new Set<string>()
762
+ const endsAt = new Map<string, number>()
763
+ for (const w of workers)
764
+ for (const r of w.running)
765
+ if (r.id !== undefined) {
766
+ runningIds.add(r.id)
767
+ endsAt.set(r.id, Math.max(endsAt.get(r.id) ?? 0, r.endsIn))
768
+ }
769
+ const { tailAt, after } = shapeOf(tasks, runningIds)
770
+ let most = 0
771
+ for (const w of workers) for (const r of w.running) most = Math.max(most, r.endsIn)
772
+ // From the running tasks' dependants: each task's deps read by id was
773
+ // most of a million-task plan's bound (2026-11-06).
774
+ const from = new Float64Array(tasks.length)
775
+ for (const [id, ds] of after)
776
+ for (const i of ds) from[i] = Math.max(from[i]!, endsAt.get(id) ?? 0)
777
+ for (let i = 0; i < tasks.length; i++) most = Math.max(most, from[i]! + tailAt(i))
778
+ return most
779
+ }
780
+
781
+ /** Two workers a play cannot tell apart, but by their place. */
782
+ const twins = (a: PlanWorker, b: PlanWorker): boolean =>
783
+ a.cores === b.cores && a.memory === b.memory && a.platform === b.platform && a.at === b.at
784
+
785
+ /** A worker of `s` that joins `at`. */
786
+ function fresh(
787
+ s: { cpu: number; memory: number; platform?: string | undefined; size?: string | undefined },
788
+ id: string,
789
+ at: number,
790
+ ): PlanWorker {
791
+ return {
792
+ id,
793
+ cores: s.cpu,
794
+ memory: s.memory,
795
+ platform: s.platform,
796
+ ...(s.size === undefined ? {} : { size: s.size }),
797
+ at,
798
+ running: [],
799
+ idle: false,
800
+ }
801
+ }
802
+
431
803
  /**
432
804
  * The workers to start and to let go. The fewest new ones (`candidates`,
433
805
  * in the order the run would start them, each joining after its size's `warmUpMs`)
@@ -449,30 +821,43 @@ export function plan(
449
821
  // The latest an earlier plan of the same work allowed, from now.
450
822
  limitMs = Infinity,
451
823
  how: Strategy = DEFAULT_STRATEGY,
452
- ): Plan {
824
+ ): Plan & Ahead {
453
825
  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
826
  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
827
+ // The fewest new ones ending within the margin of the best, found by
828
+ // halving: more workers end the run no later, so the best is all of
829
+ // them. Trying each count in turn played the whole run some 50 times a
830
+ // plan (2026-11-05: "Why it replays 64 times to pick workers").
831
+ // Two warm-ups ahead: a worker asked now joins after one, and the next
832
+ // plan, a warm-up on, sees the one after.
833
+ const horizon =
834
+ 2 *
835
+ Math.max(
836
+ warm(undefined),
837
+ ...candidates.map((c) => warm(c.name)),
838
+ ...workers.map((w) => w.at),
839
+ ) +
840
+ MARGIN_MS +
841
+ // And a task given at its end runs through: work counted as cores
842
+ // spread over the rest asked too few workers for a few long tasks.
843
+ tasks.reduce((most, t) => Math.max(most, t.ms ?? UNKNOWN_MS), 0)
844
+ // Each set of workers played once a plan: the count search, the drop
845
+ // pass, the releases and the final play each played the same set again.
846
+ const plays = new Map<string, ReturnType<typeof play>>()
847
+ const run = (ws: readonly PlanWorker[]): ReturnType<typeof play> => {
848
+ const key = ws.map((w) => `${w.id}\0${w.at}`).join('\n')
849
+ let p = plays.get(key)
850
+ if (p === undefined) plays.set(key, (p = play(tasks, ws, how, horizon)))
851
+ return p
474
852
  }
475
- const best = Math.min(...ends)
853
+ // Given no task in the play, and none past its window.
854
+ const unused = (p: ReturnType<typeof play>, id: string): boolean =>
855
+ !p.first.has(id) && p.cut !== true
856
+ const playOf = (n: number): ReturnType<typeof play> => run([...workers, ...added.slice(0, n)])
857
+ const endOf = (n: number): number => playOf(n).end
858
+ const floor = chainMs(tasks, workers)
859
+ const none = endOf(0)
860
+ const best = none <= floor ? none : Math.min(none, endOf(candidates.length))
476
861
  // Margin spent at each decision adds up: releases 4 s apart each lost
477
862
  // 4 s of 5 (tests/sim.test.ts, a growing graph). Spent once, unless the
478
863
  // rest can no longer end by then.
@@ -480,10 +865,15 @@ export function plan(
480
865
  best === Infinity
481
866
  ? Infinity
482
867
  : 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
- )
868
+ let lo = 0
869
+ let hi = candidates.length
870
+ if (none <= bound) hi = 0
871
+ while (lo < hi) {
872
+ const mid = (lo + hi) >> 1
873
+ if (endOf(mid) <= bound) hi = mid
874
+ else lo = mid + 1
875
+ }
876
+ const n = lo
487
877
  let kept = [...workers, ...added.slice(0, n)]
488
878
  // Sizes come in turns, so the prefix can hold one the plan ends as soon
489
879
  // without: a second Mac the plan needed came after a second Linux worker
@@ -491,16 +881,26 @@ export function plan(
491
881
  // Dropped only when neither the end nor its platform's moves past the
492
882
  // margin: to the run's end alone, a 110 s guess of an unmeasured chain
493
883
  // cut every other platform to it.
494
- let before = best < Infinity && n > 0 ? play(tasks, kept, how) : undefined
884
+ let before = best < Infinity && n > 0 ? playOf(n) : undefined
495
885
  if (before !== undefined)
496
- for (let i = n - 1; i >= 0; i--) {
886
+ for (let i = n - 1, dropped = false; i >= 0; i--) {
887
+ // Its twin after it stayed, and nothing moved since: without this
888
+ // one the play is that one's again, so it stays too. Each was a
889
+ // play of the whole run (2026-11-05).
890
+ if (i < n - 1 && !dropped && twins(added[i]!, added[i + 1]!)) continue
497
891
  const platform = added[i]!.platform
498
892
  const without = kept.filter((k) => k !== added[i])
499
- const after = play(tasks, without, how)
500
- if (
893
+ // Given no task in a whole play, the play without it is the same.
894
+ if (unused(before, added[i]!.id)) {
895
+ kept = without
896
+ dropped = true
897
+ continue
898
+ }
899
+ const after = run(without)
900
+ dropped =
501
901
  after.end <= before.end &&
502
902
  (after.ends.get(platform) ?? 0) <= (before.ends.get(platform) ?? 0) + MARGIN_MS
503
- ) {
903
+ if (dropped) {
504
904
  kept = without
505
905
  before = after
506
906
  }
@@ -528,11 +928,7 @@ export function plan(
528
928
  // margin, the next plan's estimates moved a second later and
529
929
  // cancelled 7 asked one at a time (PR #4214); ending the run no
530
930
  // 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
- ) {
931
+ if (twin === undefined ? unused(run(kept), w.id) : run(without).end <= bound) {
536
932
  kept = without
537
933
  if (twin === undefined) release.push(w.id)
538
934
  }
@@ -552,7 +948,9 @@ export function plan(
552
948
  })
553
949
  for (const w of idle) {
554
950
  const without = kept.filter((k) => k !== w)
555
- const next = play(tasks, [...kept, ...standIns], how).first.get(w.id)
951
+ const played = run([...kept, ...standIns])
952
+ // Given nothing in a window with work past it: needed past it, maybe.
953
+ const next = played.first.get(w.id) ?? (played.cut === true ? horizon : undefined)
556
954
  if (next === undefined) {
557
955
  kept = without
558
956
  release.push(w.id)
@@ -571,7 +969,7 @@ export function plan(
571
969
  w.id,
572
970
  next,
573
971
  )
574
- if (simulate(tasks, [...without, ...standIns, instead], how) <= bound) {
972
+ if (run([...without, ...standIns, instead]).end <= bound) {
575
973
  kept = without
576
974
  release.push(w.id)
577
975
  standIns.push(instead)
@@ -579,20 +977,43 @@ export function plan(
579
977
  }
580
978
  }
581
979
  }
582
- const { end, first } = play(tasks, [...kept, ...standIns], how)
980
+ const { end, first, free, cut } = run([...kept, ...standIns])
583
981
  const start: Size[] = []
982
+ const frees = free()
983
+ const started: { freeInMs: number; joinInMs: number }[] = []
984
+ const later: { size: Size; inMs: number; freeInMs: number; joinInMs: number }[] = []
584
985
  let againInMs: number | undefined
585
986
  for (let i = 0; i < n; i++) {
586
- const at = first.get(added[i]!.id)
987
+ const id = added[i]!.id
988
+ const at = first.get(id)
587
989
  if (at === undefined) continue
588
990
  // Given work within its warm-up and a margin: it starts now.
589
991
  const warmUp = added[i]!.at
590
992
  // Within a millisecond counts: `againInMs` of 1e-10 replanned the
591
993
  // 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)
994
+ if (at < warmUp + MARGIN_MS + 1) {
995
+ start.push(candidates[i]!)
996
+ started.push({ freeInMs: frees.get(id)!, joinInMs: warmUp })
997
+ } else {
998
+ const inMs = at - warmUp - MARGIN_MS
999
+ againInMs = Math.min(againInMs ?? Infinity, inMs)
1000
+ later.push({ size: candidates[i]!, inMs, freeInMs: frees.get(id)!, joinInMs: warmUp })
1001
+ }
1002
+ }
1003
+ later.sort((a, b) => a.inMs - b.inMs)
1004
+ // Work past the window: plan again a warm-up on, the window moved.
1005
+ const untilInMs = cut === true ? (horizon - MARGIN_MS) / 2 : undefined
1006
+ return {
1007
+ start,
1008
+ release,
1009
+ endsInMs: end,
1010
+ boundMs: bound,
1011
+ againInMs: untilInMs === undefined ? againInMs : Math.min(againInMs ?? Infinity, untilInMs),
1012
+ untilInMs,
1013
+ freeInMs: frees,
1014
+ later,
1015
+ started,
594
1016
  }
595
- return { start, release, endsInMs: end, boundMs: bound, againInMs }
596
1017
  }
597
1018
 
598
1019
  /**
@@ -676,8 +1097,35 @@ export function decide(input: {
676
1097
  * queued it, and one asked now queues behind it. None of them starts.
677
1098
  */
678
1099
  readonly queued?: ReadonlySet<string>
1100
+ /**
1101
+ * The run's last plan, kept here: while what it saw holds, the next is
1102
+ * that plan again, not played anew, and hands back no `tasks` when it
1103
+ * read none. With `now`, the run's clock.
1104
+ */
1105
+ readonly last?: { forecast?: Forecast }
1106
+ readonly now?: number
679
1107
  }): Plan & { readonly tasks: readonly PlanTask[] } {
680
1108
  const how = input.how ?? DEFAULT_STRATEGY
1109
+ const now = input.now ?? 0
1110
+ const had = input.last?.forecast
1111
+ // Following a plan reads only what events move: the workers, their
1112
+ // joins, each task as it starts and ends. Every task read at each of a
1113
+ // 5000-task run's events made it quadratic (2026-11-05: "Why do we
1114
+ // recheck every task against plan? There is no point").
1115
+ if (how.scale !== 'backlog' && had !== undefined) {
1116
+ const step = follow(had, undefined, input, undefined, now)
1117
+ // No work read, none to hand back: each step handed back the plan's
1118
+ // whole work, and the run read it all at each event.
1119
+ // A start or a release hands back the work it plans for, as the run reports it.
1120
+ if (step !== undefined && step !== 'full')
1121
+ return shown(
1122
+ input.last!,
1123
+ had,
1124
+ step,
1125
+ now,
1126
+ step.start.length > 0 || step.release.length > 0 ? had.work : [],
1127
+ )
1128
+ }
681
1129
  const tasks = missedWork(
682
1130
  input.remaining,
683
1131
  input.nodes,
@@ -702,7 +1150,9 @@ export function decide(input: {
702
1150
  )
703
1151
  if (how.scale === 'backlog')
704
1152
  return { ...backlog(tasks, input.workers, sizes, input.warmUpMs, how), tasks }
705
- const p = plan(
1153
+ const step = had === undefined ? undefined : follow(had, tasks, input, sizes, now)
1154
+ if (step !== undefined && step !== 'full') return shown(input.last!, had!, step, now, tasks)
1155
+ const { freeInMs, later, started, untilInMs, ...p } = plan(
706
1156
  tasks,
707
1157
  input.workers,
708
1158
  sizes,
@@ -712,9 +1162,341 @@ export function decide(input: {
712
1162
  tasks.every((t) => input.known?.has(t.id) === true) ? input.limitMs : undefined,
713
1163
  how,
714
1164
  )
1165
+ if (input.last !== undefined)
1166
+ input.last.forecast = forecastOf(p, { freeInMs, later, started, untilInMs }, tasks, input, now)
715
1167
  return { ...p, tasks }
716
1168
  }
717
1169
 
1170
+ /**
1171
+ * What a plan saw, to tell whether the next would differ: its work and
1172
+ * each task's time, its workers as starting, idle or busy, the tasks
1173
+ * already past their time, the sizes queued, when it wanted to look again.
1174
+ */
1175
+ export interface Forecast {
1176
+ readonly at: number
1177
+ readonly plan: Plan
1178
+ /** The work it played. */
1179
+ readonly work: readonly PlanTask[]
1180
+ /** Each waiting task's time, and each waiting or running task's cores and memory. */
1181
+ readonly ms: ReadonlyMap<string, number | undefined>
1182
+ readonly need: ReadonlyMap<string, readonly [cores: number, memory: number]>
1183
+ /** The workers it saw, and those it started as they show. */
1184
+ readonly workers: ReadonlySet<string>
1185
+ /** How many workers the run has as planned. */
1186
+ readonly count: number
1187
+ /** When each worker's last planned task ends, on the run's clock. */
1188
+ readonly free: ReadonlyMap<string, number>
1189
+ /** When each worker still starting joins, on the run's clock. */
1190
+ readonly join: ReadonlyMap<string, number>
1191
+ /** Those of each worker started and not seen yet, in the order started. */
1192
+ readonly fresh: readonly { readonly free: number; readonly join: number }[]
1193
+ /** Starts the plan wants later, each when due, on the run's clock. */
1194
+ readonly later: readonly {
1195
+ readonly size: Size
1196
+ readonly at: number
1197
+ readonly free: number
1198
+ readonly join: number
1199
+ }[]
1200
+ /** Work it saw that has since been seen running: work that left otherwise moves the plan. */
1201
+ readonly seen: Set<string>
1202
+ /** When each task seen running ends, on the run's clock. */
1203
+ readonly ends: ReadonlyMap<string, number>
1204
+ /** How many tasks the run had handed over, and those it had when planned. */
1205
+ readonly executed: number
1206
+ readonly before: ReadonlySet<string>
1207
+ /**
1208
+ * How far it read the handed-over set, kept across its steps: a Set's
1209
+ * iterator sees what is added after it, so each id is read once. Each
1210
+ * step read the whole set, O(n) an event (2026-11-05).
1211
+ */
1212
+ readonly scan: { of?: ReadonlySet<string> | undefined; it?: Iterator<string>; read: number }
1213
+ /** Each size's warm-up as it saw it: a join tells the run's own. */
1214
+ readonly warm: readonly number[]
1215
+ /** When its window moves on, on the run's clock. */
1216
+ readonly until: number
1217
+ readonly late: ReadonlySet<string>
1218
+ readonly queued: string
1219
+ readonly final: boolean
1220
+ }
1221
+
1222
+ type Seen = Pick<
1223
+ Parameters<typeof decide>[0],
1224
+ 'workers' | 'queued' | 'final' | 'sizes' | 'started' | 'warmUpMs' | 'executed' | 'nodes'
1225
+ >
1226
+
1227
+ /**
1228
+ * Running tasks past their time by more than `slack`: one that runs that
1229
+ * long moves the plan (2026-11-05: "Reality changes by leave some
1230
+ * margin. 5% or sth").
1231
+ */
1232
+ function lateOf(workers: readonly PlanWorker[], slack: number): Set<string> {
1233
+ const out = new Set<string>()
1234
+ for (const w of workers)
1235
+ for (const r of w.running) if (r.endsIn < -slack && r.id !== undefined) out.add(r.id)
1236
+ return out
1237
+ }
1238
+
1239
+ /** Each waiting and running task's cores and memory, as a plan saw them. */
1240
+ function needsOf(
1241
+ tasks: readonly PlanTask[],
1242
+ workers: readonly PlanWorker[],
1243
+ ): Map<string, readonly [number, number]> {
1244
+ const out = new Map<string, readonly [number, number]>(
1245
+ tasks.map((t) => [t.id, [t.cores, t.memory] as const]),
1246
+ )
1247
+ for (const w of workers)
1248
+ for (const r of w.running) if (r.id !== undefined) out.set(r.id, [r.cores, r.memory])
1249
+ return out
1250
+ }
1251
+
1252
+ /** Whether `is` moved from `was` past 5%, or past `least` when they are small. */
1253
+ const moved = (was: number, is: number, least: number): boolean =>
1254
+ Math.abs(is - was) > Math.max(least, Math.abs(was) * MARGIN)
1255
+
1256
+ /** How late a task may run, or how far its time may move, before the plan is made again: 5% of the run's. */
1257
+ const slackOf = (p: Plan): number => Math.max(MARGIN_MS / 5, p.endsInMs * MARGIN)
1258
+
1259
+ const warmsOf = (input: Seen): number[] => {
1260
+ const warm = warmOf(input.warmUpMs)
1261
+ return [warm(undefined), ...input.sizes.map((s) => warm(s.name))]
1262
+ }
1263
+
1264
+ function forecastOf(
1265
+ p: Plan,
1266
+ ahead: Ahead,
1267
+ tasks: readonly PlanTask[],
1268
+ input: Seen,
1269
+ now: number,
1270
+ ): Forecast {
1271
+ return {
1272
+ at: now,
1273
+ plan: p,
1274
+ work: tasks,
1275
+ ms: new Map(tasks.map((t) => [t.id, t.ms])),
1276
+ need: needsOf(tasks, input.workers),
1277
+ workers: new Set(input.workers.map((w) => w.id).filter((id) => !p.release.includes(id))),
1278
+ count: input.workers.length + p.start.length - p.release.length,
1279
+ free: new Map([...ahead.freeInMs].map(([id, ms]) => [id, now + ms])),
1280
+ join: new Map(
1281
+ input.workers.flatMap((w) => (w.starting === true ? [[w.id, now + w.at] as const] : [])),
1282
+ ),
1283
+ fresh: ahead.started.map((a) => ({ free: now + a.freeInMs, join: now + a.joinInMs })),
1284
+ later: ahead.later.map((l) => ({
1285
+ size: l.size,
1286
+ at: now + l.inMs,
1287
+ free: now + l.freeInMs,
1288
+ join: now + l.inMs + l.joinInMs,
1289
+ })),
1290
+ seen: new Set(),
1291
+ ends: new Map(
1292
+ input.workers.flatMap((w) =>
1293
+ w.running.flatMap((r) => (r.id === undefined ? [] : [[r.id, now + r.endsIn] as const])),
1294
+ ),
1295
+ ),
1296
+ executed: input.executed.size,
1297
+ before: new Set(input.executed),
1298
+ scan: { read: 0 },
1299
+ warm: warmsOf(input),
1300
+ until: ahead.untilInMs === undefined ? Infinity : now + ahead.untilInMs,
1301
+ late: lateOf(input.workers, slackOf(p)),
1302
+ queued: [...(input.queued ?? [])].sort().join('\0'),
1303
+ final: input.final,
1304
+ }
1305
+ }
1306
+
1307
+ /** The plan `had` made, followed a step: its decisions now, its times less the time since. */
1308
+ function shown(
1309
+ last: { forecast?: Forecast },
1310
+ had: Forecast,
1311
+ step: Step,
1312
+ now: number,
1313
+ tasks: readonly PlanTask[],
1314
+ ): Plan & { readonly tasks: readonly PlanTask[] } {
1315
+ last.forecast = step.next
1316
+ const again = Math.min(step.next.later[0]?.at ?? Infinity, step.next.until)
1317
+ const since = now - had.at
1318
+ return {
1319
+ start: step.start,
1320
+ release: step.release,
1321
+ endsInMs: Math.max(0, had.plan.endsInMs - since),
1322
+ boundMs: had.plan.boundMs - since,
1323
+ againInMs: Number.isFinite(again) ? again - now : undefined,
1324
+ tasks,
1325
+ }
1326
+ }
1327
+
1328
+ type Step = { start: Size[]; release: string[]; next: Forecast }
1329
+
1330
+ /**
1331
+ * The plan `f` made, followed one step on when it still stands: no new
1332
+ * work, no task's time, cores or memory moved past 5%, no worker joined
1333
+ * or left but as planned, no task ran past its time. Its starts come due
1334
+ * and its workers with their work done go, as it foresaw; undefined
1335
+ * plays it anew. Planned at every task's end, a run of 1000 tasks
1336
+ * replayed itself 10 000 times (2026-11-05: "It's just about filling
1337
+ * boxes to full"; "If simulated all goes with plan so there should be no
1338
+ * replans").
1339
+ *
1340
+ * Without `tasks` (and `sizes`), only what events move is read: work it
1341
+ * did not see is `full`, read every task first.
1342
+ */
1343
+ function follow(
1344
+ f: Forecast,
1345
+ tasks: readonly PlanTask[] | undefined,
1346
+ input: Seen,
1347
+ sizes: readonly Size[] | undefined,
1348
+ now: number,
1349
+ ): Step | 'full' | undefined {
1350
+ if (now >= f.until) return undefined
1351
+ if (f.final !== input.final) return undefined
1352
+ if ([...(input.queued ?? [])].sort().join('\0') !== f.queued) return undefined
1353
+ if (input.workers.length !== f.count) return undefined
1354
+ const warm = warmsOf(input)
1355
+ for (let i = 0; i < warm.length; i++)
1356
+ if (moved(f.warm[i]!, warm[i]!, MARGIN_MS / 5)) return undefined
1357
+ const slack = slackOf(f.plan)
1358
+ // Only what moved since is read: each task as it starts, runs and ends.
1359
+ // A waiting one's time moves with the run's, which those show.
1360
+ const ends = new Map<string, number>()
1361
+ let began = 0
1362
+ for (const w of input.workers)
1363
+ for (const r of w.running) {
1364
+ if (r.id === undefined) continue
1365
+ const was = f.need.get(r.id)
1366
+ // As for time, so for resources (2026-11-05: "And resource usage").
1367
+ if (
1368
+ was !== undefined &&
1369
+ (moved(was[0], r.cores, 0.05) || moved(was[1], r.memory, 64 * 2 ** 20))
1370
+ )
1371
+ return undefined
1372
+ const end = f.ends.get(r.id)
1373
+ if (end !== undefined) {
1374
+ ends.set(r.id, end)
1375
+ continue
1376
+ }
1377
+ // Started since: work the plan saw, as long as it played it.
1378
+ if (!f.ms.has(r.id)) return undefined
1379
+ const ms = f.ms.get(r.id)
1380
+ if (ms !== undefined && r.endsIn > ms + Math.max(MARGIN_MS / 5, ms * MARGIN)) return undefined
1381
+ ends.set(r.id, now + r.endsIn)
1382
+ f.seen.add(r.id)
1383
+ began++
1384
+ }
1385
+ // Ended since: sooner than played moves the plan, and so does one of
1386
+ // no known time ending.
1387
+ for (const [id, at] of f.ends)
1388
+ if (!ends.has(id) && (now < at - slack || (f.ms.has(id) && f.ms.get(id) === undefined)))
1389
+ return undefined
1390
+ for (const id of lateOf(input.workers, slack)) if (!f.late.has(id)) return undefined
1391
+ // More handed over than started, or fewer known: work it did not see,
1392
+ // maybe. Read it.
1393
+ if (input.executed.size > f.executed + began || input.executed.size < f.executed) {
1394
+ // Handed over and not started yet is work it saw: only one it did not
1395
+ // see sends the plan to read every task.
1396
+ if (tasks === undefined) {
1397
+ if (input.executed.size < f.executed) return 'full'
1398
+ const scan = f.scan
1399
+ if (scan.of !== input.executed) {
1400
+ scan.of = input.executed
1401
+ scan.it = input.executed.values()
1402
+ scan.read = 0
1403
+ }
1404
+ for (; scan.read < input.executed.size; scan.read++) {
1405
+ const r = scan.it!.next()
1406
+ // One left the set: the count no longer says where it read to.
1407
+ if (r.done === true) {
1408
+ scan.of = undefined
1409
+ return 'full'
1410
+ }
1411
+ const id = r.value
1412
+ if (
1413
+ !f.ms.has(id) &&
1414
+ !f.before.has(id) &&
1415
+ input.nodes.get(id)?.config.exec?.command !== undefined
1416
+ ) {
1417
+ scan.read++
1418
+ return 'full'
1419
+ }
1420
+ }
1421
+ } else {
1422
+ for (const t of tasks) if (!f.ms.has(t.id)) return undefined
1423
+ // Work that left without running (a hit, or no longer asked) frees
1424
+ // what the plan gave it.
1425
+ const waiting = new Set(tasks.map((t) => t.id))
1426
+ for (const id of f.ms.keys())
1427
+ if (!waiting.has(id) && !f.seen.has(id) && !ends.has(id)) return undefined
1428
+ }
1429
+ }
1430
+ const workers = new Set(f.workers)
1431
+ const free = new Map(f.free)
1432
+ const join = new Map(f.join)
1433
+ const fresh = [...f.fresh]
1434
+ const release: string[] = []
1435
+ for (const w of input.workers) {
1436
+ if (!workers.has(w.id)) {
1437
+ // One the plan started, shown: it takes the next started's work.
1438
+ const next = fresh.shift()
1439
+ if (next === undefined) return undefined
1440
+ workers.add(w.id)
1441
+ free.set(w.id, next.free)
1442
+ join.set(w.id, next.join)
1443
+ }
1444
+ // Joined sooner or later than played, past the margin.
1445
+ const joins = join.get(w.id)
1446
+ if (joins !== undefined) {
1447
+ if (w.starting === true) {
1448
+ if (now > joins + slack) return undefined
1449
+ } else {
1450
+ if (now < joins - slack) return undefined
1451
+ join.delete(w.id)
1452
+ }
1453
+ }
1454
+ // Idle between its tasks, as played; with its work done, it goes.
1455
+ if (!w.idle || w.starting === true) continue
1456
+ const at = free.get(w.id)
1457
+ if (at === undefined || now >= at - slack) {
1458
+ // Its work done in a window with work past it: maybe needed there,
1459
+ // and one let go still counts against its size's max. A Mac let go
1460
+ // so left a Mac task no worker, and the run never ended (#4989).
1461
+ if (f.until < Infinity) return undefined
1462
+ release.push(w.id)
1463
+ workers.delete(w.id)
1464
+ }
1465
+ }
1466
+ const start: Size[] = []
1467
+ const later = [...f.later]
1468
+ while (later.length > 0 && now >= later[0]!.at) {
1469
+ const { size, free: ends, join: joins } = later.shift()!
1470
+ // Its size taken up since: the plan no longer fits the run. Read
1471
+ // from what the run started, not from every task: each start read
1472
+ // them all (2026-11-05).
1473
+ if (sizes === undefined) {
1474
+ const kind = input.sizes.find((s) => s.name === size.name)
1475
+ if (
1476
+ input.final ||
1477
+ kind === undefined ||
1478
+ input.queued?.has(size.name) === true ||
1479
+ (input.started.get(size.name) ?? 0) >= kind.max
1480
+ )
1481
+ return undefined
1482
+ } else if (!sizes.some((s) => s.name === size.name)) return undefined
1483
+ start.push(size)
1484
+ fresh.push({ free: ends, join: joins })
1485
+ }
1486
+ const next: Forecast = {
1487
+ ...f,
1488
+ workers,
1489
+ count: f.count + start.length - release.length,
1490
+ free,
1491
+ join,
1492
+ fresh,
1493
+ later,
1494
+ ends,
1495
+ executed: input.executed.size,
1496
+ }
1497
+ return { start, release, next }
1498
+ }
1499
+
718
1500
  /**
719
1501
  * Spark's dynamic allocation: workers enough for the cores that what is
720
1502
  * ready or running asks, started at once; an idle one past that leaves.
@@ -794,8 +1576,17 @@ function backlog(
794
1576
  return { start, release, endsInMs: end, boundMs: end, againInMs: undefined }
795
1577
  }
796
1578
 
797
- /** The most kinds `Ready.asks` jumps across; past it, it reads every task. */
1579
+ /** The most kinds `Ready.asks` jumps across; past it, it jumps across the cores' classes. */
798
1580
  const MAX_JUMP_KINDS = 32
1581
+ /** Classes a doubling of cores spans (`classOf`). */
1582
+ const CLASSES = 4
1583
+ /** The class of a task that names a platform or a size to beat: never passed whole. */
1584
+ const ODD = -Infinity
1585
+ /** A task's cores' class: about 2^(c / CLASSES) to 2^((c + 1) / CLASSES), its span kept as joined. */
1586
+ const classOf = (t: PlanTask): number =>
1587
+ t.platform !== undefined || t.above !== undefined
1588
+ ? ODD
1589
+ : Math.floor(Math.log2(Math.max(t.cores, 1e-6)) * CLASSES)
799
1590
 
800
1591
  /**
801
1592
  * Ready tasks in the order `ordered` gave, by kind (`kindOf`): `assign`
@@ -806,9 +1597,8 @@ const MAX_JUMP_KINDS = 32
806
1597
  * task 17 million times a plan (2026-10-25).
807
1598
  */
808
1599
  class Ready {
809
- private readonly ids: readonly string[]
810
- private readonly at: Map<string, number>
811
- private readonly kindAt: readonly string[]
1600
+ private readonly at: Kinds['at']
1601
+ private readonly kindAt: (place: number) => string
812
1602
  private readonly taken: Uint8Array
813
1603
  /** The rank: the lower `primary` first, then the lower `seq`. */
814
1604
  private readonly primary: Float64Array
@@ -817,28 +1607,53 @@ class Ready {
817
1607
  /** In rank order; taken ones linger until the next join. */
818
1608
  private order: number[] = []
819
1609
  private kinds = new Map<string, number[]>()
1610
+ /**
1611
+ * Ready tasks by their cores' class (`classOf`), in rank order, each
1612
+ * class's first not taken and the most memory one asked: what `asks`
1613
+ * jumps across when kinds are too many.
1614
+ */
1615
+ private classes = new Map<number, number[]>()
1616
+ private readonly classHead = new Map<number, number>()
1617
+ /** The fewest and most cores and the most memory a task of each class asked. */
1618
+ private readonly classSpan = new Map<number, [number, number, number]>()
820
1619
  /** Each kind's first task not taken. */
821
1620
  private readonly head = new Map<string, number>()
822
1621
  /** No task before this place in `order` is left: a pass began at 0 and read 9000 taken ones (2026-10-25). */
823
1622
  private lo = 0
824
1623
  /** Order it again before the next step. */
825
1624
  stale = false
1625
+ /** Taken ones still in `order`: past its live ones, it is read again without them. */
1626
+ private dead = 0
1627
+ /** Each kind's tasks not taken: a kind with none leaves `kinds`. */
1628
+ private readonly liveOf = new Map<string, number>()
826
1629
 
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)
1630
+ constructor(
1631
+ private readonly tasks: readonly PlanTask[],
1632
+ kinds: Kinds = kindsOf(tasks),
1633
+ ) {
1634
+ this.at = kinds.at
1635
+ this.kindAt = kinds.kindAt
831
1636
  this.taken = new Uint8Array(tasks.length)
832
1637
  this.primary = new Float64Array(tasks.length)
833
1638
  this.seq = new Float64Array(tasks.length)
834
1639
  }
835
1640
 
836
1641
  kindOf(id: string): string {
837
- return this.kindAt[this.at.get(id)!]!
1642
+ return this.kindAt(this.at.get(id)!)
838
1643
  }
839
1644
 
840
1645
  take(id: string): void {
841
- this.taken[this.at.get(id)!] = 1
1646
+ const i = this.at.get(id)!
1647
+ this.taken[i] = 1
1648
+ this.dead++
1649
+ const k = this.kindAt(i)
1650
+ const n = this.liveOf.get(k)! - 1
1651
+ if (n > 0) this.liveOf.set(k, n)
1652
+ else {
1653
+ this.liveOf.delete(k)
1654
+ this.kinds.delete(k)
1655
+ this.head.delete(k)
1656
+ }
842
1657
  }
843
1658
 
844
1659
  live(): PlanTask[] {
@@ -861,6 +1676,11 @@ class Ready {
861
1676
  this.order = []
862
1677
  this.kinds = new Map()
863
1678
  this.head.clear()
1679
+ this.classes = new Map()
1680
+ this.classHead.clear()
1681
+ this.classSpan.clear()
1682
+ this.liveOf.clear()
1683
+ this.dead = 0
864
1684
  this.stale = false
865
1685
  const batch = ids.map((id) => {
866
1686
  const i = this.at.get(id)!
@@ -891,26 +1711,99 @@ class Ready {
891
1711
  }
892
1712
 
893
1713
  private join(batch: readonly number[]): void {
894
- this.order = this.merge(this.order, batch)
895
- this.lo = 0
1714
+ // Taken ones outnumber the rest: read without them once.
1715
+ if (this.dead > this.order.length / 2) {
1716
+ this.order = this.merge(this.order, [])
1717
+ for (const [k, q] of this.kinds) this.kinds.set(k, this.merge(q, []))
1718
+ this.head.clear()
1719
+ for (const [c, q] of this.classes) {
1720
+ const left = this.merge(q, [])
1721
+ if (left.length > 0) this.classes.set(c, left)
1722
+ else this.classes.delete(c)
1723
+ }
1724
+ this.classHead.clear()
1725
+ this.dead = 0
1726
+ this.lo = 0
1727
+ }
1728
+ const len = this.order.length + batch.length
1729
+ this.lo = Math.min(this.lo, this.insert(this.order, batch))
1730
+ // A merge left the taken ones out.
1731
+ this.dead -= len - this.order.length
896
1732
  const by = new Map<string, number[]>()
897
1733
  for (const i of batch) {
898
- const k = this.kindAt[i]!
1734
+ const k = this.kindAt(i)
1735
+ this.liveOf.set(k, (this.liveOf.get(k) ?? 0) + 1)
899
1736
  const q = by.get(k)
900
1737
  if (q === undefined) by.set(k, [i])
901
1738
  else q.push(i)
902
1739
  }
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)
1740
+ const byClass = new Map<number, number[]>()
1741
+ for (const i of batch) {
1742
+ const t = this.tasks[i]!
1743
+ const c = classOf(t)
1744
+ const span = this.classSpan.get(c)
1745
+ if (span === undefined) this.classSpan.set(c, [t.cores, t.cores, t.memory])
1746
+ else {
1747
+ span[0] = Math.min(span[0], t.cores)
1748
+ span[1] = Math.max(span[1], t.cores)
1749
+ span[2] = Math.max(span[2], t.memory)
1750
+ }
1751
+ const q = byClass.get(c)
1752
+ if (q === undefined) byClass.set(c, [i])
1753
+ else q.push(i)
1754
+ }
1755
+ for (const [c, q] of byClass) {
1756
+ const had = this.classes.get(c)
1757
+ if (had === undefined) {
1758
+ this.classes.set(c, q)
1759
+ this.classHead.delete(c)
1760
+ } else {
1761
+ const first = this.insert(had, q)
1762
+ const h = this.classHead.get(c)
1763
+ if (h !== undefined && first < h) this.classHead.set(c, first)
1764
+ }
1765
+ }
1766
+ // Only the kinds it joins: each join read every kind and every ready
1767
+ // task, and a 6000-task tree spent 2 s of a 5 s plan on it (2026-11-05).
1768
+ for (const [k, q] of by) {
1769
+ const had = this.kinds.get(k)
1770
+ if (had === undefined) {
1771
+ this.kinds.set(k, q)
1772
+ this.head.delete(k)
1773
+ } else {
1774
+ const first = this.insert(had, q)
1775
+ const h = this.head.get(k)
1776
+ if (h !== undefined && first < h) this.head.set(k, first)
1777
+ }
910
1778
  }
911
- for (const [k, q] of by) kinds.set(k, q)
912
- this.kinds = kinds
913
- this.head.clear()
1779
+ }
1780
+
1781
+ /**
1782
+ * `batch`, in rank order, into `into`, in rank order: merged when it is
1783
+ * as big, each put in its place when it is few. The first place it took.
1784
+ */
1785
+ private insert(into: number[], batch: readonly number[]): number {
1786
+ if (batch.length === 0) return into.length
1787
+ if (batch.length * 8 > into.length) {
1788
+ const merged = this.merge(into, batch)
1789
+ into.length = 0
1790
+ for (const i of merged) into.push(i)
1791
+ return 0
1792
+ }
1793
+ let first = into.length
1794
+ let lo = 0
1795
+ for (const b of batch) {
1796
+ let hi = into.length
1797
+ while (lo < hi) {
1798
+ const mid = (lo + hi) >> 1
1799
+ if (this.before(into[mid]!, b)) lo = mid + 1
1800
+ else hi = mid
1801
+ }
1802
+ into.splice(lo, 0, b)
1803
+ first = Math.min(first, lo)
1804
+ lo++
1805
+ }
1806
+ return first
914
1807
  }
915
1808
 
916
1809
  /** In order, past each kind in `spent` as `assign` reads it then. */
@@ -918,43 +1811,116 @@ class Ready {
918
1811
  // Measured needs are unrounded, so kinds can be as many as tasks: a
919
1812
  // jump across every kind then cost more than a read of every task.
920
1813
  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
- }
1814
+ yield* this.byClass(spent, bounds, kinds)
931
1815
  return
932
1816
  }
933
- const at = new Map<string, number>()
1817
+ // Arrays, not the maps: the scan below is most of a sim's time (2026-11-05).
1818
+ const ks: string[] = []
1819
+ const qs: (readonly number[])[] = []
1820
+ for (const [k, q] of this.kinds) {
1821
+ ks.push(k)
1822
+ qs.push(q)
1823
+ }
1824
+ const at = new Int32Array(ks.length).fill(-1)
934
1825
  let cursor = -1
935
1826
  for (;;) {
936
1827
  let next = -1
937
- let nextKind = ''
938
- for (const [k, q] of this.kinds) {
1828
+ let nextAt = -1
1829
+ for (let x = 0; x < ks.length; x++) {
1830
+ const k = ks[x]!
939
1831
  if (spent.has(k)) continue
940
- let p = at.get(k) ?? this.headOf(k, q)
1832
+ const q = qs[x]!
1833
+ let p = at[x]! === -1 ? this.headOf(k, q) : at[x]!
941
1834
  if (p < q.length && cursor !== -1 && !this.before(cursor, q[p]!))
942
1835
  p = this.after(q, p, cursor)
943
1836
  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))) {
1837
+ at[x] = p
1838
+ if (p === q.length) continue
1839
+ // A kind no bin fits or holds for is spent here, as `assign` would
1840
+ // spend it once read: yielded, each cost a sim of 300 tasks a
1841
+ // pass over the bins (2026-11-05). Bounds only shrink until a
1842
+ // bin is given, which clears `spent`.
1843
+ if (bounds !== undefined && dead(this.tasks[q[p]!]!, bounds)) {
1844
+ spent.add(k)
1845
+ continue
1846
+ }
1847
+ if (next === -1 || this.before(q[p]!, next)) {
946
1848
  next = q[p]!
947
- nextKind = k
1849
+ nextAt = x
948
1850
  }
949
1851
  }
950
1852
  if (next === -1) return
951
- at.set(nextKind, at.get(nextKind)! + 1)
1853
+ at[nextAt]!++
952
1854
  cursor = next
953
1855
  const t = this.tasks[next]!
954
- yield { id: t.id, need: t, kind: nextKind }
1856
+ yield { id: t.id, need: t, kind: ks[nextAt]! }
955
1857
  }
956
1858
  }
957
1859
 
1860
+ /**
1861
+ * As a read of every ready task in rank order, past each taken, spent
1862
+ * or `dead` one, but jumping across the cores' classes: a class whose
1863
+ * every task no bin fits, by the span its tasks asked, is passed whole. Read one by one, the ready
1864
+ * tasks of a 5000-task play were most of its time (2026-11-05). A task
1865
+ * found dead stays dead until a bin is given, and a give only shrinks
1866
+ * the bins' room, so the read goes past it for the rest of the pass.
1867
+ */
1868
+ private *byClass(spent: Set<string>, bounds?: Bounds, kinds?: number): Generator<Ask> {
1869
+ const cs: number[] = []
1870
+ const qs: (readonly number[])[] = []
1871
+ for (const [c, q] of this.classes) {
1872
+ cs.push(c)
1873
+ qs.push(q)
1874
+ }
1875
+ const at = new Int32Array(cs.length).fill(-1)
1876
+ let cursor = -1
1877
+ for (;;) {
1878
+ if (bounds?.done === true || (kinds !== undefined && spent.size >= kinds)) return
1879
+ let next = -1
1880
+ for (let x = 0; x < cs.length; x++) {
1881
+ const c = cs[x]!
1882
+ if (
1883
+ bounds !== undefined &&
1884
+ c !== ODD &&
1885
+ !bounds.open &&
1886
+ this.classSpan.get(c)![0] > bounds.free + 1e-9 &&
1887
+ this.classSpan.get(c)![1] < bounds.cores &&
1888
+ this.classSpan.get(c)![2] <= bounds.memory
1889
+ )
1890
+ continue
1891
+ const q = qs[x]!
1892
+ let p = at[x]! === -1 ? this.classHeadOf(c, q) : at[x]!
1893
+ if (p < q.length && cursor !== -1 && !this.before(cursor, q[p]!))
1894
+ p = this.after(q, p, cursor)
1895
+ for (; p < q.length; p++) {
1896
+ const i = q[p]!
1897
+ if (this.taken[i] === 1) continue
1898
+ const t = this.tasks[i]!
1899
+ if (bounds === undefined || !dead(t, bounds)) break
1900
+ spent.add(this.kindAt(i))
1901
+ }
1902
+ at[x] = p
1903
+ // A spent kind is passed here only: a give clears `spent`.
1904
+ let r = p
1905
+ while (r < q.length && (this.taken[q[r]!] === 1 || spent.has(this.kindAt(q[r]!)))) r++
1906
+ if (r < q.length && (next === -1 || this.before(q[r]!, next))) {
1907
+ next = q[r]!
1908
+ }
1909
+ }
1910
+ if (next === -1) return
1911
+ cursor = next
1912
+ const t = this.tasks[next]!
1913
+ yield { id: t.id, need: t, kind: this.kindAt(next) }
1914
+ }
1915
+ }
1916
+
1917
+ private classHeadOf(c: number, q: readonly number[]): number {
1918
+ let h = this.classHead.get(c) ?? 0
1919
+ while (h < q.length && this.taken[q[h]!] === 1) h++
1920
+ this.classHead.set(c, h)
1921
+ return h
1922
+ }
1923
+
958
1924
  private headOf(k: string, q: readonly number[]): number {
959
1925
  let h = this.head.get(k) ?? 0
960
1926
  while (h < q.length && this.taken[q[h]!] === 1) h++
@@ -986,3 +1952,187 @@ function floorOf(needs: Iterable<{ cores: number; memory: number }>): {
986
1952
  }
987
1953
  return { cores, memory }
988
1954
  }
1955
+
1956
+ /** Workers of one shape. */
1957
+ export interface Fleet {
1958
+ readonly count: number
1959
+ readonly cores: number
1960
+ readonly memory: number
1961
+ readonly platform?: string | undefined
1962
+ /** The declared size, when it is one. */
1963
+ readonly size?: string | undefined
1964
+ }
1965
+
1966
+ /** Workers that end `tasks` soonest, and when. */
1967
+ export interface Fleets {
1968
+ readonly fleet: readonly Fleet[]
1969
+ readonly endMs: number
1970
+ }
1971
+
1972
+ /**
1973
+ * What would have run `tasks` best, played from an empty run (`advise`):
1974
+ * on the declared sizes, how many of each; past their `max`, how many it
1975
+ * would take; and of any cores, the shape that ends as soon for the
1976
+ * least core time.
1977
+ */
1978
+ export interface Advice {
1979
+ readonly declared?: Fleets | undefined
1980
+ /** Only when more than the sizes' `max` end it past the margin sooner. */
1981
+ readonly more?: Fleets | undefined
1982
+ readonly any?: Fleets | undefined
1983
+ }
1984
+
1985
+ /** The cores a shape of `Advice.any` may have. */
1986
+ const SHAPES = [2, 4, 8, 16, 32, 64]
1987
+ /** Memory a core of a shape has when nothing declares any: as GitHub's larger runners. */
1988
+ const MEMORY_PER_CORE = 4 * 2 ** 30
1989
+
1990
+ /**
1991
+ * The run's workers, judged after it: `tasks` as each took, played from
1992
+ * nothing on the fewest of `sizes`, in the order the live plan starts
1993
+ * them (`candidates`), that end within its margin of all of them; again
1994
+ * with each size's `max` lifted; and on one shape a platform of any
1995
+ * cores. Each worker joins after its platform's `warm` warm-up.
1996
+ */
1997
+ export function advise(
1998
+ tasks: readonly PlanTask[],
1999
+ sizes: readonly Size[],
2000
+ warm: (platform: string | undefined) => number,
2001
+ how: Strategy = DEFAULT_STRATEGY,
2002
+ ): Advice {
2003
+ if (tasks.length === 0) return {}
2004
+ const perPlatform = new Map<string | undefined, number>()
2005
+ for (const t of tasks) perPlatform.set(t.platform, (perPlatform.get(t.platform) ?? 0) + 1)
2006
+ const wants = new Map(
2007
+ [...perPlatform].flatMap(([p, n]) => (p === undefined ? [] : [[p, n] as const])),
2008
+ )
2009
+ const atOf = (s: Size): number => warm(s.platform)
2010
+ const fleetOf = (kept: readonly PlanWorker[], of: readonly Size[]): Fleet[] => {
2011
+ const by = new Map<string, number>()
2012
+ for (const k of kept) if (k.size !== undefined) by.set(k.size, (by.get(k.size) ?? 0) + 1)
2013
+ return of.flatMap((s) => {
2014
+ const count = by.get(s.name) ?? 0
2015
+ return count === 0
2016
+ ? []
2017
+ : [{ count, cores: s.cpu, memory: s.memory, platform: s.platform, size: s.name }]
2018
+ })
2019
+ }
2020
+ const byName = (w: readonly Size[]) => (size: string | undefined) =>
2021
+ atOf(w.find((s) => s.name === size)!)
2022
+ // More workers than run at once end it no sooner: of `cores` on a
2023
+ // platform, at most twice its work over the longest chain, a task each
2024
+ // at most. Without the cap 1000 tasks played 1000 workers (70 s).
2025
+ const chain = longestMs(tasks)
2026
+ const work = new Map<string | undefined, number>()
2027
+ for (const t of tasks)
2028
+ work.set(t.platform, (work.get(t.platform) ?? 0) + t.cores * (t.ms ?? UNKNOWN_MS))
2029
+ const capOf = (cores: number, platform: string | undefined): number => {
2030
+ let w = 0
2031
+ let n = 0
2032
+ for (const [p, c] of perPlatform)
2033
+ if (platform === undefined || p === platform) {
2034
+ w += work.get(p) ?? 0
2035
+ n += c
2036
+ }
2037
+ return Math.min(n, 2 * Math.ceil(w / cores / Math.max(1, chain)) + 1)
2038
+ }
2039
+ /** The fewest of `of`, each up to its `max`, ending within the margin of all of them: halving. */
2040
+ const halved = (of: readonly Size[]): { fleets: Fleets; kept: PlanWorker[] } | undefined => {
2041
+ const at = byName(of)
2042
+ const all = candidates(of, new Map(), [], Infinity, wants).map((s, i) =>
2043
+ fresh({ ...s, size: s.name }, `\0advice${i}`, at(s.name)),
2044
+ )
2045
+ const best = simulate(tasks, all, how)
2046
+ if (!Number.isFinite(best)) return undefined
2047
+ const bound = best + Math.max(MARGIN_MS, best * MARGIN)
2048
+ let lo = 1
2049
+ let hi = all.length
2050
+ while (lo < hi) {
2051
+ const mid = (lo + hi) >> 1
2052
+ if (simulate(tasks, all.slice(0, mid), how) <= bound) hi = mid
2053
+ else lo = mid + 1
2054
+ }
2055
+ const kept = all.slice(0, lo)
2056
+ return { fleets: { fleet: fleetOf(kept, of), endMs: simulate(tasks, kept, how) }, kept }
2057
+ }
2058
+ const declared = sizes.length === 0 ? undefined : halved(sizes)?.fleets
2059
+ const lifted = sizes.map((s) => ({ ...s, max: Math.max(s.max, capOf(s.cpu, s.platform)) }))
2060
+ const wider = lifted.some((s, i) => s.max > sizes[i]!.max) ? halved(lifted)?.fleets : undefined
2061
+ const more =
2062
+ wider !== undefined &&
2063
+ declared !== undefined &&
2064
+ wider.endMs + Math.max(MARGIN_MS, declared.endMs * MARGIN) < declared.endMs
2065
+ ? wider
2066
+ : undefined
2067
+ // Of any cores: each platform's memory a core as its sizes have most.
2068
+ const ratio = new Map<string | undefined, number>()
2069
+ for (const s of sizes)
2070
+ ratio.set(s.platform, Math.max(ratio.get(s.platform) ?? 0, s.memory / s.cpu))
2071
+ const most = new Map<string | undefined, number>()
2072
+ for (const t of tasks) most.set(t.platform, Math.max(most.get(t.platform) ?? 0, t.memory))
2073
+ const tried: { fleets: Fleets; coreMs: number; workers: number }[] = []
2074
+ for (const cores of SHAPES) {
2075
+ const shapes: Size[] = [...perPlatform.keys()].map((platform) => ({
2076
+ name: `${cores}#${platform ?? ''}`,
2077
+ max: capOf(cores, platform),
2078
+ cpu: cores,
2079
+ memory: Math.max(
2080
+ cores * (ratio.get(platform) ?? ratio.get(undefined) ?? MEMORY_PER_CORE),
2081
+ most.get(platform) ?? 0,
2082
+ ),
2083
+ platform,
2084
+ }))
2085
+ const got = halved(shapes)
2086
+ if (got === undefined) continue
2087
+ let coreMs = 0
2088
+ for (const k of got.kept) coreMs += k.cores * Math.max(0, got.fleets.endMs - k.at)
2089
+ tried.push({ fleets: got.fleets, coreMs, workers: got.kept.length })
2090
+ }
2091
+ let any: Fleets | undefined
2092
+ if (tried.length > 0) {
2093
+ const soonest = Math.min(...tried.map((t) => t.fleets.endMs))
2094
+ const bound = soonest + Math.max(MARGIN_MS, soonest * MARGIN)
2095
+ let cheapest: (typeof tried)[number] | undefined
2096
+ for (const t of tried)
2097
+ if (
2098
+ t.fleets.endMs <= bound &&
2099
+ (cheapest === undefined ||
2100
+ t.coreMs < cheapest.coreMs ||
2101
+ // As cheap, fewer machines.
2102
+ (t.coreMs === cheapest.coreMs && t.workers < cheapest.workers))
2103
+ )
2104
+ cheapest = t
2105
+ any = {
2106
+ fleet: cheapest!.fleets.fleet.map((f) => ({
2107
+ count: f.count,
2108
+ cores: f.cores,
2109
+ memory: f.memory,
2110
+ platform: f.platform,
2111
+ })),
2112
+ endMs: cheapest!.fleets.endMs,
2113
+ }
2114
+ }
2115
+ return { declared, more, any }
2116
+ }
2117
+
2118
+ /** The longest chain of `tasks` by their `ms`, through the deps among them. */
2119
+ function longestMs(tasks: readonly PlanTask[]): number {
2120
+ const byId = new Map(tasks.map((t) => [t.id, t]))
2121
+ const memo = new Map<string, number>()
2122
+ const of = (t: PlanTask): number => {
2123
+ const had = memo.get(t.id)
2124
+ if (had !== undefined) return had
2125
+ memo.set(t.id, 0)
2126
+ let up = 0
2127
+ for (const d of t.deps) {
2128
+ const u = byId.get(d)
2129
+ if (u !== undefined) up = Math.max(up, of(u))
2130
+ }
2131
+ const out = up + (t.ms ?? UNKNOWN_MS)
2132
+ memo.set(t.id, out)
2133
+ return out
2134
+ }
2135
+ let most = 0
2136
+ for (const t of tasks) most = Math.max(most, of(t))
2137
+ return most
2138
+ }