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.
Files changed (307) hide show
  1. package/LICENSE +15 -0
  2. package/dist/client/wsWire.d.ts +49 -0
  3. package/dist/client/wsWire.js +57 -0
  4. package/dist/config/appConfig.d.ts +361 -0
  5. package/dist/config/appConfig.js +308 -0
  6. package/dist/core/activity/index.d.ts +8 -0
  7. package/dist/core/activity/index.js +7 -0
  8. package/dist/core/activity/model.d.ts +115 -0
  9. package/dist/core/activity/model.js +63 -0
  10. package/dist/core/analytics/index.d.ts +18 -0
  11. package/dist/core/analytics/index.js +18 -0
  12. package/dist/core/analytics/range.d.ts +186 -0
  13. package/dist/core/analytics/range.js +271 -0
  14. package/dist/core/analytics/schedule.d.ts +301 -0
  15. package/dist/core/analytics/schedule.js +372 -0
  16. package/dist/core/category/index.d.ts +3 -0
  17. package/dist/core/category/index.js +6 -0
  18. package/dist/core/category/memoryStore.d.ts +21 -0
  19. package/dist/core/category/memoryStore.js +97 -0
  20. package/dist/core/category/tree.d.ts +26 -0
  21. package/dist/core/category/tree.js +113 -0
  22. package/dist/core/category/types.d.ts +48 -0
  23. package/dist/core/category/types.js +8 -0
  24. package/dist/core/color.d.ts +14 -0
  25. package/dist/core/color.js +32 -0
  26. package/dist/core/colorScheme/colorSchemeStore.d.ts +70 -0
  27. package/dist/core/colorScheme/colorSchemeStore.js +123 -0
  28. package/dist/core/colorScheme/index.d.ts +7 -0
  29. package/dist/core/colorScheme/index.js +7 -0
  30. package/dist/core/domainOwners.d.ts +19 -0
  31. package/dist/core/domainOwners.js +15 -0
  32. package/dist/core/download.d.ts +14 -0
  33. package/dist/core/download.js +37 -0
  34. package/dist/core/events/ccEventBus.d.ts +44 -0
  35. package/dist/core/events/ccEventBus.js +70 -0
  36. package/dist/core/file-tree/fileTreeModel.d.ts +276 -0
  37. package/dist/core/file-tree/fileTreeModel.js +312 -0
  38. package/dist/core/folder-tree/actions.d.ts +84 -0
  39. package/dist/core/folder-tree/actions.js +119 -0
  40. package/dist/core/folder-tree/index.d.ts +3 -0
  41. package/dist/core/folder-tree/index.js +20 -0
  42. package/dist/core/folder-tree/path.d.ts +76 -0
  43. package/dist/core/folder-tree/path.js +146 -0
  44. package/dist/core/folder-tree/tree.d.ts +86 -0
  45. package/dist/core/folder-tree/tree.js +184 -0
  46. package/dist/core/layout/index.d.ts +1 -0
  47. package/dist/core/layout/index.js +7 -0
  48. package/dist/core/master-lock/index.d.ts +14 -0
  49. package/dist/core/master-lock/index.js +14 -0
  50. package/dist/core/master-lock/kdf.d.ts +63 -0
  51. package/dist/core/master-lock/kdf.js +117 -0
  52. package/dist/core/master-lock/policy.d.ts +32 -0
  53. package/dist/core/master-lock/policy.js +52 -0
  54. package/dist/core/master-lock/presence.d.ts +52 -0
  55. package/dist/core/master-lock/presence.js +61 -0
  56. package/dist/core/master-lock/wire.d.ts +139 -0
  57. package/dist/core/master-lock/wire.js +81 -0
  58. package/dist/core/media/bgRemoval.d.ts +27 -0
  59. package/dist/core/media/bgRemoval.js +101 -0
  60. package/dist/core/media/ffmpeg.d.ts +57 -0
  61. package/dist/core/media/ffmpeg.js +126 -0
  62. package/dist/core/media/format.d.ts +20 -0
  63. package/dist/core/media/format.js +54 -0
  64. package/dist/core/media/index.d.ts +12 -0
  65. package/dist/core/media/index.js +18 -0
  66. package/dist/core/media/mediaItem.d.ts +15 -0
  67. package/dist/core/media/mediaItem.js +34 -0
  68. package/dist/core/media/renderPlan.d.ts +63 -0
  69. package/dist/core/media/renderPlan.js +232 -0
  70. package/dist/core/media/segment.d.ts +79 -0
  71. package/dist/core/media/segment.js +247 -0
  72. package/dist/core/media/size.d.ts +1 -0
  73. package/dist/core/media/size.js +3 -0
  74. package/dist/core/media/trim.d.ts +21 -0
  75. package/dist/core/media/trim.js +40 -0
  76. package/dist/core/media/types.d.ts +148 -0
  77. package/dist/core/media/types.js +11 -0
  78. package/dist/core/media/uploadLimits.d.ts +7 -0
  79. package/dist/core/media/uploadLimits.js +16 -0
  80. package/dist/core/media/validate.d.ts +9 -0
  81. package/dist/core/media/validate.js +121 -0
  82. package/dist/core/media/videoCuts.d.ts +152 -0
  83. package/dist/core/media/videoCuts.js +284 -0
  84. package/dist/core/navigation/breadcrumbCompat.d.ts +24 -0
  85. package/dist/core/navigation/breadcrumbCompat.js +21 -0
  86. package/dist/core/navigation/coverageTest.d.ts +24 -0
  87. package/dist/core/navigation/coverageTest.js +48 -0
  88. package/dist/core/navigation/index.d.ts +6 -0
  89. package/dist/core/navigation/index.js +10 -0
  90. package/dist/core/navigation/migrateNavPreferences.d.ts +55 -0
  91. package/dist/core/navigation/migrateNavPreferences.js +63 -0
  92. package/dist/core/navigation/navigationModel.d.ts +165 -0
  93. package/dist/core/navigation/navigationModel.js +14 -0
  94. package/dist/core/navigation/navigationSelectors.d.ts +63 -0
  95. package/dist/core/navigation/navigationSelectors.js +167 -0
  96. package/dist/core/navigation/searchCatalogue.d.ts +23 -0
  97. package/dist/core/navigation/searchCatalogue.js +76 -0
  98. package/dist/core/net/reconnectSchedule.d.ts +46 -0
  99. package/dist/core/net/reconnectSchedule.js +38 -0
  100. package/dist/core/palette/ShortcutRegistry.d.ts +33 -0
  101. package/dist/core/palette/ShortcutRegistry.js +52 -0
  102. package/dist/core/palette/fuzzyRank.d.ts +18 -0
  103. package/dist/core/palette/fuzzyRank.js +73 -0
  104. package/dist/core/palette/hotkeys.d.ts +28 -0
  105. package/dist/core/palette/hotkeys.js +118 -0
  106. package/dist/core/palette/index.d.ts +3 -0
  107. package/dist/core/palette/index.js +8 -0
  108. package/dist/core/schema/projection.d.ts +84 -0
  109. package/dist/core/schema/projection.js +184 -0
  110. package/dist/core/session/claims.d.ts +145 -0
  111. package/dist/core/session/claims.js +157 -0
  112. package/dist/core/sharing/index.d.ts +5 -0
  113. package/dist/core/sharing/index.js +4 -0
  114. package/dist/core/sharing/model.d.ts +195 -0
  115. package/dist/core/sharing/model.js +150 -0
  116. package/dist/core/site/siteCopy.d.ts +92 -0
  117. package/dist/core/site/siteCopy.js +91 -0
  118. package/dist/core/slots.d.ts +22 -0
  119. package/dist/core/slots.js +31 -0
  120. package/dist/core/styles/constructableSheet.d.ts +51 -0
  121. package/dist/core/styles/constructableSheet.js +80 -0
  122. package/dist/core/tokens/registry.d.ts +119 -0
  123. package/dist/core/tokens/registry.js +447 -0
  124. package/dist/core/uiScale/index.d.ts +8 -0
  125. package/dist/core/uiScale/index.js +8 -0
  126. package/dist/core/uiScale/scale.d.ts +36 -0
  127. package/dist/core/uiScale/scale.js +47 -0
  128. package/dist/core/uiScale/uiScaleStore.d.ts +47 -0
  129. package/dist/core/uiScale/uiScaleStore.js +63 -0
  130. package/dist/core/wire/__fixtures__/rowsMarshalWorker.d.ts +1 -0
  131. package/dist/core/wire/__fixtures__/rowsMarshalWorker.js +23 -0
  132. package/dist/core/wire/index.d.ts +17 -0
  133. package/dist/core/wire/index.js +17 -0
  134. package/dist/core/wire/ingress.d.ts +44 -0
  135. package/dist/core/wire/ingress.js +134 -0
  136. package/dist/core/wire/marshalWorker.d.ts +70 -0
  137. package/dist/core/wire/marshalWorker.js +273 -0
  138. package/dist/core/wire/marshalling.d.ts +122 -0
  139. package/dist/core/wire/marshalling.js +126 -0
  140. package/dist/core/wire/wireHub.d.ts +80 -0
  141. package/dist/core/wire/wireHub.js +116 -0
  142. package/dist/ctgr/base64url.d.ts +8 -0
  143. package/dist/ctgr/base64url.js +39 -0
  144. package/dist/ctgr/chunkAccumulator.d.ts +214 -0
  145. package/dist/ctgr/chunkAccumulator.js +307 -0
  146. package/dist/ctgr/chunkStore.d.ts +24 -0
  147. package/dist/ctgr/chunkStore.js +125 -0
  148. package/dist/ctgr/codec.d.ts +44 -0
  149. package/dist/ctgr/codec.js +153 -0
  150. package/dist/ctgr/compress.d.ts +2 -0
  151. package/dist/ctgr/compress.js +20 -0
  152. package/dist/ctgr/crc32.d.ts +2 -0
  153. package/dist/ctgr/crc32.js +23 -0
  154. package/dist/ctgr/digest.d.ts +1 -0
  155. package/dist/ctgr/digest.js +15 -0
  156. package/dist/ctgr/index.d.ts +9 -0
  157. package/dist/ctgr/index.js +17 -0
  158. package/dist/ctgr/types.d.ts +64 -0
  159. package/dist/ctgr/types.js +21 -0
  160. package/dist/ctgr/v0compat.d.ts +27 -0
  161. package/dist/ctgr/v0compat.js +128 -0
  162. package/dist/index.d.ts +15 -0
  163. package/dist/index.js +17 -0
  164. package/dist/shared/errors.d.ts +101 -0
  165. package/dist/shared/errors.js +99 -0
  166. package/dist/testing/bytes.d.ts +32 -0
  167. package/dist/testing/bytes.js +33 -0
  168. package/dist/testing/domInspect.d.ts +59 -0
  169. package/dist/testing/domInspect.js +115 -0
  170. package/dist/testing/fakeFetch.d.ts +23 -0
  171. package/dist/testing/fakeFetch.js +21 -0
  172. package/dist/testing/index.d.ts +3 -0
  173. package/dist/testing/index.js +11 -0
  174. package/package.json +373 -0
  175. package/src/client/wsWire.ts +83 -0
  176. package/src/config/appConfig.spec.ts +158 -0
  177. package/src/config/appConfig.ts +372 -0
  178. package/src/core/activity/index.ts +21 -0
  179. package/src/core/activity/model.ts +160 -0
  180. package/src/core/analytics/index.ts +18 -0
  181. package/src/core/analytics/range.spec.ts +280 -0
  182. package/src/core/analytics/range.ts +383 -0
  183. package/src/core/analytics/schedule.spec.ts +311 -0
  184. package/src/core/analytics/schedule.ts +624 -0
  185. package/src/core/category/category.spec.ts +144 -0
  186. package/src/core/category/index.ts +19 -0
  187. package/src/core/category/memoryStore.ts +104 -0
  188. package/src/core/category/tree.ts +118 -0
  189. package/src/core/category/types.ts +59 -0
  190. package/src/core/color.spec.ts +35 -0
  191. package/src/core/color.ts +35 -0
  192. package/src/core/colorScheme/colorScheme.spec.ts +127 -0
  193. package/src/core/colorScheme/colorSchemeStore.ts +184 -0
  194. package/src/core/colorScheme/index.ts +23 -0
  195. package/src/core/domainOwners.ts +32 -0
  196. package/src/core/download.spec.ts +38 -0
  197. package/src/core/download.ts +35 -0
  198. package/src/core/events/ccEventBus.spec.ts +90 -0
  199. package/src/core/events/ccEventBus.ts +120 -0
  200. package/src/core/file-tree/fileTreeModel.spec.ts +462 -0
  201. package/src/core/file-tree/fileTreeModel.ts +511 -0
  202. package/src/core/folder-tree/actions.spec.ts +103 -0
  203. package/src/core/folder-tree/actions.ts +153 -0
  204. package/src/core/folder-tree/index.ts +46 -0
  205. package/src/core/folder-tree/path.spec.ts +113 -0
  206. package/src/core/folder-tree/path.ts +148 -0
  207. package/src/core/folder-tree/tree.spec.ts +157 -0
  208. package/src/core/folder-tree/tree.ts +244 -0
  209. package/src/core/layout/index.ts +7 -0
  210. package/src/core/master-lock/index.ts +50 -0
  211. package/src/core/master-lock/kdf.spec.ts +95 -0
  212. package/src/core/master-lock/kdf.ts +145 -0
  213. package/src/core/master-lock/policy.ts +55 -0
  214. package/src/core/master-lock/presence.spec.ts +73 -0
  215. package/src/core/master-lock/presence.ts +71 -0
  216. package/src/core/master-lock/wire.ts +155 -0
  217. package/src/core/media/bgRemoval.spec.ts +120 -0
  218. package/src/core/media/bgRemoval.ts +123 -0
  219. package/src/core/media/ffmpeg.spec.ts +135 -0
  220. package/src/core/media/ffmpeg.ts +162 -0
  221. package/src/core/media/format.spec.ts +41 -0
  222. package/src/core/media/format.ts +58 -0
  223. package/src/core/media/index.ts +105 -0
  224. package/src/core/media/mediaItem.spec.ts +46 -0
  225. package/src/core/media/mediaItem.ts +40 -0
  226. package/src/core/media/renderPlan.spec.ts +140 -0
  227. package/src/core/media/renderPlan.ts +318 -0
  228. package/src/core/media/segment.spec.ts +159 -0
  229. package/src/core/media/segment.ts +352 -0
  230. package/src/core/media/size.spec.ts +19 -0
  231. package/src/core/media/size.ts +3 -0
  232. package/src/core/media/trim.spec.ts +75 -0
  233. package/src/core/media/trim.ts +60 -0
  234. package/src/core/media/types.ts +147 -0
  235. package/src/core/media/uploadLimits.ts +18 -0
  236. package/src/core/media/validate.spec.ts +106 -0
  237. package/src/core/media/validate.ts +131 -0
  238. package/src/core/media/videoCuts.spec.ts +327 -0
  239. package/src/core/media/videoCuts.ts +355 -0
  240. package/src/core/navigation/breadcrumbCompat.ts +44 -0
  241. package/src/core/navigation/coverageTest.ts +54 -0
  242. package/src/core/navigation/index.ts +50 -0
  243. package/src/core/navigation/migrateNavPreferences.ts +119 -0
  244. package/src/core/navigation/navigation.spec.ts +388 -0
  245. package/src/core/navigation/navigationModel.ts +185 -0
  246. package/src/core/navigation/navigationSelectors.ts +206 -0
  247. package/src/core/navigation/searchCatalogue.ts +89 -0
  248. package/src/core/net/reconnectSchedule.spec.ts +49 -0
  249. package/src/core/net/reconnectSchedule.ts +78 -0
  250. package/src/core/palette/ShortcutRegistry.ts +69 -0
  251. package/src/core/palette/fuzzyRank.ts +78 -0
  252. package/src/core/palette/hotkeys.ts +132 -0
  253. package/src/core/palette/index.ts +22 -0
  254. package/src/core/palette/palette.spec.ts +160 -0
  255. package/src/core/schema/projection.spec.ts +122 -0
  256. package/src/core/schema/projection.ts +225 -0
  257. package/src/core/session/claims.ts +181 -0
  258. package/src/core/sharing/index.ts +33 -0
  259. package/src/core/sharing/model.spec.ts +104 -0
  260. package/src/core/sharing/model.ts +296 -0
  261. package/src/core/site/siteCopy.ts +115 -0
  262. package/src/core/slots.ts +38 -0
  263. package/src/core/styles/constructableSheet.ts +86 -0
  264. package/src/core/tokens/registry.spec.ts +160 -0
  265. package/src/core/tokens/registry.ts +536 -0
  266. package/src/core/uiScale/index.ts +32 -0
  267. package/src/core/uiScale/scale.ts +63 -0
  268. package/src/core/uiScale/uiScale.spec.ts +77 -0
  269. package/src/core/uiScale/uiScaleStore.ts +100 -0
  270. package/src/core/wire/__fixtures__/rowsMarshalWorker.ts +24 -0
  271. package/src/core/wire/index.ts +56 -0
  272. package/src/core/wire/ingress.spec.ts +276 -0
  273. package/src/core/wire/ingress.ts +182 -0
  274. package/src/core/wire/marshalWorker.spec.ts +196 -0
  275. package/src/core/wire/marshalWorker.ts +361 -0
  276. package/src/core/wire/marshalling.spec.ts +139 -0
  277. package/src/core/wire/marshalling.ts +238 -0
  278. package/src/core/wire/wireHub.spec.ts +137 -0
  279. package/src/core/wire/wireHub.ts +213 -0
  280. package/src/ctgr/base64url.ts +44 -0
  281. package/src/ctgr/chunkAccumulator.ts +403 -0
  282. package/src/ctgr/chunkStore.spec.ts +101 -0
  283. package/src/ctgr/chunkStore.ts +168 -0
  284. package/src/ctgr/codec.spec.ts +208 -0
  285. package/src/ctgr/codec.ts +202 -0
  286. package/src/ctgr/compress.ts +21 -0
  287. package/src/ctgr/crc32.ts +23 -0
  288. package/src/ctgr/digest.ts +14 -0
  289. package/src/ctgr/index.ts +39 -0
  290. package/src/ctgr/types.ts +80 -0
  291. package/src/ctgr/v0compat.spec.ts +73 -0
  292. package/src/ctgr/v0compat.ts +156 -0
  293. package/src/declaredDepsAreImported.spec.ts +87 -0
  294. package/src/index.ts +25 -0
  295. package/src/leafSubpathsImportNothing.spec.ts +287 -0
  296. package/src/lockfileIsTracked.spec.ts +50 -0
  297. package/src/namedSubpathsResolve.spec.ts +122 -0
  298. package/src/noPathDeps.spec.ts +24 -0
  299. package/src/publishShape.spec.ts +89 -0
  300. package/src/shared/errors.ts +153 -0
  301. package/src/skippedTestsAreAnswered.spec.ts +221 -0
  302. package/src/testTmpRoot.spec.ts +102 -0
  303. package/src/testing/bytes.ts +38 -0
  304. package/src/testing/domInspect.spec.ts +79 -0
  305. package/src/testing/domInspect.ts +127 -0
  306. package/src/testing/fakeFetch.ts +26 -0
  307. 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';