@trajectonomics/sim 0.0.0-stage → 0.1.0-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs ADDED
@@ -0,0 +1,1238 @@
1
+ // src/vocab.ts
2
+ const ACCOUNT_KINDS = ["asset", "liability"];
3
+ const FLOW_KINDS = [
4
+ "income",
5
+ "expense",
6
+ "contribution"
7
+ ];
8
+ const FREQUENCIES = [
9
+ "once",
10
+ "daily",
11
+ "weekly",
12
+ "biweekly",
13
+ "monthly",
14
+ "quarterly",
15
+ "annual"
16
+ ];
17
+ const RESOLUTIONS = ["day", "month"];
18
+ // src/check/date.ts
19
+ const DATE_SHAPE$1 = /^(\d{4})-(\d{2})-(\d{2})$/;
20
+ const MONTH_DAYS$1 = [
21
+ 31,
22
+ 28,
23
+ 31,
24
+ 30,
25
+ 31,
26
+ 30,
27
+ 31,
28
+ 31,
29
+ 30,
30
+ 31,
31
+ 30,
32
+ 31
33
+ ];
34
+ function isLeapYear$1(year) {
35
+ return year % 4 === 0 && year % 100 !== 0 || year % 400 === 0;
36
+ }
37
+ function daysInMonth$1(year, month) {
38
+ return month === 2 && isLeapYear$1(year) ? 29 : MONTH_DAYS$1[month - 1] ?? 0;
39
+ }
40
+ /** Pure calendar arithmetic (no `Date`): a real `YYYY-MM-DD` date in years 0001-9998 (so every computed anniversary stays representable). */
41
+ function isValidDate(value) {
42
+ if (typeof value !== "string") return false;
43
+ const match = DATE_SHAPE$1.exec(value);
44
+ if (!match) return false;
45
+ const [year, month, day] = [
46
+ Number(match[1]),
47
+ Number(match[2]),
48
+ Number(match[3])
49
+ ];
50
+ return year >= 1 && year <= 9998 && day >= 1 && day <= daysInMonth$1(year, month);
51
+ }
52
+ // src/check/shape.ts
53
+ const req = (check) => ({
54
+ required: true,
55
+ check
56
+ });
57
+ const opt = (check) => ({
58
+ required: false,
59
+ check
60
+ });
61
+ /** Accepts any value: `schemaVersion` is validated up front by `check()`, not per object. */
62
+ const anyVersion = () => {};
63
+ function isRecord(value) {
64
+ return typeof value === "object" && value !== null && !Array.isArray(value);
65
+ }
66
+ function checkField(value, path, key, field, emit) {
67
+ if (Object.hasOwn(value, key)) field.check(value[key], [...path, key], emit);
68
+ else if (field.required) emit("MISSING_KEY", [...path, key]);
69
+ }
70
+ /** Validates an object against `shape`: not-an-object, unknown keys, missing keys, field checks. */
71
+ function checkShape(value, path, shape, emit) {
72
+ if (!isRecord(value)) {
73
+ emit("NOT_AN_OBJECT", path);
74
+ return;
75
+ }
76
+ for (const key of Object.keys(value)) if (!Object.hasOwn(shape, key)) emit("UNKNOWN_KEY", [...path, key]);
77
+ for (const [key, field] of Object.entries(shape)) checkField(value, path, key, field, emit);
78
+ return value;
79
+ }
80
+ /**
81
+ * Builds a check that validates every element of an array with `item`. Walks every index, so a
82
+ * sparse-array hole reaches `item` as `undefined` instead of being skipped.
83
+ */
84
+ function list(item) {
85
+ return (value, path, emit) => {
86
+ if (!Array.isArray(value)) return emit("NOT_AN_ARRAY", path);
87
+ for (let index = 0; index < value.length; index++) item(value[index], [...path, index], emit);
88
+ };
89
+ }
90
+ function scanDuplicates(items, path, suffix, idOf, emit) {
91
+ if (!Array.isArray(items)) return;
92
+ const seen = /* @__PURE__ */ new Set();
93
+ for (let index = 0; index < items.length; index++) {
94
+ const id = idOf(items[index]);
95
+ if (typeof id !== "string" || id === "") continue;
96
+ if (seen.has(id)) emit("DUPLICATE_ID", [
97
+ ...path,
98
+ index,
99
+ ...suffix
100
+ ]);
101
+ seen.add(id);
102
+ }
103
+ }
104
+ /**
105
+ * Emits `DUPLICATE_ID` at the second and later occurrences of a string `key` in `items`.
106
+ * Empty ids are left to `EMPTY_STRING` and never count as duplicates.
107
+ */
108
+ function checkUnique(items, path, key, emit) {
109
+ scanDuplicates(items, path, [key], (item) => isRecord(item) ? item[key] : void 0, emit);
110
+ }
111
+ /** Like `checkUnique`, for an array of plain string ids (e.g. `cashflow.withdraw`). */
112
+ function checkUniqueValues(items, path, emit) {
113
+ scanDuplicates(items, path, [], (item) => item, emit);
114
+ }
115
+ // src/check/scalars.ts
116
+ const str = (value, path, emit) => {
117
+ if (typeof value !== "string") emit("WRONG_TYPE", path);
118
+ else if (value === "") emit("EMPTY_STRING", path);
119
+ };
120
+ const bool = (value, path, emit) => {
121
+ if (typeof value !== "boolean") emit("WRONG_TYPE", path);
122
+ };
123
+ /** A safe-integer rate above -10000 bps, so a positive growth factor always exists. */
124
+ const rate = (value, path, emit) => {
125
+ if (!Number.isSafeInteger(value)) return emit("NOT_SAFE_INTEGER", path);
126
+ if (value <= -1e4) emit("RATE_OUT_OF_RANGE", path);
127
+ };
128
+ /** Non-negative safe integer minor units. */
129
+ const amount = (value, path, emit) => {
130
+ if (!Number.isSafeInteger(value)) emit("NOT_SAFE_INTEGER", path);
131
+ else if (value < 0) emit("NEGATIVE_AMOUNT", path);
132
+ };
133
+ const date = (value, path, emit) => {
134
+ if (!isValidDate(value)) emit("INVALID_DATE", path);
135
+ };
136
+ function oneOf(allowed, code) {
137
+ return (value, path, emit) => {
138
+ if (typeof value !== "string" || !allowed.includes(value)) emit(code, path);
139
+ };
140
+ }
141
+ const growth = (value, path, emit) => {
142
+ if (value === "inflation") return;
143
+ if (!isRecord(value)) return emit("INVALID_GROWTH", path);
144
+ checkShape(value, path, { bps: req(rate) }, emit);
145
+ };
146
+ // src/check/accounts.ts
147
+ const shape$1 = {
148
+ id: req(str),
149
+ kind: req(oneOf(ACCOUNT_KINDS, "INVALID_KIND")),
150
+ rate: req(rate),
151
+ lockedUntil: opt(date)
152
+ };
153
+ const account = (value, path, emit) => {
154
+ const checked = checkShape(value, path, shape$1, emit);
155
+ if (checked?.["kind"] === "liability" && Object.hasOwn(checked, "lockedUntil")) emit("LIABILITY_MISUSE", [...path, "lockedUntil"]);
156
+ };
157
+ function checkAccounts(value, path, emit) {
158
+ list(account)(value, path, emit);
159
+ checkUnique(value, path, "id", emit);
160
+ }
161
+ /**
162
+ * Declared account ids (first declaration wins) mapped to their raw `kind`. `undefined` when
163
+ * `accounts` is not an array, so reference checks are skipped rather than flooding the output.
164
+ */
165
+ function indexAccounts(accounts) {
166
+ if (!Array.isArray(accounts)) return void 0;
167
+ const index = /* @__PURE__ */ new Map();
168
+ for (let i = 0; i < accounts.length; i++) {
169
+ const item = accounts[i];
170
+ if (isRecord(item) && typeof item["id"] === "string" && !index.has(item["id"])) index.set(item["id"], item["kind"]);
171
+ }
172
+ return index;
173
+ }
174
+ // src/check/refs.ts
175
+ /** Account id that must be declared in `scenario.accounts` (skipped when unknown). */
176
+ function accountRef(accounts) {
177
+ return (value, path, emit) => {
178
+ str(value, path, emit);
179
+ if (accounts && typeof value === "string" && !accounts.has(value)) emit("UNKNOWN_ACCOUNT", path);
180
+ };
181
+ }
182
+ /** Like `accountRef`, but liabilities are rejected (surplus targets and withdrawal sources). */
183
+ function spendableRef(accounts) {
184
+ const ref = accountRef(accounts);
185
+ return (value, path, emit) => {
186
+ ref(value, path, emit);
187
+ if (typeof value === "string" && accounts?.get(value) === "liability") emit("LIABILITY_MISUSE", path);
188
+ };
189
+ }
190
+ /** Baseline balance account: must have a rule in `scenario.accounts`. */
191
+ function balanceRef(accounts) {
192
+ return (value, path, emit) => {
193
+ str(value, path, emit);
194
+ if (accounts && typeof value === "string" && !accounts.has(value)) emit("UNDECLARED_BALANCE_ACCOUNT", path);
195
+ };
196
+ }
197
+ // src/check/baseline.ts
198
+ function checkBaseline(value, path, accounts, emit) {
199
+ const balance = (item, at, e) => void checkShape(item, at, {
200
+ account: req(balanceRef(accounts)),
201
+ amount: req(amount)
202
+ }, e);
203
+ checkUnique(checkShape(value, path, {
204
+ schemaVersion: req(anyVersion),
205
+ balances: req(list(balance))
206
+ }, emit)?.["balances"], [...path, "balances"], "account", emit);
207
+ }
208
+ // src/check/guard.ts
209
+ /**
210
+ * Runs `run`, turning an exception (a throwing getter, a revoked Proxy) into one `NOT_AN_OBJECT`
211
+ * problem at the input's root, reported at most once per root. Returns `fallback` on failure.
212
+ *
213
+ * It deliberately catches EVERY exception, not only input-access ones: `check()` must never throw
214
+ * on garbage input. The trade-off is that a genuine bug in a check surfaces as `NOT_AN_OBJECT` on
215
+ * that input instead of crashing; the tests (not this guard) are what expose such bugs.
216
+ */
217
+ function makeGuard(emit) {
218
+ const failed = /* @__PURE__ */ new Set();
219
+ return (root, fallback, run) => {
220
+ try {
221
+ return run();
222
+ } catch {
223
+ if (!failed.has(root)) emit("NOT_AN_OBJECT", [root]);
224
+ failed.add(root);
225
+ return fallback;
226
+ }
227
+ };
228
+ }
229
+ // src/check/cashflow.ts
230
+ function checkRouting(cashflow, path, emit) {
231
+ const surplus = cashflow["surplus"];
232
+ if (!Array.isArray(surplus)) return;
233
+ if (surplus.length === 0) {
234
+ emit("SURPLUS_UNROUTED", [...path, "surplus"]);
235
+ return;
236
+ }
237
+ const last = surplus[surplus.length - 1];
238
+ if (isRecord(last) && Object.hasOwn(last, "upTo")) emit("SURPLUS_UNROUTED", [
239
+ ...path,
240
+ "surplus",
241
+ surplus.length - 1,
242
+ "upTo"
243
+ ]);
244
+ }
245
+ function checkCashflow(value, path, accounts, emit) {
246
+ const account = spendableRef(accounts);
247
+ const step = (item, at, e) => void checkShape(item, at, {
248
+ account: req(account),
249
+ upTo: opt(amount)
250
+ }, e);
251
+ const cashflow = checkShape(value, path, {
252
+ surplus: req(list(step)),
253
+ withdraw: req(list(account))
254
+ }, emit);
255
+ if (cashflow) checkRouting(cashflow, path, emit);
256
+ checkUnique(cashflow?.["surplus"], [...path, "surplus"], "account", emit);
257
+ checkUniqueValues(cashflow?.["withdraw"], [...path, "withdraw"], emit);
258
+ }
259
+ // src/check/flow-shape.ts
260
+ const base = {
261
+ id: req(str),
262
+ kind: req(oneOf(FLOW_KINDS, "INVALID_KIND")),
263
+ amount: req(amount),
264
+ frequency: req(oneOf(FREQUENCIES, "INVALID_FREQUENCY")),
265
+ start: req(date),
266
+ end: opt(date),
267
+ growth: req(growth)
268
+ };
269
+ /** Keys allowed per flow kind; an invalid kind accepts every optional kind-specific key. */
270
+ function flowShape(kind, accounts) {
271
+ const target = accountRef(accounts);
272
+ if (kind === "income") return {
273
+ ...base,
274
+ earned: req(bool)
275
+ };
276
+ if (kind === "contribution") return {
277
+ ...base,
278
+ target: req(target)
279
+ };
280
+ if (kind === "expense") return base;
281
+ return {
282
+ ...base,
283
+ earned: opt(bool),
284
+ target: opt(target)
285
+ };
286
+ }
287
+ // src/check/flows.ts
288
+ /** ISO dates compare correctly as strings once validated. */
289
+ function checkDates(flow, path, emit) {
290
+ const { start, end, frequency } = flow;
291
+ if (!isValidDate(start) || !isValidDate(end)) return;
292
+ if (end < start) emit("END_BEFORE_START", [...path, "end"]);
293
+ else if (frequency === "once" && end !== start) emit("ONCE_FLOW_END_MISMATCH", [...path, "end"]);
294
+ }
295
+ function flowCheck(accounts) {
296
+ return (value, path, emit) => {
297
+ const flow = checkShape(value, path, flowShape(isRecord(value) ? value["kind"] : void 0, accounts), emit);
298
+ if (flow) checkDates(flow, path, emit);
299
+ };
300
+ }
301
+ function checkFlows(value, path, accounts, emit) {
302
+ list(flowCheck(accounts))(value, path, emit);
303
+ checkUnique(value, path, "id", emit);
304
+ }
305
+ // src/check/scenario.ts
306
+ const economy = (value, path, emit) => {
307
+ checkShape(value, path, { inflation: req(rate) }, emit);
308
+ };
309
+ function checkScenario(value, path, accounts, emit) {
310
+ checkShape(value, path, {
311
+ schemaVersion: req(anyVersion),
312
+ currency: req(str),
313
+ economy: req(economy),
314
+ accounts: req(checkAccounts),
315
+ flows: req((v, p, e) => checkFlows(v, p, accounts, e)),
316
+ cashflow: req((v, p, e) => checkCashflow(v, p, accounts, e))
317
+ }, emit);
318
+ }
319
+ // src/check/timeline.ts
320
+ const shape = {
321
+ start: req(date),
322
+ horizon: req(date),
323
+ resolution: req(oneOf(RESOLUTIONS, "INVALID_RESOLUTION"))
324
+ };
325
+ function checkTimeline(value, path, emit) {
326
+ const { start, horizon } = checkShape(value, path, shape, emit) ?? {};
327
+ if (isValidDate(start) && isValidDate(horizon) && horizon < start) emit("HORIZON_BEFORE_START", [...path, "horizon"]);
328
+ }
329
+ // src/check/index.ts
330
+ /** True (and no problem) unless a record's `schemaVersion` is not 1: then one error is emitted. */
331
+ function versionOk(value, path, emit) {
332
+ if (!isRecord(value) || value["schemaVersion"] === 1) return true;
333
+ emit("UNSUPPORTED_SCHEMA_VERSION", [...path, "schemaVersion"]);
334
+ return false;
335
+ }
336
+ /**
337
+ * Validates all three inputs in one pass. `[]` means valid; the engine refuses to run otherwise.
338
+ * Accepts arbitrary values and never throws: an input that cannot be read at all (a throwing
339
+ * getter, a revoked Proxy) yields a single `NOT_AN_OBJECT` problem at its root.
340
+ */
341
+ function check(baseline, scenario, timeline) {
342
+ const problems = [];
343
+ const emit = (code, path) => void problems.push({
344
+ code,
345
+ path
346
+ });
347
+ const guard = makeGuard(emit);
348
+ const scenarioOk = guard("scenario", false, () => versionOk(scenario, ["scenario"], emit));
349
+ const baselineOk = guard("baseline", false, () => versionOk(baseline, ["baseline"], emit));
350
+ const accounts = guard("scenario", void 0, () => scenarioOk && isRecord(scenario) ? indexAccounts(scenario["accounts"]) : void 0);
351
+ if (baselineOk) guard("baseline", void 0, () => checkBaseline(baseline, ["baseline"], accounts, emit));
352
+ if (scenarioOk) guard("scenario", void 0, () => checkScenario(scenario, ["scenario"], accounts, emit));
353
+ guard("timeline", void 0, () => checkTimeline(timeline, ["timeline"], emit));
354
+ return problems;
355
+ }
356
+ /** The closed set of problem codes. Frozen so consumers cannot mutate it at runtime. */
357
+ const ERROR_CODES = Object.freeze([
358
+ "UNSUPPORTED_SCHEMA_VERSION",
359
+ "NOT_AN_OBJECT",
360
+ "NOT_AN_ARRAY",
361
+ "MISSING_KEY",
362
+ "UNKNOWN_KEY",
363
+ "WRONG_TYPE",
364
+ "EMPTY_STRING",
365
+ "NOT_SAFE_INTEGER",
366
+ "NEGATIVE_AMOUNT",
367
+ "INVALID_DATE",
368
+ "INVALID_KIND",
369
+ "INVALID_FREQUENCY",
370
+ "INVALID_GROWTH",
371
+ "INVALID_RESOLUTION",
372
+ "HORIZON_BEFORE_START",
373
+ "END_BEFORE_START",
374
+ "ONCE_FLOW_END_MISMATCH",
375
+ "DUPLICATE_ID",
376
+ "UNKNOWN_ACCOUNT",
377
+ "UNDECLARED_BALANCE_ACCOUNT",
378
+ "LIABILITY_MISUSE",
379
+ "SURPLUS_UNROUTED",
380
+ "RATE_OUT_OF_RANGE"
381
+ ]);
382
+ // src/output/opening.ts
383
+ /** Net worth at the timeline start, before any start-date flows land. */
384
+ function openingNetWorth(baseline, rules) {
385
+ let total = 0n;
386
+ for (const balance of baseline.balances) {
387
+ const amount = BigInt(balance.amount);
388
+ total += rules.get(balance.account)?.kind === "liability" ? -amount : amount;
389
+ }
390
+ return total;
391
+ }
392
+ // src/calendar/civil.ts
393
+ const DATE_SHAPE = /^(\d{4})-(\d{2})-(\d{2})$/;
394
+ const MONTH_DAYS = [
395
+ 31,
396
+ 28,
397
+ 31,
398
+ 30,
399
+ 31,
400
+ 30,
401
+ 31,
402
+ 31,
403
+ 30,
404
+ 31,
405
+ 30,
406
+ 31
407
+ ];
408
+ function isLeapYear(year) {
409
+ return year % 4 === 0 && year % 100 !== 0 || year % 400 === 0;
410
+ }
411
+ function daysInMonth(year, month) {
412
+ return month === 2 && isLeapYear(year) ? 29 : MONTH_DAYS[month - 1] ?? 0;
413
+ }
414
+ /** Parses an already validated `YYYY-MM-DD` date. */
415
+ function parseDate(value) {
416
+ const match = DATE_SHAPE.exec(value);
417
+ if (!match) throw new RangeError(`Invalid date: ${value}`);
418
+ return {
419
+ year: Number(match[1]),
420
+ month: Number(match[2]),
421
+ day: Number(match[3])
422
+ };
423
+ }
424
+ function pad(value, width) {
425
+ return String(value).padStart(width, "0");
426
+ }
427
+ function formatDate({ year, month, day }) {
428
+ return `${pad(year, 4)}-${pad(month, 2)}-${pad(day, 2)}`;
429
+ }
430
+ // src/calendar/arith.ts
431
+ function daysBeforeYear(year) {
432
+ const previous = year - 1;
433
+ return 365 * previous + Math.floor(previous / 4) - Math.floor(previous / 100) + Math.floor(previous / 400);
434
+ }
435
+ function daysBeforeMonth(year, month) {
436
+ let days = 0;
437
+ for (let m = 1; m < month; m++) days += daysInMonth(year, m);
438
+ return days;
439
+ }
440
+ /** Zero-based serial day number; 0001-01-01 is 0. */
441
+ function dayNumber(date) {
442
+ const { year, month, day } = parseDate(date);
443
+ return daysBeforeYear(year) + daysBeforeMonth(year, month) + day - 1;
444
+ }
445
+ const FIRST_DATE = "0001-01-01";
446
+ const LAST_DATE = "9999-12-31";
447
+ const LAST_DAY = dayNumber(LAST_DATE);
448
+ /** Inverse of `dayNumber`, saturating at the supported range; no wall clock is involved. */
449
+ function fromDayNumber(value) {
450
+ if (value <= 0) return FIRST_DATE;
451
+ if (value >= LAST_DAY) return LAST_DATE;
452
+ let year = Math.floor(value / 366) + 1;
453
+ while (daysBeforeYear(year + 1) <= value) year++;
454
+ while (daysBeforeYear(year) > value) year--;
455
+ let remaining = value - daysBeforeYear(year);
456
+ let month = 1;
457
+ while (remaining >= daysInMonth(year, month)) {
458
+ remaining -= daysInMonth(year, month);
459
+ month++;
460
+ }
461
+ return formatDate({
462
+ year,
463
+ month,
464
+ day: remaining + 1
465
+ });
466
+ }
467
+ function addDays(date, days) {
468
+ return fromDayNumber(dayNumber(date) + days);
469
+ }
470
+ /** Adds calendar months, clamping the day to the target month's last day. */
471
+ function addMonthsClamped(date, months) {
472
+ const { year, month, day } = parseDate(date);
473
+ const total = year * 12 + (month - 1) + months;
474
+ const targetYear = Math.floor(total / 12);
475
+ if (targetYear > 9999) return LAST_DATE;
476
+ const targetMonth = (total % 12 + 12) % 12 + 1;
477
+ return formatDate({
478
+ year: targetYear,
479
+ month: targetMonth,
480
+ day: Math.min(day, daysInMonth(targetYear, targetMonth))
481
+ });
482
+ }
483
+ /** Number of annual growth anniversaries reached, with `Feb 29 -> Feb 28` clamping. */
484
+ function growthYears(start, date) {
485
+ const years = parseDate(date).year - parseDate(start).year;
486
+ if (years <= 0) return 0;
487
+ return addMonthsClamped(start, years * 12) <= date ? years : years - 1;
488
+ }
489
+ // src/output/periods.ts
490
+ /** Last day of every full twelve-month period that ends on or before `end`. */
491
+ function periodEndDates(start, end) {
492
+ const dates = [];
493
+ for (let index = 1;; index++) {
494
+ const last = addDays(addMonthsClamped(start, index * 12), -1);
495
+ if (last > end) return dates;
496
+ dates.push(last);
497
+ }
498
+ }
499
+ /**
500
+ * Twelve-month periods from each anniversary of `start` to the day before the next one; the
501
+ * final one is clipped by the projection's end and marked partial.
502
+ */
503
+ function buildPeriods(start, end, runsOutDate) {
504
+ const periods = [];
505
+ let from = start;
506
+ let index = 0;
507
+ while (true) {
508
+ const next = addMonthsClamped(start, (index + 1) * 12);
509
+ const yearEnd = addDays(next, -1);
510
+ const to = end < yearEnd ? end : yearEnd;
511
+ const partial = to < yearEnd || to === runsOutDate;
512
+ periods.push({
513
+ index,
514
+ from,
515
+ to,
516
+ partial
517
+ });
518
+ if (to === end) return periods;
519
+ from = next;
520
+ index++;
521
+ }
522
+ }
523
+ // src/engine/overflow.ts
524
+ const MAX_SAFE = BigInt(Number.MAX_SAFE_INTEGER);
525
+ function isSafeInteger(value) {
526
+ return value >= -MAX_SAFE && value <= MAX_SAFE;
527
+ }
528
+ function overflow(date, account) {
529
+ return account === void 0 ? {
530
+ code: "VALUE_OVERFLOW",
531
+ date
532
+ } : {
533
+ code: "VALUE_OVERFLOW",
534
+ date,
535
+ account
536
+ };
537
+ }
538
+ // src/output/rollups.ts
539
+ function sumFlows(totals, period) {
540
+ let income = 0n;
541
+ let expense = 0n;
542
+ let contributions = 0n;
543
+ for (const total of totals) {
544
+ if (total.date < period.from || total.date > period.to) continue;
545
+ income += total.income;
546
+ expense += total.expense;
547
+ contributions += total.contributions;
548
+ }
549
+ return {
550
+ income,
551
+ expense,
552
+ contributions
553
+ };
554
+ }
555
+ function unsafe(values, date) {
556
+ for (const value of values) if (!isSafeInteger(value)) return overflow(date);
557
+ }
558
+ function buildRollups(run, periods, opening) {
559
+ const snapshots = /* @__PURE__ */ new Map();
560
+ for (const snapshot of run.snapshots) snapshots.set(snapshot.date, snapshot);
561
+ const annual = [];
562
+ let openingWorth = opening;
563
+ for (const period of periods) {
564
+ const sums = sumFlows(run.totals, period);
565
+ const closing = snapshots.get(period.to).netWorth;
566
+ const unmet = run.runsOut?.date === period.to ? run.runsOut.unmet : 0n;
567
+ const interest = closing - openingWorth - sums.income + sums.expense - unmet;
568
+ const problem = unsafe([
569
+ openingWorth,
570
+ closing,
571
+ sums.income,
572
+ sums.expense,
573
+ sums.contributions,
574
+ interest,
575
+ unmet
576
+ ], period.to);
577
+ if (problem !== void 0) return {
578
+ annual: void 0,
579
+ problem
580
+ };
581
+ annual.push({
582
+ index: period.index,
583
+ from: period.from,
584
+ to: period.to,
585
+ openingNetWorth: Number(openingWorth),
586
+ closingNetWorth: Number(closing),
587
+ income: Number(sums.income),
588
+ expense: Number(sums.expense),
589
+ contributions: Number(sums.contributions),
590
+ interest: Number(interest),
591
+ unmet: Number(unmet),
592
+ partial: period.partial
593
+ });
594
+ openingWorth = closing;
595
+ }
596
+ return {
597
+ annual,
598
+ problem: void 0
599
+ };
600
+ }
601
+ // src/calendar/rows.ts
602
+ /** `start`, every monthly anniversary of it (clamped), and `horizon`. */
603
+ function monthlyRowDates(start, horizon) {
604
+ if (horizon <= start) return [start];
605
+ const dates = [start];
606
+ let index = 1;
607
+ while (true) {
608
+ const date = addMonthsClamped(start, index);
609
+ if (date >= horizon) break;
610
+ dates.push(date);
611
+ index++;
612
+ }
613
+ dates.push(horizon);
614
+ return dates;
615
+ }
616
+ /** Every date in `[start, horizon]`. */
617
+ function dailyRowDates(start, horizon) {
618
+ const dates = [];
619
+ for (let date = start; date <= horizon; date = addDays(date, 1)) dates.push(date);
620
+ return dates;
621
+ }
622
+ // src/output/row-dates.ts
623
+ /** The dates `Timeline.resolution` asks to report. */
624
+ function rowDates(timeline) {
625
+ return timeline.resolution === "day" ? dailyRowDates(timeline.start, timeline.horizon) : monthlyRowDates(timeline.start, timeline.horizon);
626
+ }
627
+ // src/calendar/counted.ts
628
+ /** Number of Feb 29 dates in years 1..year that are not after `date`. */
629
+ function leapDaysThrough(date) {
630
+ const { year, month, day } = parseDate(date);
631
+ let count = Math.floor(year / 4) - Math.floor(year / 100) + Math.floor(year / 400);
632
+ if (isLeapYear(year) && (month < 2 || month === 2 && day < 29)) count--;
633
+ return count;
634
+ }
635
+ /** Actual/365 No Leap: actual days minus the Feb 29s in `(from, to]`. */
636
+ function countedDays(from, to) {
637
+ return dayNumber(to) - dayNumber(from) - (leapDaysThrough(to) - leapDaysThrough(from));
638
+ }
639
+ // src/money/pow.ts
640
+ /** Integer exponentiation by squaring; `exponent` is a non-negative safe integer. */
641
+ function powBigInt(base, exponent) {
642
+ if (!Number.isSafeInteger(exponent) || exponent < 0) throw new RangeError("powBigInt expects a non-negative safe integer exponent");
643
+ let result = 1n;
644
+ let factor = base;
645
+ let remaining = exponent;
646
+ while (remaining > 0) {
647
+ if (remaining % 2 === 1) result *= factor;
648
+ factor *= factor;
649
+ remaining = Math.floor(remaining / 2);
650
+ }
651
+ return result;
652
+ }
653
+ // src/money/root.ts
654
+ /** Largest integer `r` with `r ** degree <= value` (`value >= 0`, `degree >= 1`). */
655
+ function nthRootFloor(value, degree) {
656
+ if (value < 0n || !Number.isSafeInteger(degree) || degree < 1) throw new RangeError("nthRootFloor expects a non-negative value and positive degree");
657
+ if (value < 2n) return value;
658
+ let high = 1n;
659
+ while (powBigInt(high, degree) <= value) high *= 2n;
660
+ let low = high / 2n;
661
+ while (low + 1n < high) {
662
+ const middle = (low + high) / 2n;
663
+ if (powBigInt(middle, degree) <= value) low = middle;
664
+ else high = middle;
665
+ }
666
+ return low;
667
+ }
668
+ // src/money/factor.ts
669
+ const RATE_SCALE = 10000n;
670
+ const FACTOR_BASE = 10n ** BigInt(30);
671
+ const cache = /* @__PURE__ */ new Map();
672
+ /**
673
+ * Largest integer `f` with `10000 * f^365 <= (10000 + rate) * 10^(30*365)`.
674
+ * The result is cached per rate; the cache is deterministic and purely additive.
675
+ */
676
+ function dailyFactor(rate) {
677
+ if (!Number.isSafeInteger(rate) || rate <= -1e4) throw new RangeError(`Rate must be a safe integer above -10000 bps: ${rate}`);
678
+ const cached = cache.get(rate);
679
+ if (cached !== void 0) return cached;
680
+ const factor = nthRootFloor((RATE_SCALE + BigInt(rate)) * FACTOR_BASE ** BigInt(365) / RATE_SCALE, 365);
681
+ cache.set(rate, factor);
682
+ return factor;
683
+ }
684
+ // src/money/round.ts
685
+ /** Half-up rounding of a non-negative integer ratio. */
686
+ function roundHalfUp(numerator, denominator) {
687
+ if (numerator < 0n) throw new RangeError("roundHalfUp expects a non-negative numerator");
688
+ if (denominator <= 0n) throw new RangeError("roundHalfUp expects a positive denominator");
689
+ const quotient = numerator / denominator;
690
+ return numerator % denominator * 2n >= denominator ? quotient + 1n : quotient;
691
+ }
692
+ // src/money/growth.ts
693
+ const factorPowers = /* @__PURE__ */ new Map();
694
+ const ratePowers = /* @__PURE__ */ new Map();
695
+ function powerAt(rate, exponent, rateBase) {
696
+ const cache = rateBase ? ratePowers : factorPowers;
697
+ let powers = cache.get(rate);
698
+ if (powers === void 0) {
699
+ powers = [1n];
700
+ cache.set(rate, powers);
701
+ }
702
+ const base = rateBase ? RATE_SCALE + BigInt(rate) : dailyFactor(rate);
703
+ while (powers.length <= exponent) powers.push(powers[powers.length - 1] * base);
704
+ return powers[exponent];
705
+ }
706
+ function splitDays(days) {
707
+ if (!Number.isSafeInteger(days) || days < 0) throw new RangeError("Growth expects a non-negative safe integer day count");
708
+ const years = Math.floor(days / 365);
709
+ return [years, days - years * 365];
710
+ }
711
+ /** `(10000 + rate)^years * f^remainder / (10000^years * 10^(30*remainder))`. */
712
+ function factorParts(rate, days) {
713
+ const [years, remainder] = splitDays(days);
714
+ return {
715
+ numerator: powerAt(rate, years, true) * powerAt(rate, remainder, false),
716
+ denominator: powerAt(0, years, true) * powerAt(0, remainder, false)
717
+ };
718
+ }
719
+ /** Grows `amount` over `days` counted days with one half-up rounding at the end. */
720
+ function grow(amount, rate, days) {
721
+ const parts = factorParts(rate, days);
722
+ return roundHalfUp(amount * parts.numerator, parts.denominator);
723
+ }
724
+ /** Today's-money value of `amount` at `inflation`, over `days` counted days. */
725
+ function deflate(amount, inflation, days) {
726
+ const parts = factorParts(inflation, days);
727
+ return roundHalfUp(amount * parts.denominator, parts.numerator);
728
+ }
729
+ // src/output/real.ts
730
+ /** Deflates each account separately, then sums the signed real balances. */
731
+ function realValues(snapshot, rules, start, inflation) {
732
+ const days = countedDays(start, snapshot.date);
733
+ const balances = [];
734
+ let net = 0n;
735
+ let accessible = 0n;
736
+ for (const balance of snapshot.balances) {
737
+ const amount = deflate(balance.amount, inflation, days);
738
+ if (!isSafeInteger(amount)) return {
739
+ values: void 0,
740
+ problem: overflow(snapshot.date, balance.account)
741
+ };
742
+ balances.push({
743
+ account: balance.account,
744
+ amount: Number(amount)
745
+ });
746
+ const rule = rules.get(balance.account);
747
+ const signed = rule?.kind === "liability" ? -amount : amount;
748
+ net += signed;
749
+ if (rule?.kind !== "asset" || rule.lockedUntil === void 0 || snapshot.date >= rule.lockedUntil) accessible += signed;
750
+ }
751
+ if (!isSafeInteger(net) || !isSafeInteger(accessible)) return {
752
+ values: void 0,
753
+ problem: overflow(snapshot.date)
754
+ };
755
+ return {
756
+ values: {
757
+ balances,
758
+ netWorth: net,
759
+ accessibleNetWorth: accessible
760
+ },
761
+ problem: void 0
762
+ };
763
+ }
764
+ // src/output/rows.ts
765
+ function nominalAmounts(snapshot) {
766
+ return snapshot.balances.map((balance) => ({
767
+ account: balance.account,
768
+ amount: Number(balance.amount)
769
+ }));
770
+ }
771
+ function buildRows(snapshots, timeline, rules, inflation) {
772
+ const rows = [];
773
+ for (const snapshot of snapshots) {
774
+ const real = realValues(snapshot, rules, timeline.start, inflation);
775
+ if (real.problem !== void 0) return {
776
+ rows: void 0,
777
+ problem: real.problem
778
+ };
779
+ const values = real.values;
780
+ rows.push({
781
+ date: snapshot.date,
782
+ balances: nominalAmounts(snapshot),
783
+ netWorth: Number(snapshot.netWorth),
784
+ accessibleNetWorth: Number(snapshot.accessibleNetWorth),
785
+ realBalances: values.balances,
786
+ realNetWorth: Number(values.netWorth),
787
+ realAccessibleNetWorth: Number(values.accessibleNetWorth)
788
+ });
789
+ }
790
+ return {
791
+ rows,
792
+ problem: void 0
793
+ };
794
+ }
795
+ // src/output/rules.ts
796
+ function accountRules(scenario) {
797
+ const rules = /* @__PURE__ */ new Map();
798
+ for (const rule of scenario.accounts) rules.set(rule.id, rule);
799
+ return rules;
800
+ }
801
+ // src/output/build.ts
802
+ function buildProjection(run, baseline, scenario, timeline) {
803
+ const rules = accountRules(scenario);
804
+ const reported = new Set(rowDates(timeline));
805
+ const rows = buildRows(run.snapshots.filter((snapshot) => reported.has(snapshot.date) || snapshot.date === run.runsOut?.date), timeline, rules, scenario.economy.inflation);
806
+ if (rows.problem !== void 0) return { problem: rows.problem };
807
+ const end = run.runsOut?.date ?? timeline.horizon;
808
+ const rollups = buildRollups(run, buildPeriods(timeline.start, end, run.runsOut?.date), openingNetWorth(baseline, rules));
809
+ if (rollups.problem !== void 0) return { problem: rollups.problem };
810
+ return { projection: run.runsOut === void 0 ? {
811
+ rows: rows.rows,
812
+ annual: rollups.annual
813
+ } : {
814
+ rows: rows.rows,
815
+ annual: rollups.annual,
816
+ runsOut: {
817
+ date: run.runsOut.date,
818
+ unmet: Number(run.runsOut.unmet)
819
+ }
820
+ } };
821
+ }
822
+ // src/engine/dates.ts
823
+ /** Sorted unique union of event dates and requested observation dates. */
824
+ function collectDates(events, observations) {
825
+ const dates = /* @__PURE__ */ new Set();
826
+ for (const event of events) dates.add(event.date);
827
+ for (const date of observations) dates.add(date);
828
+ return [...dates].sort((left, right) => left < right ? -1 : left > right ? 1 : 0);
829
+ }
830
+ // src/calendar/occurrence.ts
831
+ function stepDays(frequency) {
832
+ if (frequency === "daily") return 1;
833
+ if (frequency === "weekly") return 7;
834
+ if (frequency === "biweekly") return 14;
835
+ }
836
+ function stepMonths(frequency) {
837
+ if (frequency === "monthly") return 1;
838
+ if (frequency === "quarterly") return 3;
839
+ if (frequency === "annual") return 12;
840
+ }
841
+ function occurrenceDate(start, frequency, index) {
842
+ const months = stepMonths(frequency);
843
+ if (months !== void 0) return addMonthsClamped(start, index * months);
844
+ return addDays(start, index * (stepDays(frequency) ?? 1));
845
+ }
846
+ function firstIndex(start, frequency, from) {
847
+ if (from <= start) return 0;
848
+ const months = stepMonths(frequency);
849
+ if (months !== void 0) {
850
+ const s = start.split("-");
851
+ const f = from.split("-");
852
+ const diff = (Number(f[0]) - Number(s[0])) * 12 + (Number(f[1]) - Number(s[1]));
853
+ return Math.max(0, Math.floor(diff / months));
854
+ }
855
+ const days = stepDays(frequency) ?? 1;
856
+ return Math.max(0, Math.floor((dayNumber(from) - dayNumber(start)) / days));
857
+ }
858
+ function rangeOccurrences(start, frequency, last, from) {
859
+ const result = [];
860
+ for (let index = firstIndex(start, frequency, from);; index++) {
861
+ const date = occurrenceDate(start, frequency, index);
862
+ if (date > last) return result;
863
+ if (date >= from) result.push({
864
+ date,
865
+ years: growthYears(start, date)
866
+ });
867
+ }
868
+ }
869
+ /** Occurrences in `[from, to]`, with `end` (inclusive) capping them. */
870
+ function occurrences(start, frequency, end, from, to) {
871
+ if (to < from) return [];
872
+ const last = end !== void 0 && end < to ? end : to;
873
+ if (frequency === "once") return start >= from && start <= last ? [{
874
+ date: start,
875
+ years: 0
876
+ }] : [];
877
+ return rangeOccurrences(start, frequency, last, from);
878
+ }
879
+ // src/engine/order.ts
880
+ /** Plain UTF-16 code-unit ordering, never `localeCompare`. */
881
+ function compareIds(left, right) {
882
+ if (left < right) return -1;
883
+ if (left > right) return 1;
884
+ return 0;
885
+ }
886
+ // src/engine/events.ts
887
+ /** All flow occurrences in the timeline, sorted by date then flow id. */
888
+ function buildEvents(scenario, timeline) {
889
+ const events = [];
890
+ for (const flow of scenario.flows) {
891
+ const found = occurrences(flow.start, flow.frequency, flow.end, timeline.start, timeline.horizon);
892
+ for (const occurrence of found) events.push({
893
+ date: occurrence.date,
894
+ flow,
895
+ years: occurrence.years
896
+ });
897
+ }
898
+ return events.sort((left, right) => left.date === right.date ? compareIds(left.flow.id, right.flow.id) : left.date < right.date ? -1 : 1);
899
+ }
900
+ // src/engine/amount.ts
901
+ /** Flow amount at an occurrence, grown from the original by whole annual steps. */
902
+ function amountAt(event, inflation) {
903
+ const growth = event.flow.growth;
904
+ const rate = growth === "inflation" ? inflation : growth.bps;
905
+ return grow(BigInt(event.flow.amount), rate, event.years * 365);
906
+ }
907
+ // src/engine/aggregate.ts
908
+ function aggregate(events, inflation) {
909
+ let income = 0n;
910
+ let expense = 0n;
911
+ const contributions = /* @__PURE__ */ new Map();
912
+ for (const event of events) {
913
+ const amount = amountAt(event, inflation);
914
+ const flow = event.flow;
915
+ if (flow.kind === "income") income += amount;
916
+ else if (flow.kind === "expense") expense += amount;
917
+ else {
918
+ const previous = contributions.get(flow.target) ?? 0n;
919
+ contributions.set(flow.target, previous + amount);
920
+ }
921
+ }
922
+ return {
923
+ income,
924
+ expense,
925
+ contributions
926
+ };
927
+ }
928
+ // src/engine/min.ts
929
+ function minBigInt(left, right) {
930
+ if (left < right) return left;
931
+ return right;
932
+ }
933
+ // src/engine/value.ts
934
+ /** Read-only valuation; it never commits the account. */
935
+ function valuation(state, date) {
936
+ return grow(state.balance, state.rule.rate, countedDays(state.asOf, date));
937
+ }
938
+ // src/engine/mutate.ts
939
+ /** Commits accrued growth, then applies `delta`; returns a problem on overflow. */
940
+ function mutate(state, date, delta) {
941
+ const committed = valuation(state, date);
942
+ if (!isSafeInteger(committed)) return overflow(date, state.rule.id);
943
+ const next = committed + delta;
944
+ if (next < 0n) throw new RangeError(`Negative balance on ${state.rule.id} at ${date}`);
945
+ if (!isSafeInteger(next)) return overflow(date, state.rule.id);
946
+ state.balance = next;
947
+ state.asOf = date;
948
+ }
949
+ // src/engine/contributions.ts
950
+ function applyTarget(states, target, total, date) {
951
+ const state = states.get(target);
952
+ if (total === 0n || state === void 0) return {
953
+ outflow: 0n,
954
+ problem: void 0
955
+ };
956
+ const liability = state.rule.kind === "liability";
957
+ const payment = minBigInt(total, liability ? valuation(state, date) : total);
958
+ const problem = mutate(state, date, liability ? -payment : payment);
959
+ return problem === void 0 ? {
960
+ outflow: payment,
961
+ problem: void 0
962
+ } : {
963
+ outflow: 0n,
964
+ problem
965
+ };
966
+ }
967
+ /** Applies same-date contributions per target, capping liability payments at the amount owed. */
968
+ function applyContributions(states, contributions, date) {
969
+ let outflow = 0n;
970
+ for (const target of [...contributions.keys()].sort(compareIds)) {
971
+ const result = applyTarget(states, target, contributions.get(target) ?? 0n, date);
972
+ if (result.problem !== void 0) return {
973
+ outflow,
974
+ problem: result.problem
975
+ };
976
+ outflow += result.outflow;
977
+ }
978
+ return {
979
+ outflow,
980
+ problem: void 0
981
+ };
982
+ }
983
+ // src/engine/surplus.ts
984
+ function fillStep(states, step, date, remaining) {
985
+ const state = states.get(step.account);
986
+ const headroom = step.upTo === void 0 ? remaining : BigInt(step.upTo) - valuation(state, date);
987
+ let fill = 0n;
988
+ if (headroom > 0n) fill = minBigInt(remaining, headroom);
989
+ if (fill === 0n) return {
990
+ remaining,
991
+ problem: void 0
992
+ };
993
+ const problem = mutate(state, date, fill);
994
+ return problem === void 0 ? {
995
+ remaining: remaining - fill,
996
+ problem: void 0
997
+ } : {
998
+ remaining,
999
+ problem
1000
+ };
1001
+ }
1002
+ function applySurplus(states, scenario, date, amount) {
1003
+ let remaining = amount;
1004
+ for (const step of scenario.cashflow.surplus) {
1005
+ if (remaining === 0n) return void 0;
1006
+ const result = fillStep(states, step, date, remaining);
1007
+ remaining = result.remaining;
1008
+ if (result.problem !== void 0) return result.problem;
1009
+ }
1010
+ }
1011
+ // src/engine/withdraw.ts
1012
+ function isLocked(state, date) {
1013
+ return state.rule.kind === "asset" && state.rule.lockedUntil !== void 0 && date < state.rule.lockedUntil;
1014
+ }
1015
+ function takeFrom(state, date, remaining) {
1016
+ const take = minBigInt(remaining, valuation(state, date));
1017
+ if (take === 0n) return {
1018
+ remaining,
1019
+ problem: void 0
1020
+ };
1021
+ const problem = mutate(state, date, -take);
1022
+ return problem === void 0 ? {
1023
+ remaining: remaining - take,
1024
+ problem: void 0
1025
+ } : {
1026
+ remaining,
1027
+ problem
1028
+ };
1029
+ }
1030
+ function applyWithdraw(states, scenario, date, amount) {
1031
+ let remaining = amount;
1032
+ for (const account of scenario.cashflow.withdraw) {
1033
+ if (remaining === 0n) break;
1034
+ const state = states.get(account);
1035
+ if (isLocked(state, date)) continue;
1036
+ const result = takeFrom(state, date, remaining);
1037
+ remaining = result.remaining;
1038
+ if (result.problem !== void 0) return result;
1039
+ }
1040
+ return {
1041
+ remaining,
1042
+ problem: void 0
1043
+ };
1044
+ }
1045
+ // src/engine/occurrence.ts
1046
+ /** Applies one occurrence date in the canonical order of operations. */
1047
+ function processDate(states, scenario, date, events) {
1048
+ const day = aggregate(events, scenario.economy.inflation);
1049
+ const applied = applyContributions(states, day.contributions, date);
1050
+ const totals = {
1051
+ date,
1052
+ income: day.income,
1053
+ expense: day.expense,
1054
+ contributions: applied.outflow
1055
+ };
1056
+ if (applied.problem !== void 0) return {
1057
+ totals,
1058
+ problem: applied.problem,
1059
+ runsOut: void 0
1060
+ };
1061
+ const net = day.income - day.expense - applied.outflow;
1062
+ if (net > 0n) return {
1063
+ totals,
1064
+ problem: applySurplus(states, scenario, date, net),
1065
+ runsOut: void 0
1066
+ };
1067
+ if (net < 0n) {
1068
+ const drawn = applyWithdraw(states, scenario, date, -net);
1069
+ if (drawn.problem !== void 0) return {
1070
+ totals,
1071
+ problem: drawn.problem,
1072
+ runsOut: void 0
1073
+ };
1074
+ if (drawn.remaining > 0n) return {
1075
+ totals,
1076
+ problem: void 0,
1077
+ runsOut: {
1078
+ date,
1079
+ unmet: drawn.remaining
1080
+ }
1081
+ };
1082
+ }
1083
+ return {
1084
+ totals,
1085
+ problem: void 0,
1086
+ runsOut: void 0
1087
+ };
1088
+ }
1089
+ // src/engine/snapshot.ts
1090
+ /** Values every account without committing, and checks all reported numbers stay safe. */
1091
+ function snapshot(states, date) {
1092
+ const balances = [];
1093
+ let net = 0n;
1094
+ let accessible = 0n;
1095
+ for (const state of states.values()) {
1096
+ const amount = valuation(state, date);
1097
+ if (!isSafeInteger(amount)) return {
1098
+ snapshot: void 0,
1099
+ problem: overflow(date, state.rule.id)
1100
+ };
1101
+ balances.push({
1102
+ account: state.rule.id,
1103
+ amount
1104
+ });
1105
+ const signed = state.rule.kind === "liability" ? -amount : amount;
1106
+ net += signed;
1107
+ if (state.rule.kind === "liability" || state.rule.lockedUntil === void 0 || date >= state.rule.lockedUntil) accessible += signed;
1108
+ }
1109
+ if (!isSafeInteger(net) || !isSafeInteger(accessible)) return {
1110
+ snapshot: void 0,
1111
+ problem: overflow(date)
1112
+ };
1113
+ return {
1114
+ snapshot: {
1115
+ date,
1116
+ balances,
1117
+ netWorth: net,
1118
+ accessibleNetWorth: accessible
1119
+ },
1120
+ problem: void 0
1121
+ };
1122
+ }
1123
+ // src/engine/run-step.ts
1124
+ function addSnapshot(snapshots, states, date) {
1125
+ const outcome = snapshot(states, date);
1126
+ if (outcome.problem !== void 0) return outcome.problem;
1127
+ snapshots.push(outcome.snapshot);
1128
+ }
1129
+ function finishRunout(acc, states, runsOut) {
1130
+ if (!isSafeInteger(runsOut.unmet)) return {
1131
+ ok: false,
1132
+ problem: overflow(runsOut.date)
1133
+ };
1134
+ const problem = addSnapshot(acc.snapshots, states, runsOut.date);
1135
+ if (problem !== void 0) return {
1136
+ ok: false,
1137
+ problem
1138
+ };
1139
+ return {
1140
+ ok: true,
1141
+ run: {
1142
+ snapshots: acc.snapshots,
1143
+ totals: acc.totals,
1144
+ runsOut
1145
+ }
1146
+ };
1147
+ }
1148
+ function handleDay(acc, states, scenario, date, events) {
1149
+ if (events.length === 0) return void 0;
1150
+ const outcome = processDate(states, scenario, date, events);
1151
+ acc.totals.push(outcome.totals);
1152
+ if (outcome.problem !== void 0) return {
1153
+ ok: false,
1154
+ problem: outcome.problem
1155
+ };
1156
+ if (outcome.runsOut !== void 0) return finishRunout(acc, states, outcome.runsOut);
1157
+ }
1158
+ function handleSnapshot(acc, states, date) {
1159
+ if (!acc.wanted.has(date)) return void 0;
1160
+ const problem = addSnapshot(acc.snapshots, states, date);
1161
+ if (problem !== void 0) return {
1162
+ ok: false,
1163
+ problem
1164
+ };
1165
+ }
1166
+ function stepDate(acc, states, scenario, date, events) {
1167
+ const dayResult = handleDay(acc, states, scenario, date, events);
1168
+ if (dayResult !== void 0) return dayResult;
1169
+ return handleSnapshot(acc, states, date);
1170
+ }
1171
+ // src/engine/state.ts
1172
+ /** Every declared account gets a state; missing baseline balances open at zero. */
1173
+ function createStates(baseline, scenario, start) {
1174
+ const opening = /* @__PURE__ */ new Map();
1175
+ for (const balance of baseline.balances) opening.set(balance.account, balance.amount);
1176
+ const states = /* @__PURE__ */ new Map();
1177
+ const rules = [...scenario.accounts].sort((a, b) => compareIds(a.id, b.id));
1178
+ for (const rule of rules) states.set(rule.id, {
1179
+ rule,
1180
+ balance: BigInt(opening.get(rule.id) ?? 0),
1181
+ asOf: start
1182
+ });
1183
+ return states;
1184
+ }
1185
+ // src/engine/run.ts
1186
+ function runEngine(baseline, scenario, timeline, observations) {
1187
+ const states = createStates(baseline, scenario, timeline.start);
1188
+ const events = buildEvents(scenario, timeline);
1189
+ const acc = {
1190
+ snapshots: [],
1191
+ totals: [],
1192
+ wanted: new Set(observations)
1193
+ };
1194
+ let index = 0;
1195
+ for (const date of collectDates(events, observations)) {
1196
+ const start = index;
1197
+ while (index < events.length && events[index]?.date === date) index++;
1198
+ const result = stepDate(acc, states, scenario, date, events.slice(start, index));
1199
+ if (result !== void 0) return result;
1200
+ }
1201
+ return {
1202
+ ok: true,
1203
+ run: {
1204
+ snapshots: acc.snapshots,
1205
+ totals: acc.totals
1206
+ }
1207
+ };
1208
+ }
1209
+ // src/engine/project.ts
1210
+ /** Report rows plus the period ends the annual roll-ups close on. */
1211
+ function observationDates(timeline) {
1212
+ return [...rowDates(timeline), ...periodEndDates(timeline.start, timeline.horizon)];
1213
+ }
1214
+ /** Runs `check` first, then the deterministic engine; never throws on valid inputs. */
1215
+ function project(baseline, scenario, timeline) {
1216
+ const problems = check(baseline, scenario, timeline);
1217
+ if (problems.length > 0) return {
1218
+ ok: false,
1219
+ problems
1220
+ };
1221
+ const result = runEngine(baseline, scenario, timeline, observationDates(timeline));
1222
+ if (!result.ok) return {
1223
+ ok: false,
1224
+ problems: [result.problem]
1225
+ };
1226
+ const output = buildProjection(result.run, baseline, scenario, timeline);
1227
+ if ("problem" in output) return {
1228
+ ok: false,
1229
+ problems: [output.problem]
1230
+ };
1231
+ return {
1232
+ ok: true,
1233
+ projection: output.projection
1234
+ };
1235
+ }
1236
+ export { ERROR_CODES, check, project };
1237
+
1238
+ //# sourceMappingURL=index.mjs.map