@orkestrel/rater 0.0.1 → 0.0.2
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/README.md +19 -27
- package/dist/src/core/index.cjs +226 -1280
- package/dist/src/core/index.cjs.map +1 -1
- package/dist/src/core/index.d.cts +469 -1092
- package/dist/src/core/index.d.ts +469 -1092
- package/dist/src/core/index.js +227 -1225
- package/dist/src/core/index.js.map +1 -1
- package/package.json +5 -5
package/dist/src/core/index.js
CHANGED
|
@@ -1,52 +1,21 @@
|
|
|
1
|
-
import { arrayOf,
|
|
2
|
-
import {
|
|
1
|
+
import { arrayOf, isJSONValue, isRecord, isString, literalOf, recordOf } from "@orkestrel/contract";
|
|
2
|
+
import { buildErrorResult, createQuantitativeReasoner, createReason, formatField, isQuantitativeDefinition } from "@orkestrel/reason";
|
|
3
3
|
import { Emitter } from "@orkestrel/emitter";
|
|
4
|
-
//#region src/core/constants.ts
|
|
5
|
-
/** Default definition validation policy for `ProgramManager.add`. */
|
|
6
|
-
var DEFAULT_RATER_VALIDATE = true;
|
|
7
|
-
/** Eligibility severity order, most severe first — the {@link combineEligibilities} scan order. */
|
|
8
|
-
var ELIGIBILITY_PRECEDENCE = Object.freeze([
|
|
9
|
-
"ineligible",
|
|
10
|
-
"referral",
|
|
11
|
-
"eligible"
|
|
12
|
-
]);
|
|
13
|
-
/** Status tally precedence order — least to most resolved. */
|
|
14
|
-
var STATUS_PRECEDENCE = Object.freeze([
|
|
15
|
-
"ineligible",
|
|
16
|
-
"referral",
|
|
17
|
-
"conditional",
|
|
18
|
-
"unrated",
|
|
19
|
-
"eligible"
|
|
20
|
-
]);
|
|
21
|
-
/** The deterministic authority decision for each eligibility. */
|
|
22
|
-
var ELIGIBILITY_DECISIONS = Object.freeze({
|
|
23
|
-
eligible: "approved",
|
|
24
|
-
ineligible: "denied",
|
|
25
|
-
referral: "submitted"
|
|
26
|
-
});
|
|
27
|
-
/** The direct eligibility impact of an applied determination, by its effect. */
|
|
28
|
-
var EFFECT_ELIGIBILITIES = Object.freeze({
|
|
29
|
-
restriction: "ineligible",
|
|
30
|
-
referral: "referral",
|
|
31
|
-
condition: void 0,
|
|
32
|
-
notice: void 0,
|
|
33
|
-
limit: void 0
|
|
34
|
-
});
|
|
35
|
-
/** The reserved working-subject key a batch's aggregate projection is written under. */
|
|
36
|
-
var AGGREGATE_KEY = "aggregate";
|
|
37
|
-
/** The reserved working-subject key an authority's outcome projection is written under. */
|
|
38
|
-
var OUTCOME_KEY = "outcome";
|
|
39
|
-
//#endregion
|
|
40
4
|
//#region src/core/errors.ts
|
|
41
5
|
/**
|
|
42
6
|
* A coded programmer error thrown by the rating layer.
|
|
43
7
|
*
|
|
44
8
|
* @remarks
|
|
45
|
-
* `
|
|
46
|
-
*
|
|
47
|
-
* `DEFINITION` — a program definition failed `isProgramDefinition`. `MISMATCH` —
|
|
48
|
-
* a rated subject is not a record, or uses a reserved working-subject key.
|
|
9
|
+
* `DEFINITION` — the `rate` input failed both the array-of-lines and rating
|
|
10
|
+
* definition validation. `MISMATCH` — a rated subject is not a record.
|
|
49
11
|
* `DESTROYED` — use of a destroyed entity.
|
|
12
|
+
*
|
|
13
|
+
* @example
|
|
14
|
+
* ```ts
|
|
15
|
+
* import { RaterError } from '@orkestrel/rater'
|
|
16
|
+
*
|
|
17
|
+
* throw new RaterError('MISMATCH', 'Subject must be a record')
|
|
18
|
+
* ```
|
|
50
19
|
*/
|
|
51
20
|
var RaterError = class extends Error {
|
|
52
21
|
code;
|
|
@@ -58,205 +27,98 @@ var RaterError = class extends Error {
|
|
|
58
27
|
this.context = context;
|
|
59
28
|
}
|
|
60
29
|
};
|
|
61
|
-
/** Narrow a caught value to a {@link RaterError}. */
|
|
62
|
-
function isRaterError(value) {
|
|
63
|
-
return value instanceof RaterError;
|
|
64
|
-
}
|
|
65
|
-
//#endregion
|
|
66
|
-
//#region src/core/validators.ts
|
|
67
|
-
/** Determine whether a value is an {@link Eligibility} literal. */
|
|
68
|
-
var isEligibility = literalOf("eligible", "ineligible", "referral");
|
|
69
|
-
/** Determine whether a value is a {@link Decision} literal. */
|
|
70
|
-
var isDecision = literalOf("approved", "denied", "submitted");
|
|
71
|
-
/** Determine whether a value is a {@link Status} literal. */
|
|
72
|
-
var isStatus = literalOf("ineligible", "referral", "conditional", "unrated", "eligible");
|
|
73
|
-
/** Determine whether a value is an {@link Effect} literal. */
|
|
74
|
-
var isEffect = literalOf("restriction", "referral", "condition", "notice", "limit");
|
|
75
|
-
/** Determine whether a value is a {@link Stage} literal. */
|
|
76
|
-
var isStage = literalOf("factor", "group", "total");
|
|
77
|
-
/** Determine whether a value is an exact {@link Ruling} record. */
|
|
78
|
-
function isRuling(value) {
|
|
79
|
-
return recordOf({
|
|
80
|
-
effect: isEffect,
|
|
81
|
-
line: isString,
|
|
82
|
-
message: isString
|
|
83
|
-
}, ["line", "message"])(value);
|
|
84
|
-
}
|
|
85
|
-
/** Determine whether a value is an exact {@link Notice} record. */
|
|
86
|
-
function isNotice(value) {
|
|
87
|
-
return recordOf({
|
|
88
|
-
id: isString,
|
|
89
|
-
message: isString,
|
|
90
|
-
line: isString
|
|
91
|
-
}, ["line"])(value);
|
|
92
|
-
}
|
|
93
|
-
/** Determine whether a value is an exact {@link PassDefinition} record. */
|
|
94
|
-
function isPassDefinition(value) {
|
|
95
|
-
return recordOf({
|
|
96
|
-
line: isString,
|
|
97
|
-
definition: unionOf(isLogicalDefinition, isQuantitativeDefinition)
|
|
98
|
-
}, ["line"])(value);
|
|
99
|
-
}
|
|
100
|
-
/** Determine whether a value is an exact {@link LineDefinition} record. */
|
|
101
|
-
function isLineDefinition(value) {
|
|
102
|
-
return recordOf({
|
|
103
|
-
id: isString,
|
|
104
|
-
name: isString,
|
|
105
|
-
description: isString,
|
|
106
|
-
rate: isQuantitativeDefinition,
|
|
107
|
-
metadata: isJSONValue
|
|
108
|
-
}, ["description", "metadata"])(value);
|
|
109
|
-
}
|
|
110
|
-
/** Determine whether a value is an exact {@link AggregateDefinition} record. */
|
|
111
|
-
function isAggregateDefinition(value) {
|
|
112
|
-
return recordOf({
|
|
113
|
-
fields: arrayOf(isFieldPath),
|
|
114
|
-
by: isFieldPath,
|
|
115
|
-
gates: isLogicalDefinition
|
|
116
|
-
}, ["by", "gates"])(value);
|
|
117
|
-
}
|
|
118
|
-
/** Determine whether a value is a rule-id-keyed {@link Ruling} record. */
|
|
119
|
-
var isRulings = whereOf(isRecord, (record) => Object.values(record).every(isRuling));
|
|
120
|
-
/** Determine whether a value is an exact {@link ProgramDefinition} record. */
|
|
121
|
-
function isProgramDefinition(value) {
|
|
122
|
-
return recordOf({
|
|
123
|
-
id: isString,
|
|
124
|
-
name: isString,
|
|
125
|
-
description: isString,
|
|
126
|
-
passes: arrayOf(isPassDefinition),
|
|
127
|
-
lines: arrayOf(isLineDefinition),
|
|
128
|
-
rulings: isRulings,
|
|
129
|
-
notices: arrayOf(isNotice),
|
|
130
|
-
authority: isLogicalDefinition,
|
|
131
|
-
aggregate: isAggregateDefinition,
|
|
132
|
-
metadata: isJSONValue
|
|
133
|
-
}, [
|
|
134
|
-
"description",
|
|
135
|
-
"passes",
|
|
136
|
-
"rulings",
|
|
137
|
-
"notices",
|
|
138
|
-
"authority",
|
|
139
|
-
"aggregate",
|
|
140
|
-
"metadata"
|
|
141
|
-
])(value);
|
|
142
|
-
}
|
|
143
|
-
//#endregion
|
|
144
|
-
//#region src/core/helpers.ts
|
|
145
30
|
/**
|
|
146
|
-
*
|
|
31
|
+
* Narrow a caught value to a {@link RaterError}.
|
|
147
32
|
*
|
|
148
|
-
* @
|
|
149
|
-
*
|
|
150
|
-
* the contracts {@link resolveField} (a plain string field is ONE key, never
|
|
151
|
-
* dot-split — the split here is the token-to-path bridge). A finite number
|
|
152
|
-
* renders with `en-US` thousand grouping (`5010` → `5,010`); any other resolved
|
|
153
|
-
* value String-coerces. An UNRESOLVED path (the resolved value is `undefined`)
|
|
154
|
-
* renders as the empty string — the deterministic "nothing to show" rule.
|
|
155
|
-
*
|
|
156
|
-
* @param template - The message template carrying `{{dotted.path}}` tokens
|
|
157
|
-
* @param record - The record tokens resolve against
|
|
158
|
-
* @returns The template with every token replaced
|
|
33
|
+
* @param value - The caught value to test
|
|
34
|
+
* @returns `true` when `value` is a `RaterError`
|
|
159
35
|
*
|
|
160
36
|
* @example
|
|
161
37
|
* ```ts
|
|
162
|
-
* import {
|
|
38
|
+
* import { isRaterError, RaterError } from '@orkestrel/rater'
|
|
163
39
|
*
|
|
164
|
-
*
|
|
165
|
-
*
|
|
40
|
+
* try {
|
|
41
|
+
* throw new RaterError('DESTROYED', 'Rater has been destroyed')
|
|
42
|
+
* } catch (error) {
|
|
43
|
+
* if (isRaterError(error)) error.code // 'DESTROYED'
|
|
44
|
+
* }
|
|
166
45
|
* ```
|
|
167
46
|
*/
|
|
168
|
-
function
|
|
169
|
-
return
|
|
170
|
-
const value = resolveField(record, path.split("."));
|
|
171
|
-
if (value === void 0) return "";
|
|
172
|
-
if (isFiniteNumber(value)) return value.toLocaleString("en-US");
|
|
173
|
-
return String(value);
|
|
174
|
-
});
|
|
47
|
+
function isRaterError(value) {
|
|
48
|
+
return value instanceof RaterError;
|
|
175
49
|
}
|
|
50
|
+
//#endregion
|
|
51
|
+
//#region src/core/validators.ts
|
|
176
52
|
/**
|
|
177
|
-
*
|
|
53
|
+
* Determine whether a value is a {@link Stage} literal.
|
|
178
54
|
*
|
|
179
|
-
* @param
|
|
180
|
-
* @returns
|
|
55
|
+
* @param value - The value to test
|
|
56
|
+
* @returns `true` when `value` is `'factor'`, `'group'`, or `'total'`
|
|
181
57
|
*
|
|
182
58
|
* @example
|
|
183
59
|
* ```ts
|
|
184
|
-
* import {
|
|
60
|
+
* import { isStage } from '@orkestrel/rater'
|
|
185
61
|
*
|
|
186
|
-
*
|
|
62
|
+
* isStage('group') // true
|
|
63
|
+
* isStage('step') // false
|
|
187
64
|
* ```
|
|
188
65
|
*/
|
|
189
|
-
|
|
190
|
-
switch (comparison) {
|
|
191
|
-
case "equals": return "is";
|
|
192
|
-
case "not": return "is not";
|
|
193
|
-
case "above": return "is more than";
|
|
194
|
-
case "below": return "is less than";
|
|
195
|
-
case "from": return "is at least";
|
|
196
|
-
case "to": return "is at most";
|
|
197
|
-
case "any": return "is any of";
|
|
198
|
-
case "none": return "is none of";
|
|
199
|
-
case "between": return "is between";
|
|
200
|
-
case "outside": return "is outside";
|
|
201
|
-
}
|
|
202
|
-
}
|
|
66
|
+
var isStage = literalOf("factor", "group", "total");
|
|
203
67
|
/**
|
|
204
|
-
*
|
|
68
|
+
* Determine whether a value is an exact {@link LineDefinition} record.
|
|
205
69
|
*
|
|
206
70
|
* @remarks
|
|
207
|
-
*
|
|
208
|
-
* `
|
|
209
|
-
* comparison's expected value shape) renders its present sides joined with
|
|
210
|
-
* the word `and`; an absent side is omitted. A finite number and everything
|
|
211
|
-
* else fall back to plain `String()` coercion — a finite number is NOT
|
|
212
|
-
* grouped here (that grouping is {@link interpolateMessage}'s own,
|
|
213
|
-
* message-token-only behavior).
|
|
71
|
+
* Total guard (AGENTS §14): adversarial input (cycles, hostile prototypes)
|
|
72
|
+
* returns `false`, never throws. The record shape is EXACT — an extra key fails.
|
|
214
73
|
*
|
|
215
|
-
* @param value - The value to
|
|
216
|
-
* @returns
|
|
74
|
+
* @param value - The value to test
|
|
75
|
+
* @returns `true` when `value` is a `LineDefinition`
|
|
217
76
|
*
|
|
218
77
|
* @example
|
|
219
78
|
* ```ts
|
|
220
|
-
* import {
|
|
79
|
+
* import { quantitativeDefinition } from '@orkestrel/reason'
|
|
80
|
+
* import { isLineDefinition } from '@orkestrel/rater'
|
|
221
81
|
*
|
|
222
|
-
*
|
|
223
|
-
* describeValue({ minimum: 18, maximum: 65 }) // '18 and 65'
|
|
224
|
-
* describeValue(42) // '42'
|
|
82
|
+
* isLineDefinition({ id: 'base', name: 'Base', rate: quantitativeDefinition('base', 'Base', []) }) // true
|
|
225
83
|
* ```
|
|
226
84
|
*/
|
|
227
|
-
function
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
}
|
|
235
|
-
return String(value);
|
|
85
|
+
function isLineDefinition(value) {
|
|
86
|
+
return recordOf({
|
|
87
|
+
id: isString,
|
|
88
|
+
name: isString,
|
|
89
|
+
description: isString,
|
|
90
|
+
rate: isQuantitativeDefinition,
|
|
91
|
+
metadata: isJSONValue
|
|
92
|
+
}, ["description", "metadata"])(value);
|
|
236
93
|
}
|
|
237
94
|
/**
|
|
238
|
-
*
|
|
95
|
+
* Determine whether a value is an exact {@link RatingDefinition} record.
|
|
239
96
|
*
|
|
240
|
-
* @
|
|
241
|
-
*
|
|
242
|
-
*
|
|
97
|
+
* @remarks
|
|
98
|
+
* Total guard (AGENTS §14): adversarial input (cycles, hostile prototypes)
|
|
99
|
+
* returns `false`, never throws. The record shape is EXACT — an extra key fails.
|
|
100
|
+
*
|
|
101
|
+
* @param value - The value to test
|
|
102
|
+
* @returns `true` when `value` is a `RatingDefinition`
|
|
243
103
|
*
|
|
244
104
|
* @example
|
|
245
105
|
* ```ts
|
|
246
|
-
* import {
|
|
106
|
+
* import { isRatingDefinition } from '@orkestrel/rater'
|
|
247
107
|
*
|
|
248
|
-
*
|
|
249
|
-
* // 'age is more than 18 ? met'
|
|
108
|
+
* isRatingDefinition({ id: 'r1', name: 'Rating', lines: [] }) // true
|
|
250
109
|
* ```
|
|
251
110
|
*/
|
|
252
|
-
function
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
111
|
+
function isRatingDefinition(value) {
|
|
112
|
+
return recordOf({
|
|
113
|
+
id: isString,
|
|
114
|
+
name: isString,
|
|
115
|
+
description: isString,
|
|
116
|
+
lines: arrayOf(isLineDefinition),
|
|
117
|
+
metadata: isJSONValue
|
|
118
|
+
}, ["description", "metadata"])(value);
|
|
259
119
|
}
|
|
120
|
+
//#endregion
|
|
121
|
+
//#region src/core/helpers.ts
|
|
260
122
|
/**
|
|
261
123
|
* Build a {@link Premise} from an evaluated {@link Check}.
|
|
262
124
|
*
|
|
@@ -265,6 +127,15 @@ function describePremise(entry, labels) {
|
|
|
265
127
|
* @param met - Whether the check was met (absent when not yet evaluated)
|
|
266
128
|
* @param labels - Optional field-to-label overrides, keyed by dot-joined field
|
|
267
129
|
* @returns A fresh premise
|
|
130
|
+
*
|
|
131
|
+
* @example
|
|
132
|
+
* ```ts
|
|
133
|
+
* import { check } from '@orkestrel/reason'
|
|
134
|
+
* import { premiseCheck } from '@orkestrel/rater'
|
|
135
|
+
*
|
|
136
|
+
* const entry = check('age', 'above', 18)
|
|
137
|
+
* premiseCheck(entry, 25, true) // { field: 'age', comparison: 'above', expected: 18, actual: 25, met: true }
|
|
138
|
+
* ```
|
|
268
139
|
*/
|
|
269
140
|
function premiseCheck(check, actual, met, labels) {
|
|
270
141
|
const field = formatField(check.field);
|
|
@@ -285,6 +156,15 @@ function premiseCheck(check, actual, met, labels) {
|
|
|
285
156
|
* @param results - The corresponding {@link CheckResult}s, in the same order
|
|
286
157
|
* @param labels - Optional field-to-label overrides, keyed by dot-joined field
|
|
287
158
|
* @returns A fresh list of premises, one per authored check
|
|
159
|
+
*
|
|
160
|
+
* @example
|
|
161
|
+
* ```ts
|
|
162
|
+
* import { check } from '@orkestrel/reason'
|
|
163
|
+
* import { checkPremises } from '@orkestrel/rater'
|
|
164
|
+
*
|
|
165
|
+
* const checks = [check('age', 'above', 18)]
|
|
166
|
+
* checkPremises(checks, [{ field: 'age', met: true, actual: 25 }])
|
|
167
|
+
* ```
|
|
288
168
|
*/
|
|
289
169
|
function checkPremises(checks, results, labels) {
|
|
290
170
|
return (checks ?? []).map((check, index) => {
|
|
@@ -293,62 +173,21 @@ function checkPremises(checks, results, labels) {
|
|
|
293
173
|
});
|
|
294
174
|
}
|
|
295
175
|
/**
|
|
296
|
-
* Describe a logical {@link Expression} tree without atom-specific evidence.
|
|
297
|
-
*
|
|
298
|
-
* @param expression - The expression to describe
|
|
299
|
-
* @param labels - Optional field-to-label overrides, keyed by dot-joined field
|
|
300
|
-
* @returns A display-neutral description
|
|
301
|
-
*/
|
|
302
|
-
function describeExpression(expression, labels) {
|
|
303
|
-
if (expression.form === "atom") return describePremise(premiseCheck(expression.check, void 0, void 0, labels), labels);
|
|
304
|
-
const descriptions = expression.operands.map((operand) => describeExpression(operand, labels));
|
|
305
|
-
return `${expression.operator} (${descriptions.join(", ")})`;
|
|
306
|
-
}
|
|
307
|
-
/**
|
|
308
|
-
* Build rich premises for one fired {@link Rule} by walking its premise atoms
|
|
309
|
-
* and re-evaluating each against the working subject.
|
|
310
|
-
*
|
|
311
|
-
* @remarks
|
|
312
|
-
* A reason {@link RuleResult} carries only booleans, so this is rater's own
|
|
313
|
-
* premise-audit projection: each authored premise expression is flattened to
|
|
314
|
-
* its atom leaves via {@link extractAtoms}, and each leaf's {@link Check} is
|
|
315
|
-
* re-evaluated through the injected `evaluator`. A membership check (`any` /
|
|
316
|
-
* `none`) over an EMPTY array value is content-free — a tautology or
|
|
317
|
-
* contradiction that reads as "is none of —" in every surface — and is skipped.
|
|
318
|
-
*
|
|
319
|
-
* @param rule - The authored rule
|
|
320
|
-
* @param working - The working subject to evaluate against
|
|
321
|
-
* @param evaluator - The shared reason check evaluator
|
|
322
|
-
* @param labels - Optional field-to-label overrides, keyed by dot-joined field
|
|
323
|
-
* @returns A fresh, ordered list of rich premises
|
|
324
|
-
*/
|
|
325
|
-
function logicalPremises(rule, working, evaluator, labels) {
|
|
326
|
-
const output = [];
|
|
327
|
-
for (const premise of rule.premises) for (const atom of extractAtoms(premise)) {
|
|
328
|
-
const { check } = atom;
|
|
329
|
-
if ((check.operator === "any" || check.operator === "none") && Array.isArray(check.value) && check.value.length === 0) continue;
|
|
330
|
-
const result = evaluator.evaluate(check, working);
|
|
331
|
-
output.push(premiseCheck(check, result.actual, result.met, labels));
|
|
332
|
-
}
|
|
333
|
-
return output;
|
|
334
|
-
}
|
|
335
|
-
/**
|
|
336
|
-
* Locate an authored {@link Rule} by id.
|
|
337
|
-
*
|
|
338
|
-
* @param definition - The logical definition to search
|
|
339
|
-
* @param id - The rule id
|
|
340
|
-
* @returns The matching rule, or `undefined`
|
|
341
|
-
*/
|
|
342
|
-
function findRule(definition, id) {
|
|
343
|
-
return definition.rules.find((rule) => rule.id === id);
|
|
344
|
-
}
|
|
345
|
-
/**
|
|
346
176
|
* Join one authored quantitative factor to its evaluated {@link FactorResult}.
|
|
347
177
|
*
|
|
348
178
|
* @param definition - The authored factor
|
|
349
179
|
* @param results - The group's factor results
|
|
350
180
|
* @param labels - Optional field-to-label overrides, keyed by dot-joined field
|
|
351
181
|
* @returns A fresh worksheet factor
|
|
182
|
+
*
|
|
183
|
+
* @example
|
|
184
|
+
* ```ts
|
|
185
|
+
* import { fieldFactor } from '@orkestrel/reason'
|
|
186
|
+
* import { worksheetFactor } from '@orkestrel/rater'
|
|
187
|
+
*
|
|
188
|
+
* const factor = fieldFactor('seats', 'seats')
|
|
189
|
+
* worksheetFactor(factor, [{ id: 'seats', applied: true, value: 10 }])
|
|
190
|
+
* ```
|
|
352
191
|
*/
|
|
353
192
|
function worksheetFactor(definition, results, labels) {
|
|
354
193
|
const result = results.find((entry) => entry.id === definition.id);
|
|
@@ -368,6 +207,15 @@ function worksheetFactor(definition, results, labels) {
|
|
|
368
207
|
* @param results - The definition's group results
|
|
369
208
|
* @param labels - Optional field-to-label overrides, keyed by dot-joined field
|
|
370
209
|
* @returns A fresh worksheet group
|
|
210
|
+
*
|
|
211
|
+
* @example
|
|
212
|
+
* ```ts
|
|
213
|
+
* import { factorGroup, staticFactor } from '@orkestrel/reason'
|
|
214
|
+
* import { worksheetGroup } from '@orkestrel/rater'
|
|
215
|
+
*
|
|
216
|
+
* const group = factorGroup('charge', 'sum', [staticFactor('base', 100)])
|
|
217
|
+
* worksheetGroup(group, [{ id: 'charge', applied: true, value: 100, factors: [] }])
|
|
218
|
+
* ```
|
|
371
219
|
*/
|
|
372
220
|
function worksheetGroup(definition, results, labels) {
|
|
373
221
|
const result = results.find((entry) => entry.id === definition.id);
|
|
@@ -389,6 +237,14 @@ function worksheetGroup(definition, results, labels) {
|
|
|
389
237
|
* @param value - The stage's resolved value
|
|
390
238
|
* @param expression - A display-neutral expression string
|
|
391
239
|
* @returns A fresh step
|
|
240
|
+
*
|
|
241
|
+
* @example
|
|
242
|
+
* ```ts
|
|
243
|
+
* import { worksheetStep } from '@orkestrel/rater'
|
|
244
|
+
*
|
|
245
|
+
* worksheetStep('total', 'quote', 'Quote', 110, 'sum = 110')
|
|
246
|
+
* // { stage: 'total', id: 'quote', name: 'Quote', value: 110, expression: 'sum = 110' }
|
|
247
|
+
* ```
|
|
392
248
|
*/
|
|
393
249
|
function worksheetStep(stage, id, name, value, expression) {
|
|
394
250
|
return {
|
|
@@ -406,6 +262,18 @@ function worksheetStep(stage, id, name, value, expression) {
|
|
|
406
262
|
* @param result - The evaluated quantitative result
|
|
407
263
|
* @param groups - The definition's already-joined worksheet groups
|
|
408
264
|
* @returns A fresh, ordered list of steps: applied factors, then groups, then the total
|
|
265
|
+
*
|
|
266
|
+
* @example
|
|
267
|
+
* ```ts
|
|
268
|
+
* import { createQuantitativeReasoner, createReason, factorGroup, quantitativeDefinition, staticFactor } from '@orkestrel/reason'
|
|
269
|
+
* import { worksheetGroup, worksheetSteps } from '@orkestrel/rater'
|
|
270
|
+
*
|
|
271
|
+
* const definition = quantitativeDefinition('base', 'Base', [factorGroup('g', 'sum', [staticFactor('flat', 100)])])
|
|
272
|
+
* const result = createReason({ reasoners: [createQuantitativeReasoner()] }).reason({}, definition)
|
|
273
|
+
* if (result.reasoning === 'quantitative') {
|
|
274
|
+
* worksheetSteps(definition, result, definition.groups.map((group) => worksheetGroup(group, result.groups)))
|
|
275
|
+
* }
|
|
276
|
+
* ```
|
|
409
277
|
*/
|
|
410
278
|
function worksheetSteps(definition, result, groups) {
|
|
411
279
|
const output = [];
|
|
@@ -424,6 +292,16 @@ function worksheetSteps(definition, result, groups) {
|
|
|
424
292
|
* @param result - The evaluated quantitative result
|
|
425
293
|
* @param labels - Optional field-to-label overrides, keyed by dot-joined field
|
|
426
294
|
* @returns A fresh worksheet
|
|
295
|
+
*
|
|
296
|
+
* @example
|
|
297
|
+
* ```ts
|
|
298
|
+
* import { createQuantitativeReasoner, createReason, factorGroup, quantitativeDefinition, staticFactor } from '@orkestrel/reason'
|
|
299
|
+
* import { resultsWorksheet } from '@orkestrel/rater'
|
|
300
|
+
*
|
|
301
|
+
* const definition = quantitativeDefinition('base', 'Base', [factorGroup('g', 'sum', [staticFactor('flat', 100)])])
|
|
302
|
+
* const result = createReason({ reasoners: [createQuantitativeReasoner()] }).reason({}, definition)
|
|
303
|
+
* if (result.reasoning === 'quantitative') resultsWorksheet(definition, result) // the full audit trail
|
|
304
|
+
* ```
|
|
427
305
|
*/
|
|
428
306
|
function resultsWorksheet(definition, result, labels) {
|
|
429
307
|
const groups = definition.groups.map((group) => worksheetGroup(group, result.groups, labels));
|
|
@@ -441,186 +319,31 @@ function resultsWorksheet(definition, result, labels) {
|
|
|
441
319
|
};
|
|
442
320
|
}
|
|
443
321
|
/**
|
|
444
|
-
* Convert fired logical {@link RuleResult}s into line- or program-scoped
|
|
445
|
-
* {@link Determination}s.
|
|
446
|
-
*
|
|
447
|
-
* @param definition - The authored logical definition
|
|
448
|
-
* @param result - The evaluated logical result
|
|
449
|
-
* @param rulings - Authored consequences, keyed by rule id
|
|
450
|
-
* @param working - The working subject the rules ran against
|
|
451
|
-
* @param line - The line id this pass is scoped to, when it is line-scoped
|
|
452
|
-
* @param evaluator - The shared reason check evaluator
|
|
453
|
-
* @param labels - Optional field-to-label overrides, keyed by dot-joined field
|
|
454
|
-
* @returns A fresh list of determinations
|
|
455
|
-
*/
|
|
456
|
-
function rulesToDeterminations(definition, result, rulings, working, line, evaluator, labels) {
|
|
457
|
-
const output = [];
|
|
458
|
-
for (const entry of result.rules) {
|
|
459
|
-
const authored = findRule(definition, entry.id);
|
|
460
|
-
if (authored === void 0) continue;
|
|
461
|
-
const routed = rulings?.[entry.id];
|
|
462
|
-
if (!entry.applied && routed === void 0) continue;
|
|
463
|
-
const resolved = routed?.line ?? line;
|
|
464
|
-
output.push({
|
|
465
|
-
id: entry.id,
|
|
466
|
-
effect: routed?.effect ?? "restriction",
|
|
467
|
-
applied: entry.applied,
|
|
468
|
-
...resolved === void 0 ? {} : { line: resolved },
|
|
469
|
-
...routed?.message === void 0 ? {} : { message: interpolateMessage(routed.message, working) },
|
|
470
|
-
premises: logicalPremises(authored, working, evaluator, labels)
|
|
471
|
-
});
|
|
472
|
-
}
|
|
473
|
-
return output;
|
|
474
|
-
}
|
|
475
|
-
/**
|
|
476
|
-
* Convert an authority's fired {@link RuleResult}s into `limit`
|
|
477
|
-
* {@link Determination}s.
|
|
478
|
-
*
|
|
479
|
-
* @param definition - The authority's logical definition
|
|
480
|
-
* @param result - The evaluated logical result
|
|
481
|
-
* @param rulings - Authored consequences, keyed by rule id
|
|
482
|
-
* @param working - The working subject the authority ran against
|
|
483
|
-
* @param evaluator - The shared reason check evaluator
|
|
484
|
-
* @param labels - Optional field-to-label overrides, keyed by dot-joined field
|
|
485
|
-
* @returns A fresh list of `limit` determinations
|
|
486
|
-
*/
|
|
487
|
-
function authorityToDeterminations(definition, result, rulings, working, evaluator, labels) {
|
|
488
|
-
const output = [];
|
|
489
|
-
for (const entry of result.rules) {
|
|
490
|
-
if (!entry.applied) continue;
|
|
491
|
-
const authored = findRule(definition, entry.id);
|
|
492
|
-
if (authored === void 0) continue;
|
|
493
|
-
const routed = rulings?.[entry.id];
|
|
494
|
-
output.push({
|
|
495
|
-
id: entry.id,
|
|
496
|
-
effect: "limit",
|
|
497
|
-
applied: true,
|
|
498
|
-
...routed?.line === void 0 ? {} : { line: routed.line },
|
|
499
|
-
...routed?.message === void 0 ? {} : { message: interpolateMessage(routed.message, working) },
|
|
500
|
-
premises: logicalPremises(authored, working, evaluator, labels)
|
|
501
|
-
});
|
|
502
|
-
}
|
|
503
|
-
return output;
|
|
504
|
-
}
|
|
505
|
-
/**
|
|
506
|
-
* Convert authored {@link Notice}s into unconditionally-applied
|
|
507
|
-
* `notice` {@link Determination}s.
|
|
508
|
-
*
|
|
509
|
-
* @param notices - The authored notices
|
|
510
|
-
* @param working - The working subject to interpolate messages against
|
|
511
|
-
* @returns A fresh list of notice determinations
|
|
512
|
-
*/
|
|
513
|
-
function noticesToDeterminations(notices, working) {
|
|
514
|
-
return (notices ?? []).map((entry) => ({
|
|
515
|
-
id: entry.id,
|
|
516
|
-
effect: "notice",
|
|
517
|
-
applied: true,
|
|
518
|
-
...entry.line === void 0 ? {} : { line: entry.line },
|
|
519
|
-
message: interpolateMessage(entry.message, working),
|
|
520
|
-
premises: []
|
|
521
|
-
}));
|
|
522
|
-
}
|
|
523
|
-
/**
|
|
524
|
-
* Keep only the determinations scoped to one line.
|
|
525
|
-
*
|
|
526
|
-
* @param determinations - The determinations to filter
|
|
527
|
-
* @param id - The line id to keep
|
|
528
|
-
* @returns The determinations whose `line` matches `id`
|
|
529
|
-
*/
|
|
530
|
-
function filterLineDeterminations(determinations, id) {
|
|
531
|
-
return determinations.filter((entry) => entry.line === id);
|
|
532
|
-
}
|
|
533
|
-
/**
|
|
534
|
-
* Keep only program-scoped (line-unscoped) determinations.
|
|
535
|
-
*
|
|
536
|
-
* @param determinations - The determinations to filter
|
|
537
|
-
* @returns The determinations with no `line`
|
|
538
|
-
*/
|
|
539
|
-
function filterProgramDeterminations(determinations) {
|
|
540
|
-
return determinations.filter((entry) => entry.line === void 0);
|
|
541
|
-
}
|
|
542
|
-
/**
|
|
543
|
-
* Derive the eligibility impact of a set of determinations.
|
|
544
|
-
*
|
|
545
|
-
* @param determinations - The determinations to fold
|
|
546
|
-
* @returns The most severe eligibility any APPLIED determination's effect carries
|
|
547
|
-
*
|
|
548
|
-
* @example
|
|
549
|
-
* ```ts
|
|
550
|
-
* import { deriveDeterminationEligibility } from '@orkestrel/rater'
|
|
551
|
-
*
|
|
552
|
-
* deriveDeterminationEligibility([{ id: 'r1', effect: 'restriction', applied: true, premises: [] }])
|
|
553
|
-
* // 'ineligible'
|
|
554
|
-
* ```
|
|
555
|
-
*/
|
|
556
|
-
function deriveDeterminationEligibility(determinations) {
|
|
557
|
-
const impacts = ["eligible"];
|
|
558
|
-
for (const entry of determinations) {
|
|
559
|
-
const eligibility = entry.applied ? EFFECT_ELIGIBILITIES[entry.effect] : void 0;
|
|
560
|
-
if (eligibility !== void 0) impacts.push(eligibility);
|
|
561
|
-
}
|
|
562
|
-
return combineEligibilities(impacts);
|
|
563
|
-
}
|
|
564
|
-
/**
|
|
565
|
-
* Return the most severe {@link Eligibility} in a list.
|
|
566
|
-
*
|
|
567
|
-
* @param eligibilities - The eligibilities to combine
|
|
568
|
-
* @returns The most severe eligibility, or `'eligible'` for an empty list
|
|
569
|
-
*
|
|
570
|
-
* @example
|
|
571
|
-
* ```ts
|
|
572
|
-
* import { combineEligibilities } from '@orkestrel/rater'
|
|
573
|
-
*
|
|
574
|
-
* combineEligibilities(['eligible', 'referral']) // 'referral'
|
|
575
|
-
* ```
|
|
576
|
-
*/
|
|
577
|
-
function combineEligibilities(eligibilities) {
|
|
578
|
-
for (const eligibility of ELIGIBILITY_PRECEDENCE) if (eligibilities.includes(eligibility)) return eligibility;
|
|
579
|
-
return "eligible";
|
|
580
|
-
}
|
|
581
|
-
/**
|
|
582
|
-
* Convert an {@link Eligibility} to its deterministic authority {@link Decision}.
|
|
583
|
-
*
|
|
584
|
-
* @param eligibility - The eligibility to convert
|
|
585
|
-
* @returns The matching decision
|
|
586
|
-
*/
|
|
587
|
-
function decideEligibility(eligibility) {
|
|
588
|
-
return ELIGIBILITY_DECISIONS[eligibility];
|
|
589
|
-
}
|
|
590
|
-
/**
|
|
591
|
-
* Derive the final {@link Status} from eligibility, determinations, and rated
|
|
592
|
-
* line evidence.
|
|
593
|
-
*
|
|
594
|
-
* @param eligibility - The program's combined eligibility
|
|
595
|
-
* @param determinations - Every determination in scope (program and line)
|
|
596
|
-
* @param lines - The program's rated lines
|
|
597
|
-
* @returns The derived status
|
|
598
|
-
*/
|
|
599
|
-
function deriveStatus(eligibility, determinations, lines) {
|
|
600
|
-
if (eligibility === "ineligible") return "ineligible";
|
|
601
|
-
if (eligibility === "referral") return "referral";
|
|
602
|
-
if (determinations.some((entry) => entry.applied && entry.effect === "condition")) return "conditional";
|
|
603
|
-
if (lines.some((entry) => !(entry.worksheet?.success ?? false) || entry.amount === void 0)) return "unrated";
|
|
604
|
-
return "eligible";
|
|
605
|
-
}
|
|
606
|
-
/**
|
|
607
322
|
* Build a rated {@link LineResult} from a line's evaluated
|
|
608
323
|
* {@link QuantitativeResult}.
|
|
609
324
|
*
|
|
610
325
|
* @param definition - The authored line definition
|
|
611
326
|
* @param result - The evaluated quantitative result
|
|
612
|
-
* @param determinations - The line-scoped determinations
|
|
613
327
|
* @param labels - Optional field-to-label overrides, keyed by dot-joined field
|
|
614
328
|
* @returns A fresh line result
|
|
329
|
+
*
|
|
330
|
+
* @example
|
|
331
|
+
* ```ts
|
|
332
|
+
* import { createQuantitativeReasoner, createReason, quantitativeDefinition } from '@orkestrel/reason'
|
|
333
|
+
* import { lineDefinition, ratedLine } from '@orkestrel/rater'
|
|
334
|
+
*
|
|
335
|
+
* const line = lineDefinition('base', 'Base', quantitativeDefinition('base', 'Base', []))
|
|
336
|
+
* const result = createReason({ reasoners: [createQuantitativeReasoner()] }).reason({}, line.rate)
|
|
337
|
+
* if (result.reasoning === 'quantitative') ratedLine(line, result) // amount present only when success
|
|
338
|
+
* ```
|
|
615
339
|
*/
|
|
616
|
-
function ratedLine(definition, result,
|
|
340
|
+
function ratedLine(definition, result, labels) {
|
|
617
341
|
return {
|
|
618
342
|
id: definition.id,
|
|
619
343
|
name: definition.name,
|
|
620
|
-
eligibility: deriveDeterminationEligibility(determinations),
|
|
621
344
|
...result.success ? { amount: result.value } : {},
|
|
622
345
|
worksheet: resultsWorksheet(definition.rate, result, labels),
|
|
623
|
-
|
|
346
|
+
success: result.success
|
|
624
347
|
};
|
|
625
348
|
}
|
|
626
349
|
/**
|
|
@@ -628,6 +351,17 @@ function ratedLine(definition, result, determinations, labels) {
|
|
|
628
351
|
*
|
|
629
352
|
* @param lines - The rated lines
|
|
630
353
|
* @returns The sum of every line's `amount`, or `undefined` when no line has one
|
|
354
|
+
*
|
|
355
|
+
* @example
|
|
356
|
+
* ```ts
|
|
357
|
+
* import { createQuantitativeReasoner, createReason, factorGroup, quantitativeDefinition, staticFactor } from '@orkestrel/reason'
|
|
358
|
+
* import { lineDefinition, ratedLine, sumAmounts } from '@orkestrel/rater'
|
|
359
|
+
*
|
|
360
|
+
* const rate = quantitativeDefinition('base', 'Base', [factorGroup('g', 'sum', [staticFactor('flat', 100)])])
|
|
361
|
+
* const line = lineDefinition('base', 'Base', rate)
|
|
362
|
+
* const result = createReason({ reasoners: [createQuantitativeReasoner()] }).reason({}, rate)
|
|
363
|
+
* if (result.reasoning === 'quantitative') sumAmounts([ratedLine(line, result)]) // 100
|
|
364
|
+
* ```
|
|
631
365
|
*/
|
|
632
366
|
function sumAmounts(lines) {
|
|
633
367
|
let total = 0;
|
|
@@ -638,703 +372,100 @@ function sumAmounts(lines) {
|
|
|
638
372
|
}
|
|
639
373
|
return count === 0 ? void 0 : total;
|
|
640
374
|
}
|
|
375
|
+
//#endregion
|
|
376
|
+
//#region src/core/Rater.ts
|
|
641
377
|
/**
|
|
642
|
-
*
|
|
643
|
-
*
|
|
644
|
-
* @param result - The program result computed before authority runs
|
|
645
|
-
* @returns A record shaped for the authority's `outcome` projection
|
|
646
|
-
*/
|
|
647
|
-
function outcomeProjection(result) {
|
|
648
|
-
const lines = {};
|
|
649
|
-
for (const rated of result.lines) if (rated.amount !== void 0) lines[rated.id] = rated.amount;
|
|
650
|
-
return {
|
|
651
|
-
eligibility: result.eligibility,
|
|
652
|
-
status: result.status,
|
|
653
|
-
total: result.total,
|
|
654
|
-
lines
|
|
655
|
-
};
|
|
656
|
-
}
|
|
657
|
-
/**
|
|
658
|
-
* Assemble a final {@link ProgramResult} from its rated parts.
|
|
659
|
-
*
|
|
660
|
-
* @param definition - The authored program definition
|
|
661
|
-
* @param lines - The program's rated lines
|
|
662
|
-
* @param determinations - The program-scoped determinations
|
|
663
|
-
* @param derivations - The pass-level quantitative worksheets
|
|
664
|
-
* @param total - The program's total amount, when the total handler produced one
|
|
665
|
-
* @param decision - The authority-derived decision, when one was reached
|
|
666
|
-
* @param trace - The accumulated reasoning trace
|
|
667
|
-
* @param errors - The accumulated reasoning errors
|
|
668
|
-
* @returns A fresh program result
|
|
669
|
-
*/
|
|
670
|
-
function programResult(definition, lines, determinations, derivations, total, decision, trace, errors) {
|
|
671
|
-
const eligibility = combineEligibilities([deriveDeterminationEligibility(determinations), ...lines.map((entry) => entry.eligibility)]);
|
|
672
|
-
const allDeterminations = [...determinations, ...lines.flatMap((entry) => entry.determinations)];
|
|
673
|
-
return {
|
|
674
|
-
id: definition.id,
|
|
675
|
-
name: definition.name,
|
|
676
|
-
eligibility,
|
|
677
|
-
status: deriveStatus(eligibility, allDeterminations, lines),
|
|
678
|
-
...decision === void 0 ? {} : { decision },
|
|
679
|
-
lines,
|
|
680
|
-
determinations,
|
|
681
|
-
derivations,
|
|
682
|
-
...total === void 0 ? {} : { total },
|
|
683
|
-
success: errors.length === 0 && lines.every((entry) => entry.worksheet?.success ?? true),
|
|
684
|
-
trace,
|
|
685
|
-
errors
|
|
686
|
-
};
|
|
687
|
-
}
|
|
688
|
-
/**
|
|
689
|
-
* Return authored line references (in passes, rulings, or notices) that name
|
|
690
|
-
* no line on the program.
|
|
691
|
-
*
|
|
692
|
-
* @param definition - The program definition to check
|
|
693
|
-
* @returns A fresh, deduped list of missing line ids
|
|
694
|
-
*/
|
|
695
|
-
function findMissingLineReferences(definition) {
|
|
696
|
-
const ids = new Set(definition.lines.map((entry) => entry.id));
|
|
697
|
-
const missing = /* @__PURE__ */ new Set();
|
|
698
|
-
for (const entry of definition.passes ?? []) if (entry.line !== void 0 && !ids.has(entry.line)) missing.add(entry.line);
|
|
699
|
-
for (const entry of Object.values(definition.rulings ?? {})) if (entry.line !== void 0 && !ids.has(entry.line)) missing.add(entry.line);
|
|
700
|
-
for (const entry of definition.notices ?? []) if (entry.line !== void 0 && !ids.has(entry.line)) missing.add(entry.line);
|
|
701
|
-
return [...missing];
|
|
702
|
-
}
|
|
703
|
-
/** Determine whether a working record already carries a reserved rater key. */
|
|
704
|
-
function hasReservedKey(subject) {
|
|
705
|
-
return Object.hasOwn(subject, "aggregate") || Object.hasOwn(subject, "outcome");
|
|
706
|
-
}
|
|
707
|
-
/**
|
|
708
|
-
* Locate a {@link ProgramDefinition}'s reserved-key collisions — a quantitative
|
|
709
|
-
* pass whose id shadows {@link AGGREGATE_KEY} / {@link OUTCOME_KEY}, or a
|
|
710
|
-
* logical pass's or the authority's fired rule conclusion whose asserted
|
|
711
|
-
* field's FIRST path segment shadows one.
|
|
712
|
-
*
|
|
713
|
-
* @remarks
|
|
714
|
-
* A quantitative pass writes its value onto the working subject under its own
|
|
715
|
-
* definition id (`assignField`), silently clobbering the reserved aggregate or
|
|
716
|
-
* outcome projection when the id collides. A fired rule's conclusion atoms
|
|
717
|
-
* merge onto the working subject (`mergeSubjects`/`extractConclusions`) keyed
|
|
718
|
-
* by `formatField(check.field)` — an atom whose field's first segment is a
|
|
719
|
-
* reserved key is flagged here even when its FULL formatted key is a nested
|
|
720
|
-
* form (`outcome.total`), since authoring a conclusion under a reserved
|
|
721
|
-
* top-level segment is never intentional.
|
|
722
|
-
*
|
|
723
|
-
* @param definition - The program definition to scan
|
|
724
|
-
* @returns A fresh, deduped list of offending pass or rule ids
|
|
725
|
-
*
|
|
726
|
-
* @example
|
|
727
|
-
* ```ts
|
|
728
|
-
* import { quantitativeDefinition } from '@orkestrel/reason'
|
|
729
|
-
* import { findReservedCollisions, passDefinition, programDefinition } from '@orkestrel/rater'
|
|
730
|
-
*
|
|
731
|
-
* findReservedCollisions(
|
|
732
|
-
* programDefinition('p1', 'P1', [], {
|
|
733
|
-
* passes: [passDefinition(quantitativeDefinition('aggregate', 'Aggregate', []))],
|
|
734
|
-
* }),
|
|
735
|
-
* ) // ['aggregate']
|
|
736
|
-
* ```
|
|
737
|
-
*/
|
|
738
|
-
function findReservedCollisions(definition) {
|
|
739
|
-
const offending = /* @__PURE__ */ new Set();
|
|
740
|
-
const logicals = [];
|
|
741
|
-
for (const pass of definition.passes ?? []) if (pass.definition.reasoning === "quantitative") {
|
|
742
|
-
if (pass.definition.id === "aggregate" || pass.definition.id === "outcome") offending.add(pass.definition.id);
|
|
743
|
-
} else if (pass.definition.reasoning === "logical") logicals.push(pass.definition);
|
|
744
|
-
if (definition.authority !== void 0) logicals.push(definition.authority);
|
|
745
|
-
for (const logical of logicals) for (const rule of logical.rules) for (const atom of extractAtoms(rule.conclusion)) {
|
|
746
|
-
const head = Array.isArray(atom.check.field) ? atom.check.field[0] : atom.check.field;
|
|
747
|
-
if (head === "aggregate" || head === "outcome") offending.add(rule.id);
|
|
748
|
-
}
|
|
749
|
-
return [...offending];
|
|
750
|
-
}
|
|
751
|
-
/**
|
|
752
|
-
* Assert a value is a valid rater {@link Subject}, narrowing it in place.
|
|
753
|
-
*
|
|
754
|
-
* @param subject - The candidate subject to validate
|
|
755
|
-
* @throws {@link RaterError} `'MISMATCH'` when the value is not a record, or
|
|
756
|
-
* when it already carries a reserved rater working-subject key
|
|
757
|
-
*/
|
|
758
|
-
function assertSubject(subject) {
|
|
759
|
-
if (!isRecord(subject)) throw new RaterError("MISMATCH", "Subject must be a record");
|
|
760
|
-
if (hasReservedKey(subject)) throw new RaterError("MISMATCH", "Subject uses a reserved rater key");
|
|
761
|
-
}
|
|
762
|
-
/**
|
|
763
|
-
* Sum aggregate fields across a batch of subjects.
|
|
764
|
-
*
|
|
765
|
-
* @param subjects - The batch of subjects
|
|
766
|
-
* @param fields - The fields to sum
|
|
767
|
-
* @returns A fresh record of dot-joined field to summed finite value
|
|
768
|
-
*/
|
|
769
|
-
function aggregateSums(subjects, fields) {
|
|
770
|
-
const sums = {};
|
|
771
|
-
for (const field of fields) sums[formatField(field)] = 0;
|
|
772
|
-
for (const subject of subjects) for (const field of fields) {
|
|
773
|
-
const key = formatField(field);
|
|
774
|
-
const value = resolveField(subject, field);
|
|
775
|
-
if (isFiniteNumber(value)) sums[key] = (sums[key] ?? 0) + value;
|
|
776
|
-
}
|
|
777
|
-
return sums;
|
|
778
|
-
}
|
|
779
|
-
/**
|
|
780
|
-
* Partition a batch of subjects by a field, summing aggregate fields per
|
|
781
|
-
* partition.
|
|
782
|
-
*
|
|
783
|
-
* @remarks
|
|
784
|
-
* The partition key is the resolved `by` field, coerced with `String` —
|
|
785
|
-
* `undefined` collapses to the empty string, so a subject missing the field
|
|
786
|
-
* and a subject whose field is literally `''` land in the SAME partition,
|
|
787
|
-
* and a numeric `1` collides with the string `'1'`.
|
|
788
|
-
*
|
|
789
|
-
* @param subjects - The batch of subjects
|
|
790
|
-
* @param fields - The fields to sum within each partition
|
|
791
|
-
* @param by - The partition key field; no partition is built when absent
|
|
792
|
-
* @returns A fresh list of aggregate groups, or an empty list when `by` is absent
|
|
793
|
-
*/
|
|
794
|
-
function aggregateGroups(subjects, fields, by) {
|
|
795
|
-
if (by === void 0) return [];
|
|
796
|
-
const groups = /* @__PURE__ */ new Map();
|
|
797
|
-
for (const subject of subjects) {
|
|
798
|
-
const key = String(resolveField(subject, by) ?? "");
|
|
799
|
-
const entries = groups.get(key) ?? [];
|
|
800
|
-
entries.push(subject);
|
|
801
|
-
groups.set(key, entries);
|
|
802
|
-
}
|
|
803
|
-
return [...groups.entries()].map(([key, entries]) => ({
|
|
804
|
-
key,
|
|
805
|
-
count: entries.length,
|
|
806
|
-
sums: aggregateSums(entries, fields)
|
|
807
|
-
}));
|
|
808
|
-
}
|
|
809
|
-
/**
|
|
810
|
-
* Collect the deduped, ordered union of every program's aggregate fields.
|
|
811
|
-
*
|
|
812
|
-
* @param programs - The compiled programs to scan
|
|
813
|
-
* @returns A fresh list of aggregate field paths, in first-seen order
|
|
814
|
-
*
|
|
815
|
-
* @example
|
|
816
|
-
* ```ts
|
|
817
|
-
* import { aggregateFields } from '@orkestrel/rater'
|
|
818
|
-
*
|
|
819
|
-
* aggregateFields(programs) // [['income'], ['assets']]
|
|
820
|
-
* ```
|
|
821
|
-
*/
|
|
822
|
-
function aggregateFields(programs) {
|
|
823
|
-
const fields = [];
|
|
824
|
-
const keys = /* @__PURE__ */ new Set();
|
|
825
|
-
for (const program of programs) for (const field of program.definition.aggregate?.fields ?? []) {
|
|
826
|
-
const key = formatField(field);
|
|
827
|
-
if (!keys.has(key)) {
|
|
828
|
-
keys.add(key);
|
|
829
|
-
fields.push(field);
|
|
830
|
-
}
|
|
831
|
-
}
|
|
832
|
-
return fields;
|
|
833
|
-
}
|
|
834
|
-
/**
|
|
835
|
-
* Locate the {@link AggregateGroup} a subject belongs to.
|
|
378
|
+
* The rating orchestrator — owns (or receives) the shared quantitative
|
|
379
|
+
* reasoning engine and projects results into the rating domain vocabulary.
|
|
836
380
|
*
|
|
837
381
|
* @remarks
|
|
838
|
-
*
|
|
839
|
-
*
|
|
840
|
-
*
|
|
841
|
-
*
|
|
842
|
-
*
|
|
843
|
-
*
|
|
844
|
-
* @
|
|
845
|
-
* @param by - The partition key field; no group is found when absent
|
|
846
|
-
* @returns The subject's matching group, or `undefined`
|
|
382
|
+
* When no `engine` is injected, `Rater` builds and OWNS its own
|
|
383
|
+
* quantitative-only engine (`bail: false`) — Rater performs NO evaluation
|
|
384
|
+
* arithmetic of its own; it only orchestrates and projects. The
|
|
385
|
+
* array-of-lines `rate` overload is declared FIRST (AGENTS §9.2). `destroy()`
|
|
386
|
+
* destroys an OWNED engine, then the emitter LAST (AGENTS §13); an INJECTED
|
|
387
|
+
* engine is never destroyed. Afterwards every other method throws
|
|
388
|
+
* {@link RaterError} `'DESTROYED'`.
|
|
847
389
|
*
|
|
848
390
|
* @example
|
|
849
391
|
* ```ts
|
|
850
|
-
* import {
|
|
392
|
+
* import { Rater } from '@orkestrel/rater'
|
|
851
393
|
*
|
|
852
|
-
*
|
|
853
|
-
*
|
|
394
|
+
* const rater = new Rater()
|
|
395
|
+
* rater.rate([], { id: 'subject-1' }) // { lines: [], success: true }
|
|
396
|
+
* rater.destroy()
|
|
854
397
|
* ```
|
|
855
398
|
*/
|
|
856
|
-
|
|
857
|
-
if (by === void 0) return void 0;
|
|
858
|
-
const key = String(resolveField(subject, by) ?? "");
|
|
859
|
-
return groups.find((entry) => entry.key === key);
|
|
860
|
-
}
|
|
861
|
-
/**
|
|
862
|
-
* Build the batch aggregate working projection written under
|
|
863
|
-
* {@link AGGREGATE_KEY}'s value.
|
|
864
|
-
*
|
|
865
|
-
* @param count - The batch (or partition) subject count
|
|
866
|
-
* @param sums - The summed aggregate fields
|
|
867
|
-
* @param group - The subject's own partition, when the aggregate is partitioned
|
|
868
|
-
* @returns A fresh aggregate projection record
|
|
869
|
-
*/
|
|
870
|
-
function aggregateProjection(count, sums, group) {
|
|
871
|
-
return {
|
|
872
|
-
count,
|
|
873
|
-
sums,
|
|
874
|
-
...group === void 0 ? {} : { group }
|
|
875
|
-
};
|
|
876
|
-
}
|
|
877
|
-
/**
|
|
878
|
-
* Build the reserved-key record an aggregate gate definition runs against.
|
|
879
|
-
*
|
|
880
|
-
* @param count - The batch (or partition) subject count
|
|
881
|
-
* @param sums - The summed aggregate fields
|
|
882
|
-
* @param group - The subject's own partition, when the aggregate is partitioned
|
|
883
|
-
* @returns A fresh record carrying the aggregate projection under {@link AGGREGATE_KEY}
|
|
884
|
-
*/
|
|
885
|
-
function aggregateRecord(count, sums, group) {
|
|
886
|
-
return { [AGGREGATE_KEY]: aggregateProjection(count, sums, group) };
|
|
887
|
-
}
|
|
888
|
-
/**
|
|
889
|
-
* Build zero sums for a set of fields.
|
|
890
|
-
*
|
|
891
|
-
* @param fields - The fields to zero
|
|
892
|
-
* @returns A fresh record of dot-joined field to `0`
|
|
893
|
-
*/
|
|
894
|
-
function emptySums(fields) {
|
|
895
|
-
const sums = {};
|
|
896
|
-
for (const field of fields) sums[formatField(field)] = 0;
|
|
897
|
-
return sums;
|
|
898
|
-
}
|
|
899
|
-
/**
|
|
900
|
-
* Complete a partial status tally record with zero entries for every missing
|
|
901
|
-
* {@link Status}.
|
|
902
|
-
*
|
|
903
|
-
* @param entries - The partial tally entries to complete
|
|
904
|
-
* @returns A record with all five statuses present
|
|
905
|
-
*/
|
|
906
|
-
function completeTallies(entries) {
|
|
907
|
-
return {
|
|
908
|
-
ineligible: entries.ineligible ?? {
|
|
909
|
-
count: 0,
|
|
910
|
-
sums: {}
|
|
911
|
-
},
|
|
912
|
-
referral: entries.referral ?? {
|
|
913
|
-
count: 0,
|
|
914
|
-
sums: {}
|
|
915
|
-
},
|
|
916
|
-
conditional: entries.conditional ?? {
|
|
917
|
-
count: 0,
|
|
918
|
-
sums: {}
|
|
919
|
-
},
|
|
920
|
-
unrated: entries.unrated ?? {
|
|
921
|
-
count: 0,
|
|
922
|
-
sums: {}
|
|
923
|
-
},
|
|
924
|
-
eligible: entries.eligible ?? {
|
|
925
|
-
count: 0,
|
|
926
|
-
sums: {}
|
|
927
|
-
}
|
|
928
|
-
};
|
|
929
|
-
}
|
|
930
|
-
/**
|
|
931
|
-
* Build empty status tallies in precedence order.
|
|
932
|
-
*
|
|
933
|
-
* @param fields - The fields each tally's sums are zeroed for
|
|
934
|
-
* @returns A fresh, complete tally record
|
|
935
|
-
*/
|
|
936
|
-
function emptyTallies(fields) {
|
|
937
|
-
const entries = {};
|
|
938
|
-
for (const status of STATUS_PRECEDENCE) entries[status] = {
|
|
939
|
-
count: 0,
|
|
940
|
-
sums: emptySums(fields)
|
|
941
|
-
};
|
|
942
|
-
return completeTallies(entries);
|
|
943
|
-
}
|
|
944
|
-
/**
|
|
945
|
-
* Add one subject's aggregate contribution to a status tally record.
|
|
946
|
-
*
|
|
947
|
-
* @param tallies - The tallies to update
|
|
948
|
-
* @param status - The subject's derived status
|
|
949
|
-
* @param subject - The subject to fold in
|
|
950
|
-
* @param fields - The fields to sum
|
|
951
|
-
* @returns A fresh, complete tally record with the subject folded in
|
|
952
|
-
*/
|
|
953
|
-
function tallySubject(tallies, status, subject, fields) {
|
|
954
|
-
const updated = { ...tallies };
|
|
955
|
-
const current = tallies[status];
|
|
956
|
-
const sums = { ...current.sums };
|
|
957
|
-
for (const field of fields) {
|
|
958
|
-
const key = formatField(field);
|
|
959
|
-
const value = resolveField(subject, field);
|
|
960
|
-
if (isFiniteNumber(value)) sums[key] = (sums[key] ?? 0) + value;
|
|
961
|
-
}
|
|
962
|
-
updated[status] = {
|
|
963
|
-
count: current.count + 1,
|
|
964
|
-
sums
|
|
965
|
-
};
|
|
966
|
-
return completeTallies(updated);
|
|
967
|
-
}
|
|
968
|
-
//#endregion
|
|
969
|
-
//#region src/core/programs/Program.ts
|
|
970
|
-
/**
|
|
971
|
-
* A compiled program — rates one subject at a time over an injected, shared
|
|
972
|
-
* reasoning engine.
|
|
973
|
-
*
|
|
974
|
-
* @remarks
|
|
975
|
-
* The engine is INJECTED and never owned, destroyed, or otherwise mutated by
|
|
976
|
-
* the program: every actual evaluation (passes, lines, authority) delegates to
|
|
977
|
-
* it. `rate` builds its working subject through copy-on-write overlays only —
|
|
978
|
-
* the caller's `subject` is never mutated.
|
|
979
|
-
*/
|
|
980
|
-
var Program = class {
|
|
981
|
-
#definition;
|
|
982
|
-
#engine;
|
|
983
|
-
#evaluator;
|
|
984
|
-
#total;
|
|
985
|
-
#labels;
|
|
986
|
-
constructor(definition, reason, options) {
|
|
987
|
-
const missing = findMissingLineReferences(definition);
|
|
988
|
-
if (missing.length > 0) throw new RaterError("MISSING", `Unknown line reference: ${missing.join(", ")}`, { program: definition.id });
|
|
989
|
-
const collisions = findReservedCollisions(definition);
|
|
990
|
-
if (collisions.length > 0) throw new RaterError("DEFINITION", `Reserved key collision: ${collisions.join(", ")}`, {
|
|
991
|
-
program: definition.id,
|
|
992
|
-
keys: collisions
|
|
993
|
-
});
|
|
994
|
-
this.#definition = definition;
|
|
995
|
-
this.#engine = reason;
|
|
996
|
-
this.#evaluator = createEvaluator();
|
|
997
|
-
this.#total = options?.total;
|
|
998
|
-
this.#labels = options?.labels;
|
|
999
|
-
}
|
|
1000
|
-
get id() {
|
|
1001
|
-
return this.#definition.id;
|
|
1002
|
-
}
|
|
1003
|
-
get name() {
|
|
1004
|
-
return this.#definition.name;
|
|
1005
|
-
}
|
|
1006
|
-
get definition() {
|
|
1007
|
-
return this.#definition;
|
|
1008
|
-
}
|
|
1009
|
-
rate(subject, aggregate) {
|
|
1010
|
-
assertSubject(subject);
|
|
1011
|
-
let working = aggregate === void 0 ? subject : assignField(subject, AGGREGATE_KEY, aggregate);
|
|
1012
|
-
const determinations = [];
|
|
1013
|
-
const derivations = [];
|
|
1014
|
-
const trace = [];
|
|
1015
|
-
const errors = [];
|
|
1016
|
-
for (const pass of this.#definition.passes ?? []) if (pass.definition.reasoning === "quantitative") {
|
|
1017
|
-
const result = this.#engine.reason(working, pass.definition);
|
|
1018
|
-
trace.push(...result.trace);
|
|
1019
|
-
errors.push(...result.errors);
|
|
1020
|
-
if (result.reasoning === "quantitative") {
|
|
1021
|
-
working = assignField(working, pass.definition.id, result.value);
|
|
1022
|
-
derivations.push(resultsWorksheet(pass.definition, result, this.#labels));
|
|
1023
|
-
}
|
|
1024
|
-
} else {
|
|
1025
|
-
const result = this.#engine.reason(working, pass.definition);
|
|
1026
|
-
trace.push(...result.trace);
|
|
1027
|
-
errors.push(...result.errors);
|
|
1028
|
-
if (result.reasoning === "logical") {
|
|
1029
|
-
determinations.push(...rulesToDeterminations(pass.definition, result, this.#definition.rulings, working, pass.line, this.#evaluator, this.#labels));
|
|
1030
|
-
for (const entry of result.rules) {
|
|
1031
|
-
if (!entry.applied) continue;
|
|
1032
|
-
const rule = findRule(pass.definition, entry.id);
|
|
1033
|
-
if (rule !== void 0) working = mergeSubjects(working, extractConclusions(rule.conclusion));
|
|
1034
|
-
}
|
|
1035
|
-
}
|
|
1036
|
-
}
|
|
1037
|
-
determinations.push(...noticesToDeterminations(this.#definition.notices, working));
|
|
1038
|
-
const scoped = filterProgramDeterminations(determinations);
|
|
1039
|
-
const lines = this.#rateLines(working, scoped, determinations);
|
|
1040
|
-
const total = this.#total?.(lines);
|
|
1041
|
-
const base = programResult(this.#definition, lines, scoped, derivations, total, void 0, trace, errors);
|
|
1042
|
-
if (this.#definition.authority === void 0) return base;
|
|
1043
|
-
const authorityWorking = assignField(working, OUTCOME_KEY, outcomeProjection(base));
|
|
1044
|
-
const authorityResult = this.#engine.reason(authorityWorking, this.#definition.authority);
|
|
1045
|
-
if (authorityResult.reasoning !== "logical") return base;
|
|
1046
|
-
const limits = authorityToDeterminations(this.#definition.authority, authorityResult, this.#definition.rulings, authorityWorking, this.#evaluator, this.#labels);
|
|
1047
|
-
const decision = limits.length === 0 && authorityResult.errors.length === 0 ? decideEligibility(base.eligibility) : void 0;
|
|
1048
|
-
return programResult(this.#definition, lines, [...scoped, ...limits], derivations, total, decision, [...trace, ...authorityResult.trace], [...errors, ...authorityResult.errors]);
|
|
1049
|
-
}
|
|
1050
|
-
#rateLines(working, context, determinations) {
|
|
1051
|
-
const lines = [];
|
|
1052
|
-
for (const entry of this.#definition.lines) {
|
|
1053
|
-
const own = filterLineDeterminations(determinations, entry.id);
|
|
1054
|
-
const line = ratedLine(entry, this.#quantitative(working, entry.rate), own, this.#labels);
|
|
1055
|
-
lines.push({
|
|
1056
|
-
...line,
|
|
1057
|
-
eligibility: deriveDeterminationEligibility([...context, ...line.determinations])
|
|
1058
|
-
});
|
|
1059
|
-
}
|
|
1060
|
-
return lines;
|
|
1061
|
-
}
|
|
1062
|
-
#quantitative(subject, definition) {
|
|
1063
|
-
const result = this.#engine.reason(subject, definition);
|
|
1064
|
-
if (result.reasoning === "quantitative") return result;
|
|
1065
|
-
const failure = buildErrorResult(definition, `Expected quantitative result, got ${result.reasoning}`);
|
|
1066
|
-
const errors = [...result.errors, ...failure.errors];
|
|
1067
|
-
if (failure.reasoning === "quantitative") return {
|
|
1068
|
-
...failure,
|
|
1069
|
-
errors
|
|
1070
|
-
};
|
|
1071
|
-
return {
|
|
1072
|
-
reasoning: "quantitative",
|
|
1073
|
-
value: 0,
|
|
1074
|
-
groups: [],
|
|
1075
|
-
count: 0,
|
|
1076
|
-
success: false,
|
|
1077
|
-
trace: [],
|
|
1078
|
-
errors
|
|
1079
|
-
};
|
|
1080
|
-
}
|
|
1081
|
-
};
|
|
1082
|
-
//#endregion
|
|
1083
|
-
//#region src/core/programs/ProgramManager.ts
|
|
1084
|
-
/**
|
|
1085
|
-
* An ordered manager over compiled {@link ProgramInterface}s (AGENTS §9), built
|
|
1086
|
-
* over an injected, shared reasoning engine.
|
|
1087
|
-
*
|
|
1088
|
-
* @remarks
|
|
1089
|
-
* OWNS its `#programs` collection and its own {@link Emitter} over
|
|
1090
|
-
* {@link ProgramManagerEventMap}. `destroy()` is idempotent and tears the
|
|
1091
|
-
* emitter down LAST; every other call afterwards throws
|
|
1092
|
-
* {@link RaterError} `'DESTROYED'`.
|
|
1093
|
-
*/
|
|
1094
|
-
var ProgramManager = class {
|
|
1095
|
-
#programs = /* @__PURE__ */ new Map();
|
|
1096
|
-
#engine;
|
|
399
|
+
var Rater = class {
|
|
1097
400
|
#emitter;
|
|
401
|
+
#engine;
|
|
402
|
+
#owned;
|
|
1098
403
|
#total;
|
|
1099
404
|
#labels;
|
|
1100
|
-
#validate;
|
|
1101
405
|
#destroyed = false;
|
|
1102
|
-
constructor(
|
|
1103
|
-
this.#engine = engine;
|
|
406
|
+
constructor(options) {
|
|
1104
407
|
this.#emitter = new Emitter({
|
|
1105
408
|
on: options?.on,
|
|
1106
409
|
error: options?.error
|
|
1107
410
|
});
|
|
411
|
+
if (options?.engine === void 0) {
|
|
412
|
+
this.#engine = createReason({
|
|
413
|
+
reasoners: [createQuantitativeReasoner()],
|
|
414
|
+
bail: false
|
|
415
|
+
});
|
|
416
|
+
this.#owned = true;
|
|
417
|
+
} else {
|
|
418
|
+
this.#engine = options.engine;
|
|
419
|
+
this.#owned = false;
|
|
420
|
+
}
|
|
1108
421
|
this.#total = options?.total;
|
|
1109
422
|
this.#labels = options?.labels;
|
|
1110
|
-
this.#validate = options?.validate ?? true;
|
|
1111
423
|
}
|
|
1112
424
|
get emitter() {
|
|
1113
425
|
return this.#emitter;
|
|
1114
426
|
}
|
|
1115
|
-
|
|
1116
|
-
this.#ensureAlive();
|
|
1117
|
-
return this.#programs.size;
|
|
1118
|
-
}
|
|
1119
|
-
has(id) {
|
|
1120
|
-
this.#ensureAlive();
|
|
1121
|
-
return this.#programs.has(id);
|
|
1122
|
-
}
|
|
1123
|
-
program(id) {
|
|
1124
|
-
this.#ensureAlive();
|
|
1125
|
-
return this.#programs.get(id);
|
|
1126
|
-
}
|
|
1127
|
-
programs() {
|
|
427
|
+
rate(input, subject) {
|
|
1128
428
|
this.#ensureAlive();
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
this.#
|
|
1133
|
-
|
|
1134
|
-
const
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
this.#emitter.emit("add", program.id);
|
|
1142
|
-
return program;
|
|
1143
|
-
}
|
|
1144
|
-
remove(target) {
|
|
1145
|
-
this.#ensureAlive();
|
|
1146
|
-
if (target === void 0) {
|
|
1147
|
-
for (const id of this.#programs.keys()) this.#emitter.emit("remove", id);
|
|
1148
|
-
this.#programs.clear();
|
|
1149
|
-
return;
|
|
1150
|
-
}
|
|
1151
|
-
if (typeof target === "string") return this.#removeOne(target);
|
|
1152
|
-
let removed = true;
|
|
1153
|
-
for (const id of target) removed = this.#removeOne(id) && removed;
|
|
1154
|
-
return removed;
|
|
1155
|
-
}
|
|
1156
|
-
destroy() {
|
|
1157
|
-
if (this.#destroyed) return;
|
|
1158
|
-
this.#programs.clear();
|
|
1159
|
-
this.#destroyed = true;
|
|
1160
|
-
this.#emitter.emit("destroy");
|
|
1161
|
-
this.#emitter.destroy();
|
|
1162
|
-
}
|
|
1163
|
-
#removeOne(id) {
|
|
1164
|
-
const removed = this.#programs.delete(id);
|
|
1165
|
-
if (removed) this.#emitter.emit("remove", id);
|
|
1166
|
-
return removed;
|
|
1167
|
-
}
|
|
1168
|
-
#ensureAlive() {
|
|
1169
|
-
if (this.#destroyed) throw new RaterError("DESTROYED", "ProgramManager has been destroyed");
|
|
1170
|
-
}
|
|
1171
|
-
};
|
|
1172
|
-
//#endregion
|
|
1173
|
-
//#region src/core/Rater.ts
|
|
1174
|
-
/**
|
|
1175
|
-
* The rating orchestrator — owns the shared reasoning engine and an ordered
|
|
1176
|
-
* {@link ProgramManager}, and projects results into the rating domain
|
|
1177
|
-
* vocabulary.
|
|
1178
|
-
*
|
|
1179
|
-
* @remarks
|
|
1180
|
-
* Constructs ONE shared reason engine (quantitative + logical reasoners,
|
|
1181
|
-
* `bail: false`) and injects it into every compiled program — Rater performs
|
|
1182
|
-
* NO evaluation arithmetic of its own; it only orchestrates and projects. The
|
|
1183
|
-
* batch `rate` overload is declared FIRST (AGENTS §9.2) so an array subject
|
|
1184
|
-
* resolves to the batch form. `destroy()` tears down the manager, then the
|
|
1185
|
-
* engine, then the emitter LAST (AGENTS §13); afterwards every other method
|
|
1186
|
-
* throws {@link RaterError} `'DESTROYED'`.
|
|
1187
|
-
*/
|
|
1188
|
-
var Rater = class {
|
|
1189
|
-
#emitter;
|
|
1190
|
-
#manager;
|
|
1191
|
-
#engine;
|
|
1192
|
-
#evaluator;
|
|
1193
|
-
#destroyed = false;
|
|
1194
|
-
constructor(options) {
|
|
1195
|
-
this.#emitter = new Emitter({
|
|
1196
|
-
on: options?.on,
|
|
1197
|
-
error: options?.error
|
|
1198
|
-
});
|
|
1199
|
-
this.#engine = createReason({
|
|
1200
|
-
reasoners: [createQuantitativeReasoner(), createLogicalReasoner()],
|
|
1201
|
-
bail: false
|
|
1202
|
-
});
|
|
1203
|
-
this.#evaluator = createEvaluator();
|
|
1204
|
-
this.#manager = new ProgramManager(this.#engine, {
|
|
1205
|
-
total: options?.total,
|
|
1206
|
-
labels: options?.labels,
|
|
1207
|
-
validate: options?.validate
|
|
1208
|
-
});
|
|
1209
|
-
for (const definition of options?.programs ?? []) this.#manager.add(definition);
|
|
1210
|
-
}
|
|
1211
|
-
get emitter() {
|
|
1212
|
-
return this.#emitter;
|
|
1213
|
-
}
|
|
1214
|
-
get programs() {
|
|
1215
|
-
return this.#manager;
|
|
1216
|
-
}
|
|
1217
|
-
rate(subject) {
|
|
1218
|
-
this.#ensureAlive();
|
|
1219
|
-
if (isRecord(subject)) return this.#rateOne(subject);
|
|
1220
|
-
return this.#rateBatch(subject);
|
|
429
|
+
const lines = this.#normalize(input);
|
|
430
|
+
if (!isRecord(subject)) throw new RaterError("MISMATCH", "Subject must be a record");
|
|
431
|
+
const results = lines.map((line) => this.#rateLine(line, subject));
|
|
432
|
+
const total = (this.#total ?? sumAmounts)(results);
|
|
433
|
+
const success = results.every((entry) => entry.success);
|
|
434
|
+
const result = {
|
|
435
|
+
lines: results,
|
|
436
|
+
...total === void 0 ? {} : { total },
|
|
437
|
+
success
|
|
438
|
+
};
|
|
439
|
+
this.#emitter.emit("rate", subject, result);
|
|
440
|
+
return result;
|
|
1221
441
|
}
|
|
1222
442
|
destroy() {
|
|
1223
443
|
if (this.#destroyed) return;
|
|
1224
|
-
this.#manager.destroy();
|
|
1225
|
-
this.#engine.destroy();
|
|
1226
444
|
this.#destroyed = true;
|
|
445
|
+
if (this.#owned) this.#engine.destroy();
|
|
1227
446
|
this.#emitter.destroy();
|
|
1228
447
|
}
|
|
1229
|
-
#
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
return result;
|
|
448
|
+
#normalize(input) {
|
|
449
|
+
if (arrayOf(isLineDefinition)(input)) return input;
|
|
450
|
+
if (isRatingDefinition(input)) return input.lines;
|
|
451
|
+
throw new RaterError("DEFINITION", "Rating input must be an array of line definitions or a rating definition");
|
|
1234
452
|
}
|
|
1235
|
-
#
|
|
1236
|
-
|
|
1237
|
-
const programs = this.#manager.programs();
|
|
1238
|
-
const sums = aggregateSums(subjects, aggregateFields(programs));
|
|
1239
|
-
const programSums = /* @__PURE__ */ new Map();
|
|
1240
|
-
const programGroups = /* @__PURE__ */ new Map();
|
|
1241
|
-
for (const program of programs) {
|
|
1242
|
-
const aggregate = program.definition.aggregate;
|
|
1243
|
-
if (aggregate === void 0) continue;
|
|
1244
|
-
programSums.set(program.id, aggregateSums(subjects, aggregate.fields));
|
|
1245
|
-
programGroups.set(program.id, aggregateGroups(subjects, aggregate.fields, aggregate.by));
|
|
1246
|
-
}
|
|
1247
|
-
const groups = this.#groups(programs, programGroups);
|
|
1248
|
-
const determinations = this.#aggregateDeterminations(programs, programSums, programGroups, subjects.length);
|
|
1249
|
-
const subjectsResult = subjects.map((subject) => this.#subject(subject, this.#projections(subject, programs, programSums, programGroups, subjects.length)));
|
|
1250
|
-
const result = {
|
|
1251
|
-
subjects: subjectsResult,
|
|
1252
|
-
determinations,
|
|
1253
|
-
groups,
|
|
1254
|
-
tallies: this.#tallies(subjects, subjectsResult, programs),
|
|
1255
|
-
count: subjects.length,
|
|
1256
|
-
sums
|
|
1257
|
-
};
|
|
1258
|
-
for (const rated of subjectsResult) this.#emitSubject(rated);
|
|
1259
|
-
for (const determination of determinations) if (determination.applied) this.#emitter.emit("determine", determination);
|
|
1260
|
-
this.#emitter.emit("aggregate", result);
|
|
1261
|
-
return result;
|
|
1262
|
-
}
|
|
1263
|
-
#subject(subject, projections) {
|
|
1264
|
-
return {
|
|
1265
|
-
subject,
|
|
1266
|
-
programs: this.#manager.programs().map((program) => {
|
|
1267
|
-
const projection = projections?.[program.id];
|
|
1268
|
-
return projection === void 0 ? program.rate(subject) : program.rate(subject, projection);
|
|
1269
|
-
})
|
|
1270
|
-
};
|
|
453
|
+
#rateLine(line, subject) {
|
|
454
|
+
return ratedLine(line, this.#reasonQuantitative(subject, line.rate), this.#labels);
|
|
1271
455
|
}
|
|
1272
|
-
#
|
|
1273
|
-
for (const program of result.programs) {
|
|
1274
|
-
const determinations = [...program.determinations, ...program.lines.flatMap((rated) => rated.determinations)];
|
|
1275
|
-
for (const determination of determinations) if (determination.applied) this.#emitter.emit("determine", determination);
|
|
1276
|
-
if (program.decision !== void 0) this.#emitter.emit("decide", program.decision, program);
|
|
1277
|
-
}
|
|
1278
|
-
this.#emitter.emit("rate", result);
|
|
1279
|
-
}
|
|
1280
|
-
#groups(programs, programGroups) {
|
|
1281
|
-
const output = [];
|
|
1282
|
-
for (const program of programs) if (program.definition.aggregate?.by !== void 0) output.push(...programGroups.get(program.id) ?? []);
|
|
1283
|
-
return output;
|
|
1284
|
-
}
|
|
1285
|
-
#projections(subject, programs, programSums, programGroups, count) {
|
|
1286
|
-
const output = {};
|
|
1287
|
-
for (const program of programs) {
|
|
1288
|
-
const aggregate = program.definition.aggregate;
|
|
1289
|
-
if (aggregate === void 0) continue;
|
|
1290
|
-
const sums = programSums.get(program.id);
|
|
1291
|
-
if (sums === void 0) continue;
|
|
1292
|
-
const group = groupFor(subject, programGroups.get(program.id) ?? [], aggregate.by);
|
|
1293
|
-
output[program.id] = aggregateProjection(count, sums, group);
|
|
1294
|
-
}
|
|
1295
|
-
return output;
|
|
1296
|
-
}
|
|
1297
|
-
#aggregateDeterminations(programs, programSums, programGroups, count) {
|
|
1298
|
-
const determinations = [];
|
|
1299
|
-
for (const program of programs) {
|
|
1300
|
-
const aggregate = program.definition.aggregate;
|
|
1301
|
-
if (aggregate?.gates === void 0) continue;
|
|
1302
|
-
const sums = programSums.get(program.id);
|
|
1303
|
-
if (sums === void 0) continue;
|
|
1304
|
-
determinations.push(...this.#gate(aggregate.gates, aggregateRecord(count, sums), program.definition.rulings));
|
|
1305
|
-
for (const group of programGroups.get(program.id) ?? []) determinations.push(...this.#gate(aggregate.gates, aggregateRecord(group.count, group.sums, group), program.definition.rulings));
|
|
1306
|
-
}
|
|
1307
|
-
return determinations;
|
|
1308
|
-
}
|
|
1309
|
-
#gate(definition, record, rulings) {
|
|
1310
|
-
return rulesToDeterminations(definition, this.#reasonLogical(record, definition), rulings, record, void 0, this.#evaluator);
|
|
1311
|
-
}
|
|
1312
|
-
#tallies(subjects, results, programs) {
|
|
1313
|
-
const output = {};
|
|
1314
|
-
for (const program of programs) {
|
|
1315
|
-
let tallies = emptyTallies(program.definition.aggregate?.fields ?? []);
|
|
1316
|
-
for (let index = 0; index < results.length; index += 1) {
|
|
1317
|
-
const subject = subjects[index];
|
|
1318
|
-
const rated = results[index]?.programs.find((entry) => entry.id === program.id);
|
|
1319
|
-
if (subject !== void 0 && rated !== void 0) tallies = tallySubject(tallies, rated.status, subject, program.definition.aggregate?.fields ?? []);
|
|
1320
|
-
}
|
|
1321
|
-
output[program.id] = tallies;
|
|
1322
|
-
}
|
|
1323
|
-
return output;
|
|
1324
|
-
}
|
|
1325
|
-
#reasonLogical(subject, definition) {
|
|
456
|
+
#reasonQuantitative(subject, definition) {
|
|
1326
457
|
const result = this.#engine.reason(subject, definition);
|
|
1327
|
-
if (result.reasoning === "
|
|
1328
|
-
const failure = buildErrorResult(definition, `Expected
|
|
458
|
+
if (result.reasoning === "quantitative") return result;
|
|
459
|
+
const failure = buildErrorResult(definition, `Expected quantitative result, got ${result.reasoning}`);
|
|
1329
460
|
const errors = [...result.errors, ...failure.errors];
|
|
1330
|
-
if (failure.reasoning === "
|
|
461
|
+
if (failure.reasoning === "quantitative") return {
|
|
1331
462
|
...failure,
|
|
1332
463
|
errors
|
|
1333
464
|
};
|
|
1334
465
|
return {
|
|
1335
|
-
reasoning: "
|
|
1336
|
-
|
|
1337
|
-
|
|
466
|
+
reasoning: "quantitative",
|
|
467
|
+
value: 0,
|
|
468
|
+
groups: [],
|
|
1338
469
|
count: 0,
|
|
1339
470
|
success: false,
|
|
1340
471
|
trace: [],
|
|
@@ -1350,7 +481,7 @@ var Rater = class {
|
|
|
1350
481
|
/**
|
|
1351
482
|
* Create a rating orchestrator.
|
|
1352
483
|
*
|
|
1353
|
-
* @param options - Optional total handler,
|
|
484
|
+
* @param options - Optional total handler, labels, injected engine, and emitter hooks
|
|
1354
485
|
* @returns A {@link RaterInterface}
|
|
1355
486
|
*
|
|
1356
487
|
* @example
|
|
@@ -1365,75 +496,6 @@ function createRater(options) {
|
|
|
1365
496
|
return new Rater(options);
|
|
1366
497
|
}
|
|
1367
498
|
/**
|
|
1368
|
-
* Create and validate one compiled program over a shared reasoning engine.
|
|
1369
|
-
*
|
|
1370
|
-
* @param definition - The authored program definition
|
|
1371
|
-
* @param reason - The shared reasoning engine, injected (never owned or destroyed by the program)
|
|
1372
|
-
* @param options - Optional total handler and labels
|
|
1373
|
-
* @returns A {@link ProgramInterface}
|
|
1374
|
-
* @throws {@link RaterError} `'DEFINITION'` when the definition fails {@link isProgramDefinition}
|
|
1375
|
-
*
|
|
1376
|
-
* @example
|
|
1377
|
-
* ```ts
|
|
1378
|
-
* import { createLogicalReasoner, createQuantitativeReasoner, createReason } from '@orkestrel/reason'
|
|
1379
|
-
* import { createProgram, programDefinition } from '@orkestrel/rater'
|
|
1380
|
-
*
|
|
1381
|
-
* const reason = createReason({ reasoners: [createQuantitativeReasoner(), createLogicalReasoner()], bail: false })
|
|
1382
|
-
* const program = createProgram(programDefinition('p1', 'Program', []), reason)
|
|
1383
|
-
* ```
|
|
1384
|
-
*/
|
|
1385
|
-
function createProgram(definition, reason, options) {
|
|
1386
|
-
const id = isRecord(definition) ? definition.id : void 0;
|
|
1387
|
-
if (!isProgramDefinition(definition)) throw new RaterError("DEFINITION", "Program definition failed validation", { program: id });
|
|
1388
|
-
return new Program(definition, reason, options);
|
|
1389
|
-
}
|
|
1390
|
-
/**
|
|
1391
|
-
* Create an ordered manager over compiled programs (AGENTS §9), built over an
|
|
1392
|
-
* injected, shared reasoning engine.
|
|
1393
|
-
*
|
|
1394
|
-
* @param reason - The shared reasoning engine, injected into every compiled program
|
|
1395
|
-
* @param options - Optional total handler, labels, validation policy, and emitter hooks
|
|
1396
|
-
* @returns A {@link ProgramManagerInterface}
|
|
1397
|
-
*
|
|
1398
|
-
* @example
|
|
1399
|
-
* ```ts
|
|
1400
|
-
* import { createLogicalReasoner, createQuantitativeReasoner, createReason } from '@orkestrel/reason'
|
|
1401
|
-
* import { createProgramManager, programDefinition } from '@orkestrel/rater'
|
|
1402
|
-
*
|
|
1403
|
-
* const reason = createReason({ reasoners: [createQuantitativeReasoner(), createLogicalReasoner()], bail: false })
|
|
1404
|
-
* const manager = createProgramManager(reason)
|
|
1405
|
-
* manager.add(programDefinition('p1', 'Program', []))
|
|
1406
|
-
* manager.destroy()
|
|
1407
|
-
* ```
|
|
1408
|
-
*/
|
|
1409
|
-
function createProgramManager(reason, options) {
|
|
1410
|
-
return new ProgramManager(reason, options);
|
|
1411
|
-
}
|
|
1412
|
-
/**
|
|
1413
|
-
* Build a {@link ProgramDefinition}.
|
|
1414
|
-
*
|
|
1415
|
-
* @param id - The program id
|
|
1416
|
-
* @param name - The display name
|
|
1417
|
-
* @param lines - The program's rateable lines
|
|
1418
|
-
* @param overrides - Optional {@link ProgramDefinition} fields merged over the defaults
|
|
1419
|
-
* @returns A fresh program definition
|
|
1420
|
-
*
|
|
1421
|
-
* @example
|
|
1422
|
-
* ```ts
|
|
1423
|
-
* import { programDefinition } from '@orkestrel/rater'
|
|
1424
|
-
*
|
|
1425
|
-
* programDefinition('p1', 'Program', [])
|
|
1426
|
-
* ```
|
|
1427
|
-
*/
|
|
1428
|
-
function programDefinition(id, name, lines, overrides) {
|
|
1429
|
-
return {
|
|
1430
|
-
id,
|
|
1431
|
-
name,
|
|
1432
|
-
lines,
|
|
1433
|
-
...overrides
|
|
1434
|
-
};
|
|
1435
|
-
}
|
|
1436
|
-
/**
|
|
1437
499
|
* Build a {@link LineDefinition}.
|
|
1438
500
|
*
|
|
1439
501
|
* @param id - The line id
|
|
@@ -1459,93 +521,33 @@ function lineDefinition(id, name, rate, overrides) {
|
|
|
1459
521
|
};
|
|
1460
522
|
}
|
|
1461
523
|
/**
|
|
1462
|
-
* Build a {@link
|
|
524
|
+
* Build a {@link RatingDefinition}.
|
|
1463
525
|
*
|
|
1464
|
-
* @param
|
|
1465
|
-
* @param
|
|
1466
|
-
* @
|
|
526
|
+
* @param id - The rating id
|
|
527
|
+
* @param name - The display name
|
|
528
|
+
* @param lines - The rating's ordered lines
|
|
529
|
+
* @param overrides - Optional {@link RatingDefinition} fields merged over the defaults
|
|
530
|
+
* @returns A fresh rating definition
|
|
1467
531
|
*
|
|
1468
532
|
* @example
|
|
1469
533
|
* ```ts
|
|
534
|
+
* import { lineDefinition, ratingDefinition } from '@orkestrel/rater'
|
|
1470
535
|
* import { quantitativeDefinition } from '@orkestrel/reason'
|
|
1471
|
-
* import { passDefinition } from '@orkestrel/rater'
|
|
1472
536
|
*
|
|
1473
|
-
*
|
|
537
|
+
* ratingDefinition('r1', 'Rating', [
|
|
538
|
+
* lineDefinition('base', 'Base Amount', quantitativeDefinition('base', 'Base', [])),
|
|
539
|
+
* ])
|
|
1474
540
|
* ```
|
|
1475
541
|
*/
|
|
1476
|
-
function
|
|
1477
|
-
return {
|
|
1478
|
-
definition,
|
|
1479
|
-
...line === void 0 ? {} : { line }
|
|
1480
|
-
};
|
|
1481
|
-
}
|
|
1482
|
-
/**
|
|
1483
|
-
* Build a {@link Ruling}.
|
|
1484
|
-
*
|
|
1485
|
-
* @param effect - The determination effect a fired rule routes to
|
|
1486
|
-
* @param line - The line id this ruling is scoped to, when it is line-scoped
|
|
1487
|
-
* @param message - An interpolated message template, when the ruling carries one
|
|
1488
|
-
* @returns A fresh ruling
|
|
1489
|
-
*
|
|
1490
|
-
* @example
|
|
1491
|
-
* ```ts
|
|
1492
|
-
* import { rulingDefinition } from '@orkestrel/rater'
|
|
1493
|
-
*
|
|
1494
|
-
* rulingDefinition('restriction', 'base', 'Amount exceeds {{limit}}')
|
|
1495
|
-
* ```
|
|
1496
|
-
*/
|
|
1497
|
-
function rulingDefinition(effect, line, message) {
|
|
1498
|
-
return {
|
|
1499
|
-
effect,
|
|
1500
|
-
...line === void 0 ? {} : { line },
|
|
1501
|
-
...message === void 0 ? {} : { message }
|
|
1502
|
-
};
|
|
1503
|
-
}
|
|
1504
|
-
/**
|
|
1505
|
-
* Build a {@link Notice}.
|
|
1506
|
-
*
|
|
1507
|
-
* @param id - The notice id
|
|
1508
|
-
* @param message - An interpolated message template
|
|
1509
|
-
* @param line - The line id this notice is scoped to, when it is line-scoped
|
|
1510
|
-
* @returns A fresh notice
|
|
1511
|
-
*
|
|
1512
|
-
* @example
|
|
1513
|
-
* ```ts
|
|
1514
|
-
* import { noticeDefinition } from '@orkestrel/rater'
|
|
1515
|
-
*
|
|
1516
|
-
* noticeDefinition('n1', 'Rated on {{date}}')
|
|
1517
|
-
* ```
|
|
1518
|
-
*/
|
|
1519
|
-
function noticeDefinition(id, message, line) {
|
|
542
|
+
function ratingDefinition(id, name, lines, overrides) {
|
|
1520
543
|
return {
|
|
1521
544
|
id,
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
}
|
|
1526
|
-
/**
|
|
1527
|
-
* Build an {@link AggregateDefinition}.
|
|
1528
|
-
*
|
|
1529
|
-
* @param fields - The batch fields to sum
|
|
1530
|
-
* @param by - The partition key field, when the aggregate is partitioned
|
|
1531
|
-
* @param gates - The logical definition gating aggregate determinations, when present
|
|
1532
|
-
* @returns A fresh aggregate definition
|
|
1533
|
-
*
|
|
1534
|
-
* @example
|
|
1535
|
-
* ```ts
|
|
1536
|
-
* import { aggregateDefinition } from '@orkestrel/rater'
|
|
1537
|
-
*
|
|
1538
|
-
* aggregateDefinition(['amount'])
|
|
1539
|
-
* ```
|
|
1540
|
-
*/
|
|
1541
|
-
function aggregateDefinition(fields, by, gates) {
|
|
1542
|
-
return {
|
|
1543
|
-
fields,
|
|
1544
|
-
...by === void 0 ? {} : { by },
|
|
1545
|
-
...gates === void 0 ? {} : { gates }
|
|
545
|
+
name,
|
|
546
|
+
lines,
|
|
547
|
+
...overrides
|
|
1546
548
|
};
|
|
1547
549
|
}
|
|
1548
550
|
//#endregion
|
|
1549
|
-
export {
|
|
551
|
+
export { Rater, RaterError, checkPremises, createRater, isLineDefinition, isRaterError, isRatingDefinition, isStage, lineDefinition, premiseCheck, ratedLine, ratingDefinition, resultsWorksheet, sumAmounts, worksheetFactor, worksheetGroup, worksheetStep, worksheetSteps };
|
|
1550
552
|
|
|
1551
553
|
//# sourceMappingURL=index.js.map
|