cursedbelt-core 1.0.0
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/LICENSE +15 -0
- package/dist/client/wsWire.d.ts +49 -0
- package/dist/client/wsWire.js +57 -0
- package/dist/config/appConfig.d.ts +361 -0
- package/dist/config/appConfig.js +308 -0
- package/dist/core/activity/index.d.ts +8 -0
- package/dist/core/activity/index.js +7 -0
- package/dist/core/activity/model.d.ts +115 -0
- package/dist/core/activity/model.js +63 -0
- package/dist/core/analytics/index.d.ts +18 -0
- package/dist/core/analytics/index.js +18 -0
- package/dist/core/analytics/range.d.ts +186 -0
- package/dist/core/analytics/range.js +271 -0
- package/dist/core/analytics/schedule.d.ts +301 -0
- package/dist/core/analytics/schedule.js +372 -0
- package/dist/core/category/index.d.ts +3 -0
- package/dist/core/category/index.js +6 -0
- package/dist/core/category/memoryStore.d.ts +21 -0
- package/dist/core/category/memoryStore.js +97 -0
- package/dist/core/category/tree.d.ts +26 -0
- package/dist/core/category/tree.js +113 -0
- package/dist/core/category/types.d.ts +48 -0
- package/dist/core/category/types.js +8 -0
- package/dist/core/color.d.ts +14 -0
- package/dist/core/color.js +32 -0
- package/dist/core/colorScheme/colorSchemeStore.d.ts +70 -0
- package/dist/core/colorScheme/colorSchemeStore.js +123 -0
- package/dist/core/colorScheme/index.d.ts +7 -0
- package/dist/core/colorScheme/index.js +7 -0
- package/dist/core/domainOwners.d.ts +19 -0
- package/dist/core/domainOwners.js +15 -0
- package/dist/core/download.d.ts +14 -0
- package/dist/core/download.js +37 -0
- package/dist/core/events/ccEventBus.d.ts +44 -0
- package/dist/core/events/ccEventBus.js +70 -0
- package/dist/core/file-tree/fileTreeModel.d.ts +276 -0
- package/dist/core/file-tree/fileTreeModel.js +312 -0
- package/dist/core/folder-tree/actions.d.ts +84 -0
- package/dist/core/folder-tree/actions.js +119 -0
- package/dist/core/folder-tree/index.d.ts +3 -0
- package/dist/core/folder-tree/index.js +20 -0
- package/dist/core/folder-tree/path.d.ts +76 -0
- package/dist/core/folder-tree/path.js +146 -0
- package/dist/core/folder-tree/tree.d.ts +86 -0
- package/dist/core/folder-tree/tree.js +184 -0
- package/dist/core/layout/index.d.ts +1 -0
- package/dist/core/layout/index.js +7 -0
- package/dist/core/master-lock/index.d.ts +14 -0
- package/dist/core/master-lock/index.js +14 -0
- package/dist/core/master-lock/kdf.d.ts +63 -0
- package/dist/core/master-lock/kdf.js +117 -0
- package/dist/core/master-lock/policy.d.ts +32 -0
- package/dist/core/master-lock/policy.js +52 -0
- package/dist/core/master-lock/presence.d.ts +52 -0
- package/dist/core/master-lock/presence.js +61 -0
- package/dist/core/master-lock/wire.d.ts +139 -0
- package/dist/core/master-lock/wire.js +81 -0
- package/dist/core/media/bgRemoval.d.ts +27 -0
- package/dist/core/media/bgRemoval.js +101 -0
- package/dist/core/media/ffmpeg.d.ts +57 -0
- package/dist/core/media/ffmpeg.js +126 -0
- package/dist/core/media/format.d.ts +20 -0
- package/dist/core/media/format.js +54 -0
- package/dist/core/media/index.d.ts +12 -0
- package/dist/core/media/index.js +18 -0
- package/dist/core/media/mediaItem.d.ts +15 -0
- package/dist/core/media/mediaItem.js +34 -0
- package/dist/core/media/renderPlan.d.ts +63 -0
- package/dist/core/media/renderPlan.js +232 -0
- package/dist/core/media/segment.d.ts +79 -0
- package/dist/core/media/segment.js +247 -0
- package/dist/core/media/size.d.ts +1 -0
- package/dist/core/media/size.js +3 -0
- package/dist/core/media/trim.d.ts +21 -0
- package/dist/core/media/trim.js +40 -0
- package/dist/core/media/types.d.ts +148 -0
- package/dist/core/media/types.js +11 -0
- package/dist/core/media/uploadLimits.d.ts +7 -0
- package/dist/core/media/uploadLimits.js +16 -0
- package/dist/core/media/validate.d.ts +9 -0
- package/dist/core/media/validate.js +121 -0
- package/dist/core/media/videoCuts.d.ts +152 -0
- package/dist/core/media/videoCuts.js +284 -0
- package/dist/core/navigation/breadcrumbCompat.d.ts +24 -0
- package/dist/core/navigation/breadcrumbCompat.js +21 -0
- package/dist/core/navigation/coverageTest.d.ts +24 -0
- package/dist/core/navigation/coverageTest.js +48 -0
- package/dist/core/navigation/index.d.ts +6 -0
- package/dist/core/navigation/index.js +10 -0
- package/dist/core/navigation/migrateNavPreferences.d.ts +55 -0
- package/dist/core/navigation/migrateNavPreferences.js +63 -0
- package/dist/core/navigation/navigationModel.d.ts +165 -0
- package/dist/core/navigation/navigationModel.js +14 -0
- package/dist/core/navigation/navigationSelectors.d.ts +63 -0
- package/dist/core/navigation/navigationSelectors.js +167 -0
- package/dist/core/navigation/searchCatalogue.d.ts +23 -0
- package/dist/core/navigation/searchCatalogue.js +76 -0
- package/dist/core/net/reconnectSchedule.d.ts +46 -0
- package/dist/core/net/reconnectSchedule.js +38 -0
- package/dist/core/palette/ShortcutRegistry.d.ts +33 -0
- package/dist/core/palette/ShortcutRegistry.js +52 -0
- package/dist/core/palette/fuzzyRank.d.ts +18 -0
- package/dist/core/palette/fuzzyRank.js +73 -0
- package/dist/core/palette/hotkeys.d.ts +28 -0
- package/dist/core/palette/hotkeys.js +118 -0
- package/dist/core/palette/index.d.ts +3 -0
- package/dist/core/palette/index.js +8 -0
- package/dist/core/schema/projection.d.ts +84 -0
- package/dist/core/schema/projection.js +184 -0
- package/dist/core/session/claims.d.ts +145 -0
- package/dist/core/session/claims.js +157 -0
- package/dist/core/sharing/index.d.ts +5 -0
- package/dist/core/sharing/index.js +4 -0
- package/dist/core/sharing/model.d.ts +195 -0
- package/dist/core/sharing/model.js +150 -0
- package/dist/core/site/siteCopy.d.ts +92 -0
- package/dist/core/site/siteCopy.js +91 -0
- package/dist/core/slots.d.ts +22 -0
- package/dist/core/slots.js +31 -0
- package/dist/core/styles/constructableSheet.d.ts +51 -0
- package/dist/core/styles/constructableSheet.js +80 -0
- package/dist/core/tokens/registry.d.ts +119 -0
- package/dist/core/tokens/registry.js +447 -0
- package/dist/core/uiScale/index.d.ts +8 -0
- package/dist/core/uiScale/index.js +8 -0
- package/dist/core/uiScale/scale.d.ts +36 -0
- package/dist/core/uiScale/scale.js +47 -0
- package/dist/core/uiScale/uiScaleStore.d.ts +47 -0
- package/dist/core/uiScale/uiScaleStore.js +63 -0
- package/dist/core/wire/__fixtures__/rowsMarshalWorker.d.ts +1 -0
- package/dist/core/wire/__fixtures__/rowsMarshalWorker.js +23 -0
- package/dist/core/wire/index.d.ts +17 -0
- package/dist/core/wire/index.js +17 -0
- package/dist/core/wire/ingress.d.ts +44 -0
- package/dist/core/wire/ingress.js +134 -0
- package/dist/core/wire/marshalWorker.d.ts +70 -0
- package/dist/core/wire/marshalWorker.js +273 -0
- package/dist/core/wire/marshalling.d.ts +122 -0
- package/dist/core/wire/marshalling.js +126 -0
- package/dist/core/wire/wireHub.d.ts +80 -0
- package/dist/core/wire/wireHub.js +116 -0
- package/dist/ctgr/base64url.d.ts +8 -0
- package/dist/ctgr/base64url.js +39 -0
- package/dist/ctgr/chunkAccumulator.d.ts +214 -0
- package/dist/ctgr/chunkAccumulator.js +307 -0
- package/dist/ctgr/chunkStore.d.ts +24 -0
- package/dist/ctgr/chunkStore.js +125 -0
- package/dist/ctgr/codec.d.ts +44 -0
- package/dist/ctgr/codec.js +153 -0
- package/dist/ctgr/compress.d.ts +2 -0
- package/dist/ctgr/compress.js +20 -0
- package/dist/ctgr/crc32.d.ts +2 -0
- package/dist/ctgr/crc32.js +23 -0
- package/dist/ctgr/digest.d.ts +1 -0
- package/dist/ctgr/digest.js +15 -0
- package/dist/ctgr/index.d.ts +9 -0
- package/dist/ctgr/index.js +17 -0
- package/dist/ctgr/types.d.ts +64 -0
- package/dist/ctgr/types.js +21 -0
- package/dist/ctgr/v0compat.d.ts +27 -0
- package/dist/ctgr/v0compat.js +128 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.js +17 -0
- package/dist/shared/errors.d.ts +101 -0
- package/dist/shared/errors.js +99 -0
- package/dist/testing/bytes.d.ts +32 -0
- package/dist/testing/bytes.js +33 -0
- package/dist/testing/domInspect.d.ts +59 -0
- package/dist/testing/domInspect.js +115 -0
- package/dist/testing/fakeFetch.d.ts +23 -0
- package/dist/testing/fakeFetch.js +21 -0
- package/dist/testing/index.d.ts +3 -0
- package/dist/testing/index.js +11 -0
- package/package.json +373 -0
- package/src/client/wsWire.ts +83 -0
- package/src/config/appConfig.spec.ts +158 -0
- package/src/config/appConfig.ts +372 -0
- package/src/core/activity/index.ts +21 -0
- package/src/core/activity/model.ts +160 -0
- package/src/core/analytics/index.ts +18 -0
- package/src/core/analytics/range.spec.ts +280 -0
- package/src/core/analytics/range.ts +383 -0
- package/src/core/analytics/schedule.spec.ts +311 -0
- package/src/core/analytics/schedule.ts +624 -0
- package/src/core/category/category.spec.ts +144 -0
- package/src/core/category/index.ts +19 -0
- package/src/core/category/memoryStore.ts +104 -0
- package/src/core/category/tree.ts +118 -0
- package/src/core/category/types.ts +59 -0
- package/src/core/color.spec.ts +35 -0
- package/src/core/color.ts +35 -0
- package/src/core/colorScheme/colorScheme.spec.ts +127 -0
- package/src/core/colorScheme/colorSchemeStore.ts +184 -0
- package/src/core/colorScheme/index.ts +23 -0
- package/src/core/domainOwners.ts +32 -0
- package/src/core/download.spec.ts +38 -0
- package/src/core/download.ts +35 -0
- package/src/core/events/ccEventBus.spec.ts +90 -0
- package/src/core/events/ccEventBus.ts +120 -0
- package/src/core/file-tree/fileTreeModel.spec.ts +462 -0
- package/src/core/file-tree/fileTreeModel.ts +511 -0
- package/src/core/folder-tree/actions.spec.ts +103 -0
- package/src/core/folder-tree/actions.ts +153 -0
- package/src/core/folder-tree/index.ts +46 -0
- package/src/core/folder-tree/path.spec.ts +113 -0
- package/src/core/folder-tree/path.ts +148 -0
- package/src/core/folder-tree/tree.spec.ts +157 -0
- package/src/core/folder-tree/tree.ts +244 -0
- package/src/core/layout/index.ts +7 -0
- package/src/core/master-lock/index.ts +50 -0
- package/src/core/master-lock/kdf.spec.ts +95 -0
- package/src/core/master-lock/kdf.ts +145 -0
- package/src/core/master-lock/policy.ts +55 -0
- package/src/core/master-lock/presence.spec.ts +73 -0
- package/src/core/master-lock/presence.ts +71 -0
- package/src/core/master-lock/wire.ts +155 -0
- package/src/core/media/bgRemoval.spec.ts +120 -0
- package/src/core/media/bgRemoval.ts +123 -0
- package/src/core/media/ffmpeg.spec.ts +135 -0
- package/src/core/media/ffmpeg.ts +162 -0
- package/src/core/media/format.spec.ts +41 -0
- package/src/core/media/format.ts +58 -0
- package/src/core/media/index.ts +105 -0
- package/src/core/media/mediaItem.spec.ts +46 -0
- package/src/core/media/mediaItem.ts +40 -0
- package/src/core/media/renderPlan.spec.ts +140 -0
- package/src/core/media/renderPlan.ts +318 -0
- package/src/core/media/segment.spec.ts +159 -0
- package/src/core/media/segment.ts +352 -0
- package/src/core/media/size.spec.ts +19 -0
- package/src/core/media/size.ts +3 -0
- package/src/core/media/trim.spec.ts +75 -0
- package/src/core/media/trim.ts +60 -0
- package/src/core/media/types.ts +147 -0
- package/src/core/media/uploadLimits.ts +18 -0
- package/src/core/media/validate.spec.ts +106 -0
- package/src/core/media/validate.ts +131 -0
- package/src/core/media/videoCuts.spec.ts +327 -0
- package/src/core/media/videoCuts.ts +355 -0
- package/src/core/navigation/breadcrumbCompat.ts +44 -0
- package/src/core/navigation/coverageTest.ts +54 -0
- package/src/core/navigation/index.ts +50 -0
- package/src/core/navigation/migrateNavPreferences.ts +119 -0
- package/src/core/navigation/navigation.spec.ts +388 -0
- package/src/core/navigation/navigationModel.ts +185 -0
- package/src/core/navigation/navigationSelectors.ts +206 -0
- package/src/core/navigation/searchCatalogue.ts +89 -0
- package/src/core/net/reconnectSchedule.spec.ts +49 -0
- package/src/core/net/reconnectSchedule.ts +78 -0
- package/src/core/palette/ShortcutRegistry.ts +69 -0
- package/src/core/palette/fuzzyRank.ts +78 -0
- package/src/core/palette/hotkeys.ts +132 -0
- package/src/core/palette/index.ts +22 -0
- package/src/core/palette/palette.spec.ts +160 -0
- package/src/core/schema/projection.spec.ts +122 -0
- package/src/core/schema/projection.ts +225 -0
- package/src/core/session/claims.ts +181 -0
- package/src/core/sharing/index.ts +33 -0
- package/src/core/sharing/model.spec.ts +104 -0
- package/src/core/sharing/model.ts +296 -0
- package/src/core/site/siteCopy.ts +115 -0
- package/src/core/slots.ts +38 -0
- package/src/core/styles/constructableSheet.ts +86 -0
- package/src/core/tokens/registry.spec.ts +160 -0
- package/src/core/tokens/registry.ts +536 -0
- package/src/core/uiScale/index.ts +32 -0
- package/src/core/uiScale/scale.ts +63 -0
- package/src/core/uiScale/uiScale.spec.ts +77 -0
- package/src/core/uiScale/uiScaleStore.ts +100 -0
- package/src/core/wire/__fixtures__/rowsMarshalWorker.ts +24 -0
- package/src/core/wire/index.ts +56 -0
- package/src/core/wire/ingress.spec.ts +276 -0
- package/src/core/wire/ingress.ts +182 -0
- package/src/core/wire/marshalWorker.spec.ts +196 -0
- package/src/core/wire/marshalWorker.ts +361 -0
- package/src/core/wire/marshalling.spec.ts +139 -0
- package/src/core/wire/marshalling.ts +238 -0
- package/src/core/wire/wireHub.spec.ts +137 -0
- package/src/core/wire/wireHub.ts +213 -0
- package/src/ctgr/base64url.ts +44 -0
- package/src/ctgr/chunkAccumulator.ts +403 -0
- package/src/ctgr/chunkStore.spec.ts +101 -0
- package/src/ctgr/chunkStore.ts +168 -0
- package/src/ctgr/codec.spec.ts +208 -0
- package/src/ctgr/codec.ts +202 -0
- package/src/ctgr/compress.ts +21 -0
- package/src/ctgr/crc32.ts +23 -0
- package/src/ctgr/digest.ts +14 -0
- package/src/ctgr/index.ts +39 -0
- package/src/ctgr/types.ts +80 -0
- package/src/ctgr/v0compat.spec.ts +73 -0
- package/src/ctgr/v0compat.ts +156 -0
- package/src/declaredDepsAreImported.spec.ts +87 -0
- package/src/index.ts +25 -0
- package/src/leafSubpathsImportNothing.spec.ts +287 -0
- package/src/lockfileIsTracked.spec.ts +50 -0
- package/src/namedSubpathsResolve.spec.ts +122 -0
- package/src/noPathDeps.spec.ts +24 -0
- package/src/publishShape.spec.ts +89 -0
- package/src/shared/errors.ts +153 -0
- package/src/skippedTestsAreAnswered.spec.ts +221 -0
- package/src/testTmpRoot.spec.ts +102 -0
- package/src/testing/bytes.ts +38 -0
- package/src/testing/domInspect.spec.ts +79 -0
- package/src/testing/domInspect.ts +127 -0
- package/src/testing/fakeFetch.ts +26 -0
- package/src/testing/index.ts +11 -0
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "How long until this queue is empty, and does another worker actually help?"
|
|
3
|
+
*
|
|
4
|
+
* ── The question this exists to answer (owner, 2026-08-23) ───────────────────
|
|
5
|
+
* *"For estimates of how long to empty tasks I would also like to be able to see
|
|
6
|
+
* them based on if I added to the fleet size or reduced it… I want to be able to
|
|
7
|
+
* tell whether there is a point where adding to the fleet slows down more than
|
|
8
|
+
* it speeds up. I also want to see the expected token usage so I know if I run
|
|
9
|
+
* out of tokens earlier and how much earlier."*
|
|
10
|
+
*
|
|
11
|
+
* Three separate claims hide in that, and a `remaining / rate` division answers
|
|
12
|
+
* none of them:
|
|
13
|
+
*
|
|
14
|
+
* 1. **Work does not parallelise freely.** Two tasks that write the same files
|
|
15
|
+
* may not run together whatever the fleet size — that is the orchestrator's
|
|
16
|
+
* own scope rule, and it is why a queue of ten tasks all scoped to one app
|
|
17
|
+
* drains at exactly the same speed with eight workers as with one.
|
|
18
|
+
* 2. **Workers are not free of each other.** This machine's own measurements say
|
|
19
|
+
* a verify gate takes 2m09s idle and 857s (6.6x) under seventeen concurrent
|
|
20
|
+
* gates. Past some point another worker makes *everyone* slower, so the
|
|
21
|
+
* makespan curve turns back UP — the "point where adding to the fleet slows
|
|
22
|
+
* down more than it speeds up" is real, has a number, and this module finds
|
|
23
|
+
* it. A scheduler with no contention term can never report one, because pure
|
|
24
|
+
* list scheduling is monotone: more workers is never worse.
|
|
25
|
+
* 3. **Tokens are spent by work, not by time.** Doubling the fleet does not
|
|
26
|
+
* double the tokens a queue costs — it spends the same budget roughly twice
|
|
27
|
+
* as fast, so the wall-clock moment you run dry arrives sooner even though the
|
|
28
|
+
* total is unchanged. Both numbers matter and they move in opposite
|
|
29
|
+
* directions.
|
|
30
|
+
*
|
|
31
|
+
* ── Why it lives in cursedbelt and not in the orchestrator ───────────────────
|
|
32
|
+
* Nothing here knows what a "task" is. It schedules ITEMS with durations,
|
|
33
|
+
* exclusion keys and dependencies over WORKERS, which is the same shape as a
|
|
34
|
+
* deploy train, a batch of renders, a backup rotation or a CI matrix. The
|
|
35
|
+
* orchestrator supplies the meaning (a scope string, a serial group, a tier's
|
|
36
|
+
* measured duration) and reads the answer back; any other dashboard in the fleet
|
|
37
|
+
* can do the same without re-deriving a scheduler.
|
|
38
|
+
*
|
|
39
|
+
* Pure, deterministic, and clock-free: the caller passes `now` when it wants
|
|
40
|
+
* absolute times. That is what lets the whole forecast be asserted in a test
|
|
41
|
+
* rather than eyeballed on a chart.
|
|
42
|
+
*/
|
|
43
|
+
/** One unit of work to be scheduled. */
|
|
44
|
+
export interface ScheduleItem {
|
|
45
|
+
id: string;
|
|
46
|
+
/** Estimated worker time at NOMINAL speed (no contention), in ms. */
|
|
47
|
+
durationMs: number;
|
|
48
|
+
/**
|
|
49
|
+
* Resources this item WRITES. Two items sharing any one of these may never run
|
|
50
|
+
* at the same time, however many workers are free. This is the orchestrator's
|
|
51
|
+
* `conflict_scopes.writes`, a deploy's target host, a render's GPU.
|
|
52
|
+
*/
|
|
53
|
+
exclusive?: readonly string[];
|
|
54
|
+
/**
|
|
55
|
+
* A named mutual exclusion — at most one member of the group runs at a time.
|
|
56
|
+
* Kept separate from {@link exclusive} because it expresses "these take turns"
|
|
57
|
+
* without anyone having to say what the shared resource IS.
|
|
58
|
+
*/
|
|
59
|
+
group?: string | null;
|
|
60
|
+
/** Ids that must have finished before this may start. */
|
|
61
|
+
dependsOn?: readonly string[];
|
|
62
|
+
/** What running it is expected to cost, in the caller's unit (tokens, dollars). */
|
|
63
|
+
cost?: number;
|
|
64
|
+
/** Higher goes first among items that are simultaneously eligible. Default 0. */
|
|
65
|
+
priority?: number;
|
|
66
|
+
/** Already in flight at t=0, with this much work left. Occupies a worker. */
|
|
67
|
+
runningRemainingMs?: number;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* How much slower every worker gets as more of them run at once.
|
|
71
|
+
*
|
|
72
|
+
* The shape is deliberately the simplest one that can bend a makespan curve back
|
|
73
|
+
* upward, because a more elaborate model would imply a precision the inputs do
|
|
74
|
+
* not have. `stretch(c) = 1 + slowdownPerExtraWorker * max(0, c - freeLanes)`,
|
|
75
|
+
* clamped at `maxStretch`: below `freeLanes` the machine absorbs the load, above
|
|
76
|
+
* it every extra worker taxes all of them.
|
|
77
|
+
*
|
|
78
|
+
* 🔴 The default is NOT "no contention". A model with `slowdownPerExtraWorker: 0`
|
|
79
|
+
* always reports that more workers are better, which is the wrong answer on this
|
|
80
|
+
* machine and would make the whole panel a machine for justifying a bigger fleet.
|
|
81
|
+
* Calibrate it from measured runs where you can — {@link estimateContention} —
|
|
82
|
+
* and state on the panel that it is an estimate where you cannot.
|
|
83
|
+
*/
|
|
84
|
+
export interface ContentionModel {
|
|
85
|
+
/** Concurrency up to and including this runs at nominal speed. */
|
|
86
|
+
freeLanes: number;
|
|
87
|
+
/** Fractional slowdown added to EVERY worker per extra concurrent worker. */
|
|
88
|
+
slowdownPerExtraWorker: number;
|
|
89
|
+
/** Upper bound on the multiplier. Default 8 — beyond that the machine is thrashing. */
|
|
90
|
+
maxStretch?: number;
|
|
91
|
+
}
|
|
92
|
+
/** No contention at all — the ideal machine. Useful as a comparison line, and as
|
|
93
|
+
* the honest choice when the work is not CPU-bound on one host. */
|
|
94
|
+
export declare const NO_CONTENTION: ContentionModel;
|
|
95
|
+
export declare function stretchAt(model: ContentionModel, concurrency: number): number;
|
|
96
|
+
export interface SimulateOptions {
|
|
97
|
+
/** How many items may run at once. */
|
|
98
|
+
workers: number;
|
|
99
|
+
contention?: ContentionModel;
|
|
100
|
+
/** Absolute epoch ms for t=0, so the result carries real timestamps. Default 0. */
|
|
101
|
+
startAt?: number;
|
|
102
|
+
/**
|
|
103
|
+
* Guard against a pathological input (a dependency cycle, a zero-duration
|
|
104
|
+
* storm). Simulation aborts after this many scheduling events and reports what
|
|
105
|
+
* it could not place. Default 20,000.
|
|
106
|
+
*/
|
|
107
|
+
maxEvents?: number;
|
|
108
|
+
}
|
|
109
|
+
export interface ScheduledItem {
|
|
110
|
+
id: string;
|
|
111
|
+
/** Epoch ms (or ms from t=0 when `startAt` was omitted). */
|
|
112
|
+
startAt: number;
|
|
113
|
+
endAt: number;
|
|
114
|
+
/** Nominal duration, before contention. */
|
|
115
|
+
durationMs: number;
|
|
116
|
+
/** What it actually took at the concurrency it ran under. */
|
|
117
|
+
effectiveMs: number;
|
|
118
|
+
/** How long it sat eligible-but-blocked before starting. */
|
|
119
|
+
waitedMs: number;
|
|
120
|
+
/** Why it could not start earlier: `worker` (fleet full), `conflict`, `dependency`. */
|
|
121
|
+
blockedBy: 'worker' | 'conflict' | 'dependency' | null;
|
|
122
|
+
}
|
|
123
|
+
export interface ScheduleResult {
|
|
124
|
+
workers: number;
|
|
125
|
+
/** Wall clock from t=0 until the last item finishes. */
|
|
126
|
+
makespanMs: number;
|
|
127
|
+
/** Sum of NOMINAL durations — the work itself, independent of fleet size. */
|
|
128
|
+
workMs: number;
|
|
129
|
+
/** Sum of EFFECTIVE durations — the work plus what contention added. */
|
|
130
|
+
spentMs: number;
|
|
131
|
+
/** Worker-time wasted to contention: `spentMs - workMs`. */
|
|
132
|
+
contentionMs: number;
|
|
133
|
+
/** `workMs / (workers * makespanMs)`, 0–1. How much of the fleet was earning. */
|
|
134
|
+
utilization: number;
|
|
135
|
+
/** Mean simultaneous items across the makespan. */
|
|
136
|
+
avgConcurrency: number;
|
|
137
|
+
peakConcurrency: number;
|
|
138
|
+
/** Total of every item's `cost`. */
|
|
139
|
+
cost: number;
|
|
140
|
+
timeline: ScheduledItem[];
|
|
141
|
+
/** Ids that could never be placed (a dependency cycle or a missing dependency). */
|
|
142
|
+
unschedulable: string[];
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Event-driven list scheduling with resource exclusion and a contention term.
|
|
146
|
+
*
|
|
147
|
+
* The loop is: admit everything that can start, advance the clock to whichever
|
|
148
|
+
* running item finishes FIRST **at the current concurrency's speed**, retire it,
|
|
149
|
+
* repeat. Progress is tracked as remaining WORK rather than an end timestamp,
|
|
150
|
+
* which is what makes contention correct rather than decorative: a task that
|
|
151
|
+
* started alone and is joined by three others slows down for the rest of its run
|
|
152
|
+
* and not retroactively.
|
|
153
|
+
*
|
|
154
|
+
* Admission order is priority, then longest-first. Longest-first (LPT) is the
|
|
155
|
+
* classic list-scheduling heuristic and matters here: shortest-first leaves a
|
|
156
|
+
* multi-hour task to start last and stretches the makespan by most of its own
|
|
157
|
+
* length, which would make every large fleet look pointless.
|
|
158
|
+
*/
|
|
159
|
+
export declare function simulateSchedule(items: readonly ScheduleItem[], options: SimulateOptions): ScheduleResult;
|
|
160
|
+
export interface FleetPoint {
|
|
161
|
+
workers: number;
|
|
162
|
+
makespanMs: number;
|
|
163
|
+
/** `makespan(1) / makespan(n)`. 1 = no gain; n = perfect scaling. */
|
|
164
|
+
speedup: number;
|
|
165
|
+
/** `speedup / workers`, 0–1 — how much of each worker is actually earning. */
|
|
166
|
+
efficiency: number;
|
|
167
|
+
/** Wall clock saved against ONE FEWER worker. Negative = this worker made it worse. */
|
|
168
|
+
marginalMs: number;
|
|
169
|
+
utilization: number;
|
|
170
|
+
avgConcurrency: number;
|
|
171
|
+
peakConcurrency: number;
|
|
172
|
+
/** Worker-time lost to contention at this fleet size. */
|
|
173
|
+
contentionMs: number;
|
|
174
|
+
/** Total cost of the run (unchanged by fleet size — see {@link burnPerHour}). */
|
|
175
|
+
cost: number;
|
|
176
|
+
/** Cost per hour of wall clock at this fleet size — what empties a budget. */
|
|
177
|
+
burnPerHour: number;
|
|
178
|
+
/** Items that never got scheduled. */
|
|
179
|
+
unschedulable: number;
|
|
180
|
+
}
|
|
181
|
+
export interface FleetSweep {
|
|
182
|
+
points: FleetPoint[];
|
|
183
|
+
/**
|
|
184
|
+
* The recommendation: the SMALLEST fleet that gets within `tolerance` of the
|
|
185
|
+
* best time this queue can achieve anywhere in the sweep.
|
|
186
|
+
*
|
|
187
|
+
* 🔴 Not "the last size with a good marginal gain", which is the obvious
|
|
188
|
+
* definition and is wrong the moment contention makes the curve non-monotone.
|
|
189
|
+
* Measured on eight 10-minute tasks at a 0.6 slowdown: seven workers take 50
|
|
190
|
+
* minutes and eight take 46, so eight shows a HEALTHY +4-minute margin over
|
|
191
|
+
* seven while being six minutes worse than simply running two. A marginal
|
|
192
|
+
* reading recommends eight. Comparing against the best achievable makespan
|
|
193
|
+
* cannot: two workers is the answer, and it says so.
|
|
194
|
+
*/
|
|
195
|
+
sweetSpot: number;
|
|
196
|
+
/**
|
|
197
|
+
* The first fleet size whose makespan is WORSE than the size below it, or null
|
|
198
|
+
* when contention never wins inside the swept range. This is the literal
|
|
199
|
+
* answer to "is there a point where adding to the fleet slows it down".
|
|
200
|
+
*/
|
|
201
|
+
turnsNegativeAt: number | null;
|
|
202
|
+
/**
|
|
203
|
+
* The ceiling the WORK imposes, independent of the machine: the highest
|
|
204
|
+
* concurrency the queue's own conflicts ever allow. Adding workers past this
|
|
205
|
+
* cannot help at any speed, and no contention model is needed to say so.
|
|
206
|
+
*/
|
|
207
|
+
parallelismCeiling: number;
|
|
208
|
+
}
|
|
209
|
+
export interface SweepOptions extends Omit<SimulateOptions, 'workers'> {
|
|
210
|
+
/** Largest fleet size to try. Default 8. */
|
|
211
|
+
maxWorkers?: number;
|
|
212
|
+
/** How much slower than the best achievable makespan a fleet size may be and
|
|
213
|
+
* still count as "as good as it gets". Default 0.05 (5%). */
|
|
214
|
+
tolerance?: number;
|
|
215
|
+
}
|
|
216
|
+
export declare function sweepFleetSizes(items: readonly ScheduleItem[], options?: SweepOptions): FleetSweep;
|
|
217
|
+
export interface BudgetProjection {
|
|
218
|
+
workers: number;
|
|
219
|
+
/** Ms of wall clock until the budget is gone, null when the queue finishes first. */
|
|
220
|
+
exhaustedInMs: number | null;
|
|
221
|
+
/** How much of the queue's cost the budget covers, 0–1. */
|
|
222
|
+
coverage: number;
|
|
223
|
+
/** Cost the queue is short by, 0 when the budget covers it. */
|
|
224
|
+
shortfall: number;
|
|
225
|
+
burnPerHour: number;
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* When a spend budget runs out at each fleet size.
|
|
229
|
+
*
|
|
230
|
+
* The counter-intuitive half, and the reason the owner asked for it: **the total
|
|
231
|
+
* is the same and the deadline is not.** A queue that costs 40M tokens costs 40M
|
|
232
|
+
* whether one agent or five works it; five agents simply reach the wall in a
|
|
233
|
+
* fifth of the time. So `coverage` (do I have enough at all?) is flat across the
|
|
234
|
+
* sweep while `exhaustedInMs` shrinks — and the honest reading is "more workers
|
|
235
|
+
* does not cost more, it costs sooner".
|
|
236
|
+
*/
|
|
237
|
+
export declare function projectBudget(sweep: FleetSweep, budget: number): BudgetProjection[];
|
|
238
|
+
/** One observed run: how long it took, and how loaded the machine was. */
|
|
239
|
+
export interface ContentionSample {
|
|
240
|
+
/** Mean simultaneous workers while it ran. */
|
|
241
|
+
concurrency: number;
|
|
242
|
+
durationMs: number;
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* Fit a {@link ContentionModel} to runs that actually happened.
|
|
246
|
+
*
|
|
247
|
+
* Deliberately a two-group comparison rather than a regression: the sample is a
|
|
248
|
+
* few hundred runs of wildly different work, so a least-squares line through
|
|
249
|
+
* duration-versus-concurrency mostly measures which KIND of task tends to run
|
|
250
|
+
* alone. Comparing the median duration of "ran near-alone" against the median of
|
|
251
|
+
* "ran in a crowd" is coarse, robust, and explainable on a caveat line, which is
|
|
252
|
+
* what a number a fleet-size decision rests on has to be.
|
|
253
|
+
*
|
|
254
|
+
* Returns `null` when either group is too thin to speak — the caller must then
|
|
255
|
+
* say the model is assumed, not measured.
|
|
256
|
+
*/
|
|
257
|
+
export declare function estimateContention(samples: readonly ContentionSample[], options?: {
|
|
258
|
+
freeLanes?: number;
|
|
259
|
+
minGroup?: number;
|
|
260
|
+
}): ContentionModel | null;
|
|
261
|
+
export interface ConcurrencyChange {
|
|
262
|
+
/** When the count changed. */
|
|
263
|
+
at: number;
|
|
264
|
+
/** How many intervals were live from `at` until the next change. */
|
|
265
|
+
count: number;
|
|
266
|
+
}
|
|
267
|
+
/**
|
|
268
|
+
* Turn a set of intervals into the step function of how many were live.
|
|
269
|
+
*
|
|
270
|
+
* The exact series behind an "average tasks at once" number, so a reader can see
|
|
271
|
+
* the overlaps that produced it instead of taking the mean on faith. Ends before
|
|
272
|
+
* starts at a tie: two runs where one finishes as the next begins were never
|
|
273
|
+
* simultaneous, and counting them as two reports a concurrency the fleet's own
|
|
274
|
+
* limit forbids.
|
|
275
|
+
*/
|
|
276
|
+
export declare function concurrencyProfile(intervals: readonly {
|
|
277
|
+
from: number;
|
|
278
|
+
to: number;
|
|
279
|
+
}[]): ConcurrencyChange[];
|
|
280
|
+
/**
|
|
281
|
+
* Time-weighted mean concurrency across `[from, to)`, optionally counting only
|
|
282
|
+
* the parts of the window the fleet was actually ELIGIBLE to work.
|
|
283
|
+
*
|
|
284
|
+
* 🔴 The `exclude` argument is the whole point (owner report, 2026-08-23: *"the
|
|
285
|
+
* Tasks at once card says '.2'… if it is using paused time to calculate that
|
|
286
|
+
* there were usually 0 tasks at once let's stop letting it do that"*). Averaging
|
|
287
|
+
* a running fleet over a window it was switched off for 90% of does not describe
|
|
288
|
+
* the fleet, it describes the switch — and it reports a busy afternoon as 0.2.
|
|
289
|
+
* Excluded spans are removed from BOTH the numerator and the denominator, so the
|
|
290
|
+
* answer is "while it was running, this many at once".
|
|
291
|
+
*/
|
|
292
|
+
export declare function meanConcurrency(intervals: readonly {
|
|
293
|
+
from: number;
|
|
294
|
+
to: number;
|
|
295
|
+
}[], window: {
|
|
296
|
+
from: number;
|
|
297
|
+
to: number;
|
|
298
|
+
}, exclude?: readonly {
|
|
299
|
+
from: number;
|
|
300
|
+
to: number;
|
|
301
|
+
}[]): number;
|
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "How long until this queue is empty, and does another worker actually help?"
|
|
3
|
+
*
|
|
4
|
+
* ── The question this exists to answer (owner, 2026-08-23) ───────────────────
|
|
5
|
+
* *"For estimates of how long to empty tasks I would also like to be able to see
|
|
6
|
+
* them based on if I added to the fleet size or reduced it… I want to be able to
|
|
7
|
+
* tell whether there is a point where adding to the fleet slows down more than
|
|
8
|
+
* it speeds up. I also want to see the expected token usage so I know if I run
|
|
9
|
+
* out of tokens earlier and how much earlier."*
|
|
10
|
+
*
|
|
11
|
+
* Three separate claims hide in that, and a `remaining / rate` division answers
|
|
12
|
+
* none of them:
|
|
13
|
+
*
|
|
14
|
+
* 1. **Work does not parallelise freely.** Two tasks that write the same files
|
|
15
|
+
* may not run together whatever the fleet size — that is the orchestrator's
|
|
16
|
+
* own scope rule, and it is why a queue of ten tasks all scoped to one app
|
|
17
|
+
* drains at exactly the same speed with eight workers as with one.
|
|
18
|
+
* 2. **Workers are not free of each other.** This machine's own measurements say
|
|
19
|
+
* a verify gate takes 2m09s idle and 857s (6.6x) under seventeen concurrent
|
|
20
|
+
* gates. Past some point another worker makes *everyone* slower, so the
|
|
21
|
+
* makespan curve turns back UP — the "point where adding to the fleet slows
|
|
22
|
+
* down more than it speeds up" is real, has a number, and this module finds
|
|
23
|
+
* it. A scheduler with no contention term can never report one, because pure
|
|
24
|
+
* list scheduling is monotone: more workers is never worse.
|
|
25
|
+
* 3. **Tokens are spent by work, not by time.** Doubling the fleet does not
|
|
26
|
+
* double the tokens a queue costs — it spends the same budget roughly twice
|
|
27
|
+
* as fast, so the wall-clock moment you run dry arrives sooner even though the
|
|
28
|
+
* total is unchanged. Both numbers matter and they move in opposite
|
|
29
|
+
* directions.
|
|
30
|
+
*
|
|
31
|
+
* ── Why it lives in cursedbelt and not in the orchestrator ───────────────────
|
|
32
|
+
* Nothing here knows what a "task" is. It schedules ITEMS with durations,
|
|
33
|
+
* exclusion keys and dependencies over WORKERS, which is the same shape as a
|
|
34
|
+
* deploy train, a batch of renders, a backup rotation or a CI matrix. The
|
|
35
|
+
* orchestrator supplies the meaning (a scope string, a serial group, a tier's
|
|
36
|
+
* measured duration) and reads the answer back; any other dashboard in the fleet
|
|
37
|
+
* can do the same without re-deriving a scheduler.
|
|
38
|
+
*
|
|
39
|
+
* Pure, deterministic, and clock-free: the caller passes `now` when it wants
|
|
40
|
+
* absolute times. That is what lets the whole forecast be asserted in a test
|
|
41
|
+
* rather than eyeballed on a chart.
|
|
42
|
+
*/
|
|
43
|
+
/** No contention at all — the ideal machine. Useful as a comparison line, and as
|
|
44
|
+
* the honest choice when the work is not CPU-bound on one host. */
|
|
45
|
+
export const NO_CONTENTION = { freeLanes: Number.POSITIVE_INFINITY, slowdownPerExtraWorker: 0 };
|
|
46
|
+
export function stretchAt(model, concurrency) {
|
|
47
|
+
if (concurrency <= model.freeLanes)
|
|
48
|
+
return 1;
|
|
49
|
+
const raw = 1 + model.slowdownPerExtraWorker * (concurrency - model.freeLanes);
|
|
50
|
+
return Math.min(raw, model.maxStretch ?? 8);
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Event-driven list scheduling with resource exclusion and a contention term.
|
|
54
|
+
*
|
|
55
|
+
* The loop is: admit everything that can start, advance the clock to whichever
|
|
56
|
+
* running item finishes FIRST **at the current concurrency's speed**, retire it,
|
|
57
|
+
* repeat. Progress is tracked as remaining WORK rather than an end timestamp,
|
|
58
|
+
* which is what makes contention correct rather than decorative: a task that
|
|
59
|
+
* started alone and is joined by three others slows down for the rest of its run
|
|
60
|
+
* and not retroactively.
|
|
61
|
+
*
|
|
62
|
+
* Admission order is priority, then longest-first. Longest-first (LPT) is the
|
|
63
|
+
* classic list-scheduling heuristic and matters here: shortest-first leaves a
|
|
64
|
+
* multi-hour task to start last and stretches the makespan by most of its own
|
|
65
|
+
* length, which would make every large fleet look pointless.
|
|
66
|
+
*/
|
|
67
|
+
export function simulateSchedule(items, options) {
|
|
68
|
+
const workers = Math.max(1, Math.floor(options.workers));
|
|
69
|
+
const contention = options.contention ?? NO_CONTENTION;
|
|
70
|
+
const startAt = options.startAt ?? 0;
|
|
71
|
+
const maxEvents = options.maxEvents ?? 20_000;
|
|
72
|
+
const pending = [...items].sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0) || b.durationMs - a.durationMs);
|
|
73
|
+
const live = [];
|
|
74
|
+
const done = new Set();
|
|
75
|
+
const timeline = [];
|
|
76
|
+
const known = new Set(items.map((i) => i.id));
|
|
77
|
+
// Items already in flight occupy workers from t=0 and block their scopes.
|
|
78
|
+
for (const item of pending.filter((i) => typeof i.runningRemainingMs === 'number')) {
|
|
79
|
+
live.push({ item, remainingMs: Math.max(0, item.runningRemainingMs), startAt: 0, effectiveMs: 0 });
|
|
80
|
+
}
|
|
81
|
+
let queue = pending.filter((i) => typeof i.runningRemainingMs !== 'number');
|
|
82
|
+
let t = 0;
|
|
83
|
+
let peak = live.length;
|
|
84
|
+
// Area under the concurrency curve, for the time-weighted mean.
|
|
85
|
+
let concurrencyArea = 0;
|
|
86
|
+
let events = 0;
|
|
87
|
+
const conflicts = (candidate) => live.some((l) => blocks(candidate, l.item));
|
|
88
|
+
const depsMet = (candidate) => (candidate.dependsOn ?? []).every((id) => done.has(id) || !known.has(id));
|
|
89
|
+
while ((queue.length > 0 || live.length > 0) && events++ < maxEvents) {
|
|
90
|
+
// ── Admit ────────────────────────────────────────────────────────────────
|
|
91
|
+
let admitted = true;
|
|
92
|
+
while (admitted && live.length < workers) {
|
|
93
|
+
admitted = false;
|
|
94
|
+
for (let i = 0; i < queue.length; i++) {
|
|
95
|
+
const candidate = queue[i];
|
|
96
|
+
if (!depsMet(candidate) || conflicts(candidate))
|
|
97
|
+
continue;
|
|
98
|
+
queue.splice(i, 1);
|
|
99
|
+
live.push({
|
|
100
|
+
item: candidate,
|
|
101
|
+
remainingMs: candidate.durationMs,
|
|
102
|
+
startAt: t,
|
|
103
|
+
effectiveMs: 0,
|
|
104
|
+
});
|
|
105
|
+
admitted = true;
|
|
106
|
+
break;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
if (live.length > peak)
|
|
110
|
+
peak = live.length;
|
|
111
|
+
if (live.length === 0)
|
|
112
|
+
break; // everything left is unschedulable
|
|
113
|
+
// ── Advance ──────────────────────────────────────────────────────────────
|
|
114
|
+
// Everyone runs at the same rate, so the first to finish is simply the one
|
|
115
|
+
// with the least work left.
|
|
116
|
+
const stretch = stretchAt(contention, live.length);
|
|
117
|
+
const slice = Math.min(...live.map((l) => l.remainingMs)) * stretch;
|
|
118
|
+
// A zero-duration item would otherwise spin the loop without advancing.
|
|
119
|
+
const step = slice > 0 ? slice : 0;
|
|
120
|
+
const work = step / stretch;
|
|
121
|
+
for (const l of live) {
|
|
122
|
+
l.remainingMs -= work;
|
|
123
|
+
l.effectiveMs += step;
|
|
124
|
+
}
|
|
125
|
+
concurrencyArea += live.length * step;
|
|
126
|
+
t += step;
|
|
127
|
+
// ── Retire ───────────────────────────────────────────────────────────────
|
|
128
|
+
for (let i = live.length - 1; i >= 0; i--) {
|
|
129
|
+
// Floating-point: `remainingMs` lands on ~1e-10 rather than exactly 0.
|
|
130
|
+
if (live[i].remainingMs > 1e-6)
|
|
131
|
+
continue;
|
|
132
|
+
const l = live[i];
|
|
133
|
+
live.splice(i, 1);
|
|
134
|
+
done.add(l.item.id);
|
|
135
|
+
timeline.push({
|
|
136
|
+
id: l.item.id,
|
|
137
|
+
startAt: startAt + l.startAt,
|
|
138
|
+
endAt: startAt + t,
|
|
139
|
+
durationMs: l.item.durationMs,
|
|
140
|
+
effectiveMs: l.effectiveMs,
|
|
141
|
+
waitedMs: l.startAt,
|
|
142
|
+
blockedBy: l.startAt === 0 ? null : whyWaited(l.item, timeline, known),
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
const workMs = items.reduce((s, i) => s + (i.runningRemainingMs ?? i.durationMs), 0);
|
|
147
|
+
const spentMs = timeline.reduce((s, r) => s + r.effectiveMs, 0);
|
|
148
|
+
const makespanMs = t;
|
|
149
|
+
return {
|
|
150
|
+
workers,
|
|
151
|
+
makespanMs,
|
|
152
|
+
workMs,
|
|
153
|
+
spentMs,
|
|
154
|
+
contentionMs: Math.max(0, spentMs - timeline.reduce((s, r) => s + r.durationMs, 0)),
|
|
155
|
+
utilization: makespanMs === 0 ? 0 : clamp01(workMs / (workers * makespanMs)),
|
|
156
|
+
avgConcurrency: makespanMs === 0 ? 0 : round2(concurrencyArea / makespanMs),
|
|
157
|
+
peakConcurrency: peak,
|
|
158
|
+
cost: items.reduce((s, i) => s + (i.cost ?? 0), 0),
|
|
159
|
+
timeline: timeline.sort((a, b) => a.startAt - b.startAt),
|
|
160
|
+
unschedulable: queue.map((i) => i.id),
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
/** May `candidate` NOT run beside `other`? */
|
|
164
|
+
function blocks(candidate, other) {
|
|
165
|
+
if (candidate.id === other.id)
|
|
166
|
+
return true;
|
|
167
|
+
if (candidate.group != null && candidate.group === other.group)
|
|
168
|
+
return true;
|
|
169
|
+
const mine = candidate.exclusive ?? [];
|
|
170
|
+
const theirs = other.exclusive ?? [];
|
|
171
|
+
if (mine.length === 0 || theirs.length === 0)
|
|
172
|
+
return false;
|
|
173
|
+
// `*` is the wildcard the orchestrator uses for fleet-wide work: it conflicts
|
|
174
|
+
// with everything that declares anything.
|
|
175
|
+
if (mine.includes('*') || theirs.includes('*'))
|
|
176
|
+
return true;
|
|
177
|
+
return mine.some((s) => theirs.includes(s));
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* A best-effort reason an item started late — for the table behind the chart.
|
|
181
|
+
*
|
|
182
|
+
* Deliberately approximate and labelled as such: the precise answer would need
|
|
183
|
+
* the admission loop to record every refusal at every event, which is a lot of
|
|
184
|
+
* bookkeeping for a column. "Was it waiting on a dependency, on a conflicting
|
|
185
|
+
* neighbour, or simply for a free worker" is the distinction a reader acts on,
|
|
186
|
+
* and that is decidable from the item alone.
|
|
187
|
+
*/
|
|
188
|
+
function whyWaited(item, _timeline, known) {
|
|
189
|
+
if ((item.dependsOn ?? []).some((id) => known.has(id)))
|
|
190
|
+
return 'dependency';
|
|
191
|
+
if ((item.exclusive?.length ?? 0) > 0 || item.group != null)
|
|
192
|
+
return 'conflict';
|
|
193
|
+
return 'worker';
|
|
194
|
+
}
|
|
195
|
+
export function sweepFleetSizes(items, options = {}) {
|
|
196
|
+
const maxWorkers = Math.max(1, Math.floor(options.maxWorkers ?? 8));
|
|
197
|
+
const tolerance = options.tolerance ?? 0.05;
|
|
198
|
+
const results = [];
|
|
199
|
+
for (let w = 1; w <= maxWorkers; w++) {
|
|
200
|
+
results.push(simulateSchedule(items, { ...options, workers: w }));
|
|
201
|
+
}
|
|
202
|
+
const base = results[0].makespanMs;
|
|
203
|
+
const points = results.map((r, i) => ({
|
|
204
|
+
workers: r.workers,
|
|
205
|
+
makespanMs: Math.round(r.makespanMs),
|
|
206
|
+
speedup: r.makespanMs === 0 ? 1 : round2(base / r.makespanMs),
|
|
207
|
+
efficiency: r.makespanMs === 0 ? 1 : round2(base / r.makespanMs / r.workers),
|
|
208
|
+
marginalMs: Math.round(i === 0 ? 0 : results[i - 1].makespanMs - r.makespanMs),
|
|
209
|
+
utilization: r.utilization,
|
|
210
|
+
avgConcurrency: r.avgConcurrency,
|
|
211
|
+
peakConcurrency: r.peakConcurrency,
|
|
212
|
+
contentionMs: Math.round(r.contentionMs),
|
|
213
|
+
cost: r.cost,
|
|
214
|
+
burnPerHour: r.makespanMs === 0 ? 0 : Math.round((r.cost / r.makespanMs) * 3_600_000),
|
|
215
|
+
unschedulable: r.unschedulable.length,
|
|
216
|
+
}));
|
|
217
|
+
// 🔴 The SMALLEST fleet that is as good as it gets — not the fastest, and not
|
|
218
|
+
// the last one with a positive margin. See `sweetSpot`'s doc for the measured
|
|
219
|
+
// case where the marginal reading recommends four times too many workers.
|
|
220
|
+
const best = Math.min(...points.map((p) => p.makespanMs));
|
|
221
|
+
const sweetSpot = points.find((p) => p.makespanMs <= best * (1 + tolerance))?.workers ?? 1;
|
|
222
|
+
const worse = points.find((p) => p.workers > 1 && p.marginalMs < 0);
|
|
223
|
+
return {
|
|
224
|
+
points,
|
|
225
|
+
sweetSpot,
|
|
226
|
+
turnsNegativeAt: worse ? worse.workers : null,
|
|
227
|
+
parallelismCeiling: Math.max(...results.map((r) => r.peakConcurrency), 1),
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* When a spend budget runs out at each fleet size.
|
|
232
|
+
*
|
|
233
|
+
* The counter-intuitive half, and the reason the owner asked for it: **the total
|
|
234
|
+
* is the same and the deadline is not.** A queue that costs 40M tokens costs 40M
|
|
235
|
+
* whether one agent or five works it; five agents simply reach the wall in a
|
|
236
|
+
* fifth of the time. So `coverage` (do I have enough at all?) is flat across the
|
|
237
|
+
* sweep while `exhaustedInMs` shrinks — and the honest reading is "more workers
|
|
238
|
+
* does not cost more, it costs sooner".
|
|
239
|
+
*/
|
|
240
|
+
export function projectBudget(sweep, budget) {
|
|
241
|
+
return sweep.points.map((p) => {
|
|
242
|
+
const coverage = p.cost === 0 ? 1 : clamp01(budget / p.cost);
|
|
243
|
+
return {
|
|
244
|
+
workers: p.workers,
|
|
245
|
+
exhaustedInMs: p.burnPerHour <= 0 || budget >= p.cost ? null : Math.round((budget / p.burnPerHour) * 3_600_000),
|
|
246
|
+
coverage: round2(coverage),
|
|
247
|
+
shortfall: Math.max(0, p.cost - budget),
|
|
248
|
+
burnPerHour: p.burnPerHour,
|
|
249
|
+
};
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* Fit a {@link ContentionModel} to runs that actually happened.
|
|
254
|
+
*
|
|
255
|
+
* Deliberately a two-group comparison rather than a regression: the sample is a
|
|
256
|
+
* few hundred runs of wildly different work, so a least-squares line through
|
|
257
|
+
* duration-versus-concurrency mostly measures which KIND of task tends to run
|
|
258
|
+
* alone. Comparing the median duration of "ran near-alone" against the median of
|
|
259
|
+
* "ran in a crowd" is coarse, robust, and explainable on a caveat line, which is
|
|
260
|
+
* what a number a fleet-size decision rests on has to be.
|
|
261
|
+
*
|
|
262
|
+
* Returns `null` when either group is too thin to speak — the caller must then
|
|
263
|
+
* say the model is assumed, not measured.
|
|
264
|
+
*/
|
|
265
|
+
export function estimateContention(samples, options = {}) {
|
|
266
|
+
const freeLanes = options.freeLanes ?? 2;
|
|
267
|
+
const minGroup = options.minGroup ?? 5;
|
|
268
|
+
const quiet = samples.filter((s) => s.concurrency <= freeLanes).map((s) => s.durationMs);
|
|
269
|
+
const busy = samples.filter((s) => s.concurrency > freeLanes);
|
|
270
|
+
if (quiet.length < minGroup || busy.length < minGroup)
|
|
271
|
+
return null;
|
|
272
|
+
const quietMedian = medianOf(quiet);
|
|
273
|
+
const busyMedian = medianOf(busy.map((s) => s.durationMs));
|
|
274
|
+
if (quietMedian <= 0)
|
|
275
|
+
return null;
|
|
276
|
+
const excess = medianOf(busy.map((s) => s.concurrency)) - freeLanes;
|
|
277
|
+
if (excess <= 0)
|
|
278
|
+
return null;
|
|
279
|
+
const stretch = busyMedian / quietMedian;
|
|
280
|
+
// A stretch below 1 means the crowded runs were FASTER — real, and it means
|
|
281
|
+
// the sample is dominated by task mix rather than by load. Report no
|
|
282
|
+
// measurable contention rather than a negative slowdown, which would make the
|
|
283
|
+
// simulator recommend an unbounded fleet.
|
|
284
|
+
const slope = Math.max(0, (stretch - 1) / excess);
|
|
285
|
+
return { freeLanes, slowdownPerExtraWorker: round2(slope), maxStretch: 8 };
|
|
286
|
+
}
|
|
287
|
+
/**
|
|
288
|
+
* Turn a set of intervals into the step function of how many were live.
|
|
289
|
+
*
|
|
290
|
+
* The exact series behind an "average tasks at once" number, so a reader can see
|
|
291
|
+
* the overlaps that produced it instead of taking the mean on faith. Ends before
|
|
292
|
+
* starts at a tie: two runs where one finishes as the next begins were never
|
|
293
|
+
* simultaneous, and counting them as two reports a concurrency the fleet's own
|
|
294
|
+
* limit forbids.
|
|
295
|
+
*/
|
|
296
|
+
export function concurrencyProfile(intervals) {
|
|
297
|
+
const points = [];
|
|
298
|
+
for (const i of intervals) {
|
|
299
|
+
if (i.to <= i.from)
|
|
300
|
+
continue;
|
|
301
|
+
points.push({ at: i.from, d: 1 }, { at: i.to, d: -1 });
|
|
302
|
+
}
|
|
303
|
+
points.sort((a, b) => a.at - b.at || a.d - b.d);
|
|
304
|
+
const out = [];
|
|
305
|
+
let live = 0;
|
|
306
|
+
for (const p of points) {
|
|
307
|
+
live += p.d;
|
|
308
|
+
const last = out[out.length - 1];
|
|
309
|
+
if (last && last.at === p.at)
|
|
310
|
+
last.count = live;
|
|
311
|
+
else
|
|
312
|
+
out.push({ at: p.at, count: live });
|
|
313
|
+
}
|
|
314
|
+
return out;
|
|
315
|
+
}
|
|
316
|
+
/**
|
|
317
|
+
* Time-weighted mean concurrency across `[from, to)`, optionally counting only
|
|
318
|
+
* the parts of the window the fleet was actually ELIGIBLE to work.
|
|
319
|
+
*
|
|
320
|
+
* 🔴 The `exclude` argument is the whole point (owner report, 2026-08-23: *"the
|
|
321
|
+
* Tasks at once card says '.2'… if it is using paused time to calculate that
|
|
322
|
+
* there were usually 0 tasks at once let's stop letting it do that"*). Averaging
|
|
323
|
+
* a running fleet over a window it was switched off for 90% of does not describe
|
|
324
|
+
* the fleet, it describes the switch — and it reports a busy afternoon as 0.2.
|
|
325
|
+
* Excluded spans are removed from BOTH the numerator and the denominator, so the
|
|
326
|
+
* answer is "while it was running, this many at once".
|
|
327
|
+
*/
|
|
328
|
+
export function meanConcurrency(intervals, window, exclude = []) {
|
|
329
|
+
const width = liveWidth(window, exclude);
|
|
330
|
+
if (width <= 0)
|
|
331
|
+
return 0;
|
|
332
|
+
let area = 0;
|
|
333
|
+
for (const i of intervals) {
|
|
334
|
+
const from = Math.max(i.from, window.from);
|
|
335
|
+
const to = Math.min(i.to, window.to);
|
|
336
|
+
if (to <= from)
|
|
337
|
+
continue;
|
|
338
|
+
area += liveWidth({ from, to }, exclude);
|
|
339
|
+
}
|
|
340
|
+
return round2(area / width);
|
|
341
|
+
}
|
|
342
|
+
/** Width of `window` minus everything `exclude` covers. Overlapping excludes are
|
|
343
|
+
* merged first, or a nested pause subtracts twice and the mean explodes. */
|
|
344
|
+
function liveWidth(window, exclude) {
|
|
345
|
+
const total = Math.max(0, window.to - window.from);
|
|
346
|
+
if (total === 0 || exclude.length === 0)
|
|
347
|
+
return total;
|
|
348
|
+
const clipped = exclude
|
|
349
|
+
.map((e) => ({ from: Math.max(e.from, window.from), to: Math.min(e.to, window.to) }))
|
|
350
|
+
.filter((e) => e.to > e.from)
|
|
351
|
+
.sort((a, b) => a.from - b.from);
|
|
352
|
+
let covered = 0;
|
|
353
|
+
let cursor = -Infinity;
|
|
354
|
+
for (const e of clipped) {
|
|
355
|
+
const from = Math.max(e.from, cursor);
|
|
356
|
+
if (e.to > from) {
|
|
357
|
+
covered += e.to - from;
|
|
358
|
+
cursor = e.to;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
return Math.max(0, total - covered);
|
|
362
|
+
}
|
|
363
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
364
|
+
const clamp01 = (n) => Math.max(0, Math.min(1, n));
|
|
365
|
+
const round2 = (n) => Math.round(n * 100) / 100;
|
|
366
|
+
function medianOf(values) {
|
|
367
|
+
if (values.length === 0)
|
|
368
|
+
return 0;
|
|
369
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
370
|
+
const mid = Math.floor(sorted.length / 2);
|
|
371
|
+
return sorted.length % 2 === 0 ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[mid];
|
|
372
|
+
}
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
export type { Category, CategoryCreateInput, CategoryNode, CategoryStore, } from './types';
|
|
2
|
+
export { buildCategoryForest, categoryPath, descendantCategoryIds, flattenCategoryForest, wouldCreateCycle, } from './tree';
|
|
3
|
+
export { createMemoryCategoryStore } from './memoryStore';
|