@diagrammo/dgmo 0.47.0 → 0.48.1

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 (345) hide show
  1. package/dist/advanced.js +11 -11
  2. package/dist/auto.js +16 -16
  3. package/dist/auto.mjs +41 -12
  4. package/dist/block.js +4 -4
  5. package/dist/{chunk-FNB3PNGF.js → chunk-7DTWEFUE.js} +3 -3
  6. package/dist/{chunk-2DXMZRWJ.js → chunk-B65G5DNL.js} +8 -1
  7. package/dist/{chunk-RZCF2VVM.js → chunk-B7JUCQUA.js} +3 -1
  8. package/dist/{chunk-DU7W62N5.js → chunk-FANX6UJR.js} +3 -3
  9. package/dist/{chunk-6FZYCKXB.js → chunk-HSH73DP6.js} +2 -2
  10. package/dist/{chunk-P4RH4OSP.js → chunk-ILKORZ3H.js} +2 -2
  11. package/dist/{chunk-Y5QGVTVC.js → chunk-JTSMEYIZ.js} +7 -3
  12. package/dist/{chunk-RCERWOJR.js → chunk-ODZVHU3R.js} +1 -2
  13. package/dist/{chunk-DZAQBW35.js → chunk-SGZYXELT.js} +3 -7
  14. package/dist/{chunk-CUVHQHZ7.js → chunk-THXCFRTU.js} +3 -3
  15. package/dist/{chunk-IH6ZMVQ5.js → chunk-UC7TFHUM.js} +29 -11
  16. package/dist/{chunk-EMS2SK26.js → chunk-ZBWMSD5N.js} +3 -1
  17. package/dist/cli.cjs +15 -15
  18. package/dist/{dgmo-router-DZCIDUO5.js → dgmo-router-X35VLZLM.js} +3 -3
  19. package/dist/element.js +16 -16
  20. package/dist/element.mjs +41 -12
  21. package/dist/{flowchart-renderer-ZAEOL4DX.js → flowchart-renderer-SNDSII4D.js} +2 -2
  22. package/dist/index.d.ts +4 -2
  23. package/dist/index.js +4 -4
  24. package/dist/{parser-FUSKFGZN.js → parser-2EWECDSA.js} +2 -2
  25. package/dist/{renderer-UWA5XS74.js → renderer-4JHRUBE6.js} +3 -3
  26. package/dist/{renderer-WZRREQ2P.js → renderer-5MYBMBYO.js} +2 -2
  27. package/dist/{renderer-TOCOVLHS.js → renderer-B2OMQIQG.js} +3 -3
  28. package/dist/{renderer-2XQQQ6QR.js → renderer-N43WR5GV.js} +3 -3
  29. package/dist/{renderer-T7YKC5VP.js → renderer-ZRNG54CW.js} +3 -3
  30. package/dist/{state-renderer-RFYHSP4K.js → state-renderer-B4DQBM6M.js} +2 -2
  31. package/package.json +23 -71
  32. package/dist/advanced.cjs +0 -71648
  33. package/dist/advanced.d.cts +0 -5719
  34. package/dist/auto.cjs +0 -70648
  35. package/dist/auto.d.cts +0 -54
  36. package/dist/block.cjs +0 -70208
  37. package/dist/block.d.cts +0 -115
  38. package/dist/editor.cjs +0 -526
  39. package/dist/editor.d.cts +0 -26
  40. package/dist/element.cjs +0 -70530
  41. package/dist/element.d.cts +0 -62
  42. package/dist/highlight.cjs +0 -852
  43. package/dist/highlight.d.cts +0 -49
  44. package/dist/index.cjs +0 -70491
  45. package/dist/index.d.cts +0 -160
  46. package/dist/pert.cjs +0 -325
  47. package/dist/pert.d.cts +0 -559
  48. package/dist/themes-1-CuKpeH.d.cts +0 -908
  49. package/src/advanced.ts +0 -873
  50. package/src/arc/parser.ts +0 -15
  51. package/src/arc/renderer.ts +0 -633
  52. package/src/auto/index.ts +0 -621
  53. package/src/auto/shared.ts +0 -370
  54. package/src/auto/styles.ts +0 -73
  55. package/src/block/layout.ts +0 -198
  56. package/src/block/parser.ts +0 -427
  57. package/src/block/renderer.ts +0 -484
  58. package/src/block/types.ts +0 -67
  59. package/src/boxes-and-lines/collapse.ts +0 -96
  60. package/src/boxes-and-lines/focus.ts +0 -257
  61. package/src/boxes-and-lines/label-placement.ts +0 -339
  62. package/src/boxes-and-lines/layout-grouped.ts +0 -912
  63. package/src/boxes-and-lines/layout-layered.ts +0 -722
  64. package/src/boxes-and-lines/layout-search.ts +0 -1678
  65. package/src/boxes-and-lines/layout.ts +0 -398
  66. package/src/boxes-and-lines/parser.ts +0 -1181
  67. package/src/boxes-and-lines/renderer.ts +0 -1465
  68. package/src/boxes-and-lines/types.ts +0 -66
  69. package/src/c4/layout.ts +0 -2441
  70. package/src/c4/parser.ts +0 -1158
  71. package/src/c4/renderer.ts +0 -2008
  72. package/src/c4/types.ts +0 -89
  73. package/src/chart-type-registry.ts +0 -529
  74. package/src/chart-types.ts +0 -225
  75. package/src/chart.ts +0 -685
  76. package/src/charts-d3/bar.ts +0 -338
  77. package/src/charts-d3/chord.ts +0 -140
  78. package/src/charts-d3/function.ts +0 -147
  79. package/src/charts-d3/funnel.ts +0 -104
  80. package/src/charts-d3/heatmap.ts +0 -145
  81. package/src/charts-d3/index.ts +0 -337
  82. package/src/charts-d3/interactions.ts +0 -477
  83. package/src/charts-d3/line.ts +0 -344
  84. package/src/charts-d3/mount.ts +0 -151
  85. package/src/charts-d3/pie.ts +0 -117
  86. package/src/charts-d3/polar.ts +0 -89
  87. package/src/charts-d3/radar.ts +0 -0
  88. package/src/charts-d3/sankey.ts +0 -0
  89. package/src/charts-d3/scatter.ts +0 -185
  90. package/src/charts-d3/shared.ts +0 -208
  91. package/src/class/layout.ts +0 -326
  92. package/src/class/parser.ts +0 -635
  93. package/src/class/renderer.ts +0 -793
  94. package/src/class/types.ts +0 -58
  95. package/src/cli-banner.ts +0 -106
  96. package/src/cli.ts +0 -1080
  97. package/src/colors-diagnostics.ts +0 -35
  98. package/src/colors.ts +0 -379
  99. package/src/completion-types.ts +0 -27
  100. package/src/completion.ts +0 -2668
  101. package/src/cycle/layout.ts +0 -1026
  102. package/src/cycle/parser.ts +0 -387
  103. package/src/cycle/renderer.ts +0 -653
  104. package/src/cycle/types.ts +0 -101
  105. package/src/d3.ts +0 -1434
  106. package/src/data-chart-parser.ts +0 -1007
  107. package/src/dgmo-router.ts +0 -296
  108. package/src/diagnostics-registry.ts +0 -108
  109. package/src/diagnostics.ts +0 -0
  110. package/src/dimensions.ts +0 -20
  111. package/src/directives-registry.ts +0 -396
  112. package/src/editor/dgmo.grammar +0 -76
  113. package/src/editor/dgmo.grammar.d.ts +0 -2
  114. package/src/editor/dgmo.grammar.js +0 -18
  115. package/src/editor/dgmo.grammar.terms.d.ts +0 -5
  116. package/src/editor/dgmo.grammar.terms.js +0 -35
  117. package/src/editor/highlight-api.ts +0 -596
  118. package/src/editor/highlight.ts +0 -36
  119. package/src/editor/index.ts +0 -27
  120. package/src/editor/keywords.ts +0 -128
  121. package/src/editor/tokens.ts +0 -30
  122. package/src/element/index.ts +0 -432
  123. package/src/embed/css.ts +0 -312
  124. package/src/embed/escape.ts +0 -15
  125. package/src/embed/index.ts +0 -280
  126. package/src/er/classify.ts +0 -228
  127. package/src/er/layout.ts +0 -530
  128. package/src/er/parser.ts +0 -750
  129. package/src/er/renderer.ts +0 -725
  130. package/src/er/types.ts +0 -52
  131. package/src/error-card.ts +0 -269
  132. package/src/event-line/diagnostics.ts +0 -117
  133. package/src/event-line/parser.ts +0 -643
  134. package/src/event-line/renderer.ts +0 -1742
  135. package/src/event-line/types.ts +0 -74
  136. package/src/fonts.ts +0 -3
  137. package/src/gantt/calculator.ts +0 -979
  138. package/src/gantt/parser.ts +0 -1641
  139. package/src/gantt/renderer.ts +0 -3787
  140. package/src/gantt/resolver.ts +0 -172
  141. package/src/gantt/types.ts +0 -206
  142. package/src/graph/diagnostics.ts +0 -64
  143. package/src/graph/edge-spline.ts +0 -29
  144. package/src/graph/flowchart-parser.ts +0 -759
  145. package/src/graph/flowchart-renderer.ts +0 -817
  146. package/src/graph/layout.ts +0 -472
  147. package/src/graph/notes.ts +0 -21
  148. package/src/graph/state-collapse.ts +0 -78
  149. package/src/graph/state-parser.ts +0 -574
  150. package/src/graph/state-renderer.ts +0 -653
  151. package/src/graph/types.ts +0 -65
  152. package/src/index.ts +0 -261
  153. package/src/infra/compute.ts +0 -1385
  154. package/src/infra/layout.ts +0 -888
  155. package/src/infra/parser.ts +0 -1234
  156. package/src/infra/renderer.ts +0 -2534
  157. package/src/infra/roles.ts +0 -75
  158. package/src/infra/types.ts +0 -216
  159. package/src/infra/validation.ts +0 -201
  160. package/src/journey-map/layout.ts +0 -490
  161. package/src/journey-map/parser.ts +0 -557
  162. package/src/journey-map/renderer.ts +0 -1580
  163. package/src/journey-map/types.ts +0 -47
  164. package/src/kanban/mutations.ts +0 -175
  165. package/src/kanban/parser.ts +0 -509
  166. package/src/kanban/renderer.ts +0 -1449
  167. package/src/kanban/types.ts +0 -39
  168. package/src/label-layout.ts +0 -368
  169. package/src/map/colorize.ts +0 -54
  170. package/src/map/completion.ts +0 -316
  171. package/src/map/context-labels.ts +0 -609
  172. package/src/map/data/PROVENANCE.json +0 -1
  173. package/src/map/data/README.md +0 -40
  174. package/src/map/data/airport-collisions.json +0 -1
  175. package/src/map/data/airports.json +0 -1
  176. package/src/map/data/gazetteer.json +0 -1
  177. package/src/map/data/lakes.json +0 -1
  178. package/src/map/data/mountain-ranges.json +0 -1
  179. package/src/map/data/na-lakes.json +0 -1
  180. package/src/map/data/na-land.json +0 -1
  181. package/src/map/data/region-names.json +0 -1
  182. package/src/map/data/rivers.json +0 -1
  183. package/src/map/data/types.ts +0 -121
  184. package/src/map/data/us-states.json +0 -1
  185. package/src/map/data/water-bodies.json +0 -1
  186. package/src/map/data/world-coarse.json +0 -1
  187. package/src/map/data/world-detail.json +0 -1
  188. package/src/map/diagnostics.ts +0 -134
  189. package/src/map/dimensions.ts +0 -150
  190. package/src/map/geo-query.ts +0 -295
  191. package/src/map/geo.ts +0 -438
  192. package/src/map/invert.ts +0 -111
  193. package/src/map/layout.ts +0 -5069
  194. package/src/map/legend-band.ts +0 -127
  195. package/src/map/load-data.ts +0 -186
  196. package/src/map/parser.ts +0 -783
  197. package/src/map/renderer.ts +0 -1445
  198. package/src/map/resolved-types.ts +0 -160
  199. package/src/map/resolver.ts +0 -1146
  200. package/src/map/types.ts +0 -194
  201. package/src/mindmap/collapse.ts +0 -67
  202. package/src/mindmap/layout.ts +0 -649
  203. package/src/mindmap/parser.ts +0 -430
  204. package/src/mindmap/renderer.ts +0 -637
  205. package/src/mindmap/text-wrap.ts +0 -218
  206. package/src/mindmap/types.ts +0 -56
  207. package/src/org/collapse.ts +0 -151
  208. package/src/org/layout.ts +0 -1262
  209. package/src/org/parser.ts +0 -519
  210. package/src/org/renderer.ts +0 -866
  211. package/src/org/resolver.ts +0 -636
  212. package/src/palettes/atlas.ts +0 -77
  213. package/src/palettes/blueprint.ts +0 -73
  214. package/src/palettes/catppuccin.ts +0 -84
  215. package/src/palettes/color-utils.ts +0 -443
  216. package/src/palettes/index.ts +0 -67
  217. package/src/palettes/nord.ts +0 -67
  218. package/src/palettes/registry.ts +0 -114
  219. package/src/palettes/slate.ts +0 -73
  220. package/src/palettes/tidewater.ts +0 -73
  221. package/src/palettes/tokyo-night.ts +0 -86
  222. package/src/palettes/types.ts +0 -88
  223. package/src/pert/analyzer.ts +0 -1183
  224. package/src/pert/diagnostics.ts +0 -110
  225. package/src/pert/index.ts +0 -43
  226. package/src/pert/internal.ts +0 -252
  227. package/src/pert/layout.ts +0 -971
  228. package/src/pert/monte-carlo.ts +0 -446
  229. package/src/pert/parser.ts +0 -1606
  230. package/src/pert/renderer.ts +0 -4009
  231. package/src/pert/share-normalize.ts +0 -62
  232. package/src/pert/types.ts +0 -604
  233. package/src/pyramid/parser.ts +0 -185
  234. package/src/pyramid/renderer.ts +0 -768
  235. package/src/pyramid/types.ts +0 -28
  236. package/src/quadrant/parser.ts +0 -18
  237. package/src/quadrant/renderer.ts +0 -638
  238. package/src/raci/diagnostics.ts +0 -213
  239. package/src/raci/index.ts +0 -23
  240. package/src/raci/mutations.ts +0 -321
  241. package/src/raci/parser.ts +0 -699
  242. package/src/raci/renderer.ts +0 -1627
  243. package/src/raci/types.ts +0 -82
  244. package/src/raci/variants.ts +0 -320
  245. package/src/render.ts +0 -268
  246. package/src/ring/parser.ts +0 -212
  247. package/src/ring/renderer.ts +0 -490
  248. package/src/ring/types.ts +0 -27
  249. package/src/sequence/collapse.ts +0 -174
  250. package/src/sequence/parser.ts +0 -1596
  251. package/src/sequence/participant-inference.ts +0 -152
  252. package/src/sequence/renderer.ts +0 -3168
  253. package/src/sequence/tag-resolution.ts +0 -164
  254. package/src/sharing.ts +0 -211
  255. package/src/sitemap/collapse.ts +0 -100
  256. package/src/sitemap/layout.ts +0 -925
  257. package/src/sitemap/parser.ts +0 -755
  258. package/src/sitemap/renderer.ts +0 -828
  259. package/src/sitemap/types.ts +0 -42
  260. package/src/slope/parser.ts +0 -18
  261. package/src/slope/renderer.ts +0 -378
  262. package/src/swimlane/diagnostics.ts +0 -94
  263. package/src/swimlane/layout.ts +0 -597
  264. package/src/swimlane/parser.ts +0 -613
  265. package/src/swimlane/renderer.ts +0 -681
  266. package/src/swimlane/types.ts +0 -151
  267. package/src/tech-radar/interactive.ts +0 -1129
  268. package/src/tech-radar/layout.ts +0 -197
  269. package/src/tech-radar/parser.ts +0 -436
  270. package/src/tech-radar/renderer.ts +0 -1239
  271. package/src/tech-radar/shared.ts +0 -151
  272. package/src/tech-radar/types.ts +0 -86
  273. package/src/themes.ts +0 -22
  274. package/src/timeline/parser.ts +0 -472
  275. package/src/timeline/renderer.ts +0 -3024
  276. package/src/timeline/types.ts +0 -33
  277. package/src/timeline/viz-parser.ts +0 -21
  278. package/src/treemap/diagnostics.ts +0 -62
  279. package/src/treemap/layout-radial.ts +0 -233
  280. package/src/treemap/layout.ts +0 -189
  281. package/src/treemap/parser.ts +0 -463
  282. package/src/treemap/renderer-radial.ts +0 -438
  283. package/src/treemap/renderer.ts +0 -572
  284. package/src/treemap/treemap-shared.ts +0 -309
  285. package/src/treemap/types.ts +0 -60
  286. package/src/utils/arrow-markers.ts +0 -51
  287. package/src/utils/arrows-diagnostics.ts +0 -62
  288. package/src/utils/arrows.ts +0 -227
  289. package/src/utils/brand.ts +0 -61
  290. package/src/utils/card.ts +0 -183
  291. package/src/utils/collapse-engine/tree-edges.ts +0 -115
  292. package/src/utils/collapse-engine/tree.ts +0 -117
  293. package/src/utils/d3-helpers.ts +0 -219
  294. package/src/utils/d3-types.ts +0 -9
  295. package/src/utils/description-helpers.ts +0 -38
  296. package/src/utils/duration.ts +0 -312
  297. package/src/utils/export-container.ts +0 -41
  298. package/src/utils/expr-eval.ts +0 -27
  299. package/src/utils/fit-canvas.ts +0 -64
  300. package/src/utils/hover-styles.ts +0 -673
  301. package/src/utils/inline-markdown.ts +0 -96
  302. package/src/utils/legend-constants.ts +0 -48
  303. package/src/utils/legend-d3.ts +0 -618
  304. package/src/utils/legend-integration.ts +0 -107
  305. package/src/utils/legend-layout.ts +0 -776
  306. package/src/utils/legend-svg.ts +0 -214
  307. package/src/utils/legend-types.ts +0 -272
  308. package/src/utils/name-normalize.ts +0 -163
  309. package/src/utils/note-box/constants.ts +0 -25
  310. package/src/utils/note-box/index.ts +0 -11
  311. package/src/utils/note-box/metrics.ts +0 -90
  312. package/src/utils/note-box/svg.ts +0 -331
  313. package/src/utils/notes/bounds.ts +0 -30
  314. package/src/utils/notes/build.ts +0 -131
  315. package/src/utils/notes/index.ts +0 -18
  316. package/src/utils/notes/model.ts +0 -19
  317. package/src/utils/notes/parse.ts +0 -131
  318. package/src/utils/notes/place.ts +0 -177
  319. package/src/utils/notes/resolve.ts +0 -88
  320. package/src/utils/number-format.ts +0 -36
  321. package/src/utils/parsing.ts +0 -953
  322. package/src/utils/reserved-key-registry.ts +0 -239
  323. package/src/utils/safe-href.ts +0 -38
  324. package/src/utils/scaling.ts +0 -97
  325. package/src/utils/svg-embed.ts +0 -251
  326. package/src/utils/tag-groups.ts +0 -607
  327. package/src/utils/text-measure.ts +0 -122
  328. package/src/utils/time-ticks.ts +0 -253
  329. package/src/utils/title-constants.ts +0 -24
  330. package/src/utils/visual-conventions.ts +0 -61
  331. package/src/utils/wrapped-desc.ts +0 -87
  332. package/src/venn/parser.ts +0 -18
  333. package/src/venn/renderer.ts +0 -1047
  334. package/src/version-control/diagnostics.ts +0 -67
  335. package/src/version-control/parser.ts +0 -544
  336. package/src/version-control/renderer.ts +0 -724
  337. package/src/version-control/types.ts +0 -100
  338. package/src/visualizations/parse.ts +0 -1560
  339. package/src/visualizations/types.ts +0 -262
  340. package/src/wireframe/layout.ts +0 -479
  341. package/src/wireframe/parser.ts +0 -1114
  342. package/src/wireframe/renderer.ts +0 -1318
  343. package/src/wireframe/types.ts +0 -110
  344. package/src/wordcloud/parser.ts +0 -18
  345. package/src/wordcloud/renderer.ts +0 -357
@@ -1,1183 +0,0 @@
1
- // ============================================================
2
- // PERT Analyzer — forward/backward pass + critical path + project μ/σ
3
- // ============================================================
4
- //
5
- // Phase 1 scope: M-world analytical PERT only. Phase 2 wires Monte
6
- // Carlo into `monteCarloResult`.
7
- //
8
- // TBD-poison-downstream semantics: activities with a null duration
9
- // propagate null through every descendant's ES/EF/LS/LF/slack; if any
10
- // TBD is upstream of the project end, projectMu and projectSigma are
11
- // also null. Cycles emit a diagnostic naming at least one offending
12
- // activity (AC2.5).
13
-
14
- import { makeDgmoError } from '../diagnostics';
15
- import type { DgmoError } from '../diagnostics';
16
- import type { Duration, DurationUnit } from '../gantt/types';
17
- import type {
18
- Anchor,
19
- CaptionRow,
20
- ParsedPert,
21
- PertActivity,
22
- PertEdge,
23
- PertGroup,
24
- ResolvedActivity,
25
- ResolvedGroup,
26
- ResolvedPert,
27
- MonteCarloResult,
28
- TornadoSwing,
29
- } from './types';
30
- import type { DurationEstimate } from './internal';
31
- import {
32
- addCalendarDays,
33
- resolveConfidence,
34
- unitToDays,
35
- CONFIDENCE_TABLE,
36
- } from './internal';
37
- import { simulateCanonical, type ExpandedActivity } from './monte-carlo';
38
-
39
- // ============================================================
40
- // Duration arithmetic helpers
41
- // ============================================================
42
-
43
- /** Convert any Duration to a canonical `timeUnit` amount. */
44
- const UNIT_TO_DAYS: Record<DurationUnit, number> = {
45
- min: 1 / (60 * 24),
46
- h: 1 / 24,
47
- d: 1,
48
- bd: 1, // PERT has no calendar; bd ≈ d for analytical purposes
49
- w: 7,
50
- m: 30,
51
- q: 90,
52
- y: 365,
53
- s: 14, // fallback when sprint-length isn't authored; the analyzer
54
- // overrides this from `options.sprintLength` when in sprint mode.
55
- };
56
-
57
- function unitDays(unit: DurationUnit, sprintDaysOverride?: number): number {
58
- if (unit === 's' && sprintDaysOverride !== undefined) {
59
- return sprintDaysOverride;
60
- }
61
- return UNIT_TO_DAYS[unit];
62
- }
63
-
64
- function toDays(d: Duration, sprintDaysOverride?: number): number {
65
- return d.amount * unitDays(d.unit, sprintDaysOverride);
66
- }
67
-
68
- function fromDays(
69
- days: number,
70
- unit: DurationUnit,
71
- sprintDaysOverride?: number
72
- ): number {
73
- return days / unitDays(unit, sprintDaysOverride);
74
- }
75
-
76
- function resolveSprintDays(opts: {
77
- sprintMode: 'auto' | 'explicit' | null;
78
- sprintLength: Duration | null;
79
- }): number | undefined {
80
- if (!opts.sprintMode || !opts.sprintLength) return undefined;
81
- return opts.sprintLength.amount * UNIT_TO_DAYS[opts.sprintLength.unit];
82
- }
83
-
84
- // ============================================================
85
- // Estimate expansion (M-only heuristic + Beta-PERT μ/σ)
86
- // ============================================================
87
-
88
- interface ExpandedEstimate {
89
- /** Optimistic, in canonical days. */
90
- o: number;
91
- /** Most-likely, in canonical days. */
92
- m: number;
93
- /** Pessimistic, in canonical days. */
94
- p: number;
95
- /** Beta-PERT mean = (O + 4M + P) / 6. */
96
- mean: number;
97
- /** Beta-PERT std dev = (P − O) / 6. */
98
- sigma: number;
99
- }
100
-
101
- function expandEstimate(
102
- estimate: DurationEstimate,
103
- diagramConfidence: string,
104
- activityConfidence: string | undefined,
105
- sprintDays?: number
106
- ): ExpandedEstimate {
107
- const o = toDays(estimate.o, sprintDays);
108
- const m = toDays(estimate.m, sprintDays);
109
- const p = toDays(estimate.p, sprintDays);
110
-
111
- // Heuristic-pending sentinel: parser set `mOnly = true` when only a
112
- // single M token was given. Expand using confidence factors.
113
- let oFinal = o;
114
- let pFinal = p;
115
- if (estimate.mOnly) {
116
- const fromActivity = activityConfidence
117
- ? resolveConfidence(activityConfidence)
118
- : null;
119
- const factors =
120
- fromActivity ??
121
- resolveConfidence(diagramConfidence) ??
122
- CONFIDENCE_TABLE.medium;
123
- oFinal = m * factors.oFactor;
124
- pFinal = m * factors.pFactor;
125
- }
126
-
127
- const mean = (oFinal + 4 * m + pFinal) / 6;
128
- const sigma = (pFinal - oFinal) / 6;
129
- return { o: oFinal, m, p: pFinal, mean, sigma };
130
- }
131
-
132
- // ============================================================
133
- // analyzePert
134
- // ============================================================
135
-
136
- export function analyzePert(parsed: ParsedPert): ResolvedPert {
137
- const diagnostics: DgmoError[] = [...parsed.diagnostics];
138
- const error = (line: number, msg: string): void => {
139
- diagnostics.push(makeDgmoError(line, msg, 'error'));
140
- };
141
-
142
- const activities = parsed.activities;
143
- const edges = parsed.edges;
144
- const warn = (line: number, msg: string): void => {
145
- diagnostics.push(makeDgmoError(line, msg, 'warning'));
146
- };
147
-
148
- // Sprint mode override: when the diagram opts into sprint mode, the
149
- // `s` unit means "one sprint" worth of canonical days as configured
150
- // by `sprint-length`. Without this, every `s` defaults to the 14-day
151
- // fallback, which silently disagrees with the user's `sprint-length`
152
- // directive.
153
- const sprintDays = resolveSprintDays(parsed.options);
154
-
155
- // Build successor/predecessor maps keyed by activity id, plus parallel
156
- // maps keyed-by-edge-direction so the forward/backward pass can read
157
- // dep-type and lag for each (source, target) link.
158
- const successors = new Map<string, string[]>();
159
- const predecessors = new Map<string, string[]>();
160
- const incomingEdges = new Map<string, PertEdge[]>();
161
- const outgoingEdges = new Map<string, PertEdge[]>();
162
- for (const a of activities) {
163
- successors.set(a.id, []);
164
- predecessors.set(a.id, []);
165
- incomingEdges.set(a.id, []);
166
- outgoingEdges.set(a.id, []);
167
- }
168
- for (const e of edges) {
169
- successors.get(e.source)?.push(e.target);
170
- predecessors.get(e.target)?.push(e.source);
171
- incomingEdges.get(e.target)?.push(e);
172
- outgoingEdges.get(e.source)?.push(e);
173
- }
174
-
175
- // Lead-exceeds-duration validation. For an FS edge with negative lag
176
- // (lead) of magnitude L, if L > predecessor's duration, the constraint
177
- // implies B.ES < A.ES — logically impossible without an SS/SF edge.
178
- // Surface as a warning so the user can correct it; analyzer still
179
- // computes a schedule (the warning is informational).
180
- for (const e of edges) {
181
- if (!e.lag || e.lag.amount >= 0) continue;
182
- const src = activities.find((a) => a.id === e.source);
183
- if (!src?.duration) continue;
184
- const leadDays = -toDays(e.lag, sprintDays);
185
- const srcDurDays = toDays(src.duration.m, sprintDays);
186
- if (e.type === 'FS' && leadDays > srcDurDays) {
187
- warn(
188
- e.lineNumber,
189
- `Lead (${-e.lag.amount}${e.lag.unit}) exceeds predecessor "${src.name}" duration — ` +
190
- `the FS edge implies the successor starts before the predecessor.`
191
- );
192
- }
193
- }
194
-
195
- // Topological sort (Kahn's algorithm). On cycle, emit diagnostic.
196
- const topo: string[] = [];
197
- const inDegree = new Map<string, number>();
198
- for (const a of activities)
199
- inDegree.set(a.id, predecessors.get(a.id)!.length);
200
- const queue: string[] = [];
201
- for (const [id, deg] of inDegree) if (deg === 0) queue.push(id);
202
-
203
- while (queue.length > 0) {
204
- const id = queue.shift()!;
205
- topo.push(id);
206
- for (const next of successors.get(id) ?? []) {
207
- const deg = (inDegree.get(next) ?? 0) - 1;
208
- inDegree.set(next, deg);
209
- if (deg === 0) queue.push(next);
210
- }
211
- }
212
-
213
- if (topo.length < activities.length) {
214
- // Surviving in-degree > 0 means cycle. Pick the first surviving id.
215
- const culprit = activities.find((a) => (inDegree.get(a.id) ?? 0) > 0);
216
- if (culprit) {
217
- error(culprit.lineNumber, `Cycle detected involving "${culprit.name}".`);
218
- } else {
219
- error(0, `Cycle detected in PERT graph.`);
220
- }
221
- // Bail out — analysis on a cyclic graph is meaningless.
222
- return emptyResolved(parsed, diagnostics);
223
- }
224
-
225
- // Expand all estimates up-front (canonical days).
226
- const expandedById = new Map<string, ExpandedEstimate | null>();
227
- for (const a of activities) {
228
- if (a.duration === null) {
229
- expandedById.set(a.id, null);
230
- continue;
231
- }
232
- expandedById.set(
233
- a.id,
234
- expandEstimate(
235
- a.duration,
236
- parsed.options.confidence,
237
- a.confidence,
238
- sprintDays
239
- )
240
- );
241
- }
242
-
243
- // Compute the poisoned set: any activity transitively downstream of a
244
- // TBD activity. Run BFS from TBD nodes outward.
245
- const poisoned = new Set<string>();
246
- for (const a of activities) {
247
- if (a.duration === null) {
248
- const stack = [a.id];
249
- poisoned.add(a.id);
250
- while (stack.length > 0) {
251
- const id = stack.pop()!;
252
- for (const next of successors.get(id) ?? []) {
253
- if (!poisoned.has(next)) {
254
- poisoned.add(next);
255
- stack.push(next);
256
- }
257
- }
258
- }
259
- }
260
- }
261
-
262
- // Forward pass: ES/EF for each non-poisoned activity, in topo order.
263
- // Each incoming edge contributes a lower bound according to its type:
264
- // FS: B.ES ≥ A.EF + lag (constrains ES)
265
- // SS: B.ES ≥ A.ES + lag (constrains ES)
266
- // FF: B.EF ≥ A.EF + lag (constrains EF)
267
- // SF: B.EF ≥ A.ES + lag (constrains EF)
268
- // ES then EF are picked so EF − ES = duration, and both bounds hold.
269
- const es = new Map<string, number | null>();
270
- const ef = new Map<string, number | null>();
271
- for (const id of topo) {
272
- if (poisoned.has(id)) {
273
- es.set(id, null);
274
- ef.set(id, null);
275
- continue;
276
- }
277
- const expanded = expandedById.get(id)!;
278
- const dur = expanded.mean;
279
-
280
- let esLower = 0;
281
- let efLower = -Infinity;
282
- for (const edge of incomingEdges.get(id) ?? []) {
283
- const aEs = es.get(edge.source);
284
- const aEf = ef.get(edge.source);
285
- if (aEs == null || aEf == null) continue;
286
- const lagDays = edge.lag ? toDays(edge.lag, sprintDays) : 0;
287
- switch (edge.type) {
288
- case 'FS':
289
- esLower = Math.max(esLower, aEf + lagDays);
290
- break;
291
- case 'SS':
292
- esLower = Math.max(esLower, aEs + lagDays);
293
- break;
294
- case 'FF':
295
- efLower = Math.max(efLower, aEf + lagDays);
296
- break;
297
- case 'SF':
298
- efLower = Math.max(efLower, aEs + lagDays);
299
- break;
300
- }
301
- }
302
- // Pin EF = ES + dur, then bump ES forward if EF-side constraints
303
- // demand a later finish than ES + dur supports.
304
- let esVal = esLower;
305
- let efVal = esVal + dur;
306
- if (efLower > efVal) {
307
- efVal = efLower;
308
- esVal = efVal - dur;
309
- }
310
- es.set(id, esVal);
311
- ef.set(id, efVal);
312
- }
313
-
314
- // Project μ = max(EF) over all non-poisoned activities. Pure FS graphs
315
- // have monotonic EF along edges so terminals always carry the project
316
- // end, but SS/FF/SF edges break that invariant — a non-terminal
317
- // predecessor can finish after its SS-successor. Considering every
318
- // activity gives the right answer for any mix of dep types.
319
- // If ANY terminal is poisoned, projectMu becomes null — the actual
320
- // project end is unknowable until TBDs are estimated. Per AC2.3 +
321
- // AC3.3a: poisoned terminals invalidate the analytical projectMu.
322
- let projectMuDays: number | null = null;
323
- let endId: string | null = null;
324
- let anyTerminalPoisoned = false;
325
- for (const a of activities) {
326
- const isTerminal = (successors.get(a.id) ?? []).length === 0;
327
- if (poisoned.has(a.id)) {
328
- if (isTerminal) anyTerminalPoisoned = true;
329
- continue;
330
- }
331
- const efVal = ef.get(a.id);
332
- if (efVal != null && (projectMuDays === null || efVal > projectMuDays)) {
333
- projectMuDays = efVal;
334
- endId = a.id;
335
- }
336
- }
337
- if (anyTerminalPoisoned) {
338
- projectMuDays = null;
339
- endId = null;
340
- }
341
-
342
- // Backward pass: LS/LF.
343
- const ls = new Map<string, number | null>();
344
- const lf = new Map<string, number | null>();
345
- // Initialize all non-poisoned activities' LF to projectMu (or to their
346
- // own EF when they are dead-ends with smaller EF — that gives them
347
- // slack equal to projectMu - EF). Standard PERT convention is LF =
348
- // projectMu for terminal nodes, then back-propagate via min.
349
- for (const a of activities) {
350
- if (poisoned.has(a.id) || projectMuDays === null) {
351
- ls.set(a.id, null);
352
- lf.set(a.id, null);
353
- } else {
354
- lf.set(a.id, projectMuDays);
355
- const exp = expandedById.get(a.id)!;
356
- ls.set(a.id, projectMuDays - exp.mean);
357
- }
358
- }
359
- // Walk topo in reverse. Each outgoing edge contributes an upper bound:
360
- // FS: A.LF ≤ B.LS − lag
361
- // SS: A.LS ≤ B.LS − lag
362
- // FF: A.LF ≤ B.LF − lag
363
- // SF: A.LS ≤ B.LF − lag
364
- // LS then LF are picked so LF − LS = duration, and both bounds hold.
365
- for (let i = topo.length - 1; i >= 0; i--) {
366
- // In-bounds by loop guard.
367
- const id = topo[i]!;
368
- if (poisoned.has(id) || projectMuDays === null) continue;
369
- const out = outgoingEdges.get(id) ?? [];
370
- if (out.length === 0) continue; // already initialized to projectMu
371
-
372
- const exp = expandedById.get(id)!;
373
- const dur = exp.mean;
374
-
375
- let lfUpper = Infinity;
376
- let lsUpper = Infinity;
377
- for (const edge of out) {
378
- const bLs = ls.get(edge.target);
379
- const bLf = lf.get(edge.target);
380
- if (bLs == null || bLf == null) continue;
381
- const lagDays = edge.lag ? toDays(edge.lag, sprintDays) : 0;
382
- switch (edge.type) {
383
- case 'FS':
384
- lfUpper = Math.min(lfUpper, bLs - lagDays);
385
- break;
386
- case 'SS':
387
- lsUpper = Math.min(lsUpper, bLs - lagDays);
388
- break;
389
- case 'FF':
390
- lfUpper = Math.min(lfUpper, bLf - lagDays);
391
- break;
392
- case 'SF':
393
- lsUpper = Math.min(lsUpper, bLf - lagDays);
394
- break;
395
- }
396
- }
397
- let lfVal = lfUpper;
398
- let lsVal = lfVal - dur;
399
- if (lsUpper < lsVal) {
400
- lsVal = lsUpper;
401
- lfVal = lsVal + dur;
402
- }
403
- lf.set(id, lfVal);
404
- ls.set(id, lsVal);
405
- }
406
-
407
- // Critical path = walk backward from `endId` taking the predecessor
408
- // with maximum EF (ties broken by predecessor source-order). This
409
- // gives a single chain through the M-world longest path.
410
- const criticalPath: string[] = [];
411
- if (endId !== null) {
412
- let cursor: string | null = endId;
413
- while (cursor !== null) {
414
- criticalPath.unshift(cursor);
415
- const preds: string[] = predecessors.get(cursor) ?? [];
416
- if (preds.length === 0) break;
417
- let bestPred: string | null = null;
418
- let bestEf = -Infinity;
419
- for (const pid of preds) {
420
- const efp = ef.get(pid);
421
- if (efp != null && efp > bestEf) {
422
- bestEf = efp;
423
- bestPred = pid;
424
- }
425
- }
426
- cursor = bestPred;
427
- }
428
- }
429
- const criticalSet = new Set(criticalPath);
430
-
431
- // Project σ: sqrt(sum of variances on critical path).
432
- let projectSigmaDays: number | null = null;
433
- if (projectMuDays !== null && criticalPath.length > 0) {
434
- let varSum = 0;
435
- for (const id of criticalPath) {
436
- const exp = expandedById.get(id);
437
- if (exp) varSum += exp.sigma * exp.sigma;
438
- }
439
- projectSigmaDays = Math.sqrt(varSum);
440
- }
441
-
442
- // Render activity records (convert canonical days back to options.timeUnit).
443
- const unit = parsed.options.timeUnit;
444
- const resolvedActivities: ResolvedActivity[] = activities.map((a) => {
445
- const exp = expandedById.get(a.id);
446
- const slackDays =
447
- ls.get(a.id) !== null && es.get(a.id) !== null
448
- ? (ls.get(a.id) as number) - (es.get(a.id) as number)
449
- : null;
450
- return {
451
- activity: a,
452
- es: nullableToUnit(es.get(a.id) ?? null, unit, sprintDays),
453
- ef: nullableToUnit(ef.get(a.id) ?? null, unit, sprintDays),
454
- ls: nullableToUnit(ls.get(a.id) ?? null, unit, sprintDays),
455
- lf: nullableToUnit(lf.get(a.id) ?? null, unit, sprintDays),
456
- slack: nullableToUnit(slackDays, unit, sprintDays),
457
- isCriticalPath: criticalSet.has(a.id),
458
- mu: exp ? fromDays(exp.mean, unit, sprintDays) : null,
459
- sigma: exp ? fromDays(exp.sigma, unit, sprintDays) : null,
460
- criticality: null,
461
- isAuthored: a.duration !== null && !a.duration.mOnly && !a.isMilestone,
462
- };
463
- });
464
-
465
- // Resolved groups: μ/σ rolled up along a path through the group. When
466
- // MC is on, the rollup uses the modal-longest-path observed across
467
- // trials; otherwise the M-world critical path.
468
- let monteCarloResult: MonteCarloResult | null = null;
469
-
470
- // Auto-derive mode from data: monte-carlo when at least one
471
- // non-milestone activity carries a duration. M-only activities count —
472
- // the parser fills O and P from `default-confidence`, and analytical
473
- // mode already uses those spreads to compute project σ.
474
- const dataDrivenMC = activities.some((a) => hasDuration(a));
475
- // Trials clamp: nonsense percentiles from low-N samples → fall back
476
- // to analytical and surface the reason in the caption.
477
- const trialsClamped = dataDrivenMC && parsed.options.trials < 100;
478
- let mode: 'monte-carlo' | 'analytical' =
479
- dataDrivenMC && !trialsClamped ? 'monte-carlo' : 'analytical';
480
-
481
- if (mode === 'monte-carlo') {
482
- const allTerminalsPoisoned = activities
483
- .filter((a) => (successors.get(a.id) ?? []).length === 0)
484
- .every((a) => poisoned.has(a.id));
485
- if (allTerminalsPoisoned && activities.length > 0) {
486
- // Silently downgrade — the TBD-fallback caption already names
487
- // the unestimated activities. Erroring would block render even
488
- // when the user didn't ask for MC explicitly (auto-derive).
489
- mode = 'analytical';
490
- } else {
491
- const expandedArr: ExpandedActivity[] = [];
492
- for (const a of activities) {
493
- const exp = expandedById.get(a.id);
494
- if (exp) expandedArr.push({ id: a.id, o: exp.o, m: exp.m, p: exp.p });
495
- }
496
- // Build a preliminary ResolvedPert just for the simulator's graph
497
- // shape. The simulator uses only `activities` (for ids) and `edges`.
498
- const prelim: ResolvedPert = {
499
- options: parsed.options,
500
- activities: resolvedActivities,
501
- edges: edges.slice(),
502
- groups: [],
503
- tagGroups: parsed.tagGroups.slice(),
504
- mode: 'monte-carlo',
505
- summaryRows: null,
506
- projectSubtitle: null,
507
- projectMu: null,
508
- projectSigma: null,
509
- criticalPath,
510
- projectStart: null,
511
- monteCarloResult: null,
512
- expandedActivities: expandedArr,
513
- diagnostics: [],
514
- error: null,
515
- };
516
- monteCarloResult = simulateCanonical(prelim, expandedArr, {
517
- trials: parsed.options.trials,
518
- seed: parsed.options.seed,
519
- });
520
- // Populate per-activity criticality on the resolved activities.
521
- for (const ra of resolvedActivities) {
522
- if (poisoned.has(ra.activity.id)) continue;
523
- const c = monteCarloResult.criticalityByActivity[ra.activity.id];
524
- ra.criticality = typeof c === 'number' ? c : null;
525
- }
526
- // True tornado swings — for each activity, hold every other at μ
527
- // and re-run the deterministic forward pass twice (at O, then at P)
528
- // to measure how much the project end-date moves. Stored in the MC
529
- // result so the renderer can paint two-sided bars.
530
- monteCarloResult.tornadoSwings = computeTornadoSwings(
531
- activities,
532
- topo,
533
- incomingEdges,
534
- expandedById,
535
- poisoned,
536
- monteCarloResult.criticalityByActivity,
537
- sprintDays
538
- );
539
- }
540
- }
541
-
542
- // For hammock rollup: use MC-derived modal-longest-path when MC is on.
543
- const rollupSet =
544
- monteCarloResult && monteCarloResult.modalCriticalPath.length > 0
545
- ? new Set(monteCarloResult.modalCriticalPath)
546
- : criticalSet;
547
- const resolvedById = new Map(
548
- resolvedActivities.map((r) => [r.activity.id, r])
549
- );
550
- const resolvedGroups: ResolvedGroup[] = parsed.groups.map((g) =>
551
- rollupGroup(g, expandedById, resolvedById, rollupSet, unit, sprintDays)
552
- );
553
-
554
- // Always populate the public expanded-activities cache so Workers
555
- // (Phase 3b) can re-run the simulator without needing the original
556
- // ParsedPert. TBDs are omitted; the simulator already treats absent
557
- // entries as zero-duration.
558
- const publicExpanded: ExpandedActivity[] = [];
559
- for (const a of activities) {
560
- const exp = expandedById.get(a.id);
561
- if (exp) publicExpanded.push({ id: a.id, o: exp.o, m: exp.m, p: exp.p });
562
- }
563
-
564
- const projectMuOut =
565
- projectMuDays === null ? null : fromDays(projectMuDays, unit, sprintDays);
566
- const projectSigmaOut =
567
- projectSigmaDays === null
568
- ? null
569
- : fromDays(projectSigmaDays, unit, sprintDays);
570
-
571
- // Derive projectStart from the optional anchor. Renderer reads this
572
- // directly — no mode-specific logic in the presentation layer.
573
- // forward → literal start-date
574
- // backward → end-date − projectMu (in calendar days), rounded once
575
- // no anchor or backward+TBD upstream → null
576
- let projectStart: string | null = null;
577
- const anchor = parsed.options.anchor;
578
- if (anchor !== null) {
579
- if (anchor.kind === 'forward') {
580
- projectStart = anchor.date;
581
- } else if (anchor.kind === 'backward' && projectMuDays !== null) {
582
- projectStart = addCalendarDays(anchor.date, -projectMuDays);
583
- }
584
- }
585
-
586
- const summaryRows = buildSummary({
587
- mode,
588
- projectMu: projectMuOut,
589
- projectSigma: projectSigmaOut,
590
- unit,
591
- parsedActivities: activities,
592
- monteCarloResult,
593
- trialsClamped,
594
- anchor: parsed.options.anchor,
595
- today: parsed.options.today,
596
- ...(sprintDays !== undefined && { sprintDays }),
597
- });
598
-
599
- const projectSubtitle = buildProjectSubtitle({
600
- projectMu: projectMuOut,
601
- projectSigma: projectSigmaOut,
602
- unit,
603
- anchor: parsed.options.anchor,
604
- });
605
-
606
- return {
607
- options: parsed.options,
608
- activities: resolvedActivities,
609
- edges: edges.slice(),
610
- groups: resolvedGroups,
611
- tagGroups: parsed.tagGroups.slice(),
612
- mode,
613
- summaryRows,
614
- projectSubtitle,
615
- projectMu: projectMuOut,
616
- projectSigma: projectSigmaOut,
617
- criticalPath,
618
- projectStart,
619
- monteCarloResult,
620
- expandedActivities: publicExpanded,
621
- diagnostics,
622
- error: parsed.error ?? firstFatal(diagnostics),
623
- };
624
- }
625
-
626
- /**
627
- * True iff the activity has a duration we can simulate — either an
628
- * explicit O/M/P triple or an M-only value (whose O/P are filled from
629
- * `default-confidence`). Milestones report false (zero-duration
630
- * sentinels); zero-duration M-only activities also report false since
631
- * factor × 0 = 0 → no spread for MC to simulate.
632
- */
633
- function hasDuration(a: PertActivity): boolean {
634
- if (a.duration === null || a.isMilestone) return false;
635
- if (a.duration.mOnly) return a.duration.m.amount > 0;
636
- return true;
637
- }
638
-
639
- /**
640
- * Type-aware deterministic forward pass: project end (max EF across
641
- * activities) in canonical days, given a duration per activity.
642
- * Mirrors the edge-aware schedule math in `analyzePert` so SS / FF /
643
- * SF / lag are honored. Poisoned activities contribute 0 days but
644
- * still propagate edges (we treat them as zero-duration sentinels).
645
- */
646
- function computeProjectEndDays(
647
- activities: readonly PertActivity[],
648
- topo: string[],
649
- incomingEdges: Map<string, PertEdge[]>,
650
- durations: Map<string, number>,
651
- poisoned: Set<string>,
652
- sprintDays: number | undefined
653
- ): number {
654
- const es = new Map<string, number>();
655
- const ef = new Map<string, number>();
656
- for (const id of topo) {
657
- const dur = poisoned.has(id) ? 0 : (durations.get(id) ?? 0);
658
- let esLower = 0;
659
- let efLower = -Infinity;
660
- for (const edge of incomingEdges.get(id) ?? []) {
661
- const aEs = es.get(edge.source) ?? 0;
662
- const aEf = ef.get(edge.source) ?? 0;
663
- const lag = edge.lag ? toDays(edge.lag, sprintDays) : 0;
664
- switch (edge.type) {
665
- case 'FS':
666
- esLower = Math.max(esLower, aEf + lag);
667
- break;
668
- case 'SS':
669
- esLower = Math.max(esLower, aEs + lag);
670
- break;
671
- case 'FF':
672
- efLower = Math.max(efLower, aEf + lag);
673
- break;
674
- case 'SF':
675
- efLower = Math.max(efLower, aEs + lag);
676
- break;
677
- }
678
- }
679
- let esVal = esLower;
680
- let efVal = esVal + dur;
681
- if (efLower > efVal) {
682
- efVal = efLower;
683
- esVal = efVal - dur;
684
- }
685
- es.set(id, esVal);
686
- ef.set(id, efVal);
687
- }
688
- let projectEnd = 0;
689
- for (const a of activities) {
690
- const e = ef.get(a.id) ?? 0;
691
- if (e > projectEnd) projectEnd = e;
692
- }
693
- return projectEnd;
694
- }
695
-
696
- /**
697
- * Build true two-sided tornado swings. For each activity:
698
- * 1. Hold every other activity at its μ
699
- * 2. Run the forward pass once with this activity = O → low end
700
- * 3. Run again with this activity = P → high end
701
- * 4. lowSwing = baseline - low end, highSwing = high end - baseline
702
- *
703
- * Returns an array sorted descending by total swing. Milestones,
704
- * poisoned activities, and activities with zero swing in both
705
- * directions are excluded.
706
- */
707
- function computeTornadoSwings(
708
- activities: readonly PertActivity[],
709
- topo: string[],
710
- incomingEdges: Map<string, PertEdge[]>,
711
- expandedById: Map<string, ExpandedEstimate | null>,
712
- poisoned: Set<string>,
713
- criticalityByActivity: Record<string, number>,
714
- sprintDays?: number
715
- ): TornadoSwing[] {
716
- const meansById = new Map<string, number>();
717
- for (const a of activities) {
718
- if (poisoned.has(a.id)) continue;
719
- const exp = expandedById.get(a.id);
720
- if (exp) meansById.set(a.id, exp.mean);
721
- }
722
- const baseline = computeProjectEndDays(
723
- activities,
724
- topo,
725
- incomingEdges,
726
- meansById,
727
- poisoned,
728
- sprintDays
729
- );
730
- const swings: TornadoSwing[] = [];
731
- for (const a of activities) {
732
- if (a.isMilestone || a.duration === null || poisoned.has(a.id)) continue;
733
- const exp = expandedById.get(a.id);
734
- if (!exp) continue;
735
- // O ≈ M means zero-width swing — skip the work AND skip the row.
736
- if (exp.o === exp.m && exp.p === exp.m) continue;
737
-
738
- const lowMap = new Map(meansById);
739
- lowMap.set(a.id, exp.o);
740
- const lowEnd = computeProjectEndDays(
741
- activities,
742
- topo,
743
- incomingEdges,
744
- lowMap,
745
- poisoned,
746
- sprintDays
747
- );
748
-
749
- const highMap = new Map(meansById);
750
- highMap.set(a.id, exp.p);
751
- const highEnd = computeProjectEndDays(
752
- activities,
753
- topo,
754
- incomingEdges,
755
- highMap,
756
- poisoned,
757
- sprintDays
758
- );
759
-
760
- const lowSwing = Math.max(0, baseline - lowEnd);
761
- const highSwing = Math.max(0, highEnd - baseline);
762
- if (lowSwing === 0 && highSwing === 0) continue;
763
- const c = criticalityByActivity[a.id];
764
- swings.push({
765
- id: a.id,
766
- name: a.name,
767
- lowSwing,
768
- highSwing,
769
- criticality: typeof c === 'number' ? c : null,
770
- });
771
- }
772
- swings.sort((a, b) => b.lowSwing + b.highSwing - (a.lowSwing + a.highSwing));
773
- return swings;
774
- }
775
-
776
- // ============================================================
777
- // Helpers
778
- // ============================================================
779
-
780
- function nullableToUnit(
781
- value: number | null,
782
- unit: DurationUnit,
783
- sprintDays?: number
784
- ): number | null {
785
- return value === null ? null : fromDays(value, unit, sprintDays);
786
- }
787
-
788
- function firstFatal(diagnostics: DgmoError[]): string | null {
789
- const f = diagnostics.find((d) => d.severity === 'error');
790
- return f ? f.message : null;
791
- }
792
-
793
- function rollupGroup(
794
- group: PertGroup,
795
- expandedById: Map<string, ExpandedEstimate | null>,
796
- resolvedById: Map<string, ResolvedActivity>,
797
- criticalSet: Set<string>,
798
- unit: DurationUnit,
799
- sprintDays?: number
800
- ): ResolvedGroup {
801
- let muDays = 0;
802
- let varDays = 0;
803
- let usable = false;
804
- for (const id of group.activityIds) {
805
- if (!criticalSet.has(id)) continue;
806
- const exp = expandedById.get(id);
807
- if (!exp) continue;
808
- muDays += exp.mean;
809
- varDays += exp.sigma * exp.sigma;
810
- usable = true;
811
- }
812
-
813
- // Schedule envelope across member activities (in source unit, not days).
814
- let es: number | null = null;
815
- let ef: number | null = null;
816
- let ls: number | null = null;
817
- let lf: number | null = null;
818
- let criticality: number | null = null;
819
- for (const id of group.activityIds) {
820
- const r = resolvedById.get(id);
821
- if (!r) continue;
822
- if (r.es !== null) es = es === null ? r.es : Math.min(es, r.es);
823
- if (r.ef !== null) ef = ef === null ? r.ef : Math.max(ef, r.ef);
824
- if (r.ls !== null) ls = ls === null ? r.ls : Math.min(ls, r.ls);
825
- if (r.lf !== null) lf = lf === null ? r.lf : Math.max(lf, r.lf);
826
- if (r.criticality !== null) {
827
- criticality =
828
- criticality === null
829
- ? r.criticality
830
- : Math.max(criticality, r.criticality);
831
- }
832
- }
833
- const slack = ls !== null && es !== null ? ls - es : null;
834
-
835
- return {
836
- group,
837
- rolledMu: usable ? fromDays(muDays, unit, sprintDays) : null,
838
- rolledSigma: usable ? fromDays(Math.sqrt(varDays), unit, sprintDays) : null,
839
- entries: [],
840
- exits: [],
841
- es,
842
- ef,
843
- ls,
844
- lf,
845
- slack,
846
- criticality,
847
- };
848
- }
849
-
850
- // ============================================================
851
- // Caption (project-stats summary) builder
852
- // ============================================================
853
-
854
- /**
855
- * Build the project-stats caption emitted as `ResolvedPert.summaryRows`.
856
- * Each `CaptionRow` is one bullet in the rendered caption box. Returns
857
- * `null` when the analyzer produced no output (e.g. cycle bailout).
858
- */
859
- export interface BuildSummaryInput {
860
- mode: 'monte-carlo' | 'analytical';
861
- projectMu: number | null;
862
- projectSigma: number | null;
863
- unit: DurationUnit;
864
- parsedActivities: readonly PertActivity[];
865
- monteCarloResult: MonteCarloResult | null;
866
- trialsClamped: boolean;
867
- /**
868
- * Date anchor — when set, "Expected duration" becomes a date and
869
- * Monte-Carlo percentiles render as ISO dates instead of durations.
870
- * Forward → finish-date rows; backward → latest-safe-start rows.
871
- */
872
- anchor?: Anchor;
873
- /**
874
- * Parse-time "today" (ISO YYYY-MM-DD). Used to flag backward-mode
875
- * latest-safe-start rows that have already passed. Empty string is
876
- * treated as "unknown" — no past flagging.
877
- */
878
- today: string;
879
- /**
880
- * Days-per-sprint when sprint mode is active. Used to convert
881
- * Monte-Carlo percentile durations (canonical days) into the
882
- * displayed unit when `unit === 's'`.
883
- */
884
- sprintDays?: number;
885
- }
886
-
887
- /**
888
- * Round a fractional canonical-days duration toward whichever direction
889
- * pushes the resulting date further from "now":
890
- * - forward → ceil (the finish lands later, more conservative)
891
- * - backward → ceil (the latest-safe start lands earlier, more
892
- * conservative — `end_date − ceil(d)` is smaller)
893
- *
894
- * Both modes ceil because callers apply opposite signs (`+` vs `−`).
895
- * Keeping the rule in one helper avoids drift between caption math
896
- * and S-curve math.
897
- */
898
- function roundConservative(
899
- durationDays: number,
900
- _mode: 'forward' | 'backward'
901
- ): number {
902
- return Math.ceil(durationDays);
903
- }
904
-
905
- export function buildSummary(input: BuildSummaryInput): CaptionRow[] | null {
906
- const {
907
- mode,
908
- projectMu,
909
- projectSigma,
910
- unit,
911
- parsedActivities,
912
- monteCarloResult,
913
- trialsClamped,
914
- sprintDays,
915
- today,
916
- } = input;
917
- const anchor = input.anchor ?? null;
918
-
919
- if (parsedActivities.length === 0) return null;
920
-
921
- // Indeterminate projectMu — TBD upstream of the sink.
922
- if (projectMu === null) {
923
- // Backward + TBD: spec §13A.12 mandates `?` placeholders for both
924
- // Expected duration AND each percentile latest-safe start so the
925
- // caption shape stays parallel to the feasible case (one top row +
926
- // three percentile sub-rows).
927
- if (anchor?.kind === 'backward') {
928
- return [
929
- { text: 'Expected duration: ?', level: 0 },
930
- { text: 'P50 latest-safe start: ?', level: 0 },
931
- { text: 'P80 latest-safe start: ?', level: 0 },
932
- { text: 'P95 latest-safe start: ?', level: 0 },
933
- ];
934
- }
935
- // Forward / unanchored: existing wording — names the unestimated
936
- // activities so the author knows what to fill in.
937
- const tbdCount = parsedActivities.filter(
938
- (a) => a.duration === null && !a.isMilestone
939
- ).length;
940
- return [
941
- {
942
- text: `Expected duration unknown — ${tbdCount} ${
943
- tbdCount === 1 ? 'activity has' : 'activities have'
944
- } no estimate.`,
945
- level: 0,
946
- },
947
- ];
948
- }
949
-
950
- const rows: CaptionRow[] = [];
951
- const mc = mode === 'monte-carlo' && monteCarloResult !== null;
952
- const sigmaPositive = projectSigma !== null && projectSigma > 0;
953
- const showMcDetail = mc && sigmaPositive;
954
-
955
- // 1. Expected duration / finish / start — fold σ into a "(± X)"
956
- // parenthetical when MC ran with σ > 0. Reads more naturally than a
957
- // separate "Standard deviation:" bullet; for a roughly-normal
958
- // distribution ±1σ covers ~68% of outcomes.
959
- const sigmaParen = showMcDetail
960
- ? ` (± ${roundForCaption(projectSigma!)} ${pluralizeUnit(projectSigma!, unit)})`
961
- : '';
962
- if (anchor?.kind === 'forward') {
963
- const projectMuDays = projectMu * unitToDays(unit);
964
- rows.push({
965
- text: `Expected finish: ${addCalendarDays(anchor.date, projectMuDays)}${sigmaParen}.`,
966
- level: 0,
967
- });
968
- } else if (anchor?.kind === 'backward') {
969
- const projectMuDays = projectMu * unitToDays(unit);
970
- rows.push({
971
- text: `Expected start: ${addCalendarDays(anchor.date, -projectMuDays)}${sigmaParen}.`,
972
- level: 0,
973
- });
974
- } else {
975
- const muStr = `${roundForCaption(projectMu)} ${pluralizeUnit(projectMu, unit)}`;
976
- rows.push({
977
- text: `Expected duration: ${muStr}${sigmaParen}.`,
978
- level: 0,
979
- });
980
- }
981
-
982
- // 2. Percentiles — uniform shape per spec §13A.10/§13A.12:
983
- // - Forward: P{X} finish: <date>
984
- // - Backward: P{X} latest-safe start: <date> [(latest-safe start has passed)]
985
- // - Unanchored: P{X}: <duration>
986
- // Sub-rows (level 1) so they indent under the preceding "Expected …"
987
- // row, matching the existing visual rhythm.
988
- if (showMcDetail) {
989
- const percentiles: Array<{ pct: 50 | 80 | 95; days: number }> = [
990
- { pct: 50, days: monteCarloResult!.p50 },
991
- { pct: 80, days: monteCarloResult!.p80 },
992
- { pct: 95, days: monteCarloResult!.p95 },
993
- ];
994
- if (anchor?.kind === 'forward') {
995
- for (const { pct, days } of percentiles) {
996
- const offsetDays = roundConservative(days, 'forward');
997
- const date = addCalendarDays(anchor.date, offsetDays);
998
- rows.push({ text: `P${pct} finish: ${date}.`, level: 1 });
999
- }
1000
- } else if (anchor?.kind === 'backward') {
1001
- for (const { pct, days } of percentiles) {
1002
- const offsetDays = roundConservative(days, 'backward');
1003
- const date = addCalendarDays(anchor.date, -offsetDays);
1004
- // ISO YYYY-MM-DD strings are lexicographically sortable, so a
1005
- // string compare is the correct past-date check here.
1006
- const isPast = today.length > 0 && date < today;
1007
- const suffix = isPast ? ' (latest-safe start has passed)' : '';
1008
- const row: CaptionRow = {
1009
- text: `P${pct} latest-safe start: ${date}${suffix}`,
1010
- level: 1,
1011
- };
1012
- if (isPast) row.isPast = true;
1013
- rows.push(row);
1014
- }
1015
- } else {
1016
- for (const { pct, days } of percentiles) {
1017
- const v = fromDays(days, unit, sprintDays);
1018
- rows.push({ text: `P${pct}: ${formatPercentile(v, unit)}.`, level: 1 });
1019
- }
1020
- }
1021
- }
1022
-
1023
- // 3. Zero-variance fallback (replaces percentile rows when O=M=P
1024
- // across every authored activity).
1025
- if (mc && projectSigma === 0) {
1026
- const filtered: CaptionRow[] = rows.filter((r) =>
1027
- r.text.startsWith('Expected ')
1028
- );
1029
- filtered.push({
1030
- text: '(No variance in estimates — all activities have O = M = P.)',
1031
- level: 0,
1032
- });
1033
- return filtered;
1034
- }
1035
-
1036
- // 4. Trials caveat (mode auto-derived to MC then clamped back).
1037
- if (trialsClamped) {
1038
- rows.push({
1039
- text: 'Insufficient trials configured (`trials` < 100) — falling back to deterministic analysis.',
1040
- level: 0,
1041
- });
1042
- }
1043
-
1044
- return rows;
1045
- }
1046
-
1047
- /**
1048
- * Build the one-line project subtitle rendered under the diagram title.
1049
- * Distinct from `buildSummary` because the subtitle sits in the title
1050
- * region (always visible, independent of the Analysis-row toggle) and
1051
- * collapses the σ + duration restatement into a single bullet-free line.
1052
- *
1053
- * Shape per mode (§13A.7):
1054
- * - Forward: `Expected finish: <date> · ≈ <μ> <unit> of work (± <σ>)`
1055
- * - Backward: `Expected start: <date> · ≈ <μ> <unit> lead time (± <σ>)`
1056
- * - Unanchored: `≈ <μ> <unit> (± <σ>)`
1057
- *
1058
- * Returns null when projectMu is indeterminate (TBD upstream) AND no
1059
- * anchor is present — there's nothing useful to say. With an anchor we
1060
- * still render `Expected finish: ? · ≈ ? <unit>` to preserve the slot.
1061
- */
1062
- export function buildProjectSubtitle(input: {
1063
- projectMu: number | null;
1064
- projectSigma: number | null;
1065
- unit: DurationUnit;
1066
- anchor?: Anchor;
1067
- }): string | null {
1068
- const anchor = input.anchor ?? null;
1069
- const { projectMu, projectSigma, unit } = input;
1070
-
1071
- const sigmaPositive = projectSigma !== null && projectSigma > 0;
1072
- const sigmaParen = sigmaPositive
1073
- ? ` (± ${roundForCaption(projectSigma!)})`
1074
- : '';
1075
-
1076
- if (projectMu === null) {
1077
- // Anchored + TBD: keep the framing prefix, mark the math as ?.
1078
- if (anchor?.kind === 'forward') {
1079
- return `Expected finish: ? · ≈ ? ${pluralizeUnit(2, unit)} of work`;
1080
- }
1081
- if (anchor?.kind === 'backward') {
1082
- return `Expected start: ? · ≈ ? ${pluralizeUnit(2, unit)} lead time`;
1083
- }
1084
- // Unanchored + TBD: surface that the total is unknown. The per-node
1085
- // `?` placeholders convey which activities are missing estimates.
1086
- return `≈ ? ${pluralizeUnit(2, unit)}`;
1087
- }
1088
-
1089
- const muStr = `${roundForCaption(projectMu)} ${pluralizeUnit(projectMu, unit)}`;
1090
-
1091
- if (anchor?.kind === 'forward') {
1092
- const projectMuDays = projectMu * unitToDays(unit);
1093
- return `Expected finish: ${addCalendarDays(anchor.date, projectMuDays)} · ≈ ${muStr} of work${sigmaParen}`;
1094
- }
1095
- if (anchor?.kind === 'backward') {
1096
- const projectMuDays = projectMu * unitToDays(unit);
1097
- return `Expected start: ${addCalendarDays(anchor.date, -projectMuDays)} · ≈ ${muStr} lead time${sigmaParen}`;
1098
- }
1099
- return `≈ ${muStr}${sigmaParen}`;
1100
- }
1101
-
1102
- function roundForCaption(n: number): string {
1103
- let rounded: number;
1104
- const abs = Math.abs(n);
1105
- if (abs < 10) rounded = Math.round(n * 100) / 100;
1106
- else if (abs < 100) rounded = Math.round(n * 10) / 10;
1107
- else rounded = Math.round(n);
1108
- // Trim trailing zeros after the decimal: 29.20 → 29.2, 29.00 → 29.
1109
- const str = rounded.toString();
1110
- return str.includes('.') ? str.replace(/\.?0+$/, '') : str;
1111
- }
1112
-
1113
- const UNIT_WORDS: Record<DurationUnit, [string, string]> = {
1114
- min: ['minute', 'minutes'],
1115
- h: ['hour', 'hours'],
1116
- d: ['day', 'days'],
1117
- bd: ['business day', 'business days'],
1118
- w: ['week', 'weeks'],
1119
- m: ['month', 'months'],
1120
- q: ['quarter', 'quarters'],
1121
- y: ['year', 'years'],
1122
- s: ['sprint', 'sprints'],
1123
- };
1124
-
1125
- function pluralizeUnit(value: number, unit: DurationUnit): string {
1126
- const [singular, plural] = UNIT_WORDS[unit];
1127
- return roundForCaption(value) === '1' ? singular : plural;
1128
- }
1129
-
1130
- function formatPercentile(value: number, unit: DurationUnit): string {
1131
- return `${roundForCaption(value)} ${pluralizeUnit(value, unit)}`;
1132
- }
1133
-
1134
- function emptyResolved(
1135
- parsed: ParsedPert,
1136
- diagnostics: DgmoError[]
1137
- ): ResolvedPert {
1138
- return {
1139
- options: parsed.options,
1140
- activities: parsed.activities.map((a) => ({
1141
- activity: a,
1142
- es: null,
1143
- ef: null,
1144
- ls: null,
1145
- lf: null,
1146
- slack: null,
1147
- isCriticalPath: false,
1148
- mu: null,
1149
- sigma: null,
1150
- criticality: null,
1151
- isAuthored: false,
1152
- })),
1153
- edges: parsed.edges.slice(),
1154
- groups: parsed.groups.map((g) => ({
1155
- group: g,
1156
- rolledMu: null,
1157
- rolledSigma: null,
1158
- entries: [],
1159
- exits: [],
1160
- es: null,
1161
- ef: null,
1162
- ls: null,
1163
- lf: null,
1164
- slack: null,
1165
- criticality: null,
1166
- })),
1167
- tagGroups: parsed.tagGroups.slice(),
1168
- mode: 'analytical',
1169
- summaryRows: null,
1170
- projectSubtitle: null,
1171
- projectMu: null,
1172
- projectSigma: null,
1173
- criticalPath: [],
1174
- projectStart: null,
1175
- monteCarloResult: null,
1176
- expandedActivities: [],
1177
- diagnostics,
1178
- error: firstFatal(diagnostics) ?? parsed.error,
1179
- };
1180
- }
1181
-
1182
- // Re-export `_unused` markers so lint doesn't complain (PertEdge is read above).
1183
- export type { PertEdge };