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