@orkestrel/qualifier 0.0.12 → 0.0.14
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 +27 -18
- package/dist/src/core/index.cjs +402 -218
- package/dist/src/core/index.cjs.map +1 -1
- package/dist/src/core/index.d.cts +740 -454
- package/dist/src/core/index.d.ts +740 -454
- package/dist/src/core/index.js +394 -209
- package/dist/src/core/index.js.map +1 -1
- package/package.json +15 -17
package/dist/src/core/index.js
CHANGED
|
@@ -1,18 +1,27 @@
|
|
|
1
1
|
import { arrayOf, isBoolean, isFiniteNumber, isNumber, isRecord, isString, literalOf, objectOf, recordOf, resolveField, whereOf } from "@orkestrel/contract";
|
|
2
|
-
import { createEvaluator, createLogicalReasoner, createQuantitativeReasoner, createReason, extractAtoms, extractConclusions, findDuplicates, formatField, isBounds, isComparison, isFieldPath, isLogicalDefinition, isQuantitativeDefinition, isReasonError
|
|
2
|
+
import { createEvaluator, createLogicalReasoner, createQuantitativeReasoner, createReason, extractAtoms, extractConclusions, findDuplicates, formatField, isBounds, isComparison, isFieldPath, isLogicalDefinition, isQuantitativeDefinition, isReasonError } from "@orkestrel/reason";
|
|
3
3
|
import { Emitter } from "@orkestrel/emitter";
|
|
4
4
|
//#region src/core/constants.ts
|
|
5
|
-
/**
|
|
5
|
+
/**
|
|
6
|
+
* Holds the default definition validation policy for `createQualifier` and
|
|
7
|
+
* `Qualifier.qualify`, `true`.
|
|
8
|
+
*/
|
|
6
9
|
var DEFAULT_QUALIFIER_VALIDATE = true;
|
|
7
|
-
/**
|
|
10
|
+
/**
|
|
11
|
+
* Names `'qualification'`, the reserved internal projection namespace a pass's working
|
|
12
|
+
* projection is written under.
|
|
13
|
+
*/
|
|
8
14
|
var QUALIFICATION_KEY = "qualification";
|
|
9
|
-
/**
|
|
15
|
+
/** Lists the eligibility severities most to least severe: `ineligible`, `referral`, `eligible`. */
|
|
10
16
|
var ELIGIBILITY_PRECEDENCE = Object.freeze([
|
|
11
17
|
"ineligible",
|
|
12
18
|
"referral",
|
|
13
19
|
"eligible"
|
|
14
20
|
]);
|
|
15
|
-
/**
|
|
21
|
+
/**
|
|
22
|
+
* Maps each {@link QualificationEffect} to its eligibility impact — `restriction` to
|
|
23
|
+
* `ineligible`, `referral` to `referral`, and `condition` to `eligible`.
|
|
24
|
+
*/
|
|
16
25
|
var EFFECT_ELIGIBILITIES = Object.freeze({
|
|
17
26
|
restriction: "ineligible",
|
|
18
27
|
referral: "referral",
|
|
@@ -21,14 +30,20 @@ var EFFECT_ELIGIBILITIES = Object.freeze({
|
|
|
21
30
|
//#endregion
|
|
22
31
|
//#region src/core/errors.ts
|
|
23
32
|
/**
|
|
24
|
-
*
|
|
33
|
+
* Represents a coded programmer error thrown by the qualifier layer, carrying a
|
|
34
|
+
* `QualifierErrorCode` and an optional context record.
|
|
25
35
|
*
|
|
26
36
|
* @remarks
|
|
27
|
-
* `DEFINITION` — a qualification definition failed semantic validation
|
|
28
|
-
* `
|
|
29
|
-
*
|
|
30
|
-
* `
|
|
31
|
-
*
|
|
37
|
+
* `DEFINITION` — a qualification definition failed semantic validation; when
|
|
38
|
+
* `qualify`'s own validation raises it, `context.definition` names the
|
|
39
|
+
* definition, and when an engine throw maps to it, `context.pass` names the pass
|
|
40
|
+
* and `context.cause` preserves the original throw. `MISMATCH` — a subject is
|
|
41
|
+
* not a record or already carries the reserved `qualification` key.
|
|
42
|
+
* `DESTROYED` — use of a destroyed qualifier; an engine-mapped one carries
|
|
43
|
+
* `context.pass` and `context.cause`. `ENGINE` — the underlying reason engine
|
|
44
|
+
* threw while running a pass; `context.pass` names the pass and `context.cause`
|
|
45
|
+
* preserves the original throw. `context` is absent for a throw that carries no
|
|
46
|
+
* payload.
|
|
32
47
|
*/
|
|
33
48
|
var QualifierError = class extends Error {
|
|
34
49
|
code;
|
|
@@ -40,39 +55,66 @@ var QualifierError = class extends Error {
|
|
|
40
55
|
this.context = context;
|
|
41
56
|
}
|
|
42
57
|
};
|
|
43
|
-
/**
|
|
58
|
+
/**
|
|
59
|
+
* Narrows a caught value to a {@link QualifierError}.
|
|
60
|
+
*
|
|
61
|
+
* @param value - The caught value to test
|
|
62
|
+
* @returns True if `value` is a {@link QualifierError} instance; false otherwise
|
|
63
|
+
*/
|
|
44
64
|
function isQualifierError(value) {
|
|
45
65
|
return value instanceof QualifierError;
|
|
46
66
|
}
|
|
47
67
|
//#endregion
|
|
48
68
|
//#region src/core/validators.ts
|
|
49
|
-
/**
|
|
69
|
+
/**
|
|
70
|
+
* Determines whether a value is an {@link Eligibility} literal.
|
|
71
|
+
*
|
|
72
|
+
* @remarks
|
|
73
|
+
* The guard is total and leaves nothing unchecked: a scalar union has no open or exact
|
|
74
|
+
* axis.
|
|
75
|
+
*
|
|
76
|
+
* @param value - The value to test
|
|
77
|
+
* @returns True if `value` is one of the eligibility literals; false otherwise
|
|
78
|
+
*/
|
|
50
79
|
var isEligibility = literalOf("eligible", "ineligible", "referral");
|
|
51
|
-
/**
|
|
80
|
+
/**
|
|
81
|
+
* Determines whether a value is a {@link QualificationEffect} literal.
|
|
82
|
+
*
|
|
83
|
+
* @remarks
|
|
84
|
+
* The guard is total and leaves nothing unchecked: a scalar union has no open or exact
|
|
85
|
+
* axis.
|
|
86
|
+
*
|
|
87
|
+
* @param value - The value to test
|
|
88
|
+
* @returns True if `value` is one of the effect literals; false otherwise
|
|
89
|
+
*/
|
|
52
90
|
var isQualificationEffect = literalOf("restriction", "referral", "condition");
|
|
53
91
|
/**
|
|
54
|
-
*
|
|
92
|
+
* Determines whether a value is an open string-keyed record of {@link Eligibility} values.
|
|
55
93
|
*
|
|
56
94
|
* @remarks
|
|
57
|
-
* Every own string-named
|
|
58
|
-
* symbol-named members are left unchecked because they
|
|
95
|
+
* Every own string-named value is checked through {@link isEligibility}, including
|
|
96
|
+
* non-enumerable values. Inherited and symbol-named members are left unchecked because they
|
|
97
|
+
* are outside the own string-keyed record this guard certifies.
|
|
59
98
|
*
|
|
60
99
|
* @param value - The value to test
|
|
61
|
-
* @returns
|
|
100
|
+
* @returns True if `value` is a non-array object whose own string-named values are
|
|
101
|
+
* eligibilities; false otherwise
|
|
62
102
|
*/
|
|
63
103
|
function isEligibilityRecord(value) {
|
|
64
104
|
return whereOf(objectOf({}), (record) => Object.getOwnPropertyNames(record).every((key) => isEligibility(Reflect.get(record, key))))(value);
|
|
65
105
|
}
|
|
66
106
|
/**
|
|
67
|
-
*
|
|
107
|
+
* Determines whether a value is an open result-side {@link Premise}.
|
|
68
108
|
*
|
|
69
109
|
* @remarks
|
|
70
|
-
* `
|
|
71
|
-
*
|
|
72
|
-
*
|
|
110
|
+
* The optional `field`, `label`, `description`, `comparison`, and `met` members are checked
|
|
111
|
+
* when they are defined. `expected` is unchecked and may be absent or contain any value
|
|
112
|
+
* because its published type is `unknown`. `actual` is unchecked and may be absent or contain
|
|
113
|
+
* any value for the same reason. Unknown members are admitted, because an open result admits
|
|
114
|
+
* what the published contract does not name.
|
|
73
115
|
*
|
|
74
116
|
* @param value - The value to test
|
|
75
|
-
* @returns
|
|
117
|
+
* @returns True if every checked premise member follows its published type; false otherwise
|
|
76
118
|
*/
|
|
77
119
|
function isPremise(value) {
|
|
78
120
|
return objectOf({
|
|
@@ -90,14 +132,16 @@ function isPremise(value) {
|
|
|
90
132
|
])(value);
|
|
91
133
|
}
|
|
92
134
|
/**
|
|
93
|
-
*
|
|
135
|
+
* Determines whether a value is an open result-side {@link Finding}.
|
|
94
136
|
*
|
|
95
137
|
* @remarks
|
|
96
|
-
*
|
|
97
|
-
* `undefined`; a present defined value must be a
|
|
138
|
+
* Every published member is checked, including each nested {@link Premise}. Optional `scope`
|
|
139
|
+
* and `message` members may be absent or `undefined`; a present defined value must be a
|
|
140
|
+
* string. Unknown members are admitted, because a foreign result implementation may add them
|
|
141
|
+
* without changing the published contract.
|
|
98
142
|
*
|
|
99
143
|
* @param value - The value to test
|
|
100
|
-
* @returns
|
|
144
|
+
* @returns True if every published finding member is valid; false otherwise
|
|
101
145
|
*/
|
|
102
146
|
function isFinding(value) {
|
|
103
147
|
return objectOf({
|
|
@@ -112,14 +156,16 @@ function isFinding(value) {
|
|
|
112
156
|
}, ["scope", "message"])(value);
|
|
113
157
|
}
|
|
114
158
|
/**
|
|
115
|
-
*
|
|
159
|
+
* Determines whether a value is an open result-side {@link Derivation}.
|
|
116
160
|
*
|
|
117
161
|
* @remarks
|
|
118
|
-
*
|
|
119
|
-
* infinities.
|
|
162
|
+
* Every published member is checked: `value` follows the published `number` type, including
|
|
163
|
+
* `NaN` and the infinities, and `trace` and `errors` hold strings. Unknown members are
|
|
164
|
+
* admitted, because a foreign result implementation may add them without changing the
|
|
165
|
+
* published contract.
|
|
120
166
|
*
|
|
121
167
|
* @param value - The value to test
|
|
122
|
-
* @returns
|
|
168
|
+
* @returns True if every published derivation member is valid; false otherwise
|
|
123
169
|
*/
|
|
124
170
|
function isDerivation(value) {
|
|
125
171
|
return objectOf({
|
|
@@ -131,14 +177,17 @@ function isDerivation(value) {
|
|
|
131
177
|
})(value);
|
|
132
178
|
}
|
|
133
179
|
/**
|
|
134
|
-
*
|
|
180
|
+
* Determines whether a value is an open {@link QualificationResult} returned by a qualifier.
|
|
135
181
|
*
|
|
136
182
|
* @remarks
|
|
137
183
|
* This guard is result-postured for values returned through a borrowed qualifier interface. It
|
|
138
|
-
*
|
|
184
|
+
* checks the complete published result closure — eligibility, scope values, findings,
|
|
185
|
+
* derivations, success, and the audit trail — and admits unknown members and class instances,
|
|
186
|
+
* because a borrowed `QualifierInterface` implementation may add them beyond the
|
|
187
|
+
* published contract.
|
|
139
188
|
*
|
|
140
189
|
* @param value - The value to test
|
|
141
|
-
* @returns
|
|
190
|
+
* @returns True if every published qualification-result member is valid; false otherwise
|
|
142
191
|
*/
|
|
143
192
|
function isQualificationResult(value) {
|
|
144
193
|
return objectOf({
|
|
@@ -154,23 +203,29 @@ function isQualificationResult(value) {
|
|
|
154
203
|
})(value);
|
|
155
204
|
}
|
|
156
205
|
/**
|
|
157
|
-
*
|
|
206
|
+
* Determines whether a value is a {@link QualificationPass} (a quantitative or logical definition).
|
|
158
207
|
*
|
|
159
208
|
* @remarks
|
|
160
|
-
*
|
|
161
|
-
*
|
|
162
|
-
*
|
|
163
|
-
* later reason release adds.
|
|
209
|
+
* The guard is exact and requires either complete reason shape, the quantitative definition or
|
|
210
|
+
* the logical one. Nothing is left unchecked, because the reason package owns both authored
|
|
211
|
+
* input shapes.
|
|
164
212
|
*
|
|
165
213
|
* @param value - The value to test
|
|
166
|
-
* @returns `
|
|
214
|
+
* @returns True if `value` is a complete reason quantitative or logical definition; false otherwise
|
|
167
215
|
*/
|
|
168
|
-
var isQualificationValidationResult = isReasonValidationResult;
|
|
169
|
-
/** Determine whether a value is a {@link QualificationPass} (a quantitative or logical definition). */
|
|
170
216
|
function isQualificationPass(value) {
|
|
171
217
|
return isQuantitativeDefinition(value) || isLogicalDefinition(value);
|
|
172
218
|
}
|
|
173
|
-
/**
|
|
219
|
+
/**
|
|
220
|
+
* Determines whether a value is an exact {@link Ruling} record.
|
|
221
|
+
*
|
|
222
|
+
* @remarks
|
|
223
|
+
* Every authored ruling member and the complete key set are checked. Nothing is left
|
|
224
|
+
* unchecked, because this package owns the authored input record.
|
|
225
|
+
*
|
|
226
|
+
* @param value - The value to test
|
|
227
|
+
* @returns True if `value` carries every ruling member and no unknown key; false otherwise
|
|
228
|
+
*/
|
|
174
229
|
function isRuling(value) {
|
|
175
230
|
return recordOf({
|
|
176
231
|
id: isString,
|
|
@@ -181,7 +236,17 @@ function isRuling(value) {
|
|
|
181
236
|
message: isString
|
|
182
237
|
}, ["scope", "message"])(value);
|
|
183
238
|
}
|
|
184
|
-
/**
|
|
239
|
+
/**
|
|
240
|
+
* Determines whether a value is an exact {@link QualificationDefinition} record.
|
|
241
|
+
*
|
|
242
|
+
* @remarks
|
|
243
|
+
* Every authored definition member, the nested pass and ruling shapes, and the complete key
|
|
244
|
+
* set are checked. Nothing is left unchecked, because this package owns the authored input
|
|
245
|
+
* record.
|
|
246
|
+
*
|
|
247
|
+
* @param value - The value to test
|
|
248
|
+
* @returns True if `value` carries every definition member and no unknown key; false otherwise
|
|
249
|
+
*/
|
|
185
250
|
function isQualificationDefinition(value) {
|
|
186
251
|
return recordOf({
|
|
187
252
|
id: isString,
|
|
@@ -199,14 +264,14 @@ function isQualificationDefinition(value) {
|
|
|
199
264
|
//#endregion
|
|
200
265
|
//#region src/core/helpers.ts
|
|
201
266
|
/**
|
|
202
|
-
*
|
|
267
|
+
* Interpolates `{{dotted.path}}` tokens in a message template against a subject.
|
|
203
268
|
*
|
|
204
269
|
* @remarks
|
|
205
270
|
* Each token is split on `.` into a `FieldPath` array and resolved with
|
|
206
|
-
* `resolveField` (a plain string field is
|
|
271
|
+
* `resolveField` (a plain string field is one key, never dot-split — the split
|
|
207
272
|
* here is the token-to-path bridge). A finite number renders with `en-US`
|
|
208
273
|
* thousand grouping (`5010` → `5,010`); any other resolved value String-coerces.
|
|
209
|
-
* An
|
|
274
|
+
* An unresolved path renders as the empty string.
|
|
210
275
|
*
|
|
211
276
|
* @param template - The message template carrying `{{dotted.path}}` tokens
|
|
212
277
|
* @param subject - The record tokens resolve against
|
|
@@ -229,19 +294,19 @@ function interpolateMessage(template, subject) {
|
|
|
229
294
|
});
|
|
230
295
|
}
|
|
231
296
|
/**
|
|
232
|
-
*
|
|
297
|
+
* Renders a {@link Premise} comparison as a display-neutral verb phrase.
|
|
233
298
|
*
|
|
234
299
|
* @param comparison - The comparison to describe
|
|
235
300
|
* @returns A display-neutral phrase
|
|
236
301
|
*
|
|
237
302
|
* @example
|
|
238
303
|
* ```ts
|
|
239
|
-
* import {
|
|
304
|
+
* import { renderComparison } from '@orkestrel/qualifier'
|
|
240
305
|
*
|
|
241
|
-
*
|
|
306
|
+
* renderComparison('above') // 'is more than'
|
|
242
307
|
* ```
|
|
243
308
|
*/
|
|
244
|
-
function
|
|
309
|
+
function renderComparison(comparison) {
|
|
245
310
|
switch (comparison) {
|
|
246
311
|
case "equals": return "is";
|
|
247
312
|
case "not": return "is not";
|
|
@@ -256,14 +321,14 @@ function describeComparison(comparison) {
|
|
|
256
321
|
}
|
|
257
322
|
}
|
|
258
323
|
/**
|
|
259
|
-
*
|
|
324
|
+
* Renders a structured or scalar expected value display-neutrally.
|
|
260
325
|
*
|
|
261
326
|
* @remarks
|
|
262
327
|
* An array renders as its elements, each `String`-coerced, joined with `', '`.
|
|
263
328
|
* A reason `Bounds`-shaped record (the `between` / `outside` expected value)
|
|
264
329
|
* renders its present sides joined with the word `and`; an absent side is
|
|
265
330
|
* omitted. A finite number and everything else fall back to plain `String()` —
|
|
266
|
-
* a finite number is
|
|
331
|
+
* a finite number is not grouped here (grouping is {@link interpolateMessage}'s
|
|
267
332
|
* message-token-only behavior).
|
|
268
333
|
*
|
|
269
334
|
* @param value - The value to render
|
|
@@ -271,14 +336,14 @@ function describeComparison(comparison) {
|
|
|
271
336
|
*
|
|
272
337
|
* @example
|
|
273
338
|
* ```ts
|
|
274
|
-
* import {
|
|
339
|
+
* import { renderValue } from '@orkestrel/qualifier'
|
|
275
340
|
*
|
|
276
|
-
*
|
|
277
|
-
*
|
|
278
|
-
*
|
|
341
|
+
* renderValue([18, 25, 40]) // '18, 25, 40'
|
|
342
|
+
* renderValue({ minimum: 18, maximum: 65 }) // '18 and 65'
|
|
343
|
+
* renderValue(42) // '42'
|
|
279
344
|
* ```
|
|
280
345
|
*/
|
|
281
|
-
function
|
|
346
|
+
function renderValue(value) {
|
|
282
347
|
if (Array.isArray(value)) return value.map((entry) => String(entry)).join(", ");
|
|
283
348
|
if (isBounds(value)) {
|
|
284
349
|
const sides = [];
|
|
@@ -289,10 +354,10 @@ function describeValue(value) {
|
|
|
289
354
|
return String(value);
|
|
290
355
|
}
|
|
291
356
|
/**
|
|
292
|
-
*
|
|
357
|
+
* Renders one {@link Premise} into a display-neutral sentence.
|
|
293
358
|
*
|
|
294
359
|
* @remarks
|
|
295
|
-
* The checked form renders only when `field` and `comparison` are
|
|
360
|
+
* The checked form renders only when `field` and `comparison` are both
|
|
296
361
|
* present, and `description` then goes unused; a premise missing either half
|
|
297
362
|
* of the checked pair renders as described instead.
|
|
298
363
|
*
|
|
@@ -302,25 +367,25 @@ function describeValue(value) {
|
|
|
302
367
|
*
|
|
303
368
|
* @example
|
|
304
369
|
* ```ts
|
|
305
|
-
* import {
|
|
370
|
+
* import { renderPremise } from '@orkestrel/qualifier'
|
|
306
371
|
*
|
|
307
|
-
*
|
|
372
|
+
* renderPremise({ field: 'age', comparison: 'above', expected: 18, actual: 25, met: true })
|
|
308
373
|
* // 'age is more than 18 → met'
|
|
309
374
|
*
|
|
310
|
-
*
|
|
375
|
+
* renderPremise({ description: 'Applicant is enrolled', met: true })
|
|
311
376
|
* // 'Applicant is enrolled → met'
|
|
312
377
|
* ```
|
|
313
378
|
*/
|
|
314
|
-
function
|
|
379
|
+
function renderPremise(entry, labels) {
|
|
315
380
|
const status = entry.met === void 0 ? "unknown" : entry.met ? "met" : "not met";
|
|
316
381
|
if (entry.field === void 0 || entry.comparison === void 0) return `${entry.description ?? "Premise"} → ${status}`;
|
|
317
382
|
const field = formatField(entry.field);
|
|
318
383
|
const label = labels?.[field] ?? entry.label ?? field;
|
|
319
|
-
const expected = entry.expected === void 0 ? "" : ` ${
|
|
320
|
-
return `${label} ${
|
|
384
|
+
const expected = entry.expected === void 0 ? "" : ` ${renderValue(entry.expected)}`;
|
|
385
|
+
return `${label} ${renderComparison(entry.comparison)}${expected} → ${status}`;
|
|
321
386
|
}
|
|
322
387
|
/**
|
|
323
|
-
*
|
|
388
|
+
* Builds a {@link Premise} from an authored {@link Check} and its evaluated
|
|
324
389
|
* `CheckResult`.
|
|
325
390
|
*
|
|
326
391
|
* @param check - The authored check
|
|
@@ -330,15 +395,15 @@ function describePremise(entry, labels) {
|
|
|
330
395
|
*
|
|
331
396
|
* @example
|
|
332
397
|
* ```ts
|
|
333
|
-
* import {
|
|
334
|
-
* import {
|
|
398
|
+
* import { checkToPremise } from '@orkestrel/qualifier'
|
|
399
|
+
* import { createCheck } from '@orkestrel/reason'
|
|
335
400
|
*
|
|
336
|
-
* const authored =
|
|
337
|
-
*
|
|
401
|
+
* const authored = createCheck('age', 'above', 18)
|
|
402
|
+
* checkToPremise(authored, { field: 'age', actual: 25, met: true })
|
|
338
403
|
* // { field: 'age', comparison: 'above', expected: 18, actual: 25, met: true }
|
|
339
404
|
* ```
|
|
340
405
|
*/
|
|
341
|
-
function
|
|
406
|
+
function checkToPremise(check, result, labels) {
|
|
342
407
|
const field = formatField(check.field);
|
|
343
408
|
return {
|
|
344
409
|
field: check.field,
|
|
@@ -350,14 +415,14 @@ function premiseCheck(check, result, labels) {
|
|
|
350
415
|
};
|
|
351
416
|
}
|
|
352
417
|
/**
|
|
353
|
-
*
|
|
418
|
+
* Builds rich premises for one fired {@link Rule} by walking its premise atoms
|
|
354
419
|
* and re-evaluating each against the working subject.
|
|
355
420
|
*
|
|
356
421
|
* @remarks
|
|
357
422
|
* A reason rule result carries only booleans, so this is the qualifier's own
|
|
358
423
|
* premise-audit projection: each authored premise expression is flattened to its
|
|
359
|
-
* atom leaves
|
|
360
|
-
* the injected `evaluator`. A membership check (`any` / `none`) over an
|
|
424
|
+
* atom leaves through `extractAtoms`, and each leaf's `Check` is re-evaluated through
|
|
425
|
+
* the injected `evaluator`. A membership check (`any` / `none`) over an empty
|
|
361
426
|
* array value is content-free and is skipped.
|
|
362
427
|
*
|
|
363
428
|
* @param rule - The authored rule
|
|
@@ -368,29 +433,29 @@ function premiseCheck(check, result, labels) {
|
|
|
368
433
|
*
|
|
369
434
|
* @example
|
|
370
435
|
* ```ts
|
|
371
|
-
* import {
|
|
372
|
-
* import {
|
|
436
|
+
* import { ruleToPremises } from '@orkestrel/qualifier'
|
|
437
|
+
* import { createAtom, createEvaluator, createLogicalDefinition, createRule } from '@orkestrel/reason'
|
|
373
438
|
*
|
|
374
|
-
* const gates =
|
|
375
|
-
*
|
|
439
|
+
* const gates = createLogicalDefinition('gates', 'Gates', [
|
|
440
|
+
* createRule('licensed', [createAtom('licensed', 'equals', false)], createAtom('blocked', 'equals', true)),
|
|
376
441
|
* ])
|
|
377
442
|
* const evaluator = createEvaluator()
|
|
378
|
-
* const premises =
|
|
443
|
+
* const premises = ruleToPremises(gates.rules[0], { licensed: false }, evaluator)
|
|
379
444
|
*
|
|
380
445
|
* premises[0]?.met // true
|
|
381
446
|
* ```
|
|
382
447
|
*/
|
|
383
|
-
function
|
|
448
|
+
function ruleToPremises(rule, working, evaluator, labels) {
|
|
384
449
|
const output = [];
|
|
385
450
|
for (const premise of rule.premises) for (const atom of extractAtoms(premise)) {
|
|
386
451
|
const { check } = atom;
|
|
387
452
|
if ((check.operator === "any" || check.operator === "none") && Array.isArray(check.value) && check.value.length === 0) continue;
|
|
388
|
-
output.push(
|
|
453
|
+
output.push(checkToPremise(check, evaluator.evaluate(check, working), labels));
|
|
389
454
|
}
|
|
390
455
|
return output;
|
|
391
456
|
}
|
|
392
457
|
/**
|
|
393
|
-
*
|
|
458
|
+
* Locates an authored {@link Rule} by id.
|
|
394
459
|
*
|
|
395
460
|
* @param definition - The logical definition to search
|
|
396
461
|
* @param id - The rule id
|
|
@@ -399,10 +464,10 @@ function logicalPremises(rule, working, evaluator, labels) {
|
|
|
399
464
|
* @example
|
|
400
465
|
* ```ts
|
|
401
466
|
* import { findRule } from '@orkestrel/qualifier'
|
|
402
|
-
* import {
|
|
467
|
+
* import { createAtom, createLogicalDefinition, createRule } from '@orkestrel/reason'
|
|
403
468
|
*
|
|
404
|
-
* const gates =
|
|
405
|
-
*
|
|
469
|
+
* const gates = createLogicalDefinition('gates', 'Gates', [
|
|
470
|
+
* createRule('licensed', [createAtom('licensed', 'equals', false)], createAtom('blocked', 'equals', true)),
|
|
406
471
|
* ])
|
|
407
472
|
*
|
|
408
473
|
* findRule(gates, 'licensed')?.id // 'licensed'
|
|
@@ -412,7 +477,7 @@ function findRule(definition, id) {
|
|
|
412
477
|
return definition.rules.find((rule) => rule.id === id);
|
|
413
478
|
}
|
|
414
479
|
/**
|
|
415
|
-
*
|
|
480
|
+
* Projects one reason result into the internal qualification namespace.
|
|
416
481
|
*
|
|
417
482
|
* @remarks
|
|
418
483
|
* A quantitative result contributes its numeric `value`. A logical result
|
|
@@ -427,10 +492,10 @@ function findRule(definition, id) {
|
|
|
427
492
|
* @example
|
|
428
493
|
* ```ts
|
|
429
494
|
* import { reasonResultToProjection } from '@orkestrel/qualifier'
|
|
430
|
-
* import {
|
|
495
|
+
* import { createFactorGroup, createQuantitativeDefinition, createStaticFactor } from '@orkestrel/reason'
|
|
431
496
|
*
|
|
432
|
-
* const cap =
|
|
433
|
-
*
|
|
497
|
+
* const cap = createQuantitativeDefinition('cap', 'TIV cap', [
|
|
498
|
+
* createFactorGroup('limit', 'sum', [createStaticFactor('base', 500_000)]),
|
|
434
499
|
* ])
|
|
435
500
|
* const result = {
|
|
436
501
|
* reasoning: 'quantitative' as const,
|
|
@@ -458,7 +523,7 @@ function reasonResultToProjection(definition, result) {
|
|
|
458
523
|
return projection;
|
|
459
524
|
}
|
|
460
525
|
/**
|
|
461
|
-
*
|
|
526
|
+
* Projects a quantitative result into a {@link Derivation} audit record.
|
|
462
527
|
*
|
|
463
528
|
* @param id - The pass id
|
|
464
529
|
* @param result - The evaluated quantitative result
|
|
@@ -483,7 +548,7 @@ function quantitativeResultToDerivation(id, result) {
|
|
|
483
548
|
};
|
|
484
549
|
}
|
|
485
550
|
/**
|
|
486
|
-
*
|
|
551
|
+
* Wraps a {@link QualificationContext} under {@link QUALIFICATION_KEY}.
|
|
487
552
|
*
|
|
488
553
|
* @param context - The accumulated projection context
|
|
489
554
|
* @returns A record carrying the context under the reserved key
|
|
@@ -499,7 +564,7 @@ function qualificationToRecord(context) {
|
|
|
499
564
|
return { [QUALIFICATION_KEY]: context };
|
|
500
565
|
}
|
|
501
566
|
/**
|
|
502
|
-
*
|
|
567
|
+
* Merges one pass projection into the context, copy-on-write.
|
|
503
568
|
*
|
|
504
569
|
* @param context - The current projection context
|
|
505
570
|
* @param id - The pass id to project under
|
|
@@ -521,12 +586,12 @@ function mergeQualificationContext(context, id, projection) {
|
|
|
521
586
|
};
|
|
522
587
|
}
|
|
523
588
|
/**
|
|
524
|
-
*
|
|
589
|
+
* Joins a ruling, its logical rule result, the pass, the pre-projection subject,
|
|
525
590
|
* and an evaluator into a {@link Finding}.
|
|
526
591
|
*
|
|
527
592
|
* @remarks
|
|
528
593
|
* `applied` reflects whether the ruling's rule fired. Premises are re-evaluated
|
|
529
|
-
* against the
|
|
594
|
+
* against the same subject snapshot the pass evaluated, so a conclusion the pass
|
|
530
595
|
* derived can never satisfy its own reported premise. The message interpolates
|
|
531
596
|
* against that snapshot.
|
|
532
597
|
*
|
|
@@ -541,22 +606,22 @@ function mergeQualificationContext(context, id, projection) {
|
|
|
541
606
|
* @example
|
|
542
607
|
* ```ts
|
|
543
608
|
* import { rulingToFinding } from '@orkestrel/qualifier'
|
|
544
|
-
* import {
|
|
609
|
+
* import { createAtom, createEvaluator, createLogicalDefinition, createRule } from '@orkestrel/reason'
|
|
545
610
|
*
|
|
546
|
-
* const gates =
|
|
547
|
-
*
|
|
611
|
+
* const gates = createLogicalDefinition('gates', 'Gates', [
|
|
612
|
+
* createRule('licensed', [createAtom('licensed', 'equals', false)], createAtom('blocked', 'equals', true)),
|
|
548
613
|
* ])
|
|
549
614
|
* const ruling = { id: 'license', pass: 'gates', rule: 'licensed', effect: 'restriction' as const }
|
|
550
|
-
* const result = { reasoning: 'logical' as const, conclusion: true, rules: [{ id: 'licensed', applied: true, premises: [true]
|
|
615
|
+
* const result = { reasoning: 'logical' as const, conclusion: true, rules: [{ id: 'licensed', applied: true, premises: [true] }], count: 1, success: true, trace: [], errors: [] }
|
|
551
616
|
* const finding = rulingToFinding(ruling, gates, result, { licensed: false }, createEvaluator())
|
|
552
617
|
*
|
|
553
618
|
* finding.applied // true
|
|
554
619
|
* ```
|
|
555
620
|
*/
|
|
556
621
|
function rulingToFinding(ruling, pass, result, subject, evaluator, labels) {
|
|
557
|
-
const applied = result.rules.find((
|
|
622
|
+
const applied = result.rules.find((resolved) => resolved.id === ruling.rule)?.applied ?? false;
|
|
558
623
|
const rule = pass.reasoning === "logical" ? findRule(pass, ruling.rule) : void 0;
|
|
559
|
-
const premises = rule === void 0 ? [] :
|
|
624
|
+
const premises = rule === void 0 ? [] : ruleToPremises(rule, subject, evaluator, labels);
|
|
560
625
|
return {
|
|
561
626
|
id: ruling.id,
|
|
562
627
|
pass: ruling.pass,
|
|
@@ -569,7 +634,7 @@ function rulingToFinding(ruling, pass, result, subject, evaluator, labels) {
|
|
|
569
634
|
};
|
|
570
635
|
}
|
|
571
636
|
/**
|
|
572
|
-
*
|
|
637
|
+
* Derives global eligibility from applied, unscoped findings.
|
|
573
638
|
*
|
|
574
639
|
* @remarks
|
|
575
640
|
* Only findings that both applied and carry no `scope` shape global eligibility.
|
|
@@ -577,7 +642,8 @@ function rulingToFinding(ruling, pass, result, subject, evaluator, labels) {
|
|
|
577
642
|
* `failed`.
|
|
578
643
|
*
|
|
579
644
|
* @param findings - The resolved findings
|
|
580
|
-
* @param failed -
|
|
645
|
+
* @param failed - If `true`, adds a synthetic `referral` impact so an operational failure
|
|
646
|
+
* fails closed; if `false`, derives eligibility from the findings alone. Default: `false`
|
|
581
647
|
* @returns The most severe global eligibility
|
|
582
648
|
*
|
|
583
649
|
* @example
|
|
@@ -594,7 +660,7 @@ function deriveFindingEligibility(findings, failed = false) {
|
|
|
594
660
|
return combineEligibilities(eligibilities);
|
|
595
661
|
}
|
|
596
662
|
/**
|
|
597
|
-
*
|
|
663
|
+
* Returns the most severe {@link Eligibility} in a list.
|
|
598
664
|
*
|
|
599
665
|
* @param eligibilities - The eligibilities to combine
|
|
600
666
|
* @returns The most severe eligibility, or `'eligible'` for an empty list
|
|
@@ -611,7 +677,7 @@ function combineEligibilities(eligibilities) {
|
|
|
611
677
|
return "eligible";
|
|
612
678
|
}
|
|
613
679
|
/**
|
|
614
|
-
*
|
|
680
|
+
* Derives one eligibility per finding scope.
|
|
615
681
|
*
|
|
616
682
|
* @remarks
|
|
617
683
|
* Only applied, scoped findings contribute. Each named scope combines its own
|
|
@@ -641,29 +707,29 @@ function deriveScopeEligibilities(findings) {
|
|
|
641
707
|
return scopes;
|
|
642
708
|
}
|
|
643
709
|
/**
|
|
644
|
-
*
|
|
645
|
-
*
|
|
710
|
+
* Describes each ruling whose pass or rule does not exist or whose pass is not
|
|
711
|
+
* logical, and each pass id shadowing the reserved {@link QUALIFICATION_KEY}.
|
|
646
712
|
*
|
|
647
713
|
* @param definition - The qualification definition to check
|
|
648
714
|
* @returns A fresh list of reference-error messages
|
|
649
715
|
*
|
|
650
716
|
* @example
|
|
651
717
|
* ```ts
|
|
652
|
-
* import {
|
|
653
|
-
* import {
|
|
718
|
+
* import { createQualificationDefinition, createRuling, describeMissingReferences } from '@orkestrel/qualifier'
|
|
719
|
+
* import { createAtom, createLogicalDefinition, createRule } from '@orkestrel/reason'
|
|
654
720
|
*
|
|
655
|
-
* const gates =
|
|
656
|
-
*
|
|
721
|
+
* const gates = createLogicalDefinition('gates', 'Gates', [
|
|
722
|
+
* createRule('licensed', [createAtom('licensed', 'equals', false)], createAtom('blocked', 'equals', true)),
|
|
657
723
|
* ])
|
|
658
|
-
* const definition =
|
|
659
|
-
* rulings: [
|
|
724
|
+
* const definition = createQualificationDefinition('standard', 'Standard', [gates], {
|
|
725
|
+
* rulings: [createRuling('license', 'gates', 'absent', 'restriction')],
|
|
660
726
|
* })
|
|
661
727
|
*
|
|
662
|
-
*
|
|
728
|
+
* describeMissingReferences(definition)
|
|
663
729
|
* // ["Ruling 'license' references missing rule 'absent' in pass 'gates'"]
|
|
664
730
|
* ```
|
|
665
731
|
*/
|
|
666
|
-
function
|
|
732
|
+
function describeMissingReferences(definition) {
|
|
667
733
|
const errors = [];
|
|
668
734
|
const passes = new Map(definition.passes.map((pass) => [pass.id, pass]));
|
|
669
735
|
for (const pass of definition.passes) if (pass.id === "qualification") errors.push(`Pass id must not equal reserved key '${QUALIFICATION_KEY}'`);
|
|
@@ -682,10 +748,10 @@ function findMissingReferences(definition) {
|
|
|
682
748
|
return errors;
|
|
683
749
|
}
|
|
684
750
|
/**
|
|
685
|
-
*
|
|
751
|
+
* Determines whether a subject already owns the reserved {@link QUALIFICATION_KEY}.
|
|
686
752
|
*
|
|
687
753
|
* @param subject - The subject to check
|
|
688
|
-
* @returns
|
|
754
|
+
* @returns True if the subject owns the reserved key; false otherwise
|
|
689
755
|
*
|
|
690
756
|
* @example
|
|
691
757
|
* ```ts
|
|
@@ -698,7 +764,7 @@ function hasReservedKey(subject) {
|
|
|
698
764
|
return Object.hasOwn(subject, QUALIFICATION_KEY);
|
|
699
765
|
}
|
|
700
766
|
/**
|
|
701
|
-
*
|
|
767
|
+
* Asserts a value is a valid qualification {@link Subject}, narrowing it in place.
|
|
702
768
|
*
|
|
703
769
|
* @param value - The candidate subject to validate
|
|
704
770
|
* @throws {@link QualifierError} `'MISMATCH'` when the value is not a record, or
|
|
@@ -716,12 +782,12 @@ function assertSubject(value) {
|
|
|
716
782
|
if (hasReservedKey(value)) throw new QualifierError("MISMATCH", `Qualification subject must not contain reserved key '${QUALIFICATION_KEY}'`);
|
|
717
783
|
}
|
|
718
784
|
/**
|
|
719
|
-
*
|
|
785
|
+
* Maps an engine throw caught while running one pass to a typed {@link QualifierError}.
|
|
720
786
|
*
|
|
721
787
|
* @remarks
|
|
722
788
|
* A reason `ReasonError('INVALID')` maps to `DEFINITION` (the engine rejected the
|
|
723
789
|
* pass as an invalid definition); `ReasonError('DESTROYED')` maps to `DESTROYED`;
|
|
724
|
-
* every other `ReasonError` code (
|
|
790
|
+
* every other `ReasonError` code (for example `MISSING`) and every non-`ReasonError` throw
|
|
725
791
|
* (including a `bail: true` reasoner rethrow) maps to `ENGINE`. The original throw
|
|
726
792
|
* is preserved as `context.cause`.
|
|
727
793
|
*
|
|
@@ -758,7 +824,7 @@ function mapEngineError(error, pass) {
|
|
|
758
824
|
});
|
|
759
825
|
}
|
|
760
826
|
/**
|
|
761
|
-
*
|
|
827
|
+
* Describes each logical pass carrying no rulings.
|
|
762
828
|
*
|
|
763
829
|
* @remarks
|
|
764
830
|
* A logical pass with no matching ruling can never contribute a finding, so its
|
|
@@ -770,18 +836,18 @@ function mapEngineError(error, pass) {
|
|
|
770
836
|
*
|
|
771
837
|
* @example
|
|
772
838
|
* ```ts
|
|
773
|
-
* import {
|
|
774
|
-
* import {
|
|
839
|
+
* import { createQualificationDefinition, describeEmptyLogicalPasses } from '@orkestrel/qualifier'
|
|
840
|
+
* import { createAtom, createLogicalDefinition, createRule } from '@orkestrel/reason'
|
|
775
841
|
*
|
|
776
|
-
* const gates =
|
|
777
|
-
*
|
|
842
|
+
* const gates = createLogicalDefinition('gates', 'Gates', [
|
|
843
|
+
* createRule('licensed', [createAtom('licensed', 'equals', false)], createAtom('blocked', 'equals', true)),
|
|
778
844
|
* ])
|
|
779
|
-
* const definition =
|
|
845
|
+
* const definition = createQualificationDefinition('standard', 'Standard', [gates])
|
|
780
846
|
*
|
|
781
|
-
*
|
|
847
|
+
* describeEmptyLogicalPasses(definition) // ["Logical pass 'gates' has no rulings"]
|
|
782
848
|
* ```
|
|
783
849
|
*/
|
|
784
|
-
function
|
|
850
|
+
function describeEmptyLogicalPasses(definition) {
|
|
785
851
|
const warnings = [];
|
|
786
852
|
for (const pass of definition.passes) {
|
|
787
853
|
if (pass.reasoning !== "logical") continue;
|
|
@@ -790,7 +856,7 @@ function findEmptyLogicalPasses(definition) {
|
|
|
790
856
|
return warnings;
|
|
791
857
|
}
|
|
792
858
|
/**
|
|
793
|
-
*
|
|
859
|
+
* Describes each quantitative pass never read by a later pass.
|
|
794
860
|
*
|
|
795
861
|
* @remarks
|
|
796
862
|
* A quantitative pass's projected value lives under `qualification.<id>` — if no
|
|
@@ -804,18 +870,18 @@ function findEmptyLogicalPasses(definition) {
|
|
|
804
870
|
*
|
|
805
871
|
* @example
|
|
806
872
|
* ```ts
|
|
807
|
-
* import {
|
|
808
|
-
* import {
|
|
873
|
+
* import { createQualificationDefinition, describeUnreadDerivations } from '@orkestrel/qualifier'
|
|
874
|
+
* import { createFactorGroup, createQuantitativeDefinition, createStaticFactor } from '@orkestrel/reason'
|
|
809
875
|
*
|
|
810
|
-
* const cap =
|
|
811
|
-
*
|
|
876
|
+
* const cap = createQuantitativeDefinition('cap', 'TIV cap', [
|
|
877
|
+
* createFactorGroup('limit', 'sum', [createStaticFactor('base', 500_000)]),
|
|
812
878
|
* ])
|
|
813
|
-
* const definition =
|
|
879
|
+
* const definition = createQualificationDefinition('standard', 'Standard', [cap])
|
|
814
880
|
*
|
|
815
|
-
*
|
|
881
|
+
* describeUnreadDerivations(definition) // ["Quantitative pass 'cap' is never read by a later pass"]
|
|
816
882
|
* ```
|
|
817
883
|
*/
|
|
818
|
-
function
|
|
884
|
+
function describeUnreadDerivations(definition) {
|
|
819
885
|
const warnings = [];
|
|
820
886
|
definition.passes.forEach((pass, index) => {
|
|
821
887
|
if (pass.reasoning !== "quantitative") return;
|
|
@@ -832,73 +898,15 @@ function findUnreadDerivations(definition) {
|
|
|
832
898
|
});
|
|
833
899
|
return warnings;
|
|
834
900
|
}
|
|
835
|
-
/**
|
|
836
|
-
* Build a {@link QualificationDefinition}.
|
|
837
|
-
*
|
|
838
|
-
* @remarks
|
|
839
|
-
* Returns a fresh top-level definition, omitting absent optional keys;
|
|
840
|
-
* `passes` and `rulings` arrays are copied, and record `metadata` is
|
|
841
|
-
* shallow-copied so nested values are not deep-cloned.
|
|
842
|
-
*
|
|
843
|
-
* @param id - The definition id
|
|
844
|
-
* @param name - The display name
|
|
845
|
-
* @param passes - The ordered qualification passes
|
|
846
|
-
* @param input - Optional description, rulings, and metadata
|
|
847
|
-
* @returns A fresh qualification definition
|
|
848
|
-
*
|
|
849
|
-
* @example
|
|
850
|
-
* ```ts
|
|
851
|
-
* import { qualificationDefinition } from '@orkestrel/qualifier'
|
|
852
|
-
*
|
|
853
|
-
* qualificationDefinition('standard', 'Standard', [gates], { rulings: [ruling] })
|
|
854
|
-
* ```
|
|
855
|
-
*/
|
|
856
|
-
function qualificationDefinition(id, name, passes, input) {
|
|
857
|
-
return {
|
|
858
|
-
id,
|
|
859
|
-
name,
|
|
860
|
-
passes: [...passes],
|
|
861
|
-
...input?.description === void 0 ? {} : { description: input.description },
|
|
862
|
-
...input?.rulings === void 0 ? {} : { rulings: [...input.rulings] },
|
|
863
|
-
...input?.metadata === void 0 ? {} : { metadata: { ...input.metadata } }
|
|
864
|
-
};
|
|
865
|
-
}
|
|
866
|
-
/**
|
|
867
|
-
* Build a {@link Ruling} — one authored consequence for one rule in one pass.
|
|
868
|
-
*
|
|
869
|
-
* @param id - The ruling id
|
|
870
|
-
* @param pass - The logical pass id the rule lives in
|
|
871
|
-
* @param rule - The rule id whose firing this ruling reacts to
|
|
872
|
-
* @param effect - The eligibility impact when the rule fires
|
|
873
|
-
* @param input - Optional selection scope and message template
|
|
874
|
-
* @returns A fresh ruling
|
|
875
|
-
*
|
|
876
|
-
* @example
|
|
877
|
-
* ```ts
|
|
878
|
-
* import { rulingDefinition } from '@orkestrel/qualifier'
|
|
879
|
-
*
|
|
880
|
-
* rulingDefinition('license', 'gates', 'licensed', 'restriction', { message: 'A license is required' })
|
|
881
|
-
* ```
|
|
882
|
-
*/
|
|
883
|
-
function rulingDefinition(id, pass, rule, effect, input) {
|
|
884
|
-
return {
|
|
885
|
-
id,
|
|
886
|
-
pass,
|
|
887
|
-
rule,
|
|
888
|
-
effect,
|
|
889
|
-
...input?.scope === void 0 ? {} : { scope: input.scope },
|
|
890
|
-
...input?.message === void 0 ? {} : { message: input.message }
|
|
891
|
-
};
|
|
892
|
-
}
|
|
893
901
|
//#endregion
|
|
894
902
|
//#region src/core/Qualifier.ts
|
|
895
903
|
/**
|
|
896
|
-
*
|
|
897
|
-
* eligibility.
|
|
904
|
+
* Runs ordered passes over one reason engine and returns eligibility.
|
|
898
905
|
*
|
|
899
906
|
* @remarks
|
|
900
|
-
* The engine is
|
|
901
|
-
* when injected (never destroyed).
|
|
907
|
+
* The engine is owned when self-created (destroyed on `destroy()`) and borrowed
|
|
908
|
+
* when injected (never destroyed). Semantic validation runs before the first pass
|
|
909
|
+
* whenever the `validate` option is on. `qualify` builds its working subject through
|
|
902
910
|
* copy-on-write overlays only — the caller's `subject` is never mutated, and the
|
|
903
911
|
* qualifier's internal projection is discarded after each call.
|
|
904
912
|
*/
|
|
@@ -924,17 +932,92 @@ var Qualifier = class {
|
|
|
924
932
|
this.#validate = options?.validate ?? true;
|
|
925
933
|
this.#labels = options?.labels;
|
|
926
934
|
}
|
|
935
|
+
/**
|
|
936
|
+
* Holds the typed observation surface carrying `derive`, `finding`, `qualify`, and
|
|
937
|
+
* `destroy`.
|
|
938
|
+
*
|
|
939
|
+
* @returns The emitter this qualifier owns
|
|
940
|
+
*
|
|
941
|
+
* @example
|
|
942
|
+
* ```ts
|
|
943
|
+
* import { Qualifier } from '@orkestrel/qualifier'
|
|
944
|
+
*
|
|
945
|
+
* const qualifier = new Qualifier()
|
|
946
|
+
* qualifier.emitter.on('qualify', (result) => console.log(result.eligibility))
|
|
947
|
+
* qualifier.destroy()
|
|
948
|
+
* ```
|
|
949
|
+
*/
|
|
927
950
|
get emitter() {
|
|
928
951
|
return this.#emitter;
|
|
929
952
|
}
|
|
953
|
+
/**
|
|
954
|
+
* Qualifies one subject against one authored definition.
|
|
955
|
+
*
|
|
956
|
+
* @remarks
|
|
957
|
+
* Semantic validation runs first when the `validate` option is on, so an invalid
|
|
958
|
+
* definition throws before any pass runs. Passes run in authored order over a
|
|
959
|
+
* copy-on-write working subject, and the caller's `subject` is never mutated. An
|
|
960
|
+
* unscoped restriction stops the remaining passes because no later finding can be
|
|
961
|
+
* more severe.
|
|
962
|
+
*
|
|
963
|
+
* @param subject - The record to qualify
|
|
964
|
+
* @param definition - The authored qualification definition
|
|
965
|
+
* @returns A fresh, frozen qualification result
|
|
966
|
+
* @throws {@link QualifierError} `'DEFINITION'` when validation is enabled and the
|
|
967
|
+
* definition fails semantic validation, or when the engine rejects a pass as an invalid
|
|
968
|
+
* definition; `'MISMATCH'` when the subject is not a record or already carries the
|
|
969
|
+
* reserved `qualification` key; `'DESTROYED'` after `destroy`, or when the engine
|
|
970
|
+
* reports itself destroyed mid-pass; `'ENGINE'` for every other engine throw
|
|
971
|
+
*
|
|
972
|
+
* @example
|
|
973
|
+
* ```ts
|
|
974
|
+
* import { createQualificationDefinition, createRuling, Qualifier } from '@orkestrel/qualifier'
|
|
975
|
+
* import { createAtom, createLogicalDefinition, createRule } from '@orkestrel/reason'
|
|
976
|
+
*
|
|
977
|
+
* const gates = createLogicalDefinition('gates', 'Eligibility gates', [
|
|
978
|
+
* createRule('licensed', [createAtom('licensed', 'equals', false)], createAtom('blocked', 'equals', true)),
|
|
979
|
+
* ])
|
|
980
|
+
* const definition = createQualificationDefinition('standard', 'Standard eligibility', [gates], {
|
|
981
|
+
* rulings: [createRuling('license', 'gates', 'licensed', 'restriction')],
|
|
982
|
+
* })
|
|
983
|
+
*
|
|
984
|
+
* const qualifier = new Qualifier()
|
|
985
|
+
* qualifier.qualify({ id: 'risk-1', licensed: false }, definition).eligibility // 'ineligible'
|
|
986
|
+
* qualifier.destroy()
|
|
987
|
+
* ```
|
|
988
|
+
*/
|
|
930
989
|
qualify(subject, definition) {
|
|
931
990
|
this.#alive();
|
|
932
991
|
if (this.#validate) {
|
|
933
992
|
const validation = this.validate(definition);
|
|
934
|
-
if (!validation.valid) throw new QualifierError("DEFINITION", `Qualification definition is invalid: ${validation.errors.join(", ")}`, definition.id);
|
|
993
|
+
if (!validation.valid) throw new QualifierError("DEFINITION", `Qualification definition is invalid: ${validation.errors.join(", ")}`, { definition: definition.id });
|
|
935
994
|
}
|
|
936
995
|
return this.#qualify(subject, definition);
|
|
937
996
|
}
|
|
997
|
+
/**
|
|
998
|
+
* Validates one authored definition semantically, without running it.
|
|
999
|
+
*
|
|
1000
|
+
* @remarks
|
|
1001
|
+
* Structural shape is `isQualificationDefinition`'s job. This checks the meaning:
|
|
1002
|
+
* non-empty id and name, valid and uniquely identified passes and rulings, every
|
|
1003
|
+
* ruling reference resolving to a logical pass and one of its rules, and no pass id
|
|
1004
|
+
* shadowing `QUALIFICATION_KEY`. An empty definition, a logical pass carrying no
|
|
1005
|
+
* ruling, and a derivation no later pass reads are warnings rather than errors.
|
|
1006
|
+
*
|
|
1007
|
+
* @param definition - The authored qualification definition
|
|
1008
|
+
* @returns A fresh validation result carrying `valid`, `errors`, and `warnings`
|
|
1009
|
+
* @throws {@link QualifierError} `'DESTROYED'` after `destroy`
|
|
1010
|
+
*
|
|
1011
|
+
* @example
|
|
1012
|
+
* ```ts
|
|
1013
|
+
* import { createQualificationDefinition, Qualifier } from '@orkestrel/qualifier'
|
|
1014
|
+
*
|
|
1015
|
+
* const qualifier = new Qualifier()
|
|
1016
|
+
* qualifier.validate(createQualificationDefinition('empty', 'Empty', []))
|
|
1017
|
+
* // { valid: true, errors: [], warnings: ['Definition has no passes'] }
|
|
1018
|
+
* qualifier.destroy()
|
|
1019
|
+
* ```
|
|
1020
|
+
*/
|
|
938
1021
|
validate(definition) {
|
|
939
1022
|
this.#alive();
|
|
940
1023
|
const errors = [];
|
|
@@ -949,16 +1032,33 @@ var Qualifier = class {
|
|
|
949
1032
|
});
|
|
950
1033
|
for (const id of findDuplicates(definition.passes)) errors.push(`Duplicate pass id '${id}'`);
|
|
951
1034
|
for (const id of findDuplicates(definition.rulings ?? [])) errors.push(`Duplicate ruling id '${id}'`);
|
|
952
|
-
for (const reference of
|
|
1035
|
+
for (const reference of describeMissingReferences(definition)) errors.push(reference);
|
|
953
1036
|
if (definition.passes.length === 0) warnings.push("Definition has no passes");
|
|
954
|
-
for (const warning of
|
|
955
|
-
for (const warning of
|
|
1037
|
+
for (const warning of describeEmptyLogicalPasses(definition)) warnings.push(warning);
|
|
1038
|
+
for (const warning of describeUnreadDerivations(definition)) warnings.push(warning);
|
|
956
1039
|
return {
|
|
957
1040
|
valid: errors.length === 0,
|
|
958
1041
|
errors,
|
|
959
1042
|
warnings
|
|
960
1043
|
};
|
|
961
1044
|
}
|
|
1045
|
+
/**
|
|
1046
|
+
* Destroys this qualifier, idempotently.
|
|
1047
|
+
*
|
|
1048
|
+
* @remarks
|
|
1049
|
+
* An engine this qualifier created is destroyed; an injected engine stays
|
|
1050
|
+
* caller-owned and is left alone. The `destroy` event fires before the emitter
|
|
1051
|
+
* itself is destroyed, and a later call returns without re-emitting.
|
|
1052
|
+
*
|
|
1053
|
+
* @example
|
|
1054
|
+
* ```ts
|
|
1055
|
+
* import { Qualifier } from '@orkestrel/qualifier'
|
|
1056
|
+
*
|
|
1057
|
+
* const qualifier = new Qualifier()
|
|
1058
|
+
* qualifier.destroy()
|
|
1059
|
+
* qualifier.destroy() // idempotent
|
|
1060
|
+
* ```
|
|
1061
|
+
*/
|
|
962
1062
|
destroy() {
|
|
963
1063
|
if (this.#destroyed) return;
|
|
964
1064
|
if (this.#owned) this.#engine.destroy();
|
|
@@ -1034,29 +1134,114 @@ var Qualifier = class {
|
|
|
1034
1134
|
//#endregion
|
|
1035
1135
|
//#region src/core/factories.ts
|
|
1036
1136
|
/**
|
|
1037
|
-
*
|
|
1137
|
+
* Creates one {@link QualifierInterface} over a reason engine.
|
|
1038
1138
|
*
|
|
1039
1139
|
* @remarks
|
|
1040
|
-
* A standalone qualifier creates and
|
|
1140
|
+
* A standalone qualifier creates and owns one shared quantitative-plus-logical
|
|
1041
1141
|
* reason engine, destroying it on `destroy()`. An injected `options.engine`
|
|
1042
|
-
* remains caller-owned and is never destroyed.
|
|
1142
|
+
* remains caller-owned and is never destroyed. `validate` runs semantic
|
|
1143
|
+
* validation before every qualification. Default: `true`. `labels` overrides the
|
|
1144
|
+
* display name of a premise field, keyed by the dot-joined field path
|
|
1145
|
+
* `formatField` produces, and takes precedence over a premise's own `label`.
|
|
1043
1146
|
*
|
|
1044
1147
|
* @param options - Optional injected engine, validation, labels, and emitter hooks
|
|
1045
1148
|
* @returns A {@link QualifierInterface}
|
|
1046
1149
|
*
|
|
1047
|
-
* @example
|
|
1150
|
+
* @example Create a qualifier
|
|
1048
1151
|
* ```ts
|
|
1049
|
-
* import { createQualifier } from '@orkestrel/qualifier'
|
|
1152
|
+
* import { createQualificationDefinition, createQualifier, createRuling } from '@orkestrel/qualifier'
|
|
1153
|
+
* import { createAtom, createLogicalDefinition, createRule } from '@orkestrel/reason'
|
|
1154
|
+
*
|
|
1155
|
+
* const gates = createLogicalDefinition('gates', 'Eligibility gates', [
|
|
1156
|
+
* createRule(
|
|
1157
|
+
* 'licensed',
|
|
1158
|
+
* [createAtom('licensed', 'equals', false)],
|
|
1159
|
+
* createAtom('blocked', 'equals', true),
|
|
1160
|
+
* ),
|
|
1161
|
+
* ])
|
|
1162
|
+
*
|
|
1163
|
+
* const bare = createRuling('license', 'gates', 'licensed', 'restriction')
|
|
1164
|
+
* const messaged = createRuling('license', 'gates', 'licensed', 'restriction', {
|
|
1165
|
+
* message: 'A license is required',
|
|
1166
|
+
* })
|
|
1167
|
+
*
|
|
1168
|
+
* 'message' in bare // false — an absent optional key is omitted, never written as undefined
|
|
1169
|
+
* messaged.message // 'A license is required'
|
|
1170
|
+
*
|
|
1171
|
+
* const passes = [gates]
|
|
1172
|
+
* const definition = createQualificationDefinition('standard', 'Standard eligibility', passes, {
|
|
1173
|
+
* rulings: [messaged],
|
|
1174
|
+
* })
|
|
1175
|
+
*
|
|
1176
|
+
* 'description' in definition // false
|
|
1177
|
+
* definition.passes === passes // false — the factory copies what it is handed
|
|
1050
1178
|
*
|
|
1051
1179
|
* const qualifier = createQualifier()
|
|
1052
|
-
* qualifier.qualify({ id: 'risk-1' }, definition)
|
|
1180
|
+
* qualifier.qualify({ id: 'risk-1', licensed: false }, definition)
|
|
1053
1181
|
* qualifier.destroy()
|
|
1054
1182
|
* ```
|
|
1055
1183
|
*/
|
|
1056
1184
|
function createQualifier(options) {
|
|
1057
1185
|
return new Qualifier(options);
|
|
1058
1186
|
}
|
|
1187
|
+
/**
|
|
1188
|
+
* Creates a fresh {@link QualificationDefinition}, omitting every absent optional key.
|
|
1189
|
+
*
|
|
1190
|
+
* @remarks
|
|
1191
|
+
* `passes` and `rulings` arrays are copied, and record `metadata` is
|
|
1192
|
+
* shallow-copied so nested values are not deep-cloned.
|
|
1193
|
+
*
|
|
1194
|
+
* @param id - The definition id
|
|
1195
|
+
* @param name - The display name
|
|
1196
|
+
* @param passes - The ordered qualification passes
|
|
1197
|
+
* @param input - Optional description, rulings, and metadata
|
|
1198
|
+
* @returns A fresh qualification definition
|
|
1199
|
+
*
|
|
1200
|
+
* @example
|
|
1201
|
+
* ```ts
|
|
1202
|
+
* import { createQualificationDefinition } from '@orkestrel/qualifier'
|
|
1203
|
+
*
|
|
1204
|
+
* createQualificationDefinition('standard', 'Standard', [gates], { rulings: [ruling] })
|
|
1205
|
+
* ```
|
|
1206
|
+
*/
|
|
1207
|
+
function createQualificationDefinition(id, name, passes, input) {
|
|
1208
|
+
return {
|
|
1209
|
+
id,
|
|
1210
|
+
name,
|
|
1211
|
+
passes: [...passes],
|
|
1212
|
+
...input?.description === void 0 ? {} : { description: input.description },
|
|
1213
|
+
...input?.rulings === void 0 ? {} : { rulings: [...input.rulings] },
|
|
1214
|
+
...input?.metadata === void 0 ? {} : { metadata: { ...input.metadata } }
|
|
1215
|
+
};
|
|
1216
|
+
}
|
|
1217
|
+
/**
|
|
1218
|
+
* Creates a fresh {@link Ruling} from the rule it reacts to and the effect it applies.
|
|
1219
|
+
*
|
|
1220
|
+
* @param id - The ruling id
|
|
1221
|
+
* @param pass - The logical pass id the rule lives in
|
|
1222
|
+
* @param rule - The rule id whose firing this ruling reacts to
|
|
1223
|
+
* @param effect - The eligibility impact when the rule fires
|
|
1224
|
+
* @param input - Optional selection scope and message template
|
|
1225
|
+
* @returns A fresh ruling
|
|
1226
|
+
*
|
|
1227
|
+
* @example
|
|
1228
|
+
* ```ts
|
|
1229
|
+
* import { createRuling } from '@orkestrel/qualifier'
|
|
1230
|
+
*
|
|
1231
|
+
* createRuling('license', 'gates', 'licensed', 'restriction', { message: 'A license is required' })
|
|
1232
|
+
* ```
|
|
1233
|
+
*/
|
|
1234
|
+
function createRuling(id, pass, rule, effect, input) {
|
|
1235
|
+
return {
|
|
1236
|
+
id,
|
|
1237
|
+
pass,
|
|
1238
|
+
rule,
|
|
1239
|
+
effect,
|
|
1240
|
+
...input?.scope === void 0 ? {} : { scope: input.scope },
|
|
1241
|
+
...input?.message === void 0 ? {} : { message: input.message }
|
|
1242
|
+
};
|
|
1243
|
+
}
|
|
1059
1244
|
//#endregion
|
|
1060
|
-
export { DEFAULT_QUALIFIER_VALIDATE, EFFECT_ELIGIBILITIES, ELIGIBILITY_PRECEDENCE, QUALIFICATION_KEY, Qualifier, QualifierError, assertSubject, combineEligibilities,
|
|
1245
|
+
export { DEFAULT_QUALIFIER_VALIDATE, EFFECT_ELIGIBILITIES, ELIGIBILITY_PRECEDENCE, QUALIFICATION_KEY, Qualifier, QualifierError, assertSubject, checkToPremise, combineEligibilities, createQualificationDefinition, createQualifier, createRuling, deriveFindingEligibility, deriveScopeEligibilities, describeEmptyLogicalPasses, describeMissingReferences, describeUnreadDerivations, findRule, hasReservedKey, interpolateMessage, isDerivation, isEligibility, isEligibilityRecord, isFinding, isPremise, isQualificationDefinition, isQualificationEffect, isQualificationPass, isQualificationResult, isQualifierError, isRuling, mapEngineError, mergeQualificationContext, qualificationToRecord, quantitativeResultToDerivation, reasonResultToProjection, renderComparison, renderPremise, renderValue, ruleToPremises, rulingToFinding };
|
|
1061
1246
|
|
|
1062
1247
|
//# sourceMappingURL=index.js.map
|