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