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,311 @@
1
+ import { describe, expect, it } from 'bun:test';
2
+ import {
3
+ concurrencyProfile,
4
+ type ContentionModel,
5
+ estimateContention,
6
+ meanConcurrency,
7
+ NO_CONTENTION,
8
+ projectBudget,
9
+ type ScheduleItem,
10
+ simulateSchedule,
11
+ stretchAt,
12
+ sweepFleetSizes,
13
+ } from './schedule';
14
+
15
+ const MIN = 60_000;
16
+ const item = (id: string, minutes: number, rest: Partial<ScheduleItem> = {}): ScheduleItem => ({
17
+ id,
18
+ durationMs: minutes * MIN,
19
+ ...rest,
20
+ });
21
+
22
+ describe('simulateSchedule', () => {
23
+ it('runs independent work in parallel up to the worker count', () => {
24
+ const items = [item('a', 10), item('b', 10), item('c', 10), item('d', 10)];
25
+ expect(simulateSchedule(items, { workers: 1 }).makespanMs).toBe(40 * MIN);
26
+ expect(simulateSchedule(items, { workers: 2 }).makespanMs).toBe(20 * MIN);
27
+ expect(simulateSchedule(items, { workers: 4 }).makespanMs).toBe(10 * MIN);
28
+ // Nothing left to parallelise — the fifth worker is inert.
29
+ expect(simulateSchedule(items, { workers: 5 }).makespanMs).toBe(10 * MIN);
30
+ });
31
+
32
+ it('refuses to co-schedule items that write the same resource, at any fleet size', () => {
33
+ const items = [
34
+ item('a', 10, { exclusive: ['repo:x'] }),
35
+ item('b', 10, { exclusive: ['repo:x'] }),
36
+ item('c', 10, { exclusive: ['repo:x'] }),
37
+ ];
38
+ // The whole point: eight workers do not beat one when the work is serial.
39
+ expect(simulateSchedule(items, { workers: 8 }).makespanMs).toBe(30 * MIN);
40
+ expect(simulateSchedule(items, { workers: 8 }).peakConcurrency).toBe(1);
41
+ });
42
+
43
+ it('treats `*` as conflicting with everything that declares a scope', () => {
44
+ const items = [item('fleet', 10, { exclusive: ['*'] }), item('app', 10, { exclusive: ['repo:x'] })];
45
+ expect(simulateSchedule(items, { workers: 4 }).makespanMs).toBe(20 * MIN);
46
+ });
47
+
48
+ it('lets undeclared items run beside anything — a missing scope is not a lock', () => {
49
+ const items = [item('scoped', 10, { exclusive: ['repo:x'] }), item('bare', 10)];
50
+ expect(simulateSchedule(items, { workers: 2 }).makespanMs).toBe(10 * MIN);
51
+ });
52
+
53
+ it('serialises a named group without needing a shared resource', () => {
54
+ const items = [item('a', 5, { group: 'gpu' }), item('b', 5, { group: 'gpu' })];
55
+ expect(simulateSchedule(items, { workers: 4 }).makespanMs).toBe(10 * MIN);
56
+ });
57
+
58
+ it('honours dependencies', () => {
59
+ const items = [item('second', 10, { dependsOn: ['first'] }), item('first', 10)];
60
+ const r = simulateSchedule(items, { workers: 4 });
61
+ expect(r.makespanMs).toBe(20 * MIN);
62
+ const byId = new Map(r.timeline.map((t) => [t.id, t]));
63
+ expect(byId.get('second')?.startAt).toBe(10 * MIN);
64
+ expect(byId.get('second')?.blockedBy).toBe('dependency');
65
+ });
66
+
67
+ it('reports a dependency cycle as unschedulable instead of spinning', () => {
68
+ const items = [item('a', 1, { dependsOn: ['b'] }), item('b', 1, { dependsOn: ['a'] })];
69
+ const r = simulateSchedule(items, { workers: 2 });
70
+ expect(r.unschedulable.sort()).toEqual(['a', 'b']);
71
+ expect(r.makespanMs).toBe(0);
72
+ });
73
+
74
+ it('counts work already in flight against the fleet', () => {
75
+ const items = [
76
+ item('running', 0, { runningRemainingMs: 10 * MIN }),
77
+ item('queued', 10),
78
+ ];
79
+ // One worker: the in-flight item holds it, so the queued one waits.
80
+ expect(simulateSchedule(items, { workers: 1 }).makespanMs).toBe(20 * MIN);
81
+ expect(simulateSchedule(items, { workers: 2 }).makespanMs).toBe(10 * MIN);
82
+ });
83
+
84
+ it('schedules longest-first so a big task does not tail the run', () => {
85
+ const items = [item('small1', 1), item('small2', 1), item('big', 10)];
86
+ // Shortest-first would finish at 12m (big starts at 2m). LPT starts it at 0.
87
+ expect(simulateSchedule(items, { workers: 2 }).makespanMs).toBe(10 * MIN);
88
+ });
89
+
90
+ it('stamps absolute times when given a start', () => {
91
+ const r = simulateSchedule([item('a', 5)], { workers: 1, startAt: 1_000_000 });
92
+ expect(r.timeline[0].startAt).toBe(1_000_000);
93
+ expect(r.timeline[0].endAt).toBe(1_000_000 + 5 * MIN);
94
+ });
95
+ });
96
+
97
+ describe('contention', () => {
98
+ const busy: ContentionModel = { freeLanes: 2, slowdownPerExtraWorker: 0.5, maxStretch: 8 };
99
+
100
+ it('leaves work at nominal speed inside the free lanes', () => {
101
+ expect(stretchAt(busy, 1)).toBe(1);
102
+ expect(stretchAt(busy, 2)).toBe(1);
103
+ expect(stretchAt(busy, 3)).toBe(1.5);
104
+ expect(stretchAt(busy, 4)).toBe(2);
105
+ });
106
+
107
+ it('caps the multiplier so an absurd fleet does not produce an infinite ETA', () => {
108
+ expect(stretchAt(busy, 100)).toBe(8);
109
+ });
110
+
111
+ it('makes a big fleet SLOWER than a small one — the owner’s actual question', () => {
112
+ const items = Array.from({ length: 8 }, (_, i) => item(`t${i}`, 10));
113
+ const steep: ContentionModel = { freeLanes: 2, slowdownPerExtraWorker: 0.6 };
114
+ const two = simulateSchedule(items, { workers: 2, contention: steep }).makespanMs;
115
+ const eight = simulateSchedule(items, { workers: 8, contention: steep }).makespanMs;
116
+ // Throughput is `workers / stretch(workers)`: 2/1 = 2 against 8/4.6 = 1.74.
117
+ // Without a contention term this comparison cannot come out this way at all.
118
+ expect(eight).toBeGreaterThan(two);
119
+ });
120
+
121
+ it('breaks even exactly where the slowdown cancels the extra worker', () => {
122
+ const items = Array.from({ length: 8 }, (_, i) => item(`t${i}`, 10));
123
+ // stretch(8) = 1 + 0.5*6 = 4, so 8/4 === 2/1: the same throughput, and the
124
+ // same makespan. The boundary case is worth pinning — it is what tells the
125
+ // reader the model is a real trade-off and not a fudge factor.
126
+ expect(simulateSchedule(items, { workers: 8, contention: busy }).makespanMs).toBe(
127
+ simulateSchedule(items, { workers: 2, contention: busy }).makespanMs,
128
+ );
129
+ });
130
+
131
+ it('charges the extra time to contention, not to the work', () => {
132
+ const items = [item('a', 10), item('b', 10), item('c', 10), item('d', 10)];
133
+ const r = simulateSchedule(items, { workers: 4, contention: busy });
134
+ expect(r.workMs).toBe(40 * MIN);
135
+ expect(r.spentMs).toBeGreaterThan(r.workMs);
136
+ expect(r.contentionMs).toBeGreaterThan(0);
137
+ });
138
+
139
+ it('is a no-op under NO_CONTENTION', () => {
140
+ const items = [item('a', 10), item('b', 10)];
141
+ const r = simulateSchedule(items, { workers: 2, contention: NO_CONTENTION });
142
+ expect(r.contentionMs).toBe(0);
143
+ expect(r.makespanMs).toBe(10 * MIN);
144
+ });
145
+ });
146
+
147
+ describe('sweepFleetSizes', () => {
148
+ it('finds the ceiling the WORK imposes, regardless of fleet size', () => {
149
+ const items = [
150
+ item('a', 10, { exclusive: ['repo:x'] }),
151
+ item('b', 10, { exclusive: ['repo:x'] }),
152
+ item('c', 10, { exclusive: ['repo:x'] }),
153
+ ];
154
+ const sweep = sweepFleetSizes(items, { maxWorkers: 6 });
155
+ expect(sweep.parallelismCeiling).toBe(1);
156
+ expect(sweep.sweetSpot).toBe(1);
157
+ expect(sweep.points.every((p) => p.makespanMs === 30 * MIN)).toBe(true);
158
+ });
159
+
160
+ it('recommends the last worker that earned its place, not the fastest', () => {
161
+ // Six 10m tasks: 1w=60m, 2w=30m, 3w=20m, 4w=20m, 5w=20m, 6w=10m.
162
+ const items = Array.from({ length: 6 }, (_, i) => item(`t${i}`, 10));
163
+ const sweep = sweepFleetSizes(items, { maxWorkers: 6 });
164
+ // 4 and 5 buy nothing at all; 6 buys another 10m, which is >5% of 60m.
165
+ expect(sweep.points.find((p) => p.workers === 4)?.marginalMs).toBe(0);
166
+ expect(sweep.sweetSpot).toBe(6);
167
+ expect(sweep.turnsNegativeAt).toBeNull();
168
+ });
169
+
170
+ it('names the fleet size where another worker starts costing wall clock', () => {
171
+ const items = Array.from({ length: 8 }, (_, i) => item(`t${i}`, 10));
172
+ const sweep = sweepFleetSizes(items, {
173
+ maxWorkers: 8,
174
+ contention: { freeLanes: 2, slowdownPerExtraWorker: 0.6 },
175
+ });
176
+ // 1w 80m · 2w 40m · 3w 42m — the third agent is where it turns.
177
+ expect(sweep.turnsNegativeAt).toBe(3);
178
+ expect(sweep.sweetSpot).toBe(2);
179
+ });
180
+
181
+ it('does not recommend a big fleet that merely beats a slightly worse big fleet', () => {
182
+ // The measured trap: at a 0.6 slowdown, 7 workers take 50m and 8 take 46m,
183
+ // so 8 shows a healthy +4m MARGIN while being 6m worse than just running 2.
184
+ const items = Array.from({ length: 8 }, (_, i) => item(`t${i}`, 10));
185
+ const sweep = sweepFleetSizes(items, {
186
+ maxWorkers: 8,
187
+ contention: { freeLanes: 2, slowdownPerExtraWorker: 0.6 },
188
+ });
189
+ expect(sweep.points.find((p) => p.workers === 8)?.marginalMs).toBeGreaterThan(0);
190
+ expect(sweep.sweetSpot).toBe(2);
191
+ });
192
+
193
+ it('reports speedup and efficiency against one worker', () => {
194
+ const items = Array.from({ length: 4 }, (_, i) => item(`t${i}`, 10));
195
+ const sweep = sweepFleetSizes(items, { maxWorkers: 4 });
196
+ const four = sweep.points.find((p) => p.workers === 4);
197
+ expect(four?.speedup).toBe(4);
198
+ expect(four?.efficiency).toBe(1);
199
+ });
200
+ });
201
+
202
+ describe('projectBudget', () => {
203
+ const items = Array.from({ length: 4 }, (_, i) => item(`t${i}`, 10, { cost: 1_000_000 }));
204
+
205
+ it('keeps the TOTAL flat across fleet sizes and moves only the deadline', () => {
206
+ const sweep = sweepFleetSizes(items, { maxWorkers: 4 });
207
+ const projected = projectBudget(sweep, 2_000_000);
208
+ expect(new Set(sweep.points.map((p) => p.cost)).size).toBe(1);
209
+ expect(projected.every((p) => p.coverage === 0.5)).toBe(true);
210
+ const one = projected.find((p) => p.workers === 1) as (typeof projected)[number];
211
+ const four = projected.find((p) => p.workers === 4) as (typeof projected)[number];
212
+ // Four workers reach the wall four times sooner.
213
+ expect(one.exhaustedInMs).toBe(20 * MIN);
214
+ expect(four.exhaustedInMs).toBe(5 * MIN);
215
+ expect(one.shortfall).toBe(2_000_000);
216
+ });
217
+
218
+ it('reports no exhaustion when the budget covers the queue', () => {
219
+ const sweep = sweepFleetSizes(items, { maxWorkers: 2 });
220
+ expect(projectBudget(sweep, 10_000_000).every((p) => p.exhaustedInMs === null)).toBe(true);
221
+ });
222
+ });
223
+
224
+ describe('estimateContention', () => {
225
+ it('measures a slowdown from real runs', () => {
226
+ const samples = [
227
+ ...Array.from({ length: 10 }, () => ({ concurrency: 1, durationMs: 10 * MIN })),
228
+ ...Array.from({ length: 10 }, () => ({ concurrency: 4, durationMs: 20 * MIN })),
229
+ ];
230
+ const model = estimateContention(samples, { freeLanes: 2 });
231
+ // Median busy concurrency 4, two lanes over free, 2x stretch → 0.5 per worker.
232
+ expect(model).toEqual({ freeLanes: 2, slowdownPerExtraWorker: 0.5, maxStretch: 8 });
233
+ });
234
+
235
+ it('returns null rather than a confident model when a group is thin', () => {
236
+ const samples = [
237
+ { concurrency: 1, durationMs: 10 * MIN },
238
+ { concurrency: 5, durationMs: 40 * MIN },
239
+ ];
240
+ expect(estimateContention(samples)).toBeNull();
241
+ });
242
+
243
+ it('never reports a NEGATIVE slowdown, which would justify an unbounded fleet', () => {
244
+ const samples = [
245
+ ...Array.from({ length: 10 }, () => ({ concurrency: 1, durationMs: 30 * MIN })),
246
+ ...Array.from({ length: 10 }, () => ({ concurrency: 5, durationMs: 5 * MIN })),
247
+ ];
248
+ expect(estimateContention(samples, { freeLanes: 2 })?.slowdownPerExtraWorker).toBe(0);
249
+ });
250
+ });
251
+
252
+ describe('concurrencyProfile', () => {
253
+ it('emits one step per change, with ends before starts at a tie', () => {
254
+ expect(
255
+ concurrencyProfile([
256
+ { from: 0, to: 10 },
257
+ { from: 10, to: 20 },
258
+ ]),
259
+ ).toEqual([
260
+ { at: 0, count: 1 },
261
+ { at: 10, count: 1 },
262
+ { at: 20, count: 0 },
263
+ ]);
264
+ });
265
+
266
+ it('counts genuine overlap', () => {
267
+ const profile = concurrencyProfile([
268
+ { from: 0, to: 10 },
269
+ { from: 5, to: 15 },
270
+ ]);
271
+ expect(profile).toEqual([
272
+ { at: 0, count: 1 },
273
+ { at: 5, count: 2 },
274
+ { at: 10, count: 1 },
275
+ { at: 15, count: 0 },
276
+ ]);
277
+ });
278
+
279
+ it('drops zero-width intervals', () => {
280
+ expect(concurrencyProfile([{ from: 5, to: 5 }])).toEqual([]);
281
+ });
282
+ });
283
+
284
+ describe('meanConcurrency', () => {
285
+ const window = { from: 0, to: 100 };
286
+
287
+ it('is time-weighted across the window', () => {
288
+ expect(meanConcurrency([{ from: 0, to: 50 }], window)).toBe(0.5);
289
+ });
290
+
291
+ it('excludes a paused span from BOTH halves of the average', () => {
292
+ const intervals = [{ from: 90, to: 100 }];
293
+ // Naively: 10 of 100 = 0.1 — the number the owner saw as ".2".
294
+ expect(meanConcurrency(intervals, window)).toBe(0.1);
295
+ // Excluding the 90 units the engine was off: 10 of 10 = 1 task at once.
296
+ expect(meanConcurrency(intervals, window, [{ from: 0, to: 90 }])).toBe(1);
297
+ });
298
+
299
+ it('merges overlapping exclusions instead of subtracting twice', () => {
300
+ const intervals = [{ from: 80, to: 100 }];
301
+ const nested = [
302
+ { from: 0, to: 80 },
303
+ { from: 10, to: 40 },
304
+ ];
305
+ expect(meanConcurrency(intervals, window, nested)).toBe(1);
306
+ });
307
+
308
+ it('is 0, not NaN, when the window is entirely excluded', () => {
309
+ expect(meanConcurrency([{ from: 0, to: 10 }], window, [{ from: 0, to: 100 }])).toBe(0);
310
+ });
311
+ });