@orkestrel/program 0.0.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.
@@ -0,0 +1,1435 @@
1
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
+ let _orkestrel_contract = require("@orkestrel/contract");
3
+ let _orkestrel_qualifier = require("@orkestrel/qualifier");
4
+ let _orkestrel_rater = require("@orkestrel/rater");
5
+ let _orkestrel_reason = require("@orkestrel/reason");
6
+ let _orkestrel_emitter = require("@orkestrel/emitter");
7
+ //#region src/core/constants.ts
8
+ /** Default definition validation policy for `createProgram` / `ProgramManager.add`. */
9
+ var DEFAULT_PROGRAM_VALIDATE = true;
10
+ /** Status tally precedence order — least to most resolved. */
11
+ var STATUS_PRECEDENCE = Object.freeze([
12
+ "ineligible",
13
+ "referral",
14
+ "conditional",
15
+ "unrated",
16
+ "eligible"
17
+ ]);
18
+ /** The deterministic authority decision for each global eligibility. */
19
+ var ELIGIBILITY_DECISIONS = Object.freeze({
20
+ eligible: "approved",
21
+ ineligible: "denied",
22
+ referral: "submitted"
23
+ });
24
+ /** The reserved working-subject key a batch's aggregate projection is written under. */
25
+ var AGGREGATE_KEY = "aggregate";
26
+ /** The reserved working-subject key the authority's outcome projection is written under. */
27
+ var OUTCOME_KEY = "outcome";
28
+ //#endregion
29
+ //#region src/core/errors.ts
30
+ /**
31
+ * A coded programmer error thrown by the program layer.
32
+ *
33
+ * @remarks
34
+ * `DUPLICATE` — a program id collision on `ProgramManager.add`, or a duplicate
35
+ * authored rating-line or notice id. `MISSING` — an
36
+ * authored notice or qualification ruling scope names no rating line.
37
+ * `DEFINITION` — a program, qualification, rating, authority, or aggregate
38
+ * policy failed validation. `MISMATCH` — an injected entity or a returned
39
+ * reason result has the wrong contract. `RESERVED` — a subject already
40
+ * carries `aggregate` or `outcome`. `DESTROYED` — use of a destroyed entity.
41
+ */
42
+ var ProgramError = class extends Error {
43
+ code;
44
+ context;
45
+ constructor(code, message, context) {
46
+ super(message);
47
+ this.name = "ProgramError";
48
+ this.code = code;
49
+ this.context = context;
50
+ }
51
+ };
52
+ /** Narrow a caught value to a {@link ProgramError}. */
53
+ function isProgramError(value) {
54
+ return value instanceof ProgramError;
55
+ }
56
+ //#endregion
57
+ //#region src/core/validators.ts
58
+ /**
59
+ * Determine whether a value is a {@link Decision} literal.
60
+ *
61
+ * @param value - The candidate value
62
+ * @returns `true` when `value` is a {@link Decision}
63
+ *
64
+ * @example
65
+ * ```ts
66
+ * import { isDecision } from '@orkestrel/program'
67
+ *
68
+ * isDecision('approved') // true
69
+ * ```
70
+ */
71
+ var isDecision = (0, _orkestrel_contract.literalOf)("approved", "denied", "submitted");
72
+ /**
73
+ * Determine whether a value is a {@link Status} literal.
74
+ *
75
+ * @param value - The candidate value
76
+ * @returns `true` when `value` is a {@link Status}
77
+ *
78
+ * @example
79
+ * ```ts
80
+ * import { isStatus } from '@orkestrel/program'
81
+ *
82
+ * isStatus('eligible') // true
83
+ * ```
84
+ */
85
+ var isStatus = (0, _orkestrel_contract.literalOf)("ineligible", "referral", "conditional", "unrated", "eligible");
86
+ /**
87
+ * Determine whether a value is a {@link ProgramEffect} literal.
88
+ *
89
+ * @param value - The candidate value
90
+ * @returns `true` when `value` is a {@link ProgramEffect}
91
+ *
92
+ * @example
93
+ * ```ts
94
+ * import { isProgramEffect } from '@orkestrel/program'
95
+ *
96
+ * isProgramEffect('notice') // true
97
+ * ```
98
+ */
99
+ var isProgramEffect = (0, _orkestrel_contract.literalOf)("notice", "limit");
100
+ /**
101
+ * Determine whether a value is an exact {@link Notice} record.
102
+ *
103
+ * @param value - The candidate value
104
+ * @returns `true` when `value` is a {@link Notice}
105
+ *
106
+ * @example
107
+ * ```ts
108
+ * import { isNotice } from '@orkestrel/program'
109
+ *
110
+ * isNotice({ id: 'minimum', message: 'Minimum applies' }) // true
111
+ * ```
112
+ */
113
+ function isNotice(value) {
114
+ return (0, _orkestrel_contract.recordOf)({
115
+ id: _orkestrel_contract.isString,
116
+ message: _orkestrel_contract.isString,
117
+ scope: _orkestrel_contract.isString
118
+ }, ["scope"])(value);
119
+ }
120
+ /**
121
+ * Determine whether a value is an exact {@link AggregateDefinition} record.
122
+ *
123
+ * @param value - The candidate value
124
+ * @returns `true` when `value` is an {@link AggregateDefinition}
125
+ *
126
+ * @example
127
+ * ```ts
128
+ * import { isAggregateDefinition } from '@orkestrel/program'
129
+ *
130
+ * isAggregateDefinition({ fields: ['amount'] }) // true
131
+ * ```
132
+ */
133
+ function isAggregateDefinition(value) {
134
+ return (0, _orkestrel_contract.recordOf)({
135
+ fields: (0, _orkestrel_contract.arrayOf)(_orkestrel_reason.isFieldPath),
136
+ by: _orkestrel_reason.isFieldPath,
137
+ gates: _orkestrel_reason.isLogicalDefinition
138
+ }, ["by", "gates"])(value);
139
+ }
140
+ /**
141
+ * Determine whether a value is an exact {@link ProgramDefinition} record.
142
+ *
143
+ * @remarks
144
+ * `rating` is optional — an omitted `rating` authors an eligibility-only
145
+ * program (see {@link ProgramDefinition}).
146
+ *
147
+ * @param value - The candidate value
148
+ * @returns `true` when `value` is a {@link ProgramDefinition}
149
+ *
150
+ * @example
151
+ * ```ts
152
+ * import { isProgramDefinition } from '@orkestrel/program'
153
+ *
154
+ * isProgramDefinition({ id: 'p', name: 'P', qualification }) // true
155
+ * ```
156
+ */
157
+ function isProgramDefinition(value) {
158
+ return (0, _orkestrel_contract.recordOf)({
159
+ id: _orkestrel_contract.isString,
160
+ name: _orkestrel_contract.isString,
161
+ description: _orkestrel_contract.isString,
162
+ qualification: _orkestrel_qualifier.isQualificationDefinition,
163
+ rating: _orkestrel_rater.isRatingDefinition,
164
+ notices: (0, _orkestrel_contract.arrayOf)(isNotice),
165
+ authority: _orkestrel_reason.isLogicalDefinition,
166
+ aggregate: isAggregateDefinition,
167
+ metadata: _orkestrel_contract.isJSONValue
168
+ }, [
169
+ "description",
170
+ "rating",
171
+ "notices",
172
+ "authority",
173
+ "aggregate",
174
+ "metadata"
175
+ ])(value);
176
+ }
177
+ //#endregion
178
+ //#region src/core/helpers.ts
179
+ /**
180
+ * Return a fresh JSON value tree that does not alias the input.
181
+ *
182
+ * @remarks
183
+ * The input must be an acyclic JSON tree of bounded depth — a pathologically
184
+ * deep tree throws the engine's `RangeError` (stack exhaustion) rather than
185
+ * hanging. Each copied record uses `Object.defineProperty` for own-property
186
+ * definition, which defends against prototype-pollution keys (`__proto__`).
187
+ *
188
+ * @param value - The JSON value to copy
189
+ * @returns A fresh JSON value
190
+ *
191
+ * @example
192
+ * ```ts
193
+ * import { copyJSONValue } from '@orkestrel/program'
194
+ *
195
+ * copyJSONValue({ a: [1, 2] }) // { a: [1, 2] }, a fresh copy
196
+ * ```
197
+ */
198
+ function copyJSONValue(value) {
199
+ if (value === null || typeof value !== "object") return value;
200
+ if (Array.isArray(value)) return value.map(copyJSONValue);
201
+ const copy = {};
202
+ for (const [key, entry] of Object.entries(value)) Object.defineProperty(copy, key, {
203
+ value: copyJSONValue(entry),
204
+ enumerable: true,
205
+ writable: true,
206
+ configurable: true
207
+ });
208
+ return copy;
209
+ }
210
+ /**
211
+ * Determine whether a caller subject already carries a reserved program key.
212
+ *
213
+ * @remarks
214
+ * `aggregate` and `outcome` are program-private working-subject namespaces — the
215
+ * batch aggregate projection and the authority outcome projection are written
216
+ * under them. A caller subject that already owns either key would silently
217
+ * collide with a projection, so it is rejected before qualification.
218
+ *
219
+ * @param subject - The caller subject to check
220
+ * @returns `true` when the subject owns `aggregate` or `outcome`
221
+ *
222
+ * @example
223
+ * ```ts
224
+ * import { hasReservedKey } from '@orkestrel/program'
225
+ *
226
+ * hasReservedKey({ id: 'r1' }) // false
227
+ * hasReservedKey({ id: 'r1', aggregate: {} }) // true
228
+ * ```
229
+ */
230
+ function hasReservedKey(subject) {
231
+ return Object.hasOwn(subject, "aggregate") || Object.hasOwn(subject, "outcome");
232
+ }
233
+ /**
234
+ * Assert a value is a valid program {@link Subject}, narrowing it in place.
235
+ *
236
+ * @param subject - The candidate subject to validate
237
+ * @throws {@link ProgramError} `'MISMATCH'` when the value is not a record, or
238
+ * `'RESERVED'` when it already carries the `aggregate` or `outcome` key
239
+ *
240
+ * @example
241
+ * ```ts
242
+ * import { assertProgramSubject } from '@orkestrel/program'
243
+ *
244
+ * assertProgramSubject({ id: 'r1' }) // does not throw
245
+ * ```
246
+ */
247
+ function assertProgramSubject(subject) {
248
+ if (!(0, _orkestrel_contract.isRecord)(subject)) throw new ProgramError("MISMATCH", "Program subject must be a record");
249
+ if (hasReservedKey(subject)) {
250
+ const key = Object.hasOwn(subject, "aggregate") ? AGGREGATE_KEY : OUTCOME_KEY;
251
+ throw new ProgramError("RESERVED", `Subject contains a reserved program key '${key}'`, key);
252
+ }
253
+ }
254
+ /**
255
+ * Select the rating lines a subject may be rated on from scoped eligibility.
256
+ *
257
+ * @remarks
258
+ * A scope names a rating-line id. A line survives when its scope is absent
259
+ * (eligible by default), `eligible`, or a `condition` (which is not an
260
+ * eligibility value and never appears here). A scoped `ineligible` or `referral`
261
+ * removes the line BEFORE the rater is invoked — the excluded line is never
262
+ * evaluated merely to discard its amount.
263
+ *
264
+ * @param lines - The program's authored rating lines
265
+ * @param scopes - The qualification's per-scope eligibility
266
+ * @returns The surviving line definitions, in authored order
267
+ *
268
+ * @example
269
+ * ```ts
270
+ * import { selectProgramLines } from '@orkestrel/program'
271
+ *
272
+ * selectProgramLines(lines, { wind: 'ineligible' }) // every line except 'wind'
273
+ * ```
274
+ */
275
+ function selectProgramLines(lines, scopes) {
276
+ return lines.filter((line) => {
277
+ const eligibility = scopes[line.id];
278
+ return eligibility !== "ineligible" && eligibility !== "referral";
279
+ });
280
+ }
281
+ /**
282
+ * Derive the final program {@link Status} from a definition's rating policy and
283
+ * qualification/rating evidence.
284
+ *
285
+ * @remarks
286
+ * Explicit policy, not an opaque precedence reduce (AGENTS §10): global
287
+ * ineligibility or referral is terminal; a scoped referral yields `referral`;
288
+ * an applied `condition` or an applied scoped `restriction` (a line was
289
+ * removed but others rated) is `conditional`. When the definition OMITS
290
+ * `rating` the program is eligibility-only — status resolves to `conditional`
291
+ * or `eligible` and is NEVER `unrated`. Otherwise a subject with no successful
292
+ * rating is `unrated`.
293
+ *
294
+ * @param definition - The authored program definition
295
+ * @param qualification - The subject's qualification result
296
+ * @param rating - The subject's rating result, when rating occurred
297
+ * @returns The derived status
298
+ *
299
+ * @example
300
+ * ```ts
301
+ * import { deriveStatus } from '@orkestrel/program'
302
+ *
303
+ * deriveStatus(definition, qualification, rating) // 'eligible'
304
+ * ```
305
+ */
306
+ function deriveStatus(definition, qualification, rating) {
307
+ if (qualification.eligibility === "ineligible") return "ineligible";
308
+ if (qualification.eligibility === "referral") return "referral";
309
+ if (Object.values(qualification.scopes).includes("referral")) return "referral";
310
+ const conditional = qualification.findings.some((finding) => finding.applied && (finding.effect === "condition" || finding.scope !== void 0 && finding.effect === "restriction"));
311
+ if (definition.rating === void 0) return conditional ? "conditional" : "eligible";
312
+ if (rating === void 0 || rating.lines.length === 0 || !rating.success) return "unrated";
313
+ return conditional ? "conditional" : "eligible";
314
+ }
315
+ /**
316
+ * Map a global {@link Eligibility} to its deterministic authority {@link Decision}.
317
+ *
318
+ * @param eligibility - The global eligibility
319
+ * @returns The matching decision
320
+ *
321
+ * @example
322
+ * ```ts
323
+ * import { decideEligibility } from '@orkestrel/program'
324
+ *
325
+ * decideEligibility('eligible') // 'approved'
326
+ * decideEligibility('referral') // 'submitted'
327
+ * ```
328
+ */
329
+ function decideEligibility(eligibility) {
330
+ return ELIGIBILITY_DECISIONS[eligibility];
331
+ }
332
+ /**
333
+ * Resolve authored {@link Notice}s into unconditionally-applied `notice`
334
+ * {@link Determination}s.
335
+ *
336
+ * @remarks
337
+ * Notices are program output only — they never affect eligibility, status, line
338
+ * selection, or the decision. Each message interpolates against the original
339
+ * subject.
340
+ *
341
+ * @param notices - The authored notices
342
+ * @param subject - The original subject notices interpolate against
343
+ * @returns A fresh list of notice determinations
344
+ *
345
+ * @example
346
+ * ```ts
347
+ * import { buildNotices } from '@orkestrel/program'
348
+ *
349
+ * buildNotices([{ id: 'min', message: 'Minimum applies' }], { id: 'r1' })
350
+ * ```
351
+ */
352
+ function buildNotices(notices, subject) {
353
+ return notices.map((notice) => ({
354
+ id: notice.id,
355
+ effect: "notice",
356
+ applied: true,
357
+ ...notice.scope === void 0 ? {} : { scope: notice.scope },
358
+ message: (0, _orkestrel_qualifier.interpolateMessage)(notice.message, subject),
359
+ premises: []
360
+ }));
361
+ }
362
+ /**
363
+ * Convert a logical result's applied rules into `limit` {@link Determination}s.
364
+ *
365
+ * @remarks
366
+ * Fires for both the per-subject authority and the batch aggregate gates — both
367
+ * are plain {@link LogicalDefinition}s with no program-authored ruling map, so a
368
+ * fired rule's own `description` (from `@orkestrel/reason`) is the message
369
+ * template, interpolated against the working record the definition ran against.
370
+ * Rich premises reuse the qualifier's {@link logicalPremises}. A rule that never
371
+ * fires produces no determination — program has no authored ruling map to keep
372
+ * evidence for.
373
+ *
374
+ * @param definition - The authority or aggregate-gate logical definition
375
+ * @param result - The evaluated logical result
376
+ * @param working - The working record the definition ran against
377
+ * @param evaluator - The shared reason check evaluator
378
+ * @param labels - Optional field-to-label overrides, keyed by dot-joined field
379
+ * @returns A fresh list of `limit` determinations
380
+ *
381
+ * @example
382
+ * ```ts
383
+ * import { buildLimits } from '@orkestrel/program'
384
+ *
385
+ * buildLimits(authority, resolved, outcome, evaluator)
386
+ * ```
387
+ */
388
+ function buildLimits(definition, result, working, evaluator, labels) {
389
+ const output = [];
390
+ for (const entry of result.rules) {
391
+ if (!entry.applied) continue;
392
+ const rule = (0, _orkestrel_qualifier.findRule)(definition, entry.id);
393
+ if (rule === void 0) continue;
394
+ output.push({
395
+ id: entry.id,
396
+ effect: "limit",
397
+ applied: true,
398
+ ...rule.description === void 0 ? {} : { message: (0, _orkestrel_qualifier.interpolateMessage)(rule.description, working) },
399
+ premises: (0, _orkestrel_qualifier.logicalPremises)(rule, working, evaluator, labels)
400
+ });
401
+ }
402
+ return output;
403
+ }
404
+ /**
405
+ * Build the private authority outcome projection from an assembled program result.
406
+ *
407
+ * @remarks
408
+ * The authority reads this record under {@link OUTCOME_KEY}; it never receives
409
+ * the mutable internal state of either sibling engine. `total` is carried from
410
+ * the nested rating result when rating occurred.
411
+ *
412
+ * @param result - The preliminary program result computed before authority runs
413
+ * @returns A record shaped for the authority's `outcome` projection
414
+ *
415
+ * @example
416
+ * ```ts
417
+ * import { buildOutcomeProjection } from '@orkestrel/program'
418
+ *
419
+ * buildOutcomeProjection(result) // { id, eligibility, status, rated, scopes }
420
+ * ```
421
+ */
422
+ function buildOutcomeProjection(result) {
423
+ const total = result.rating?.total;
424
+ return {
425
+ id: result.id,
426
+ eligibility: result.eligibility,
427
+ status: result.status,
428
+ rated: result.rating !== void 0,
429
+ ...total === void 0 ? {} : { total },
430
+ scopes: { ...result.qualification.scopes }
431
+ };
432
+ }
433
+ /**
434
+ * Assemble a {@link ProgramResult} from its qualification, rating, and
435
+ * determination parts — before or after authority.
436
+ *
437
+ * @remarks
438
+ * `eligibility` mirrors the qualification. `success` is execution integrity: the
439
+ * qualification succeeded, rating (when it ran) succeeded, and authority (when it
440
+ * ran) produced no errors — a valid ineligible or referral outcome still
441
+ * succeeds. `trace` and `errors` accumulate the qualification's, every rated
442
+ * line's worksheet trail, and the authority's. A `decision` is present ONLY when
443
+ * an authority ran (`options.authority`), the execution SUCCEEDED (`success`),
444
+ * no `limit` determination applied, and status is not `unrated`.
445
+ *
446
+ * @param definition - The authored program definition
447
+ * @param qualification - The subject's qualification result
448
+ * @param rating - The subject's rating result, when rating occurred
449
+ * @param determinations - The program-scoped determinations (notices, then limits)
450
+ * @param status - The already-derived status
451
+ * @param options - Optional authority result driving the decision projection
452
+ * @returns A fresh program result
453
+ *
454
+ * @example
455
+ * ```ts
456
+ * import { buildProgramResult } from '@orkestrel/program'
457
+ *
458
+ * buildProgramResult(definition, qualification, rating, [], 'eligible')
459
+ * ```
460
+ */
461
+ function buildProgramResult(definition, qualification, rating, determinations, status, options) {
462
+ const authority = options?.authority;
463
+ const ratingTrace = rating === void 0 ? [] : rating.lines.flatMap((line) => line.worksheet.trace);
464
+ const ratingErrors = rating === void 0 ? [] : rating.lines.flatMap((line) => line.worksheet.errors);
465
+ const authorityTrace = authority === void 0 ? [] : [...authority.trace];
466
+ const authorityErrors = authority === void 0 ? [] : [...authority.errors];
467
+ const trace = [
468
+ ...qualification.trace,
469
+ ...ratingTrace,
470
+ ...authorityTrace
471
+ ];
472
+ const errors = [
473
+ ...qualification.errors,
474
+ ...ratingErrors,
475
+ ...authorityErrors
476
+ ];
477
+ const success = qualification.success && (rating === void 0 || rating.success) && authorityErrors.length === 0;
478
+ const limited = determinations.some((entry) => entry.effect === "limit" && entry.applied);
479
+ const decision = authority !== void 0 && success && !limited && status !== "unrated" ? decideEligibility(qualification.eligibility) : void 0;
480
+ return {
481
+ id: definition.id,
482
+ name: definition.name,
483
+ eligibility: qualification.eligibility,
484
+ status,
485
+ ...decision === void 0 ? {} : { decision },
486
+ qualification,
487
+ ...rating === void 0 ? {} : { rating },
488
+ determinations,
489
+ success,
490
+ trace,
491
+ errors
492
+ };
493
+ }
494
+ /**
495
+ * Add optional aggregate context to a private subject copy for qualification.
496
+ *
497
+ * @remarks
498
+ * The original subject is returned unchanged when no aggregate context exists.
499
+ * When context exists the helper creates a private copy under {@link AGGREGATE_KEY}
500
+ * and defensively copies every nested record — the rater still receives the
501
+ * original subject, never this copy.
502
+ *
503
+ * @param subject - The original caller subject
504
+ * @param aggregate - The subject's aggregate projection, when a batch supplies one
505
+ * @returns The subject, or a private copy carrying the aggregate projection
506
+ *
507
+ * @example
508
+ * ```ts
509
+ * import { buildQualificationSubject } from '@orkestrel/program'
510
+ *
511
+ * buildQualificationSubject({ id: 'r1' }) // { id: 'r1' }
512
+ * ```
513
+ */
514
+ function buildQualificationSubject(subject, aggregate) {
515
+ if (aggregate === void 0) return subject;
516
+ return {
517
+ ...subject,
518
+ [AGGREGATE_KEY]: {
519
+ count: aggregate.count,
520
+ sums: { ...aggregate.sums },
521
+ ...aggregate.group === void 0 ? {} : { group: {
522
+ key: aggregate.group.key,
523
+ count: aggregate.group.count,
524
+ sums: { ...aggregate.group.sums }
525
+ } }
526
+ }
527
+ };
528
+ }
529
+ /**
530
+ * Return authored scopes (qualification ruling scopes or notice scopes) that
531
+ * name no rating line on the program.
532
+ *
533
+ * @remarks
534
+ * A scope is an opaque string to the qualifier — program alone matches it to a
535
+ * rating-line id. A scope naming no line is a hard authoring error surfaced as
536
+ * {@link ProgramError} `'MISSING'` at construction, regardless of the validate
537
+ * option.
538
+ *
539
+ * @param definition - The program definition to check
540
+ * @returns A fresh, deduped list of missing scope references
541
+ *
542
+ * @example
543
+ * ```ts
544
+ * import { findMissingScopes } from '@orkestrel/program'
545
+ *
546
+ * findMissingScopes(definition) // []
547
+ * ```
548
+ */
549
+ function findMissingScopes(definition) {
550
+ const ids = new Set((definition.rating?.lines ?? []).map((line) => line.id));
551
+ const missing = /* @__PURE__ */ new Set();
552
+ for (const ruling of definition.qualification.rulings ?? []) if (ruling.scope !== void 0 && !ids.has(ruling.scope)) missing.add(ruling.scope);
553
+ for (const notice of definition.notices ?? []) if (notice.scope !== void 0 && !ids.has(notice.scope)) missing.add(notice.scope);
554
+ return [...missing];
555
+ }
556
+ /**
557
+ * Assert a program definition's always-on construction invariants — missing
558
+ * scope references and duplicate rating-line or notice ids.
559
+ *
560
+ * @remarks
561
+ * These checks run at construction regardless of `options.validate` (unlike
562
+ * {@link validateProgramDefinition}, the standalone report-shaped validator) —
563
+ * an authoring mistake this severe cannot silently compile.
564
+ *
565
+ * @param definition - The program definition to assert
566
+ * @throws {@link ProgramError} `'MISSING'` when a ruling or notice scope names
567
+ * no rating line
568
+ * @throws {@link ProgramError} `'DUPLICATE'` when two rating lines or two
569
+ * notices share an id
570
+ *
571
+ * @example
572
+ * ```ts
573
+ * import { assertProgramDefinition } from '@orkestrel/program'
574
+ *
575
+ * assertProgramDefinition(definition) // does not throw
576
+ * ```
577
+ */
578
+ function assertProgramDefinition(definition) {
579
+ const missing = findMissingScopes(definition);
580
+ if (missing.length > 0) throw new ProgramError("MISSING", `Unknown rating line reference: ${missing.join(", ")}`, definition.id);
581
+ const duplicateLines = (0, _orkestrel_reason.findDuplicates)(definition.rating?.lines ?? []);
582
+ if (duplicateLines.length > 0) throw new ProgramError("DUPLICATE", `Duplicate rating line id: ${duplicateLines.join(", ")}`, definition.id);
583
+ const duplicateNotices = (0, _orkestrel_reason.findDuplicates)(definition.notices ?? []);
584
+ if (duplicateNotices.length > 0) throw new ProgramError("DUPLICATE", `Duplicate notice id: ${duplicateNotices.join(", ")}`, definition.id);
585
+ }
586
+ /**
587
+ * Validate a program definition's shape, references, and nested definitions.
588
+ *
589
+ * @remarks
590
+ * The single semantic-validation implementation used by `Program.validate`. It
591
+ * establishes exact shape through {@link isProgramDefinition}, validates the
592
+ * rating structurally through the rater's {@link isRatingDefinition} guard (the
593
+ * rater exposes no `validate`), delegates qualification validation to the
594
+ * injected qualifier and authority / aggregate-gate validation to the shared
595
+ * reason engine, and checks scope, notice, and aggregate-field references here.
596
+ *
597
+ * @param definition - The program definition to validate
598
+ * @param qualifier - The qualifier that validates the nested qualification
599
+ * @param engine - The reason engine that validates authority and aggregate gates
600
+ * @returns A structured validation result
601
+ *
602
+ * @example
603
+ * ```ts
604
+ * import { validateProgramDefinition } from '@orkestrel/program'
605
+ *
606
+ * validateProgramDefinition(definition, qualifier, engine) // { valid: true, ... }
607
+ * ```
608
+ */
609
+ function validateProgramDefinition(definition, qualifier, engine) {
610
+ if (!isProgramDefinition(definition)) return {
611
+ valid: false,
612
+ errors: ["Program definition has an invalid shape"],
613
+ warnings: []
614
+ };
615
+ const errors = [];
616
+ const warnings = [];
617
+ if (definition.id.length === 0) errors.push("Program id must not be empty");
618
+ if (definition.name.length === 0) errors.push("Program name must not be empty");
619
+ const qualification = qualifier.validate(definition.qualification);
620
+ errors.push(...qualification.errors.map((error) => `qualification: ${error}`));
621
+ warnings.push(...qualification.warnings.map((warning) => `qualification: ${warning}`));
622
+ const lines = new Set((definition.rating?.lines ?? []).map((line) => line.id));
623
+ if (definition.rating !== void 0 && lines.size !== definition.rating.lines.length) errors.push("rating: duplicate line id");
624
+ for (const ruling of definition.qualification.rulings ?? []) if (ruling.scope !== void 0 && !lines.has(ruling.scope)) errors.push(`Qualification ruling "${ruling.id}" references missing line "${ruling.scope}"`);
625
+ const notices = /* @__PURE__ */ new Set();
626
+ for (const notice of definition.notices ?? []) {
627
+ if (notices.has(notice.id)) errors.push(`Duplicate notice id "${notice.id}"`);
628
+ notices.add(notice.id);
629
+ if (notice.scope !== void 0 && !lines.has(notice.scope)) errors.push(`Notice "${notice.id}" references missing line "${notice.scope}"`);
630
+ }
631
+ const authority = definition.authority;
632
+ if (authority !== void 0) {
633
+ const validation = engine.validate(authority);
634
+ errors.push(...validation.errors.map((error) => `authority: ${error}`));
635
+ warnings.push(...validation.warnings.map((warning) => `authority: ${warning}`));
636
+ }
637
+ const aggregate = definition.aggregate;
638
+ if (aggregate !== void 0) {
639
+ const fields = /* @__PURE__ */ new Set();
640
+ for (const field of aggregate.fields) {
641
+ const key = (0, _orkestrel_reason.formatField)(field);
642
+ if (key.length === 0) errors.push("Aggregate fields must be non-empty");
643
+ if (fields.has(key)) errors.push(`Duplicate aggregate field "${key}"`);
644
+ fields.add(key);
645
+ }
646
+ if (aggregate.by !== void 0 && (0, _orkestrel_reason.formatField)(aggregate.by).length === 0) errors.push("Aggregate partition field must be non-empty");
647
+ if (aggregate.gates !== void 0) {
648
+ const validation = engine.validate(aggregate.gates);
649
+ errors.push(...validation.errors.map((error) => `aggregate: ${error}`));
650
+ warnings.push(...validation.warnings.map((warning) => `aggregate: ${warning}`));
651
+ if (aggregate.fields.length === 0) warnings.push("Aggregate gates are defined without aggregate fields");
652
+ }
653
+ }
654
+ if (definition.rating !== void 0 && definition.rating.lines.length === 0) warnings.push("Program rating has no lines");
655
+ return {
656
+ valid: errors.length === 0,
657
+ errors,
658
+ warnings
659
+ };
660
+ }
661
+ /**
662
+ * Coerce a subject's partition-key field to its group-key string.
663
+ *
664
+ * @remarks
665
+ * The key is the resolved field coerced with `String` — `undefined` collapses
666
+ * to the empty string, so a subject missing the field and a subject whose
667
+ * field is literally `''` land in the SAME partition, and a numeric `1`
668
+ * collides with the string `'1'`.
669
+ *
670
+ * @param subject - The subject to key
671
+ * @param by - The partition key field
672
+ * @returns The subject's group key
673
+ *
674
+ * @example
675
+ * ```ts
676
+ * import { formatGroupKey } from '@orkestrel/program'
677
+ *
678
+ * formatGroupKey({ location: 'east' }, 'location') // 'east'
679
+ * ```
680
+ */
681
+ function formatGroupKey(subject, by) {
682
+ return String((0, _orkestrel_contract.resolveField)(subject, by) ?? "");
683
+ }
684
+ /**
685
+ * Fold one subject's finite aggregate field values into a sums record.
686
+ *
687
+ * @remarks
688
+ * Returns a FRESH record — `sums` is never mutated. Only finite numbers
689
+ * contribute; a non-numeric or absent value contributes zero (never a
690
+ * coercion). A {@link FieldPath} may be nested — `formatField` renders the
691
+ * dot-joined key the returned record is keyed by.
692
+ *
693
+ * @param sums - The sums record to fold into
694
+ * @param subject - The subject to fold in
695
+ * @param fields - The fields to sum
696
+ * @returns A fresh sums record with `subject`'s contribution added
697
+ *
698
+ * @example
699
+ * ```ts
700
+ * import { sumFields } from '@orkestrel/program'
701
+ *
702
+ * sumFields({ amount: 0 }, { amount: 5 }, ['amount']) // { amount: 5 }
703
+ * ```
704
+ */
705
+ function sumFields(sums, subject, fields) {
706
+ const next = { ...sums };
707
+ for (const field of fields) {
708
+ const key = (0, _orkestrel_reason.formatField)(field);
709
+ const value = (0, _orkestrel_contract.resolveField)(subject, field);
710
+ if ((0, _orkestrel_contract.isFiniteNumber)(value)) next[key] = (next[key] ?? 0) + value;
711
+ }
712
+ return next;
713
+ }
714
+ /**
715
+ * Sum aggregate fields across a batch of subjects.
716
+ *
717
+ * @remarks
718
+ * A {@link FieldPath} may be nested — a nested path sums a nested subject field
719
+ * exactly like a top-level one, and `formatField` renders the dot-joined key the
720
+ * returned record is keyed by. Only finite numbers contribute; a non-numeric or
721
+ * absent value contributes zero (never a coercion).
722
+ *
723
+ * @param subjects - The batch of subjects
724
+ * @param fields - The fields to sum
725
+ * @returns A fresh record of dot-joined field to summed finite value
726
+ *
727
+ * @example
728
+ * ```ts
729
+ * import { aggregateSums } from '@orkestrel/program'
730
+ *
731
+ * aggregateSums([{ amount: 5 }, { amount: 3 }], ['amount']) // { amount: 8 }
732
+ * ```
733
+ */
734
+ function aggregateSums(subjects, fields) {
735
+ let sums = emptySums(fields);
736
+ for (const subject of subjects) sums = sumFields(sums, subject, fields);
737
+ return sums;
738
+ }
739
+ /**
740
+ * Partition a batch of subjects by a field, summing aggregate fields per key.
741
+ *
742
+ * @remarks
743
+ * The partition key is derived by {@link formatGroupKey}. Group order follows
744
+ * first appearance in the subject array.
745
+ *
746
+ * @param subjects - The batch of subjects
747
+ * @param fields - The fields to sum within each partition
748
+ * @param by - The partition key field; no partition is built when absent
749
+ * @returns A fresh list of aggregate groups, or an empty list when `by` is absent
750
+ *
751
+ * @example
752
+ * ```ts
753
+ * import { aggregateGroups } from '@orkestrel/program'
754
+ *
755
+ * aggregateGroups([{ location: 'east', amount: 5 }], ['amount'], 'location')
756
+ * ```
757
+ */
758
+ function aggregateGroups(subjects, fields, by) {
759
+ if (by === void 0) return [];
760
+ const records = /* @__PURE__ */ new Map();
761
+ for (const subject of subjects) {
762
+ const key = formatGroupKey(subject, by);
763
+ const group = records.get(key);
764
+ if (group === void 0) records.set(key, [subject]);
765
+ else group.push(subject);
766
+ }
767
+ return [...records.entries()].map(([key, entries]) => ({
768
+ key,
769
+ count: entries.length,
770
+ sums: aggregateSums(entries, fields)
771
+ }));
772
+ }
773
+ /**
774
+ * Build one subject's overall and optional group aggregate projection.
775
+ *
776
+ * @remarks
777
+ * The projection carries the whole-batch `count` and `sums` plus the subject's
778
+ * OWN partition, located by the same {@link formatGroupKey} key
779
+ * {@link aggregateGroups} partitions under.
780
+ *
781
+ * @param subject - The subject to project for
782
+ * @param count - The whole-batch subject count
783
+ * @param sums - The whole-batch summed aggregate fields
784
+ * @param groups - The batch partitions
785
+ * @param by - The partition key field; no group is attached when absent
786
+ * @returns A fresh aggregate projection
787
+ *
788
+ * @example
789
+ * ```ts
790
+ * import { buildAggregateProjection } from '@orkestrel/program'
791
+ *
792
+ * buildAggregateProjection(subject, 2, { amount: 8 }, groups, 'location')
793
+ * ```
794
+ */
795
+ function buildAggregateProjection(subject, count, sums, groups, by) {
796
+ const group = by === void 0 ? void 0 : groups.find((entry) => entry.key === formatGroupKey(subject, by));
797
+ return {
798
+ count,
799
+ sums: { ...sums },
800
+ ...group === void 0 ? {} : { group }
801
+ };
802
+ }
803
+ /**
804
+ * Build the reserved-key record a batch aggregate-gate definition runs against.
805
+ *
806
+ * @remarks
807
+ * Unlike a per-subject {@link buildAggregateProjection}, the batch record carries
808
+ * every `group` (a `groups` array) under {@link AGGREGATE_KEY} so a gate rule can
809
+ * read `aggregate.sums.<field>` (overall) or a partition inside `aggregate.groups`.
810
+ *
811
+ * @param count - The whole-batch subject count
812
+ * @param sums - The whole-batch summed aggregate fields
813
+ * @param groups - The batch partitions
814
+ * @returns A fresh record carrying the batch aggregate under {@link AGGREGATE_KEY}
815
+ *
816
+ * @example
817
+ * ```ts
818
+ * import { buildAggregateRecord } from '@orkestrel/program'
819
+ *
820
+ * buildAggregateRecord(2, { amount: 8 }, [])
821
+ * ```
822
+ */
823
+ function buildAggregateRecord(count, sums, groups) {
824
+ return { [AGGREGATE_KEY]: {
825
+ count,
826
+ sums,
827
+ groups
828
+ } };
829
+ }
830
+ /**
831
+ * Build a zero-sum record for a set of aggregate fields.
832
+ *
833
+ * @param fields - The fields to zero
834
+ * @returns A fresh record of dot-joined field to `0`
835
+ *
836
+ * @example
837
+ * ```ts
838
+ * import { emptySums } from '@orkestrel/program'
839
+ *
840
+ * emptySums(['amount']) // { amount: 0 }
841
+ * ```
842
+ */
843
+ function emptySums(fields) {
844
+ const sums = {};
845
+ for (const field of fields) sums[(0, _orkestrel_reason.formatField)(field)] = 0;
846
+ return sums;
847
+ }
848
+ /**
849
+ * Complete a partial status tally record with zero entries for every missing
850
+ * {@link Status}.
851
+ *
852
+ * @param entries - The partial tally entries to complete
853
+ * @returns A record with all five statuses present
854
+ *
855
+ * @example
856
+ * ```ts
857
+ * import { completeTallies } from '@orkestrel/program'
858
+ *
859
+ * completeTallies({ eligible: { count: 1, sums: {} } })
860
+ * ```
861
+ */
862
+ function completeTallies(entries) {
863
+ return {
864
+ ineligible: entries.ineligible ?? {
865
+ count: 0,
866
+ sums: {}
867
+ },
868
+ referral: entries.referral ?? {
869
+ count: 0,
870
+ sums: {}
871
+ },
872
+ conditional: entries.conditional ?? {
873
+ count: 0,
874
+ sums: {}
875
+ },
876
+ unrated: entries.unrated ?? {
877
+ count: 0,
878
+ sums: {}
879
+ },
880
+ eligible: entries.eligible ?? {
881
+ count: 0,
882
+ sums: {}
883
+ }
884
+ };
885
+ }
886
+ /**
887
+ * Build complete zero status tallies in {@link STATUS_PRECEDENCE} order.
888
+ *
889
+ * @param fields - The fields each tally's sums are zeroed for
890
+ * @returns A fresh, complete tally record
891
+ *
892
+ * @example
893
+ * ```ts
894
+ * import { emptyTallies } from '@orkestrel/program'
895
+ *
896
+ * emptyTallies(['amount'])
897
+ * ```
898
+ */
899
+ function emptyTallies(fields) {
900
+ const entries = {};
901
+ for (const status of STATUS_PRECEDENCE) entries[status] = {
902
+ count: 0,
903
+ sums: emptySums(fields)
904
+ };
905
+ return completeTallies(entries);
906
+ }
907
+ /**
908
+ * Add one subject's aggregate contribution to a status tally record.
909
+ *
910
+ * @param tallies - The tallies to update
911
+ * @param result - The subject's program result (its `status` selects the tally)
912
+ * @param subject - The subject to fold in
913
+ * @param fields - The fields to sum
914
+ * @returns A fresh, complete tally record with the subject folded in
915
+ *
916
+ * @example
917
+ * ```ts
918
+ * import { tallyProgram } from '@orkestrel/program'
919
+ *
920
+ * tallyProgram(tallies, result, { id: 'r1', amount: 5 }, ['amount'])
921
+ * ```
922
+ */
923
+ function tallyProgram(tallies, result, subject, fields) {
924
+ const status = result.status;
925
+ const current = tallies[status];
926
+ const sums = sumFields(current.sums, subject, fields);
927
+ return completeTallies({
928
+ ...tallies,
929
+ [status]: {
930
+ count: current.count + 1,
931
+ sums
932
+ }
933
+ });
934
+ }
935
+ /**
936
+ * Assemble one batch {@link AggregateResult} from its per-subject and aggregate
937
+ * parts.
938
+ *
939
+ * @remarks
940
+ * `count` is the subject count, `trace` / `errors` accumulate every subject's
941
+ * plus the batch aggregate-gate evaluation's (`options.gates`), and `success`
942
+ * requires every subject execution to succeed AND the gate evaluation to have
943
+ * produced no errors. A fired aggregate gate contributes a `limit`
944
+ * determination, never a technical failure (a non-logical gate result is a
945
+ * caller-facing `MISMATCH` thrown by `Program` before this assembles).
946
+ *
947
+ * @param definition - The authored program definition
948
+ * @param subjects - The per-subject program results, in input order
949
+ * @param determinations - The batch aggregate-gate `limit` determinations
950
+ * @param groups - The batch partitions
951
+ * @param tallies - The completed status tallies
952
+ * @param sums - The whole-batch summed aggregate fields
953
+ * @param options - Optional resolved aggregate-gate result
954
+ * @returns A fresh aggregate result
955
+ *
956
+ * @example
957
+ * ```ts
958
+ * import { buildAggregateResult } from '@orkestrel/program'
959
+ *
960
+ * buildAggregateResult(definition, subjects, [], [], tallies, { amount: 8 })
961
+ * ```
962
+ */
963
+ function buildAggregateResult(definition, subjects, determinations, groups, tallies, sums, options) {
964
+ const gates = options?.gates;
965
+ const gateTrace = gates === void 0 ? [] : [...gates.trace];
966
+ const gateErrors = gates === void 0 ? [] : [...gates.errors];
967
+ return {
968
+ id: definition.id,
969
+ name: definition.name,
970
+ subjects,
971
+ determinations,
972
+ groups,
973
+ tallies,
974
+ count: subjects.length,
975
+ sums,
976
+ success: subjects.every((entry) => entry.success) && gateErrors.length === 0,
977
+ trace: [...subjects.flatMap((entry) => entry.trace), ...gateTrace],
978
+ errors: [...subjects.flatMap((entry) => entry.errors), ...gateErrors]
979
+ };
980
+ }
981
+ //#endregion
982
+ //#region src/core/programs/Program.ts
983
+ /**
984
+ * One compiled program — composes one qualifier and one rater over a shared
985
+ * reason engine and executes single subjects or aggregate-aware batches.
986
+ *
987
+ * @remarks
988
+ * Qualification decides whether rating happens: a globally ineligible, referred,
989
+ * or failed subject never reaches the rater, and a scoped ineligibility removes
990
+ * only its line before the first rating call. The rater always receives the
991
+ * ORIGINAL subject; the qualifier's aggregate projection stays private. When no
992
+ * qualifier, rater, or engine is injected the program creates ONE shared
993
+ * quantitative-plus-logical engine, injects it into the qualifier and rater it
994
+ * creates, and destroys only what it owns. A definition failure during
995
+ * construction (an invalid definition under `options.validate`) tears down
996
+ * whatever the constructor had already allocated before throwing. `destroy()`
997
+ * is idempotent and REENTRANCY-SAFE — the destroyed flag is set BEFORE any
998
+ * teardown or the `destroy` event fires, so a listener that re-enters
999
+ * `destroy()` is a no-op — and tears the emitter down last.
1000
+ */
1001
+ var Program = class {
1002
+ #emitter;
1003
+ #qualifier;
1004
+ #rater;
1005
+ #engine;
1006
+ #evaluator;
1007
+ #qualifierOwned;
1008
+ #raterOwned;
1009
+ #engineOwned;
1010
+ #validate;
1011
+ #labels;
1012
+ #destroyed = false;
1013
+ id;
1014
+ name;
1015
+ definition;
1016
+ constructor(definition, options) {
1017
+ assertProgramDefinition(definition);
1018
+ this.id = definition.id;
1019
+ this.name = definition.name;
1020
+ this.definition = definition;
1021
+ this.#emitter = new _orkestrel_emitter.Emitter({
1022
+ on: options?.on,
1023
+ error: options?.error
1024
+ });
1025
+ this.#evaluator = (0, _orkestrel_reason.createEvaluator)();
1026
+ this.#engineOwned = options?.engine === void 0;
1027
+ this.#qualifierOwned = options?.qualifier === void 0;
1028
+ this.#raterOwned = options?.rater === void 0;
1029
+ this.#engine = options?.engine ?? (0, _orkestrel_reason.createReason)({
1030
+ reasoners: [(0, _orkestrel_reason.createQuantitativeReasoner)(), (0, _orkestrel_reason.createLogicalReasoner)()],
1031
+ bail: false
1032
+ });
1033
+ this.#qualifier = options?.qualifier ?? (0, _orkestrel_qualifier.createQualifier)({ engine: this.#engine });
1034
+ this.#rater = options?.rater ?? (0, _orkestrel_rater.createRater)({ engine: this.#engine });
1035
+ this.#validate = options?.validate ?? true;
1036
+ this.#labels = options?.labels;
1037
+ if (this.#validate) {
1038
+ const validation = this.validate();
1039
+ if (!validation.valid) {
1040
+ this.destroy();
1041
+ throw new ProgramError("DEFINITION", validation.errors.join("; "), definition.id);
1042
+ }
1043
+ }
1044
+ }
1045
+ get emitter() {
1046
+ return this.#emitter;
1047
+ }
1048
+ execute(input) {
1049
+ this.#alive();
1050
+ if ((0, _orkestrel_contract.isArray)(input)) return this.#aggregate(input);
1051
+ return this.#subject(input);
1052
+ }
1053
+ validate() {
1054
+ this.#alive();
1055
+ return validateProgramDefinition(this.definition, this.#qualifier, this.#engine);
1056
+ }
1057
+ destroy() {
1058
+ if (this.#destroyed) return;
1059
+ this.#destroyed = true;
1060
+ if (this.#qualifierOwned) this.#qualifier.destroy();
1061
+ if (this.#raterOwned) this.#rater.destroy();
1062
+ if (this.#engineOwned) this.#engine.destroy();
1063
+ this.#emitter.emit("destroy");
1064
+ this.#emitter.destroy();
1065
+ }
1066
+ #subject(subject, aggregate) {
1067
+ assertProgramSubject(subject);
1068
+ const qualified = buildQualificationSubject(subject, aggregate);
1069
+ const qualification = this.#qualifier.qualify(qualified, this.definition.qualification);
1070
+ this.#emitter.emit("qualify", qualification);
1071
+ if (!qualification.success || qualification.eligibility !== "eligible") return this.#finish(subject, qualification, void 0);
1072
+ const lines = selectProgramLines(this.definition.rating?.lines ?? [], qualification.scopes);
1073
+ const rating = lines.length === 0 ? void 0 : this.#rater.rate(lines, subject);
1074
+ if (rating !== void 0) this.#emitter.emit("rate", rating);
1075
+ return this.#finish(subject, qualification, rating);
1076
+ }
1077
+ #finish(subject, qualification, rating) {
1078
+ const notices = buildNotices(this.definition.notices ?? [], subject);
1079
+ for (const notice of notices) this.#emitter.emit("determine", notice);
1080
+ const status = deriveStatus(this.definition, qualification, rating);
1081
+ let result = buildProgramResult(this.definition, qualification, rating, notices, status);
1082
+ const authority = this.definition.authority;
1083
+ if (authority === void 0) {
1084
+ this.#emitter.emit("execute", result);
1085
+ return result;
1086
+ }
1087
+ const outcome = { [OUTCOME_KEY]: buildOutcomeProjection(result) };
1088
+ const resolved = this.#engine.reason(outcome, authority);
1089
+ if (resolved.reasoning !== "logical") throw new ProgramError("MISMATCH", "Authority returned non-logical reasoning", authority.id);
1090
+ const limits = buildLimits(authority, resolved, outcome, this.#evaluator, this.#labels);
1091
+ for (const limit of limits) this.#emitter.emit("determine", limit);
1092
+ result = buildProgramResult(this.definition, qualification, rating, [...notices, ...limits], status, { authority: resolved });
1093
+ if (result.decision !== void 0) this.#emitter.emit("decide", result.decision, result);
1094
+ this.#emitter.emit("execute", result);
1095
+ return result;
1096
+ }
1097
+ #aggregate(subjects) {
1098
+ for (const subject of subjects) assertProgramSubject(subject);
1099
+ const definition = this.definition.aggregate;
1100
+ const fields = [...definition?.fields ?? []];
1101
+ const sums = aggregateSums(subjects, fields);
1102
+ const groups = aggregateGroups(subjects, fields, definition?.by);
1103
+ let tallies = emptyTallies(fields);
1104
+ const results = subjects.map((subject) => {
1105
+ const projection = definition === void 0 ? void 0 : buildAggregateProjection(subject, subjects.length, sums, groups, definition.by);
1106
+ const result = this.#subject(subject, projection);
1107
+ tallies = tallyProgram(tallies, result, subject, fields);
1108
+ return result;
1109
+ });
1110
+ const gates = this.#aggregateLimits(subjects.length, sums, groups);
1111
+ const result = buildAggregateResult(this.definition, results, gates.determinations, groups, tallies, sums, gates.resolved === void 0 ? void 0 : { gates: gates.resolved });
1112
+ this.#emitter.emit("aggregate", result);
1113
+ return result;
1114
+ }
1115
+ #aggregateLimits(count, sums, groups) {
1116
+ const gates = this.definition.aggregate?.gates;
1117
+ if (gates === void 0) return { determinations: [] };
1118
+ const record = buildAggregateRecord(count, sums, groups);
1119
+ const resolved = this.#engine.reason(record, gates);
1120
+ if (resolved.reasoning !== "logical") throw new ProgramError("MISMATCH", "Aggregate gates returned non-logical reasoning", gates.id);
1121
+ const determinations = buildLimits(gates, resolved, record, this.#evaluator, this.#labels);
1122
+ for (const determination of determinations) this.#emitter.emit("determine", determination);
1123
+ return {
1124
+ determinations,
1125
+ resolved
1126
+ };
1127
+ }
1128
+ #alive() {
1129
+ if (this.#destroyed) throw new ProgramError("DESTROYED", "Program has been destroyed", this.id);
1130
+ }
1131
+ };
1132
+ //#endregion
1133
+ //#region src/core/programs/ProgramManager.ts
1134
+ /**
1135
+ * An ordered manager over compiled {@link ProgramInterface}s (AGENTS §9), sharing
1136
+ * one qualifier, rater, and reason engine across every program it compiles.
1137
+ *
1138
+ * @remarks
1139
+ * OWNS its ordered `#programs` collection and its own {@link Emitter} over
1140
+ * {@link ProgramManagerEventMap}. Creates or borrows one shared engine, qualifier,
1141
+ * and rater and injects the same instances into every compiled program. `remove`
1142
+ * destroys the programs it removes; `destroy()` removes all programs, then
1143
+ * destroys only the owned shared dependencies, and tears the emitter down LAST.
1144
+ * A seed-program failure during construction tears the manager down (destroying
1145
+ * whatever had already been compiled) before rethrowing the original error.
1146
+ * `destroy()` is REENTRANCY-SAFE — the destroyed flag is set BEFORE any teardown
1147
+ * or the `remove` / `destroy` events fire, so a `remove` listener that re-enters
1148
+ * `destroy()` is a no-op. Every call after `destroy()` throws {@link ProgramError}
1149
+ * `'DESTROYED'`.
1150
+ */
1151
+ var ProgramManager = class {
1152
+ #emitter;
1153
+ #programs = [];
1154
+ #qualifier;
1155
+ #rater;
1156
+ #engine;
1157
+ #qualifierOwned;
1158
+ #raterOwned;
1159
+ #engineOwned;
1160
+ #validate;
1161
+ #labels;
1162
+ #destroyed = false;
1163
+ constructor(options) {
1164
+ this.#emitter = new _orkestrel_emitter.Emitter({
1165
+ on: options?.on,
1166
+ error: options?.error
1167
+ });
1168
+ this.#labels = options?.labels;
1169
+ this.#engineOwned = options?.engine === void 0;
1170
+ this.#qualifierOwned = options?.qualifier === void 0;
1171
+ this.#raterOwned = options?.rater === void 0;
1172
+ this.#engine = options?.engine ?? (0, _orkestrel_reason.createReason)({
1173
+ reasoners: [(0, _orkestrel_reason.createQuantitativeReasoner)(), (0, _orkestrel_reason.createLogicalReasoner)()],
1174
+ bail: false
1175
+ });
1176
+ this.#qualifier = options?.qualifier ?? (0, _orkestrel_qualifier.createQualifier)({ engine: this.#engine });
1177
+ this.#rater = options?.rater ?? (0, _orkestrel_rater.createRater)({ engine: this.#engine });
1178
+ this.#validate = options?.validate ?? true;
1179
+ try {
1180
+ for (const definition of options?.programs ?? []) this.add(definition);
1181
+ } catch (error) {
1182
+ this.destroy();
1183
+ throw error;
1184
+ }
1185
+ }
1186
+ get emitter() {
1187
+ return this.#emitter;
1188
+ }
1189
+ get size() {
1190
+ this.#alive();
1191
+ return this.#programs.length;
1192
+ }
1193
+ has(id) {
1194
+ this.#alive();
1195
+ return this.#programs.some((program) => program.id === id);
1196
+ }
1197
+ program(id) {
1198
+ this.#alive();
1199
+ return this.#programs.find((program) => program.id === id);
1200
+ }
1201
+ programs() {
1202
+ this.#alive();
1203
+ return [...this.#programs];
1204
+ }
1205
+ add(definition) {
1206
+ this.#alive();
1207
+ if (this.has(definition.id)) throw new ProgramError("DUPLICATE", `Program "${definition.id}" already exists`, definition.id);
1208
+ const program = createProgram(definition, {
1209
+ qualifier: this.#qualifier,
1210
+ rater: this.#rater,
1211
+ engine: this.#engine,
1212
+ validate: this.#validate,
1213
+ labels: this.#labels
1214
+ });
1215
+ this.#programs.push(program);
1216
+ this.#emitter.emit("add", program.id);
1217
+ return program;
1218
+ }
1219
+ remove(input) {
1220
+ this.#alive();
1221
+ if (input === void 0) {
1222
+ this.#drain();
1223
+ return;
1224
+ }
1225
+ if (Array.isArray(input)) {
1226
+ let removed = true;
1227
+ for (const id of input) removed = this.#removeOne(id) && removed;
1228
+ return removed;
1229
+ }
1230
+ if (typeof input === "string") return this.#removeOne(input);
1231
+ }
1232
+ destroy() {
1233
+ if (this.#destroyed) return;
1234
+ this.#destroyed = true;
1235
+ this.#drain();
1236
+ if (this.#qualifierOwned) this.#qualifier.destroy();
1237
+ if (this.#raterOwned) this.#rater.destroy();
1238
+ if (this.#engineOwned) this.#engine.destroy();
1239
+ this.#emitter.emit("destroy");
1240
+ this.#emitter.destroy();
1241
+ }
1242
+ #drain() {
1243
+ for (const program of this.#programs.splice(0)) {
1244
+ program.destroy();
1245
+ this.#emitter.emit("remove", program.id);
1246
+ }
1247
+ }
1248
+ #removeOne(id) {
1249
+ const index = this.#programs.findIndex((program) => program.id === id);
1250
+ if (index < 0) return false;
1251
+ const removed = this.#programs.splice(index, 1)[0];
1252
+ if (removed === void 0) return false;
1253
+ removed.destroy();
1254
+ this.#emitter.emit("remove", removed.id);
1255
+ return true;
1256
+ }
1257
+ #alive() {
1258
+ if (this.#destroyed) throw new ProgramError("DESTROYED", "Program manager has been destroyed");
1259
+ }
1260
+ };
1261
+ //#endregion
1262
+ //#region src/core/factories.ts
1263
+ /**
1264
+ * Create one compiled program over a qualifier and rater.
1265
+ *
1266
+ * @remarks
1267
+ * Validates the definition at construction when `options.validate` is left at
1268
+ * its {@link DEFAULT_PROGRAM_VALIDATE} default. A standalone program creates and
1269
+ * OWNS one shared quantitative-plus-logical reason engine and injects it into the
1270
+ * qualifier and rater it creates; injected dependencies remain caller-owned.
1271
+ *
1272
+ * @param definition - The authored program definition
1273
+ * @param options - Optional injected qualifier, rater, engine, validation, labels, and emitter hooks
1274
+ * @returns A {@link ProgramInterface}
1275
+ *
1276
+ * @example
1277
+ * ```ts
1278
+ * import { createProgram, programDefinition } from '@orkestrel/program'
1279
+ *
1280
+ * const program = createProgram(programDefinition('standard', 'Standard', qualification, rating))
1281
+ * program.execute({ id: 'risk-1' })
1282
+ * program.destroy()
1283
+ * ```
1284
+ */
1285
+ function createProgram(definition, options) {
1286
+ return new Program(definition, options);
1287
+ }
1288
+ /**
1289
+ * Create one ordered manager over compiled programs.
1290
+ *
1291
+ * @remarks
1292
+ * Creates or borrows one shared reason engine, qualifier, and rater and injects
1293
+ * them into every compiled program, so a batch of definitions shares one engine.
1294
+ * Seed definitions are compiled in order.
1295
+ *
1296
+ * @param options - Optional injected qualifier, rater, engine, seed programs, validation, labels, and emitter hooks
1297
+ * @returns A {@link ProgramManagerInterface}
1298
+ *
1299
+ * @example
1300
+ * ```ts
1301
+ * import { createProgramManager } from '@orkestrel/program'
1302
+ *
1303
+ * const manager = createProgramManager({ programs: [definition] })
1304
+ * manager.program('standard')?.execute(subject)
1305
+ * manager.destroy()
1306
+ * ```
1307
+ */
1308
+ function createProgramManager(options) {
1309
+ return new ProgramManager(options);
1310
+ }
1311
+ /**
1312
+ * Build a {@link ProgramDefinition}.
1313
+ *
1314
+ * @remarks
1315
+ * Copies every collection and omits absent optional keys, so the returned
1316
+ * definition is a fresh, JSON-serializable value that never aliases its inputs.
1317
+ *
1318
+ * @param id - The program id
1319
+ * @param name - The display name
1320
+ * @param qualification - The nested qualification definition
1321
+ * @param rating - The nested rating definition; omit for an eligibility-only program
1322
+ * @param input - Optional description, notices, authority, aggregate, and metadata
1323
+ * @returns A fresh program definition
1324
+ *
1325
+ * @example
1326
+ * ```ts
1327
+ * import { programDefinition } from '@orkestrel/program'
1328
+ *
1329
+ * programDefinition('standard', 'Standard', qualification, rating, { notices: [notice] })
1330
+ * ```
1331
+ */
1332
+ function programDefinition(id, name, qualification, rating, input) {
1333
+ return {
1334
+ id,
1335
+ name,
1336
+ qualification,
1337
+ ...rating === void 0 ? {} : { rating },
1338
+ ...input?.description === void 0 ? {} : { description: input.description },
1339
+ ...input?.notices === void 0 ? {} : { notices: [...input.notices] },
1340
+ ...input?.authority === void 0 ? {} : { authority: input.authority },
1341
+ ...input?.aggregate === void 0 ? {} : { aggregate: input.aggregate },
1342
+ ...input?.metadata === void 0 ? {} : { metadata: copyJSONValue(input.metadata) }
1343
+ };
1344
+ }
1345
+ /**
1346
+ * Build a {@link Notice}.
1347
+ *
1348
+ * @param id - The notice id
1349
+ * @param message - The message template, carrying optional `{{token}}`s
1350
+ * @param input - Optional presentation scope
1351
+ * @returns A fresh notice
1352
+ *
1353
+ * @example
1354
+ * ```ts
1355
+ * import { noticeDefinition } from '@orkestrel/program'
1356
+ *
1357
+ * noticeDefinition('minimum', 'Minimum earned premium applies')
1358
+ * ```
1359
+ */
1360
+ function noticeDefinition(id, message, input) {
1361
+ return {
1362
+ id,
1363
+ message,
1364
+ ...input?.scope === void 0 ? {} : { scope: input.scope }
1365
+ };
1366
+ }
1367
+ /**
1368
+ * Build an {@link AggregateDefinition}.
1369
+ *
1370
+ * @param fields - The aggregate fields to sum across a batch
1371
+ * @param input - Optional partition field and aggregate gates
1372
+ * @returns A fresh aggregate definition
1373
+ *
1374
+ * @example
1375
+ * ```ts
1376
+ * import { aggregateDefinition } from '@orkestrel/program'
1377
+ *
1378
+ * aggregateDefinition(['amount'], { by: 'location' })
1379
+ * ```
1380
+ */
1381
+ function aggregateDefinition(fields, input) {
1382
+ return {
1383
+ fields: [...fields],
1384
+ ...input?.by === void 0 ? {} : { by: input.by },
1385
+ ...input?.gates === void 0 ? {} : { gates: input.gates }
1386
+ };
1387
+ }
1388
+ //#endregion
1389
+ exports.AGGREGATE_KEY = AGGREGATE_KEY;
1390
+ exports.DEFAULT_PROGRAM_VALIDATE = DEFAULT_PROGRAM_VALIDATE;
1391
+ exports.ELIGIBILITY_DECISIONS = ELIGIBILITY_DECISIONS;
1392
+ exports.OUTCOME_KEY = OUTCOME_KEY;
1393
+ exports.Program = Program;
1394
+ exports.ProgramError = ProgramError;
1395
+ exports.ProgramManager = ProgramManager;
1396
+ exports.STATUS_PRECEDENCE = STATUS_PRECEDENCE;
1397
+ exports.aggregateDefinition = aggregateDefinition;
1398
+ exports.aggregateGroups = aggregateGroups;
1399
+ exports.aggregateSums = aggregateSums;
1400
+ exports.assertProgramDefinition = assertProgramDefinition;
1401
+ exports.assertProgramSubject = assertProgramSubject;
1402
+ exports.buildAggregateProjection = buildAggregateProjection;
1403
+ exports.buildAggregateRecord = buildAggregateRecord;
1404
+ exports.buildAggregateResult = buildAggregateResult;
1405
+ exports.buildLimits = buildLimits;
1406
+ exports.buildNotices = buildNotices;
1407
+ exports.buildOutcomeProjection = buildOutcomeProjection;
1408
+ exports.buildProgramResult = buildProgramResult;
1409
+ exports.buildQualificationSubject = buildQualificationSubject;
1410
+ exports.completeTallies = completeTallies;
1411
+ exports.copyJSONValue = copyJSONValue;
1412
+ exports.createProgram = createProgram;
1413
+ exports.createProgramManager = createProgramManager;
1414
+ exports.decideEligibility = decideEligibility;
1415
+ exports.deriveStatus = deriveStatus;
1416
+ exports.emptySums = emptySums;
1417
+ exports.emptyTallies = emptyTallies;
1418
+ exports.findMissingScopes = findMissingScopes;
1419
+ exports.formatGroupKey = formatGroupKey;
1420
+ exports.hasReservedKey = hasReservedKey;
1421
+ exports.isAggregateDefinition = isAggregateDefinition;
1422
+ exports.isDecision = isDecision;
1423
+ exports.isNotice = isNotice;
1424
+ exports.isProgramDefinition = isProgramDefinition;
1425
+ exports.isProgramEffect = isProgramEffect;
1426
+ exports.isProgramError = isProgramError;
1427
+ exports.isStatus = isStatus;
1428
+ exports.noticeDefinition = noticeDefinition;
1429
+ exports.programDefinition = programDefinition;
1430
+ exports.selectProgramLines = selectProgramLines;
1431
+ exports.sumFields = sumFields;
1432
+ exports.tallyProgram = tallyProgram;
1433
+ exports.validateProgramDefinition = validateProgramDefinition;
1434
+
1435
+ //# sourceMappingURL=index.cjs.map