@diagrammo/dgmo 0.47.0 → 0.48.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 (319) hide show
  1. package/dist/auto.js +1 -1
  2. package/dist/auto.mjs +1 -1
  3. package/dist/element.js +1 -1
  4. package/dist/element.mjs +1 -1
  5. package/package.json +19 -70
  6. package/dist/advanced.cjs +0 -71648
  7. package/dist/advanced.d.cts +0 -5719
  8. package/dist/auto.cjs +0 -70648
  9. package/dist/auto.d.cts +0 -54
  10. package/dist/block.cjs +0 -70208
  11. package/dist/block.d.cts +0 -115
  12. package/dist/editor.cjs +0 -526
  13. package/dist/editor.d.cts +0 -26
  14. package/dist/element.cjs +0 -70530
  15. package/dist/element.d.cts +0 -62
  16. package/dist/highlight.cjs +0 -852
  17. package/dist/highlight.d.cts +0 -49
  18. package/dist/index.cjs +0 -70491
  19. package/dist/index.d.cts +0 -160
  20. package/dist/pert.cjs +0 -325
  21. package/dist/pert.d.cts +0 -559
  22. package/dist/themes-1-CuKpeH.d.cts +0 -908
  23. package/src/advanced.ts +0 -873
  24. package/src/arc/parser.ts +0 -15
  25. package/src/arc/renderer.ts +0 -633
  26. package/src/auto/index.ts +0 -621
  27. package/src/auto/shared.ts +0 -370
  28. package/src/auto/styles.ts +0 -73
  29. package/src/block/layout.ts +0 -198
  30. package/src/block/parser.ts +0 -427
  31. package/src/block/renderer.ts +0 -484
  32. package/src/block/types.ts +0 -67
  33. package/src/boxes-and-lines/collapse.ts +0 -96
  34. package/src/boxes-and-lines/focus.ts +0 -257
  35. package/src/boxes-and-lines/label-placement.ts +0 -339
  36. package/src/boxes-and-lines/layout-grouped.ts +0 -912
  37. package/src/boxes-and-lines/layout-layered.ts +0 -722
  38. package/src/boxes-and-lines/layout-search.ts +0 -1678
  39. package/src/boxes-and-lines/layout.ts +0 -398
  40. package/src/boxes-and-lines/parser.ts +0 -1181
  41. package/src/boxes-and-lines/renderer.ts +0 -1465
  42. package/src/boxes-and-lines/types.ts +0 -66
  43. package/src/c4/layout.ts +0 -2441
  44. package/src/c4/parser.ts +0 -1158
  45. package/src/c4/renderer.ts +0 -2008
  46. package/src/c4/types.ts +0 -89
  47. package/src/chart-type-registry.ts +0 -529
  48. package/src/chart-types.ts +0 -225
  49. package/src/chart.ts +0 -685
  50. package/src/charts-d3/bar.ts +0 -338
  51. package/src/charts-d3/chord.ts +0 -140
  52. package/src/charts-d3/function.ts +0 -147
  53. package/src/charts-d3/funnel.ts +0 -104
  54. package/src/charts-d3/heatmap.ts +0 -145
  55. package/src/charts-d3/index.ts +0 -337
  56. package/src/charts-d3/interactions.ts +0 -477
  57. package/src/charts-d3/line.ts +0 -344
  58. package/src/charts-d3/mount.ts +0 -151
  59. package/src/charts-d3/pie.ts +0 -117
  60. package/src/charts-d3/polar.ts +0 -89
  61. package/src/charts-d3/radar.ts +0 -0
  62. package/src/charts-d3/sankey.ts +0 -0
  63. package/src/charts-d3/scatter.ts +0 -185
  64. package/src/charts-d3/shared.ts +0 -208
  65. package/src/class/layout.ts +0 -326
  66. package/src/class/parser.ts +0 -635
  67. package/src/class/renderer.ts +0 -793
  68. package/src/class/types.ts +0 -58
  69. package/src/cli-banner.ts +0 -106
  70. package/src/cli.ts +0 -1080
  71. package/src/colors-diagnostics.ts +0 -35
  72. package/src/colors.ts +0 -379
  73. package/src/completion-types.ts +0 -27
  74. package/src/completion.ts +0 -2668
  75. package/src/cycle/layout.ts +0 -1026
  76. package/src/cycle/parser.ts +0 -387
  77. package/src/cycle/renderer.ts +0 -653
  78. package/src/cycle/types.ts +0 -101
  79. package/src/d3.ts +0 -1434
  80. package/src/data-chart-parser.ts +0 -1007
  81. package/src/dgmo-router.ts +0 -296
  82. package/src/diagnostics-registry.ts +0 -108
  83. package/src/diagnostics.ts +0 -0
  84. package/src/dimensions.ts +0 -20
  85. package/src/directives-registry.ts +0 -396
  86. package/src/editor/dgmo.grammar +0 -76
  87. package/src/editor/dgmo.grammar.d.ts +0 -2
  88. package/src/editor/dgmo.grammar.js +0 -18
  89. package/src/editor/dgmo.grammar.terms.d.ts +0 -5
  90. package/src/editor/dgmo.grammar.terms.js +0 -35
  91. package/src/editor/highlight-api.ts +0 -596
  92. package/src/editor/highlight.ts +0 -36
  93. package/src/editor/index.ts +0 -27
  94. package/src/editor/keywords.ts +0 -128
  95. package/src/editor/tokens.ts +0 -30
  96. package/src/element/index.ts +0 -432
  97. package/src/embed/css.ts +0 -312
  98. package/src/embed/escape.ts +0 -15
  99. package/src/embed/index.ts +0 -280
  100. package/src/er/classify.ts +0 -228
  101. package/src/er/layout.ts +0 -530
  102. package/src/er/parser.ts +0 -750
  103. package/src/er/renderer.ts +0 -725
  104. package/src/er/types.ts +0 -52
  105. package/src/error-card.ts +0 -269
  106. package/src/event-line/diagnostics.ts +0 -117
  107. package/src/event-line/parser.ts +0 -643
  108. package/src/event-line/renderer.ts +0 -1742
  109. package/src/event-line/types.ts +0 -74
  110. package/src/fonts.ts +0 -3
  111. package/src/gantt/calculator.ts +0 -979
  112. package/src/gantt/parser.ts +0 -1641
  113. package/src/gantt/renderer.ts +0 -3787
  114. package/src/gantt/resolver.ts +0 -172
  115. package/src/gantt/types.ts +0 -206
  116. package/src/graph/diagnostics.ts +0 -64
  117. package/src/graph/edge-spline.ts +0 -29
  118. package/src/graph/flowchart-parser.ts +0 -759
  119. package/src/graph/flowchart-renderer.ts +0 -817
  120. package/src/graph/layout.ts +0 -472
  121. package/src/graph/notes.ts +0 -21
  122. package/src/graph/state-collapse.ts +0 -78
  123. package/src/graph/state-parser.ts +0 -574
  124. package/src/graph/state-renderer.ts +0 -653
  125. package/src/graph/types.ts +0 -65
  126. package/src/index.ts +0 -261
  127. package/src/infra/compute.ts +0 -1385
  128. package/src/infra/layout.ts +0 -888
  129. package/src/infra/parser.ts +0 -1234
  130. package/src/infra/renderer.ts +0 -2534
  131. package/src/infra/roles.ts +0 -75
  132. package/src/infra/types.ts +0 -216
  133. package/src/infra/validation.ts +0 -201
  134. package/src/journey-map/layout.ts +0 -490
  135. package/src/journey-map/parser.ts +0 -557
  136. package/src/journey-map/renderer.ts +0 -1580
  137. package/src/journey-map/types.ts +0 -47
  138. package/src/kanban/mutations.ts +0 -175
  139. package/src/kanban/parser.ts +0 -509
  140. package/src/kanban/renderer.ts +0 -1449
  141. package/src/kanban/types.ts +0 -39
  142. package/src/label-layout.ts +0 -368
  143. package/src/map/colorize.ts +0 -54
  144. package/src/map/completion.ts +0 -316
  145. package/src/map/context-labels.ts +0 -609
  146. package/src/map/data/PROVENANCE.json +0 -1
  147. package/src/map/data/README.md +0 -40
  148. package/src/map/data/airport-collisions.json +0 -1
  149. package/src/map/data/airports.json +0 -1
  150. package/src/map/data/gazetteer.json +0 -1
  151. package/src/map/data/lakes.json +0 -1
  152. package/src/map/data/mountain-ranges.json +0 -1
  153. package/src/map/data/na-lakes.json +0 -1
  154. package/src/map/data/na-land.json +0 -1
  155. package/src/map/data/region-names.json +0 -1
  156. package/src/map/data/rivers.json +0 -1
  157. package/src/map/data/types.ts +0 -121
  158. package/src/map/data/us-states.json +0 -1
  159. package/src/map/data/water-bodies.json +0 -1
  160. package/src/map/data/world-coarse.json +0 -1
  161. package/src/map/data/world-detail.json +0 -1
  162. package/src/map/diagnostics.ts +0 -134
  163. package/src/map/dimensions.ts +0 -150
  164. package/src/map/geo-query.ts +0 -295
  165. package/src/map/geo.ts +0 -438
  166. package/src/map/invert.ts +0 -111
  167. package/src/map/layout.ts +0 -5069
  168. package/src/map/legend-band.ts +0 -127
  169. package/src/map/load-data.ts +0 -186
  170. package/src/map/parser.ts +0 -783
  171. package/src/map/renderer.ts +0 -1445
  172. package/src/map/resolved-types.ts +0 -160
  173. package/src/map/resolver.ts +0 -1146
  174. package/src/map/types.ts +0 -194
  175. package/src/mindmap/collapse.ts +0 -67
  176. package/src/mindmap/layout.ts +0 -649
  177. package/src/mindmap/parser.ts +0 -430
  178. package/src/mindmap/renderer.ts +0 -637
  179. package/src/mindmap/text-wrap.ts +0 -218
  180. package/src/mindmap/types.ts +0 -56
  181. package/src/org/collapse.ts +0 -151
  182. package/src/org/layout.ts +0 -1262
  183. package/src/org/parser.ts +0 -519
  184. package/src/org/renderer.ts +0 -866
  185. package/src/org/resolver.ts +0 -636
  186. package/src/palettes/atlas.ts +0 -77
  187. package/src/palettes/blueprint.ts +0 -73
  188. package/src/palettes/catppuccin.ts +0 -84
  189. package/src/palettes/color-utils.ts +0 -443
  190. package/src/palettes/index.ts +0 -67
  191. package/src/palettes/nord.ts +0 -67
  192. package/src/palettes/registry.ts +0 -114
  193. package/src/palettes/slate.ts +0 -73
  194. package/src/palettes/tidewater.ts +0 -73
  195. package/src/palettes/tokyo-night.ts +0 -86
  196. package/src/palettes/types.ts +0 -88
  197. package/src/pert/analyzer.ts +0 -1183
  198. package/src/pert/diagnostics.ts +0 -110
  199. package/src/pert/index.ts +0 -43
  200. package/src/pert/internal.ts +0 -252
  201. package/src/pert/layout.ts +0 -971
  202. package/src/pert/monte-carlo.ts +0 -446
  203. package/src/pert/parser.ts +0 -1606
  204. package/src/pert/renderer.ts +0 -4009
  205. package/src/pert/share-normalize.ts +0 -62
  206. package/src/pert/types.ts +0 -604
  207. package/src/pyramid/parser.ts +0 -185
  208. package/src/pyramid/renderer.ts +0 -768
  209. package/src/pyramid/types.ts +0 -28
  210. package/src/quadrant/parser.ts +0 -18
  211. package/src/quadrant/renderer.ts +0 -638
  212. package/src/raci/diagnostics.ts +0 -213
  213. package/src/raci/index.ts +0 -23
  214. package/src/raci/mutations.ts +0 -321
  215. package/src/raci/parser.ts +0 -699
  216. package/src/raci/renderer.ts +0 -1627
  217. package/src/raci/types.ts +0 -82
  218. package/src/raci/variants.ts +0 -320
  219. package/src/render.ts +0 -268
  220. package/src/ring/parser.ts +0 -212
  221. package/src/ring/renderer.ts +0 -490
  222. package/src/ring/types.ts +0 -27
  223. package/src/sequence/collapse.ts +0 -174
  224. package/src/sequence/parser.ts +0 -1596
  225. package/src/sequence/participant-inference.ts +0 -152
  226. package/src/sequence/renderer.ts +0 -3168
  227. package/src/sequence/tag-resolution.ts +0 -164
  228. package/src/sharing.ts +0 -211
  229. package/src/sitemap/collapse.ts +0 -100
  230. package/src/sitemap/layout.ts +0 -925
  231. package/src/sitemap/parser.ts +0 -755
  232. package/src/sitemap/renderer.ts +0 -828
  233. package/src/sitemap/types.ts +0 -42
  234. package/src/slope/parser.ts +0 -18
  235. package/src/slope/renderer.ts +0 -378
  236. package/src/swimlane/diagnostics.ts +0 -94
  237. package/src/swimlane/layout.ts +0 -597
  238. package/src/swimlane/parser.ts +0 -613
  239. package/src/swimlane/renderer.ts +0 -681
  240. package/src/swimlane/types.ts +0 -151
  241. package/src/tech-radar/interactive.ts +0 -1129
  242. package/src/tech-radar/layout.ts +0 -197
  243. package/src/tech-radar/parser.ts +0 -436
  244. package/src/tech-radar/renderer.ts +0 -1239
  245. package/src/tech-radar/shared.ts +0 -151
  246. package/src/tech-radar/types.ts +0 -86
  247. package/src/themes.ts +0 -22
  248. package/src/timeline/parser.ts +0 -472
  249. package/src/timeline/renderer.ts +0 -3024
  250. package/src/timeline/types.ts +0 -33
  251. package/src/timeline/viz-parser.ts +0 -21
  252. package/src/treemap/diagnostics.ts +0 -62
  253. package/src/treemap/layout-radial.ts +0 -233
  254. package/src/treemap/layout.ts +0 -189
  255. package/src/treemap/parser.ts +0 -463
  256. package/src/treemap/renderer-radial.ts +0 -438
  257. package/src/treemap/renderer.ts +0 -572
  258. package/src/treemap/treemap-shared.ts +0 -309
  259. package/src/treemap/types.ts +0 -60
  260. package/src/utils/arrow-markers.ts +0 -51
  261. package/src/utils/arrows-diagnostics.ts +0 -62
  262. package/src/utils/arrows.ts +0 -227
  263. package/src/utils/brand.ts +0 -61
  264. package/src/utils/card.ts +0 -183
  265. package/src/utils/collapse-engine/tree-edges.ts +0 -115
  266. package/src/utils/collapse-engine/tree.ts +0 -117
  267. package/src/utils/d3-helpers.ts +0 -219
  268. package/src/utils/d3-types.ts +0 -9
  269. package/src/utils/description-helpers.ts +0 -38
  270. package/src/utils/duration.ts +0 -312
  271. package/src/utils/export-container.ts +0 -41
  272. package/src/utils/expr-eval.ts +0 -27
  273. package/src/utils/fit-canvas.ts +0 -64
  274. package/src/utils/hover-styles.ts +0 -673
  275. package/src/utils/inline-markdown.ts +0 -96
  276. package/src/utils/legend-constants.ts +0 -48
  277. package/src/utils/legend-d3.ts +0 -618
  278. package/src/utils/legend-integration.ts +0 -107
  279. package/src/utils/legend-layout.ts +0 -776
  280. package/src/utils/legend-svg.ts +0 -214
  281. package/src/utils/legend-types.ts +0 -272
  282. package/src/utils/name-normalize.ts +0 -163
  283. package/src/utils/note-box/constants.ts +0 -25
  284. package/src/utils/note-box/index.ts +0 -11
  285. package/src/utils/note-box/metrics.ts +0 -90
  286. package/src/utils/note-box/svg.ts +0 -331
  287. package/src/utils/notes/bounds.ts +0 -30
  288. package/src/utils/notes/build.ts +0 -131
  289. package/src/utils/notes/index.ts +0 -18
  290. package/src/utils/notes/model.ts +0 -19
  291. package/src/utils/notes/parse.ts +0 -131
  292. package/src/utils/notes/place.ts +0 -177
  293. package/src/utils/notes/resolve.ts +0 -88
  294. package/src/utils/number-format.ts +0 -36
  295. package/src/utils/parsing.ts +0 -953
  296. package/src/utils/reserved-key-registry.ts +0 -239
  297. package/src/utils/safe-href.ts +0 -38
  298. package/src/utils/scaling.ts +0 -97
  299. package/src/utils/svg-embed.ts +0 -251
  300. package/src/utils/tag-groups.ts +0 -607
  301. package/src/utils/text-measure.ts +0 -122
  302. package/src/utils/time-ticks.ts +0 -253
  303. package/src/utils/title-constants.ts +0 -24
  304. package/src/utils/visual-conventions.ts +0 -61
  305. package/src/utils/wrapped-desc.ts +0 -87
  306. package/src/venn/parser.ts +0 -18
  307. package/src/venn/renderer.ts +0 -1047
  308. package/src/version-control/diagnostics.ts +0 -67
  309. package/src/version-control/parser.ts +0 -544
  310. package/src/version-control/renderer.ts +0 -724
  311. package/src/version-control/types.ts +0 -100
  312. package/src/visualizations/parse.ts +0 -1560
  313. package/src/visualizations/types.ts +0 -262
  314. package/src/wireframe/layout.ts +0 -479
  315. package/src/wireframe/parser.ts +0 -1114
  316. package/src/wireframe/renderer.ts +0 -1318
  317. package/src/wireframe/types.ts +0 -110
  318. package/src/wordcloud/parser.ts +0 -18
  319. 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 };