@kitsy/coop-core 0.0.1 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/chunk-UK4JN4TZ.js +951 -0
- package/dist/index.cjs +3866 -240
- package/dist/index.d.cts +832 -14
- package/dist/index.d.ts +832 -14
- package/dist/index.js +2886 -245
- package/dist/planning/monte-carlo-worker.cjs +670 -0
- package/dist/planning/monte-carlo-worker.d.cts +2 -0
- package/dist/planning/monte-carlo-worker.d.ts +2 -0
- package/dist/planning/monte-carlo-worker.js +14 -0
- package/package.json +2 -2
|
@@ -0,0 +1,951 @@
|
|
|
1
|
+
// src/planning/estimation.ts
|
|
2
|
+
var COMPLEXITY_FALLBACK_HOURS = {
|
|
3
|
+
trivial: 1,
|
|
4
|
+
small: 4,
|
|
5
|
+
medium: 12,
|
|
6
|
+
large: 32,
|
|
7
|
+
unknown: Number.NaN
|
|
8
|
+
};
|
|
9
|
+
function validateEstimateValue(value, field) {
|
|
10
|
+
if (!Number.isFinite(value) || value <= 0) {
|
|
11
|
+
throw new Error(`estimate.${field} must be a positive number.`);
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
function complexity_fallback_hours(complexity) {
|
|
15
|
+
if (!complexity) {
|
|
16
|
+
throw new Error("Task effort cannot be computed: missing complexity and estimate.");
|
|
17
|
+
}
|
|
18
|
+
if (complexity === "unknown") {
|
|
19
|
+
throw new Error("Task effort cannot be computed: complexity is 'unknown' and estimate is missing.");
|
|
20
|
+
}
|
|
21
|
+
return COMPLEXITY_FALLBACK_HOURS[complexity];
|
|
22
|
+
}
|
|
23
|
+
function pert_hours(estimate) {
|
|
24
|
+
validateEstimateValue(estimate.optimistic_hours, "optimistic_hours");
|
|
25
|
+
validateEstimateValue(estimate.expected_hours, "expected_hours");
|
|
26
|
+
validateEstimateValue(estimate.pessimistic_hours, "pessimistic_hours");
|
|
27
|
+
return (estimate.optimistic_hours + 4 * estimate.expected_hours + estimate.pessimistic_hours) / 6;
|
|
28
|
+
}
|
|
29
|
+
function pert_stddev(estimate) {
|
|
30
|
+
validateEstimateValue(estimate.optimistic_hours, "optimistic_hours");
|
|
31
|
+
validateEstimateValue(estimate.pessimistic_hours, "pessimistic_hours");
|
|
32
|
+
return (estimate.pessimistic_hours - estimate.optimistic_hours) / 6;
|
|
33
|
+
}
|
|
34
|
+
function task_effort_hours(task) {
|
|
35
|
+
const humanHours = task.resources?.human_hours;
|
|
36
|
+
if (typeof humanHours === "number" && Number.isFinite(humanHours)) {
|
|
37
|
+
if (humanHours < 0) {
|
|
38
|
+
throw new Error("resources.human_hours must be >= 0.");
|
|
39
|
+
}
|
|
40
|
+
return humanHours;
|
|
41
|
+
}
|
|
42
|
+
if (task.estimate) {
|
|
43
|
+
return pert_hours(task.estimate);
|
|
44
|
+
}
|
|
45
|
+
return complexity_fallback_hours(task.complexity);
|
|
46
|
+
}
|
|
47
|
+
function effort_or_default(task, config) {
|
|
48
|
+
const humanHours = task.resources?.human_hours;
|
|
49
|
+
if (typeof humanHours === "number" && Number.isFinite(humanHours)) {
|
|
50
|
+
if (humanHours < 0) {
|
|
51
|
+
throw new Error("resources.human_hours must be >= 0.");
|
|
52
|
+
}
|
|
53
|
+
return humanHours;
|
|
54
|
+
}
|
|
55
|
+
if (task.estimate) {
|
|
56
|
+
return pert_hours(task.estimate);
|
|
57
|
+
}
|
|
58
|
+
const defaultComplexity = config.defaults?.task?.complexity;
|
|
59
|
+
return complexity_fallback_hours(task.complexity ?? defaultComplexity);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// src/planning/capacity.ts
|
|
63
|
+
function toDate(value) {
|
|
64
|
+
if (value instanceof Date) return new Date(value.getTime());
|
|
65
|
+
const parsed = /* @__PURE__ */ new Date(`${value}T00:00:00.000Z`);
|
|
66
|
+
if (Number.isNaN(parsed.valueOf())) {
|
|
67
|
+
throw new Error(`Invalid date value '${value}'.`);
|
|
68
|
+
}
|
|
69
|
+
return parsed;
|
|
70
|
+
}
|
|
71
|
+
function isoDate(value) {
|
|
72
|
+
return value.toISOString().slice(0, 10);
|
|
73
|
+
}
|
|
74
|
+
function startOfWeek(date) {
|
|
75
|
+
const copy = new Date(date.getTime());
|
|
76
|
+
const day = copy.getUTCDay();
|
|
77
|
+
const offset = day === 0 ? -6 : 1 - day;
|
|
78
|
+
copy.setUTCDate(copy.getUTCDate() + offset);
|
|
79
|
+
copy.setUTCHours(0, 0, 0, 0);
|
|
80
|
+
return copy;
|
|
81
|
+
}
|
|
82
|
+
function endOfWeek(date) {
|
|
83
|
+
const start = startOfWeek(date);
|
|
84
|
+
const end = new Date(start.getTime());
|
|
85
|
+
end.setUTCDate(end.getUTCDate() + 6);
|
|
86
|
+
end.setUTCHours(0, 0, 0, 0);
|
|
87
|
+
return end;
|
|
88
|
+
}
|
|
89
|
+
function addDays(date, days) {
|
|
90
|
+
const next = new Date(date.getTime());
|
|
91
|
+
next.setUTCDate(next.getUTCDate() + days);
|
|
92
|
+
return next;
|
|
93
|
+
}
|
|
94
|
+
function isBusinessDay(date) {
|
|
95
|
+
const day = date.getUTCDay();
|
|
96
|
+
return day >= 1 && day <= 5;
|
|
97
|
+
}
|
|
98
|
+
function overlapBusinessDays(from, to, weekStart, weekEnd) {
|
|
99
|
+
const start = from > weekStart ? from : weekStart;
|
|
100
|
+
const end = to < weekEnd ? to : weekEnd;
|
|
101
|
+
if (start > end) return 0;
|
|
102
|
+
let count = 0;
|
|
103
|
+
const cursor = new Date(start.getTime());
|
|
104
|
+
while (cursor <= end) {
|
|
105
|
+
if (isBusinessDay(cursor)) {
|
|
106
|
+
count += 1;
|
|
107
|
+
}
|
|
108
|
+
cursor.setUTCDate(cursor.getUTCDate() + 1);
|
|
109
|
+
}
|
|
110
|
+
return count;
|
|
111
|
+
}
|
|
112
|
+
function clampPercent(input) {
|
|
113
|
+
if (!Number.isFinite(input)) return 0;
|
|
114
|
+
return Math.min(100, Math.max(0, Number(input)));
|
|
115
|
+
}
|
|
116
|
+
function normalizeTrackKey(input) {
|
|
117
|
+
return input.trim().toLowerCase();
|
|
118
|
+
}
|
|
119
|
+
function normalizeAgentKey(input) {
|
|
120
|
+
return input.trim().toLowerCase();
|
|
121
|
+
}
|
|
122
|
+
function simplifiedTrackKeyFromProfile(profileId) {
|
|
123
|
+
const lower = profileId.toLowerCase();
|
|
124
|
+
if (lower.endsWith("_team")) return lower.slice(0, -5);
|
|
125
|
+
if (lower.endsWith("-team")) return lower.slice(0, -5);
|
|
126
|
+
if (lower.endsWith("_cluster")) return lower.slice(0, -8);
|
|
127
|
+
if (lower.endsWith("-cluster")) return lower.slice(0, -8);
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
function valuesOfResources(resources) {
|
|
131
|
+
if (Array.isArray(resources)) return resources;
|
|
132
|
+
return [...resources.values()];
|
|
133
|
+
}
|
|
134
|
+
function selectedProfiles(delivery, resources) {
|
|
135
|
+
if (!delivery.capacity_profiles || delivery.capacity_profiles.length === 0) {
|
|
136
|
+
return resources;
|
|
137
|
+
}
|
|
138
|
+
const wanted = new Set(delivery.capacity_profiles.map((id) => id.toLowerCase()));
|
|
139
|
+
return resources.filter((profile) => wanted.has(profile.id.toLowerCase()));
|
|
140
|
+
}
|
|
141
|
+
function selectedHumanProfiles(delivery, resources) {
|
|
142
|
+
return selectedProfiles(delivery, resources).filter((profile) => profile.type === "human");
|
|
143
|
+
}
|
|
144
|
+
function dailyHours(memberHoursPerWeek) {
|
|
145
|
+
return memberHoursPerWeek / 5;
|
|
146
|
+
}
|
|
147
|
+
function effective_weekly_hours(profile, week_start, week_end) {
|
|
148
|
+
const weekStart = toDate(week_start);
|
|
149
|
+
const weekEnd = toDate(week_end);
|
|
150
|
+
let gross = 0;
|
|
151
|
+
for (const member of profile.members) {
|
|
152
|
+
const baseHours = member.hours_per_week ?? profile.defaults?.hours_per_week ?? 0;
|
|
153
|
+
let available = baseHours;
|
|
154
|
+
for (const window of member.availability ?? []) {
|
|
155
|
+
const from = toDate(window.from);
|
|
156
|
+
const to = toDate(window.to);
|
|
157
|
+
const overlapDays = overlapBusinessDays(from, to, weekStart, weekEnd);
|
|
158
|
+
if (overlapDays <= 0) continue;
|
|
159
|
+
const unavailablePerDay = Math.max(0, dailyHours(baseHours) - Math.max(0, window.hours_per_day));
|
|
160
|
+
available -= overlapDays * unavailablePerDay;
|
|
161
|
+
}
|
|
162
|
+
gross += Math.max(0, available);
|
|
163
|
+
}
|
|
164
|
+
const meetings = clampPercent(profile.overhead?.meetings_percent);
|
|
165
|
+
const contextSwitch = clampPercent(profile.overhead?.context_switch_percent);
|
|
166
|
+
const meetingFactor = 1 - meetings / 100;
|
|
167
|
+
const contextFactor = 1 - contextSwitch / 100;
|
|
168
|
+
return gross * meetingFactor * contextFactor;
|
|
169
|
+
}
|
|
170
|
+
function effective_ai_tokens_per_day(profile) {
|
|
171
|
+
if (profile.type !== "ai") return 0;
|
|
172
|
+
return profile.agents.reduce((sum, agent) => sum + Math.max(0, agent.token_limit_per_day ?? 0), 0);
|
|
173
|
+
}
|
|
174
|
+
function ai_tokens_per_agent(profile) {
|
|
175
|
+
if (profile.type !== "ai") return [];
|
|
176
|
+
return profile.agents.map((agent) => ({
|
|
177
|
+
id: normalizeAgentKey(agent.id),
|
|
178
|
+
limit: Math.max(0, agent.token_limit_per_day ?? 0)
|
|
179
|
+
}));
|
|
180
|
+
}
|
|
181
|
+
function addSlot(slots, trackId, week, hours) {
|
|
182
|
+
const key = normalizeTrackKey(trackId);
|
|
183
|
+
const byWeek = slots.get(key) ?? /* @__PURE__ */ new Map();
|
|
184
|
+
byWeek.set(week, (byWeek.get(week) ?? 0) + hours);
|
|
185
|
+
slots.set(key, byWeek);
|
|
186
|
+
}
|
|
187
|
+
function build_capacity_ledger(delivery, resources, today) {
|
|
188
|
+
const allResources = valuesOfResources(resources);
|
|
189
|
+
const start = startOfWeek(toDate(today));
|
|
190
|
+
const target = delivery.target_date ? toDate(delivery.target_date) : addDays(start, 7 * 8);
|
|
191
|
+
const finalWeekStart = startOfWeek(target);
|
|
192
|
+
const weeks = /* @__PURE__ */ new Map();
|
|
193
|
+
const slots = /* @__PURE__ */ new Map();
|
|
194
|
+
const ai_tokens = /* @__PURE__ */ new Map();
|
|
195
|
+
const ai_tokens_by_agent = /* @__PURE__ */ new Map();
|
|
196
|
+
const ai_tokens_consumed_by_agent = /* @__PURE__ */ new Map();
|
|
197
|
+
const humans = selectedHumanProfiles(delivery, allResources);
|
|
198
|
+
const selected = selectedProfiles(delivery, allResources);
|
|
199
|
+
let weekIndex = 0;
|
|
200
|
+
for (let cursor = new Date(start.getTime()); cursor <= finalWeekStart; cursor = addDays(cursor, 7), weekIndex += 1) {
|
|
201
|
+
const weekStart = new Date(cursor.getTime());
|
|
202
|
+
const weekEnd = endOfWeek(weekStart);
|
|
203
|
+
weeks.set(weekIndex, { start: isoDate(weekStart), end: isoDate(weekEnd) });
|
|
204
|
+
const totalHours = humans.reduce((sum, profile) => sum + effective_weekly_hours(profile, weekStart, weekEnd), 0);
|
|
205
|
+
addSlot(slots, "unassigned", weekIndex, totalHours);
|
|
206
|
+
for (const profile of humans) {
|
|
207
|
+
const hours = effective_weekly_hours(profile, weekStart, weekEnd);
|
|
208
|
+
addSlot(slots, profile.id, weekIndex, hours);
|
|
209
|
+
const simplified = simplifiedTrackKeyFromProfile(profile.id);
|
|
210
|
+
if (simplified) {
|
|
211
|
+
addSlot(slots, simplified, weekIndex, hours);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
const aiProfiles = selected.filter((profile) => profile.type === "ai");
|
|
216
|
+
const defaultTokensPerDay = aiProfiles.reduce((sum, profile) => sum + effective_ai_tokens_per_day(profile), 0);
|
|
217
|
+
const tokenWindowEnd = delivery.target_date ? toDate(delivery.target_date) : addDays(start, 7 * 8);
|
|
218
|
+
for (let day = new Date(start.getTime()); day <= tokenWindowEnd; day = addDays(day, 1)) {
|
|
219
|
+
const dayKey = isoDate(day);
|
|
220
|
+
ai_tokens.set(dayKey, defaultTokensPerDay);
|
|
221
|
+
for (const profile of aiProfiles) {
|
|
222
|
+
for (const agent of ai_tokens_per_agent(profile)) {
|
|
223
|
+
const byDay = ai_tokens_by_agent.get(agent.id) ?? /* @__PURE__ */ new Map();
|
|
224
|
+
byDay.set(dayKey, (byDay.get(dayKey) ?? 0) + agent.limit);
|
|
225
|
+
ai_tokens_by_agent.set(agent.id, byDay);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
return {
|
|
230
|
+
slots,
|
|
231
|
+
weeks,
|
|
232
|
+
ai_tokens,
|
|
233
|
+
ai_tokens_by_agent,
|
|
234
|
+
ai_tokens_consumed_by_agent
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
function findTrackSlots(ledger, trackId) {
|
|
238
|
+
const normalized = normalizeTrackKey(trackId ?? "unassigned");
|
|
239
|
+
return ledger.slots.get(normalized) ?? ledger.slots.get("unassigned") ?? null;
|
|
240
|
+
}
|
|
241
|
+
function allocate(ledger, task, start_week) {
|
|
242
|
+
const trackSlots = findTrackSlots(ledger, task.track);
|
|
243
|
+
if (!trackSlots) {
|
|
244
|
+
return { success: false, reason: "capacity_exhausted" };
|
|
245
|
+
}
|
|
246
|
+
let hoursRemaining = task_effort_hours(task);
|
|
247
|
+
let week = start_week;
|
|
248
|
+
while (hoursRemaining > 0) {
|
|
249
|
+
const available = trackSlots.get(week);
|
|
250
|
+
if (available == null) {
|
|
251
|
+
return { success: false, reason: "capacity_exhausted" };
|
|
252
|
+
}
|
|
253
|
+
const take = Math.min(hoursRemaining, available);
|
|
254
|
+
trackSlots.set(week, available - take);
|
|
255
|
+
hoursRemaining -= take;
|
|
256
|
+
if (hoursRemaining > 0) {
|
|
257
|
+
week += 1;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
return {
|
|
261
|
+
success: true,
|
|
262
|
+
start_week,
|
|
263
|
+
end_week: week,
|
|
264
|
+
duration_weeks: week - start_week + 1
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
function check_wip(track, graph) {
|
|
268
|
+
if (!track) return true;
|
|
269
|
+
const limit = track.constraints?.max_concurrent_tasks;
|
|
270
|
+
if (!Number.isFinite(limit) || Number(limit) <= 0) return true;
|
|
271
|
+
let inProgressCount = 0;
|
|
272
|
+
for (const task of graph.nodes.values()) {
|
|
273
|
+
if ((task.track ?? "unassigned") !== track.id) continue;
|
|
274
|
+
if (task.status === "in_progress" || task.status === "in_review") {
|
|
275
|
+
inProgressCount += 1;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
return inProgressCount < Number(limit);
|
|
279
|
+
}
|
|
280
|
+
function allocate_ai(ledger, task, day, fallback_tokens = 5e4) {
|
|
281
|
+
const tokensNeeded = task.resources?.ai_tokens ?? task.execution?.constraints?.max_tokens ?? fallback_tokens;
|
|
282
|
+
const preferredAgent = task.execution?.agent?.trim().toLowerCase();
|
|
283
|
+
const dayKey = day;
|
|
284
|
+
if (preferredAgent && ledger.ai_tokens_by_agent.size > 0) {
|
|
285
|
+
const allocated = allocate_ai_tokens(ledger, preferredAgent, tokensNeeded, dayKey);
|
|
286
|
+
if (allocated) {
|
|
287
|
+
return { success: true };
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
if (ledger.ai_tokens_by_agent.size > 0) {
|
|
291
|
+
const candidates = Array.from(ledger.ai_tokens_by_agent.keys()).sort((a, b) => a.localeCompare(b));
|
|
292
|
+
for (const agentId of candidates) {
|
|
293
|
+
if (preferredAgent && agentId === preferredAgent) continue;
|
|
294
|
+
const allocated = allocate_ai_tokens(ledger, agentId, tokensNeeded, dayKey);
|
|
295
|
+
if (allocated) {
|
|
296
|
+
return { success: true };
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
const available = ledger.ai_tokens.get(dayKey) ?? 0;
|
|
301
|
+
if (tokensNeeded > available) {
|
|
302
|
+
return { success: false, reason: "ai_capacity_exhausted" };
|
|
303
|
+
}
|
|
304
|
+
ledger.ai_tokens.set(dayKey, available - tokensNeeded);
|
|
305
|
+
return { success: true };
|
|
306
|
+
}
|
|
307
|
+
function allocate_ai_tokens(ledger, agent, tokens, day) {
|
|
308
|
+
const normalizedAgent = normalizeAgentKey(agent);
|
|
309
|
+
if (tokens <= 0) return true;
|
|
310
|
+
const byDay = ledger.ai_tokens_by_agent.get(normalizedAgent);
|
|
311
|
+
if (!byDay) return false;
|
|
312
|
+
const available = byDay.get(day) ?? 0;
|
|
313
|
+
if (tokens > available) return false;
|
|
314
|
+
byDay.set(day, available - tokens);
|
|
315
|
+
ledger.ai_tokens_by_agent.set(normalizedAgent, byDay);
|
|
316
|
+
const consumed = ledger.ai_tokens_consumed_by_agent.get(normalizedAgent) ?? /* @__PURE__ */ new Map();
|
|
317
|
+
consumed.set(day, (consumed.get(day) ?? 0) + tokens);
|
|
318
|
+
ledger.ai_tokens_consumed_by_agent.set(normalizedAgent, consumed);
|
|
319
|
+
const totalAvailable = ledger.ai_tokens.get(day) ?? 0;
|
|
320
|
+
ledger.ai_tokens.set(day, Math.max(0, totalAvailable - tokens));
|
|
321
|
+
return true;
|
|
322
|
+
}
|
|
323
|
+
function get_remaining_tokens(ledger, agent, day) {
|
|
324
|
+
const normalizedAgent = normalizeAgentKey(agent);
|
|
325
|
+
const byDay = ledger.ai_tokens_by_agent.get(normalizedAgent);
|
|
326
|
+
if (!byDay) return 0;
|
|
327
|
+
return byDay.get(day) ?? 0;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// src/planning/sampling.ts
|
|
331
|
+
var DEFAULT_CONFIG = {
|
|
332
|
+
version: 2,
|
|
333
|
+
project: {
|
|
334
|
+
name: "COOP",
|
|
335
|
+
id: "coop"
|
|
336
|
+
},
|
|
337
|
+
defaults: {
|
|
338
|
+
task: {
|
|
339
|
+
complexity: "medium"
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
};
|
|
343
|
+
function sample_standard_normal(rng) {
|
|
344
|
+
let u = 0;
|
|
345
|
+
let v = 0;
|
|
346
|
+
while (u === 0) u = rng();
|
|
347
|
+
while (v === 0) v = rng();
|
|
348
|
+
return Math.sqrt(-2 * Math.log(u)) * Math.cos(2 * Math.PI * v);
|
|
349
|
+
}
|
|
350
|
+
function sample_gamma(shape, rng) {
|
|
351
|
+
if (shape <= 0) {
|
|
352
|
+
throw new Error("Gamma shape must be > 0.");
|
|
353
|
+
}
|
|
354
|
+
if (shape < 1) {
|
|
355
|
+
const u = rng();
|
|
356
|
+
return sample_gamma(shape + 1, rng) * Math.pow(u, 1 / shape);
|
|
357
|
+
}
|
|
358
|
+
const d = shape - 1 / 3;
|
|
359
|
+
const c = 1 / Math.sqrt(9 * d);
|
|
360
|
+
while (true) {
|
|
361
|
+
const x = sample_standard_normal(rng);
|
|
362
|
+
const v = Math.pow(1 + c * x, 3);
|
|
363
|
+
if (v <= 0) continue;
|
|
364
|
+
const u = rng();
|
|
365
|
+
if (u < 1 - 0.0331 * Math.pow(x, 4)) {
|
|
366
|
+
return d * v;
|
|
367
|
+
}
|
|
368
|
+
if (Math.log(u) < 0.5 * x * x + d * (1 - v + Math.log(v))) {
|
|
369
|
+
return d * v;
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
function sample_beta(alpha, beta, rng) {
|
|
374
|
+
const x = sample_gamma(alpha, rng);
|
|
375
|
+
const y = sample_gamma(beta, rng);
|
|
376
|
+
return x / (x + y);
|
|
377
|
+
}
|
|
378
|
+
function sample_pert_beta(optimistic, expected, pessimistic, rng = Math.random) {
|
|
379
|
+
if (!Number.isFinite(optimistic) || !Number.isFinite(expected) || !Number.isFinite(pessimistic)) {
|
|
380
|
+
throw new Error("PERT inputs must be finite numbers.");
|
|
381
|
+
}
|
|
382
|
+
if (optimistic <= 0 || expected <= 0 || pessimistic <= 0) {
|
|
383
|
+
throw new Error("PERT inputs must be positive.");
|
|
384
|
+
}
|
|
385
|
+
if (optimistic > pessimistic) {
|
|
386
|
+
throw new Error("PERT optimistic must be <= pessimistic.");
|
|
387
|
+
}
|
|
388
|
+
if (optimistic === pessimistic) {
|
|
389
|
+
return optimistic;
|
|
390
|
+
}
|
|
391
|
+
const expectedClamped = Math.min(pessimistic, Math.max(optimistic, expected));
|
|
392
|
+
const range = pessimistic - optimistic;
|
|
393
|
+
const lambda = 4;
|
|
394
|
+
const alpha = 1 + lambda * ((expectedClamped - optimistic) / range);
|
|
395
|
+
const beta = 1 + lambda * ((pessimistic - expectedClamped) / range);
|
|
396
|
+
const sample = sample_beta(alpha, beta, rng);
|
|
397
|
+
return optimistic + sample * range;
|
|
398
|
+
}
|
|
399
|
+
function sample_task_hours(task, rng = Math.random) {
|
|
400
|
+
if (task.estimate) {
|
|
401
|
+
return sample_pert_beta(
|
|
402
|
+
task.estimate.optimistic_hours,
|
|
403
|
+
task.estimate.expected_hours,
|
|
404
|
+
task.estimate.pessimistic_hours,
|
|
405
|
+
rng
|
|
406
|
+
);
|
|
407
|
+
}
|
|
408
|
+
const base = effort_or_default(task, DEFAULT_CONFIG);
|
|
409
|
+
const min = base * 0.7;
|
|
410
|
+
const max = base * 1.3;
|
|
411
|
+
return min + (max - min) * rng();
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// src/planning/simulator.ts
|
|
415
|
+
import fs from "fs";
|
|
416
|
+
import os from "os";
|
|
417
|
+
import { Worker } from "worker_threads";
|
|
418
|
+
import { fileURLToPath, pathToFileURL } from "url";
|
|
419
|
+
var DEFAULT_COMPLEXITY = "medium";
|
|
420
|
+
var DEFAULT_MONTE_CARLO_WORKERS = 4;
|
|
421
|
+
function asDate(value) {
|
|
422
|
+
if (value instanceof Date) return new Date(value.getTime());
|
|
423
|
+
const parsed = /* @__PURE__ */ new Date(`${value}T00:00:00.000Z`);
|
|
424
|
+
if (Number.isNaN(parsed.valueOf())) {
|
|
425
|
+
throw new Error(`Invalid date '${value}'.`);
|
|
426
|
+
}
|
|
427
|
+
return parsed;
|
|
428
|
+
}
|
|
429
|
+
function isoDate2(value) {
|
|
430
|
+
return value.toISOString().slice(0, 10);
|
|
431
|
+
}
|
|
432
|
+
function startOfWeek2(date) {
|
|
433
|
+
const copy = new Date(date.getTime());
|
|
434
|
+
const day = copy.getUTCDay();
|
|
435
|
+
const offset = day === 0 ? -6 : 1 - day;
|
|
436
|
+
copy.setUTCDate(copy.getUTCDate() + offset);
|
|
437
|
+
copy.setUTCHours(0, 0, 0, 0);
|
|
438
|
+
return copy;
|
|
439
|
+
}
|
|
440
|
+
function addDays2(date, days) {
|
|
441
|
+
const out = new Date(date.getTime());
|
|
442
|
+
out.setUTCDate(out.getUTCDate() + days);
|
|
443
|
+
return out;
|
|
444
|
+
}
|
|
445
|
+
function addWeeks(date, weeks) {
|
|
446
|
+
return addDays2(date, weeks * 7);
|
|
447
|
+
}
|
|
448
|
+
function week_to_date(today, week) {
|
|
449
|
+
const origin = startOfWeek2(asDate(today));
|
|
450
|
+
const date = addDays2(origin, week * 7 + 6);
|
|
451
|
+
return isoDate2(date);
|
|
452
|
+
}
|
|
453
|
+
function clone_ledger(ledger) {
|
|
454
|
+
return {
|
|
455
|
+
slots: new Map(
|
|
456
|
+
Array.from(ledger.slots.entries(), ([track, byWeek]) => [track, new Map(byWeek)])
|
|
457
|
+
),
|
|
458
|
+
weeks: new Map(ledger.weeks),
|
|
459
|
+
ai_tokens: new Map(ledger.ai_tokens),
|
|
460
|
+
ai_tokens_by_agent: new Map(
|
|
461
|
+
Array.from(ledger.ai_tokens_by_agent.entries(), ([agent, byDay]) => [agent, new Map(byDay)])
|
|
462
|
+
),
|
|
463
|
+
ai_tokens_consumed_by_agent: new Map(
|
|
464
|
+
Array.from(ledger.ai_tokens_consumed_by_agent.entries(), ([agent, byDay]) => [
|
|
465
|
+
agent,
|
|
466
|
+
new Map(byDay)
|
|
467
|
+
])
|
|
468
|
+
)
|
|
469
|
+
};
|
|
470
|
+
}
|
|
471
|
+
function normalize_track(track) {
|
|
472
|
+
return (track ?? "unassigned").trim().toLowerCase();
|
|
473
|
+
}
|
|
474
|
+
function find_track_slots(ledger, track) {
|
|
475
|
+
const key = normalize_track(track);
|
|
476
|
+
return ledger.slots.get(key) ?? ledger.slots.get("unassigned") ?? null;
|
|
477
|
+
}
|
|
478
|
+
function max_week(ledger) {
|
|
479
|
+
const keys = Array.from(ledger.weeks.keys());
|
|
480
|
+
if (keys.length === 0) return 0;
|
|
481
|
+
return Math.max(...keys);
|
|
482
|
+
}
|
|
483
|
+
function track_limit(trackId, graph) {
|
|
484
|
+
const limit = graph.tracks.get(trackId)?.constraints?.max_concurrent_tasks;
|
|
485
|
+
if (typeof limit !== "number" || !Number.isFinite(limit) || limit <= 0) {
|
|
486
|
+
return Number.POSITIVE_INFINITY;
|
|
487
|
+
}
|
|
488
|
+
return limit;
|
|
489
|
+
}
|
|
490
|
+
function ordered_tasks(tasks, graph) {
|
|
491
|
+
const wanted = new Map(tasks.map((task) => [task.id, task]));
|
|
492
|
+
const ordered = [];
|
|
493
|
+
for (const id of graph.topological_order) {
|
|
494
|
+
const task = wanted.get(id);
|
|
495
|
+
if (task) {
|
|
496
|
+
ordered.push(task);
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
if (ordered.length !== wanted.size) {
|
|
500
|
+
const missing = Array.from(wanted.keys()).filter((id) => !ordered.some((task) => task.id === id));
|
|
501
|
+
throw new Error(`Tasks missing from graph topological order: ${missing.join(", ")}.`);
|
|
502
|
+
}
|
|
503
|
+
return ordered;
|
|
504
|
+
}
|
|
505
|
+
function task_with_default_complexity(task) {
|
|
506
|
+
const hasHumanHours = typeof task.resources?.human_hours === "number";
|
|
507
|
+
if (hasHumanHours || task.estimate || task.complexity) return task;
|
|
508
|
+
return {
|
|
509
|
+
...task,
|
|
510
|
+
complexity: DEFAULT_COMPLEXITY
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
function sum_hours(slots) {
|
|
514
|
+
if (!slots) return 0;
|
|
515
|
+
let total = 0;
|
|
516
|
+
for (const value of slots.values()) {
|
|
517
|
+
total += value;
|
|
518
|
+
}
|
|
519
|
+
return total;
|
|
520
|
+
}
|
|
521
|
+
function compute_utilization(initial, current, tracks) {
|
|
522
|
+
const utilization = [];
|
|
523
|
+
for (const track of tracks) {
|
|
524
|
+
const normalized = normalize_track(track);
|
|
525
|
+
const initialSlots = find_track_slots(initial, normalized);
|
|
526
|
+
const currentSlots = find_track_slots(current, normalized);
|
|
527
|
+
const capacity = sum_hours(initialSlots ?? void 0);
|
|
528
|
+
const remaining = sum_hours(currentSlots ?? void 0);
|
|
529
|
+
const allocated = Math.max(0, capacity - remaining);
|
|
530
|
+
utilization.push({
|
|
531
|
+
track: normalized,
|
|
532
|
+
allocated_hours: allocated,
|
|
533
|
+
capacity_hours: capacity,
|
|
534
|
+
utilization: capacity > 0 ? allocated / capacity : 0
|
|
535
|
+
});
|
|
536
|
+
}
|
|
537
|
+
utilization.sort((a, b) => a.track.localeCompare(b.track));
|
|
538
|
+
return utilization;
|
|
539
|
+
}
|
|
540
|
+
function plan_allocation(trackSlots, effort, start_week) {
|
|
541
|
+
let hoursRemaining = effort;
|
|
542
|
+
let week = start_week;
|
|
543
|
+
while (hoursRemaining > 0) {
|
|
544
|
+
const available = trackSlots.get(week);
|
|
545
|
+
if (available == null || available <= 0) {
|
|
546
|
+
return { success: false, reason: "capacity_exhausted" };
|
|
547
|
+
}
|
|
548
|
+
hoursRemaining -= Math.min(hoursRemaining, available);
|
|
549
|
+
if (hoursRemaining > 0) {
|
|
550
|
+
week += 1;
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
return {
|
|
554
|
+
success: true,
|
|
555
|
+
start_week,
|
|
556
|
+
end_week: week,
|
|
557
|
+
duration_weeks: week - start_week + 1
|
|
558
|
+
};
|
|
559
|
+
}
|
|
560
|
+
function apply_allocation(trackSlots, effort, allocation) {
|
|
561
|
+
let hoursRemaining = effort;
|
|
562
|
+
for (let week = allocation.start_week; week <= allocation.end_week && hoursRemaining > 0; week += 1) {
|
|
563
|
+
const available = trackSlots.get(week) ?? 0;
|
|
564
|
+
const take = Math.min(hoursRemaining, available);
|
|
565
|
+
trackSlots.set(week, available - take);
|
|
566
|
+
hoursRemaining -= take;
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
function simulate_ordered_schedule(ordered, includedTaskIds, graph, delivery, today) {
|
|
570
|
+
const ledger = build_capacity_ledger(delivery, graph.resources, today);
|
|
571
|
+
const initialLedger = clone_ledger(ledger);
|
|
572
|
+
const schedule = {};
|
|
573
|
+
const wip = /* @__PURE__ */ new Map();
|
|
574
|
+
const maxWeek = max_week(ledger);
|
|
575
|
+
const tracks = /* @__PURE__ */ new Set();
|
|
576
|
+
for (const rawTask of ordered) {
|
|
577
|
+
const task = task_with_default_complexity(rawTask);
|
|
578
|
+
const track = normalize_track(task.track);
|
|
579
|
+
tracks.add(track);
|
|
580
|
+
if (task.status === "done" || task.status === "canceled") {
|
|
581
|
+
schedule[task.id] = {
|
|
582
|
+
task_id: task.id,
|
|
583
|
+
track,
|
|
584
|
+
start_week: null,
|
|
585
|
+
end_week: null,
|
|
586
|
+
already_complete: true
|
|
587
|
+
};
|
|
588
|
+
continue;
|
|
589
|
+
}
|
|
590
|
+
let earliest = 0;
|
|
591
|
+
for (const depId of task.depends_on ?? []) {
|
|
592
|
+
const depSchedule = schedule[depId];
|
|
593
|
+
if (depSchedule && typeof depSchedule.end_week === "number") {
|
|
594
|
+
earliest = Math.max(earliest, depSchedule.end_week + 1);
|
|
595
|
+
continue;
|
|
596
|
+
}
|
|
597
|
+
const depTask = graph.nodes.get(depId);
|
|
598
|
+
if (depTask && depTask.status !== "done" && depTask.status !== "canceled" && !includedTaskIds.has(depId)) {
|
|
599
|
+
return {
|
|
600
|
+
schedule,
|
|
601
|
+
projected_completion: null,
|
|
602
|
+
total_weeks: 0,
|
|
603
|
+
utilization_by_track: compute_utilization(initialLedger, ledger, /* @__PURE__ */ new Set([track])),
|
|
604
|
+
error: {
|
|
605
|
+
code: "schedule_overflow",
|
|
606
|
+
task: task.id
|
|
607
|
+
}
|
|
608
|
+
};
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
const trackSlots = find_track_slots(ledger, track);
|
|
612
|
+
const trackWip = wip.get(track) ?? /* @__PURE__ */ new Map();
|
|
613
|
+
const limit = track_limit(track, graph);
|
|
614
|
+
const effort = task_effort_hours(task);
|
|
615
|
+
let placed = false;
|
|
616
|
+
for (let week = earliest; week <= maxWeek; week += 1) {
|
|
617
|
+
const hasCapacity = (trackSlots?.get(week) ?? 0) > 0;
|
|
618
|
+
const hasWipRoom = (trackWip.get(week) ?? 0) < limit;
|
|
619
|
+
if (!hasCapacity || !hasWipRoom || !trackSlots) continue;
|
|
620
|
+
const allocation = plan_allocation(trackSlots, effort, week);
|
|
621
|
+
if (!allocation.success || allocation.start_week == null || allocation.end_week == null) {
|
|
622
|
+
continue;
|
|
623
|
+
}
|
|
624
|
+
let violatesWip = false;
|
|
625
|
+
for (let w = allocation.start_week; w <= allocation.end_week; w += 1) {
|
|
626
|
+
const nextWip = (trackWip.get(w) ?? 0) + 1;
|
|
627
|
+
if (nextWip > limit) {
|
|
628
|
+
violatesWip = true;
|
|
629
|
+
break;
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
if (violatesWip) continue;
|
|
633
|
+
apply_allocation(trackSlots, effort, {
|
|
634
|
+
start_week: allocation.start_week,
|
|
635
|
+
end_week: allocation.end_week
|
|
636
|
+
});
|
|
637
|
+
schedule[task.id] = {
|
|
638
|
+
task_id: task.id,
|
|
639
|
+
track,
|
|
640
|
+
start_week: allocation.start_week,
|
|
641
|
+
end_week: allocation.end_week
|
|
642
|
+
};
|
|
643
|
+
for (let w = allocation.start_week; w <= allocation.end_week; w += 1) {
|
|
644
|
+
trackWip.set(w, (trackWip.get(w) ?? 0) + 1);
|
|
645
|
+
}
|
|
646
|
+
wip.set(track, trackWip);
|
|
647
|
+
placed = true;
|
|
648
|
+
break;
|
|
649
|
+
}
|
|
650
|
+
if (!placed) {
|
|
651
|
+
return {
|
|
652
|
+
schedule,
|
|
653
|
+
projected_completion: null,
|
|
654
|
+
total_weeks: 0,
|
|
655
|
+
utilization_by_track: compute_utilization(initialLedger, ledger, /* @__PURE__ */ new Set([track])),
|
|
656
|
+
error: {
|
|
657
|
+
code: "schedule_overflow",
|
|
658
|
+
task: task.id
|
|
659
|
+
}
|
|
660
|
+
};
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
let latestEnd = -1;
|
|
664
|
+
for (const entry of Object.values(schedule)) {
|
|
665
|
+
if (entry.already_complete) continue;
|
|
666
|
+
if (typeof entry.end_week === "number") {
|
|
667
|
+
latestEnd = Math.max(latestEnd, entry.end_week);
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
return {
|
|
671
|
+
schedule,
|
|
672
|
+
projected_completion: latestEnd >= 0 ? week_to_date(today, latestEnd) : isoDate2(asDate(today)),
|
|
673
|
+
total_weeks: latestEnd >= 0 ? latestEnd + 1 : 0,
|
|
674
|
+
utilization_by_track: compute_utilization(initialLedger, ledger, tracks)
|
|
675
|
+
};
|
|
676
|
+
}
|
|
677
|
+
function simulate_schedule(tasks, graph, delivery, today) {
|
|
678
|
+
const ordered = ordered_tasks(tasks, graph);
|
|
679
|
+
return simulate_ordered_schedule(ordered, new Set(ordered.map((task) => task.id)), graph, delivery, today);
|
|
680
|
+
}
|
|
681
|
+
function percentile(sorted, ratio) {
|
|
682
|
+
if (sorted.length === 0) return Number.NaN;
|
|
683
|
+
const index = Math.min(sorted.length - 1, Math.max(0, Math.floor((sorted.length - 1) * ratio)));
|
|
684
|
+
return sorted[index] ?? Number.NaN;
|
|
685
|
+
}
|
|
686
|
+
function mean(values) {
|
|
687
|
+
if (values.length === 0) return Number.NaN;
|
|
688
|
+
return values.reduce((sum, value) => sum + value, 0) / values.length;
|
|
689
|
+
}
|
|
690
|
+
function stddev(values, avg) {
|
|
691
|
+
if (values.length === 0) return Number.NaN;
|
|
692
|
+
const variance = values.reduce((sum, value) => sum + Math.pow(value - avg, 2), 0) / values.length;
|
|
693
|
+
return Math.sqrt(variance);
|
|
694
|
+
}
|
|
695
|
+
function toDayOffset(origin, value) {
|
|
696
|
+
const target = asDate(value);
|
|
697
|
+
return Math.round((target.getTime() - origin.getTime()) / (24 * 60 * 60 * 1e3));
|
|
698
|
+
}
|
|
699
|
+
function fromDayOffset(origin, offset) {
|
|
700
|
+
return isoDate2(addDays2(origin, offset));
|
|
701
|
+
}
|
|
702
|
+
function resolve_scoped_tasks(delivery, graph) {
|
|
703
|
+
const include = new Set(delivery.scope.include);
|
|
704
|
+
for (const excluded of delivery.scope.exclude) {
|
|
705
|
+
include.delete(excluded);
|
|
706
|
+
}
|
|
707
|
+
const ordered = [];
|
|
708
|
+
for (const taskId of graph.topological_order) {
|
|
709
|
+
if (!include.has(taskId)) continue;
|
|
710
|
+
const task = graph.nodes.get(taskId);
|
|
711
|
+
if (!task) continue;
|
|
712
|
+
if (task.status === "done" || task.status === "canceled") continue;
|
|
713
|
+
ordered.push(task);
|
|
714
|
+
}
|
|
715
|
+
return ordered;
|
|
716
|
+
}
|
|
717
|
+
function sample_ordered_tasks(tasks, rng) {
|
|
718
|
+
const sampled = new Array(tasks.length);
|
|
719
|
+
for (let i = 0; i < tasks.length; i += 1) {
|
|
720
|
+
const task = tasks[i];
|
|
721
|
+
sampled[i] = {
|
|
722
|
+
...task,
|
|
723
|
+
resources: {
|
|
724
|
+
...task.resources ?? {},
|
|
725
|
+
human_hours: sample_task_hours(task, rng)
|
|
726
|
+
}
|
|
727
|
+
};
|
|
728
|
+
}
|
|
729
|
+
return sampled;
|
|
730
|
+
}
|
|
731
|
+
function create_seeded_rng(seed) {
|
|
732
|
+
let state = seed >>> 0 || 1831565813;
|
|
733
|
+
return () => {
|
|
734
|
+
state = state + 1831565813 | 0;
|
|
735
|
+
let t = Math.imul(state ^ state >>> 15, 1 | state);
|
|
736
|
+
t ^= t + Math.imul(t ^ t >>> 7, 61 | t);
|
|
737
|
+
return ((t ^ t >>> 14) >>> 0) / 4294967296;
|
|
738
|
+
};
|
|
739
|
+
}
|
|
740
|
+
function normalize_seed(seed) {
|
|
741
|
+
if (typeof seed === "number" && Number.isFinite(seed)) {
|
|
742
|
+
return Math.trunc(seed) >>> 0;
|
|
743
|
+
}
|
|
744
|
+
return Math.floor(Math.random() * 4294967296) >>> 0;
|
|
745
|
+
}
|
|
746
|
+
function derive_iteration_seed(seed, iterationIndex) {
|
|
747
|
+
return (seed ^ Math.imul(iterationIndex + 1, 2654435761)) >>> 0;
|
|
748
|
+
}
|
|
749
|
+
function run_monte_carlo_chunk(delivery, graph, options) {
|
|
750
|
+
const scopedTasks = resolve_scoped_tasks(delivery, graph);
|
|
751
|
+
if (scopedTasks.length === 0) {
|
|
752
|
+
return Array.from({ length: options.iterations }, () => 0);
|
|
753
|
+
}
|
|
754
|
+
const includedTaskIds = new Set(scopedTasks.map((task) => task.id));
|
|
755
|
+
const completionOffsets = [];
|
|
756
|
+
const startIndex = options.start_index ?? 0;
|
|
757
|
+
const seed = normalize_seed(options.seed);
|
|
758
|
+
for (let i = 0; i < options.iterations; i += 1) {
|
|
759
|
+
const rng = options.rng ?? create_seeded_rng(derive_iteration_seed(seed, startIndex + i));
|
|
760
|
+
const sampledTasks = sample_ordered_tasks(scopedTasks, rng);
|
|
761
|
+
const simulated = simulate_ordered_schedule(sampledTasks, includedTaskIds, graph, delivery, options.today);
|
|
762
|
+
if (simulated.error) {
|
|
763
|
+
continue;
|
|
764
|
+
}
|
|
765
|
+
const offset = simulated.total_weeks > 0 ? (simulated.total_weeks - 1) * 7 + 6 : 0;
|
|
766
|
+
completionOffsets.push(offset);
|
|
767
|
+
}
|
|
768
|
+
if (completionOffsets.length === 0 && scopedTasks.length > 0) {
|
|
769
|
+
return [];
|
|
770
|
+
}
|
|
771
|
+
return completionOffsets;
|
|
772
|
+
}
|
|
773
|
+
function buildHistogram(offsets, origin, total) {
|
|
774
|
+
if (offsets.length === 0 || total <= 0) return [];
|
|
775
|
+
const buckets = /* @__PURE__ */ new Map();
|
|
776
|
+
for (const offset of offsets) {
|
|
777
|
+
const week = Math.max(0, Math.floor(offset / 7));
|
|
778
|
+
buckets.set(week, (buckets.get(week) ?? 0) + 1);
|
|
779
|
+
}
|
|
780
|
+
return Array.from(buckets.entries()).sort((a, b) => a[0] - b[0]).map(([week, count]) => ({
|
|
781
|
+
week_index: week,
|
|
782
|
+
start_date: isoDate2(addWeeks(origin, week)),
|
|
783
|
+
end_date: isoDate2(addDays2(addWeeks(origin, week + 1), -1)),
|
|
784
|
+
count,
|
|
785
|
+
probability: count / total
|
|
786
|
+
}));
|
|
787
|
+
}
|
|
788
|
+
function build_monte_carlo_result(offsets, origin, iterations, targetOffset) {
|
|
789
|
+
const sorted = [...offsets].sort((a, b) => a - b);
|
|
790
|
+
const p50Value = percentile(sorted, 0.5);
|
|
791
|
+
const p75Value = percentile(sorted, 0.75);
|
|
792
|
+
const p85Value = percentile(sorted, 0.85);
|
|
793
|
+
const p95Value = percentile(sorted, 0.95);
|
|
794
|
+
const avg = mean(sorted);
|
|
795
|
+
const sigma = stddev(sorted, avg);
|
|
796
|
+
let probabilityByTarget = null;
|
|
797
|
+
if (targetOffset != null && sorted.length > 0) {
|
|
798
|
+
let onTime = 0;
|
|
799
|
+
for (const offset of sorted) {
|
|
800
|
+
if (offset <= targetOffset) {
|
|
801
|
+
onTime += 1;
|
|
802
|
+
} else {
|
|
803
|
+
break;
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
probabilityByTarget = onTime / sorted.length;
|
|
807
|
+
}
|
|
808
|
+
return {
|
|
809
|
+
iterations,
|
|
810
|
+
successful_iterations: sorted.length,
|
|
811
|
+
p50: Number.isFinite(p50Value) ? fromDayOffset(origin, p50Value) : null,
|
|
812
|
+
p75: Number.isFinite(p75Value) ? fromDayOffset(origin, p75Value) : null,
|
|
813
|
+
p85: Number.isFinite(p85Value) ? fromDayOffset(origin, p85Value) : null,
|
|
814
|
+
p95: Number.isFinite(p95Value) ? fromDayOffset(origin, p95Value) : null,
|
|
815
|
+
mean_date: Number.isFinite(avg) ? fromDayOffset(origin, Math.round(avg)) : null,
|
|
816
|
+
stddev_days: Number.isFinite(sigma) ? sigma : 0,
|
|
817
|
+
probability_by_target: probabilityByTarget,
|
|
818
|
+
histogram: buildHistogram(sorted, origin, sorted.length)
|
|
819
|
+
};
|
|
820
|
+
}
|
|
821
|
+
function default_worker_count(workers) {
|
|
822
|
+
if (typeof workers === "number" && Number.isInteger(workers) && workers > 0) {
|
|
823
|
+
return workers;
|
|
824
|
+
}
|
|
825
|
+
return DEFAULT_MONTE_CARLO_WORKERS;
|
|
826
|
+
}
|
|
827
|
+
function current_module_url() {
|
|
828
|
+
try {
|
|
829
|
+
return (0, eval)("import.meta.url");
|
|
830
|
+
} catch {
|
|
831
|
+
if (typeof __filename === "string") {
|
|
832
|
+
return pathToFileURL(__filename).href;
|
|
833
|
+
}
|
|
834
|
+
return null;
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
function resolve_worker_url() {
|
|
838
|
+
const moduleUrl = current_module_url();
|
|
839
|
+
if (!moduleUrl) {
|
|
840
|
+
return null;
|
|
841
|
+
}
|
|
842
|
+
if (moduleUrl.endsWith(".ts")) {
|
|
843
|
+
return null;
|
|
844
|
+
}
|
|
845
|
+
const candidates = [
|
|
846
|
+
new URL("./planning/monte-carlo-worker.js", moduleUrl),
|
|
847
|
+
new URL("./planning/monte-carlo-worker.cjs", moduleUrl),
|
|
848
|
+
new URL("./monte-carlo-worker.js", moduleUrl),
|
|
849
|
+
new URL("./monte-carlo-worker.cjs", moduleUrl)
|
|
850
|
+
];
|
|
851
|
+
for (const candidate of candidates) {
|
|
852
|
+
if (fs.existsSync(fileURLToPath(candidate))) {
|
|
853
|
+
return candidate;
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
return null;
|
|
857
|
+
}
|
|
858
|
+
function resolve_worker_exec_argv(workerUrl) {
|
|
859
|
+
void workerUrl;
|
|
860
|
+
return void 0;
|
|
861
|
+
}
|
|
862
|
+
function should_use_worker_threads(iterations, options, workerUrl) {
|
|
863
|
+
return Boolean(workerUrl) && !options.rng && iterations > 1e3 && default_worker_count(options.workers) > 1;
|
|
864
|
+
}
|
|
865
|
+
async function run_monte_carlo_in_workers(delivery, graph, options) {
|
|
866
|
+
const workerUrl = resolve_worker_url();
|
|
867
|
+
if (!workerUrl) {
|
|
868
|
+
return run_monte_carlo_chunk(delivery, graph, options);
|
|
869
|
+
}
|
|
870
|
+
const desiredWorkers = Math.min(default_worker_count(options.workers), options.iterations, os.availableParallelism());
|
|
871
|
+
if (desiredWorkers <= 1) {
|
|
872
|
+
return run_monte_carlo_chunk(delivery, graph, options);
|
|
873
|
+
}
|
|
874
|
+
const execArgv = resolve_worker_exec_argv(workerUrl);
|
|
875
|
+
const baseChunk = Math.floor(options.iterations / desiredWorkers);
|
|
876
|
+
const remainder = options.iterations % desiredWorkers;
|
|
877
|
+
let startIndex = 0;
|
|
878
|
+
const jobs = [];
|
|
879
|
+
for (let index = 0; index < desiredWorkers; index += 1) {
|
|
880
|
+
const chunkIterations = baseChunk + (index < remainder ? 1 : 0);
|
|
881
|
+
if (chunkIterations <= 0) continue;
|
|
882
|
+
const payload = {
|
|
883
|
+
delivery,
|
|
884
|
+
graph,
|
|
885
|
+
today: typeof options.today === "string" ? options.today : isoDate2(asDate(options.today)),
|
|
886
|
+
iterations: chunkIterations,
|
|
887
|
+
start_index: startIndex,
|
|
888
|
+
seed: options.seed
|
|
889
|
+
};
|
|
890
|
+
startIndex += chunkIterations;
|
|
891
|
+
jobs.push(
|
|
892
|
+
new Promise((resolve, reject) => {
|
|
893
|
+
const worker = new Worker(workerUrl, {
|
|
894
|
+
workerData: payload,
|
|
895
|
+
execArgv
|
|
896
|
+
});
|
|
897
|
+
worker.once("message", (message) => {
|
|
898
|
+
resolve(message);
|
|
899
|
+
});
|
|
900
|
+
worker.once("error", reject);
|
|
901
|
+
worker.once("exit", (code) => {
|
|
902
|
+
if (code !== 0) {
|
|
903
|
+
reject(new Error(`Monte Carlo worker exited with code ${code}.`));
|
|
904
|
+
}
|
|
905
|
+
});
|
|
906
|
+
})
|
|
907
|
+
);
|
|
908
|
+
}
|
|
909
|
+
const chunks = await Promise.all(jobs);
|
|
910
|
+
return chunks.flat();
|
|
911
|
+
}
|
|
912
|
+
async function monte_carlo_forecast(delivery, graph, options = {}) {
|
|
913
|
+
const iterations = typeof options.iterations === "number" && Number.isInteger(options.iterations) && options.iterations > 0 ? options.iterations : 1e4;
|
|
914
|
+
const today = options.today ?? /* @__PURE__ */ new Date();
|
|
915
|
+
const origin = startOfWeek2(asDate(today));
|
|
916
|
+
const seed = normalize_seed(options.seed);
|
|
917
|
+
const targetOffset = delivery.target_date ? toDayOffset(origin, delivery.target_date) : null;
|
|
918
|
+
const workerUrl = resolve_worker_url();
|
|
919
|
+
const completionOffsets = should_use_worker_threads(iterations, options, workerUrl) ? await run_monte_carlo_in_workers(delivery, graph, {
|
|
920
|
+
iterations,
|
|
921
|
+
today,
|
|
922
|
+
seed,
|
|
923
|
+
workers: options.workers
|
|
924
|
+
}) : run_monte_carlo_chunk(delivery, graph, {
|
|
925
|
+
iterations,
|
|
926
|
+
today,
|
|
927
|
+
seed,
|
|
928
|
+
rng: options.rng
|
|
929
|
+
});
|
|
930
|
+
return build_monte_carlo_result(completionOffsets, origin, iterations, targetOffset);
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
export {
|
|
934
|
+
pert_hours,
|
|
935
|
+
pert_stddev,
|
|
936
|
+
task_effort_hours,
|
|
937
|
+
effort_or_default,
|
|
938
|
+
effective_weekly_hours,
|
|
939
|
+
build_capacity_ledger,
|
|
940
|
+
allocate,
|
|
941
|
+
check_wip,
|
|
942
|
+
allocate_ai,
|
|
943
|
+
allocate_ai_tokens,
|
|
944
|
+
get_remaining_tokens,
|
|
945
|
+
sample_pert_beta,
|
|
946
|
+
sample_task_hours,
|
|
947
|
+
simulate_schedule,
|
|
948
|
+
create_seeded_rng,
|
|
949
|
+
run_monte_carlo_chunk,
|
|
950
|
+
monte_carlo_forecast
|
|
951
|
+
};
|