@orkestrel/qualifier 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +67 -0
- package/dist/src/core/index.cjs +747 -0
- package/dist/src/core/index.cjs.map +1 -0
- package/dist/src/core/index.d.cts +569 -0
- package/dist/src/core/index.d.ts +569 -0
- package/dist/src/core/index.js +713 -0
- package/dist/src/core/index.js.map +1 -0
- package/package.json +85 -0
|
@@ -0,0 +1,747 @@
|
|
|
1
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
+
let _orkestrel_contract = require("@orkestrel/contract");
|
|
3
|
+
let _orkestrel_reason = require("@orkestrel/reason");
|
|
4
|
+
let _orkestrel_emitter = require("@orkestrel/emitter");
|
|
5
|
+
//#region src/core/constants.ts
|
|
6
|
+
/** Default definition validation policy for `createQualifier` / `Qualifier.qualify`. */
|
|
7
|
+
var DEFAULT_QUALIFIER_VALIDATE = true;
|
|
8
|
+
/** The reserved internal projection namespace a pass's working projection is written under. */
|
|
9
|
+
var QUALIFICATION_KEY = "qualification";
|
|
10
|
+
/** Eligibility severity order — most to least severe. */
|
|
11
|
+
var ELIGIBILITY_PRECEDENCE = Object.freeze([
|
|
12
|
+
"ineligible",
|
|
13
|
+
"referral",
|
|
14
|
+
"eligible"
|
|
15
|
+
]);
|
|
16
|
+
/** The eligibility impact of each {@link QualificationEffect}; `condition` remains eligible. */
|
|
17
|
+
var EFFECT_ELIGIBILITIES = Object.freeze({
|
|
18
|
+
restriction: "ineligible",
|
|
19
|
+
referral: "referral",
|
|
20
|
+
condition: "eligible"
|
|
21
|
+
});
|
|
22
|
+
//#endregion
|
|
23
|
+
//#region src/core/errors.ts
|
|
24
|
+
/**
|
|
25
|
+
* A coded programmer error thrown by the qualifier layer.
|
|
26
|
+
*
|
|
27
|
+
* @remarks
|
|
28
|
+
* `DEFINITION` — a qualification definition failed semantic validation.
|
|
29
|
+
* `MISMATCH` — a subject is not a record or already carries the reserved
|
|
30
|
+
* `qualification` key. `DESTROYED` — use of a destroyed qualifier.
|
|
31
|
+
*/
|
|
32
|
+
var QualifierError = class extends Error {
|
|
33
|
+
code;
|
|
34
|
+
context;
|
|
35
|
+
constructor(code, message, context) {
|
|
36
|
+
super(message);
|
|
37
|
+
this.name = "QualifierError";
|
|
38
|
+
this.code = code;
|
|
39
|
+
this.context = context;
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
/** Narrow a caught value to a {@link QualifierError}. */
|
|
43
|
+
function isQualifierError(value) {
|
|
44
|
+
return value instanceof QualifierError;
|
|
45
|
+
}
|
|
46
|
+
//#endregion
|
|
47
|
+
//#region src/core/validators.ts
|
|
48
|
+
/** Determine whether a value is an {@link Eligibility} literal. */
|
|
49
|
+
var isEligibility = (0, _orkestrel_contract.literalOf)("eligible", "ineligible", "referral");
|
|
50
|
+
/** Determine whether a value is a {@link QualificationEffect} literal. */
|
|
51
|
+
var isQualificationEffect = (0, _orkestrel_contract.literalOf)("restriction", "referral", "condition");
|
|
52
|
+
/** Determine whether a value is a {@link QualificationPass} (a quantitative or logical definition). */
|
|
53
|
+
function isQualificationPass(value) {
|
|
54
|
+
return (0, _orkestrel_reason.isQuantitativeDefinition)(value) || (0, _orkestrel_reason.isLogicalDefinition)(value);
|
|
55
|
+
}
|
|
56
|
+
/** Determine whether a value is an exact {@link Ruling} record. */
|
|
57
|
+
function isRuling(value) {
|
|
58
|
+
return (0, _orkestrel_contract.recordOf)({
|
|
59
|
+
id: _orkestrel_contract.isString,
|
|
60
|
+
pass: _orkestrel_contract.isString,
|
|
61
|
+
rule: _orkestrel_contract.isString,
|
|
62
|
+
effect: isQualificationEffect,
|
|
63
|
+
scope: _orkestrel_contract.isString,
|
|
64
|
+
message: _orkestrel_contract.isString
|
|
65
|
+
}, ["scope", "message"])(value);
|
|
66
|
+
}
|
|
67
|
+
/** Determine whether a value is an exact {@link QualificationDefinition} record. */
|
|
68
|
+
function isQualificationDefinition(value) {
|
|
69
|
+
if (!(0, _orkestrel_contract.isRecord)(value)) return false;
|
|
70
|
+
return (0, _orkestrel_contract.recordOf)({
|
|
71
|
+
id: _orkestrel_contract.isString,
|
|
72
|
+
name: _orkestrel_contract.isString,
|
|
73
|
+
description: _orkestrel_contract.isString,
|
|
74
|
+
passes: (0, _orkestrel_contract.arrayOf)(isQualificationPass),
|
|
75
|
+
rulings: (0, _orkestrel_contract.arrayOf)(isRuling),
|
|
76
|
+
metadata: _orkestrel_contract.isRecord
|
|
77
|
+
}, [
|
|
78
|
+
"description",
|
|
79
|
+
"rulings",
|
|
80
|
+
"metadata"
|
|
81
|
+
])(value);
|
|
82
|
+
}
|
|
83
|
+
//#endregion
|
|
84
|
+
//#region src/core/helpers.ts
|
|
85
|
+
/**
|
|
86
|
+
* Interpolate `{{dotted.path}}` tokens in a message template against a subject.
|
|
87
|
+
*
|
|
88
|
+
* @remarks
|
|
89
|
+
* Each token is split on `.` into a `FieldPath` array and resolved with
|
|
90
|
+
* `resolveField` (a plain string field is ONE key, never dot-split — the split
|
|
91
|
+
* here is the token-to-path bridge). A finite number renders with `en-US`
|
|
92
|
+
* thousand grouping (`5010` → `5,010`); any other resolved value String-coerces.
|
|
93
|
+
* An UNRESOLVED path renders as the empty string.
|
|
94
|
+
*
|
|
95
|
+
* @param template - The message template carrying `{{dotted.path}}` tokens
|
|
96
|
+
* @param subject - The record tokens resolve against
|
|
97
|
+
* @returns The template with every token replaced
|
|
98
|
+
*
|
|
99
|
+
* @example
|
|
100
|
+
* ```ts
|
|
101
|
+
* import { interpolateMessage } from '@orkestrel/qualifier'
|
|
102
|
+
*
|
|
103
|
+
* interpolateMessage('Limit is {{limit}}', { limit: 5010 }) // 'Limit is 5,010'
|
|
104
|
+
* interpolateMessage('Missing {{gone}}', {}) // 'Missing '
|
|
105
|
+
* ```
|
|
106
|
+
*/
|
|
107
|
+
function interpolateMessage(template, subject) {
|
|
108
|
+
return template.replace(/\{\{\s*([^}]+?)\s*\}\}/g, (_match, path) => {
|
|
109
|
+
const value = (0, _orkestrel_contract.resolveField)(subject, path.split("."));
|
|
110
|
+
if (value === void 0) return "";
|
|
111
|
+
if ((0, _orkestrel_contract.isFiniteNumber)(value)) return value.toLocaleString("en-US");
|
|
112
|
+
return String(value);
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Describe a {@link Premise} comparison as a display-neutral verb phrase.
|
|
117
|
+
*
|
|
118
|
+
* @param comparison - The comparison to describe
|
|
119
|
+
* @returns A display-neutral phrase
|
|
120
|
+
*
|
|
121
|
+
* @example
|
|
122
|
+
* ```ts
|
|
123
|
+
* import { describeComparison } from '@orkestrel/qualifier'
|
|
124
|
+
*
|
|
125
|
+
* describeComparison('above') // 'is more than'
|
|
126
|
+
* ```
|
|
127
|
+
*/
|
|
128
|
+
function describeComparison(comparison) {
|
|
129
|
+
switch (comparison) {
|
|
130
|
+
case "equals": return "is";
|
|
131
|
+
case "not": return "is not";
|
|
132
|
+
case "above": return "is more than";
|
|
133
|
+
case "below": return "is less than";
|
|
134
|
+
case "from": return "is at least";
|
|
135
|
+
case "to": return "is at most";
|
|
136
|
+
case "any": return "is any of";
|
|
137
|
+
case "none": return "is none of";
|
|
138
|
+
case "between": return "is between";
|
|
139
|
+
case "outside": return "is outside";
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Render a structured or scalar expected value display-neutrally.
|
|
144
|
+
*
|
|
145
|
+
* @remarks
|
|
146
|
+
* An array renders as its elements, each `String`-coerced, joined with `', '`.
|
|
147
|
+
* A reason `Bounds`-shaped record (the `between` / `outside` expected value)
|
|
148
|
+
* renders its present sides joined with the word `and`; an absent side is
|
|
149
|
+
* omitted. A finite number and everything else fall back to plain `String()` —
|
|
150
|
+
* a finite number is NOT grouped here (grouping is {@link interpolateMessage}'s
|
|
151
|
+
* message-token-only behavior).
|
|
152
|
+
*
|
|
153
|
+
* @param value - The value to render
|
|
154
|
+
* @returns A display-neutral rendering
|
|
155
|
+
*
|
|
156
|
+
* @example
|
|
157
|
+
* ```ts
|
|
158
|
+
* import { describeValue } from '@orkestrel/qualifier'
|
|
159
|
+
*
|
|
160
|
+
* describeValue([18, 25, 40]) // '18, 25, 40'
|
|
161
|
+
* describeValue({ minimum: 18, maximum: 65 }) // '18 and 65'
|
|
162
|
+
* describeValue(42) // '42'
|
|
163
|
+
* ```
|
|
164
|
+
*/
|
|
165
|
+
function describeValue(value) {
|
|
166
|
+
if (Array.isArray(value)) return value.map((entry) => String(entry)).join(", ");
|
|
167
|
+
if ((0, _orkestrel_reason.isBounds)(value)) {
|
|
168
|
+
const sides = [];
|
|
169
|
+
if (value.minimum !== void 0) sides.push(String(value.minimum));
|
|
170
|
+
if (value.maximum !== void 0) sides.push(String(value.maximum));
|
|
171
|
+
return sides.join(" and ");
|
|
172
|
+
}
|
|
173
|
+
return String(value);
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Render one {@link Premise} into a display-neutral sentence.
|
|
177
|
+
*
|
|
178
|
+
* @param entry - The premise to render
|
|
179
|
+
* @param labels - Optional field-to-label overrides, keyed by dot-joined field
|
|
180
|
+
* @returns A display-neutral sentence
|
|
181
|
+
*
|
|
182
|
+
* @example
|
|
183
|
+
* ```ts
|
|
184
|
+
* import { describePremise } from '@orkestrel/qualifier'
|
|
185
|
+
*
|
|
186
|
+
* describePremise({ field: 'age', comparison: 'above', expected: 18, actual: 25, met: true })
|
|
187
|
+
* // 'age is more than 18 → met'
|
|
188
|
+
* ```
|
|
189
|
+
*/
|
|
190
|
+
function describePremise(entry, labels) {
|
|
191
|
+
const status = entry.met === void 0 ? "unknown" : entry.met ? "met" : "not met";
|
|
192
|
+
if (entry.field === void 0 || entry.comparison === void 0) return `${entry.description ?? "Premise"} → ${status}`;
|
|
193
|
+
const field = (0, _orkestrel_reason.formatField)(entry.field);
|
|
194
|
+
const label = labels?.[field] ?? entry.label ?? field;
|
|
195
|
+
const expected = entry.expected === void 0 ? "" : ` ${describeValue(entry.expected)}`;
|
|
196
|
+
return `${label} ${describeComparison(entry.comparison)}${expected} → ${status}`;
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* Build a {@link Premise} from an authored {@link Check} and its evaluated
|
|
200
|
+
* `CheckResult`.
|
|
201
|
+
*
|
|
202
|
+
* @param check - The authored check
|
|
203
|
+
* @param result - The evaluated check result carrying the resolved `actual` and `met`
|
|
204
|
+
* @param labels - Optional field-to-label overrides, keyed by dot-joined field
|
|
205
|
+
* @returns A fresh premise
|
|
206
|
+
*
|
|
207
|
+
* @example
|
|
208
|
+
* ```ts
|
|
209
|
+
* import { premiseCheck } from '@orkestrel/qualifier'
|
|
210
|
+
* import { check } from '@orkestrel/reason'
|
|
211
|
+
*
|
|
212
|
+
* const authored = check('age', 'above', 18)
|
|
213
|
+
* premiseCheck(authored, { field: 'age', actual: 25, met: true })
|
|
214
|
+
* // { field: 'age', comparison: 'above', expected: 18, actual: 25, met: true }
|
|
215
|
+
* ```
|
|
216
|
+
*/
|
|
217
|
+
function premiseCheck(check, result, labels) {
|
|
218
|
+
const field = (0, _orkestrel_reason.formatField)(check.field);
|
|
219
|
+
return {
|
|
220
|
+
field: check.field,
|
|
221
|
+
...labels?.[field] === void 0 ? {} : { label: labels[field] },
|
|
222
|
+
comparison: check.operator,
|
|
223
|
+
expected: check.value,
|
|
224
|
+
actual: result.actual,
|
|
225
|
+
...result.met === void 0 ? {} : { met: result.met }
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* Build rich premises for one fired {@link Rule} by walking its premise atoms
|
|
230
|
+
* and re-evaluating each against the working subject.
|
|
231
|
+
*
|
|
232
|
+
* @remarks
|
|
233
|
+
* A reason rule result carries only booleans, so this is the qualifier's own
|
|
234
|
+
* premise-audit projection: each authored premise expression is flattened to its
|
|
235
|
+
* atom leaves via `extractAtoms`, and each leaf's `Check` is re-evaluated through
|
|
236
|
+
* the injected `evaluator`. A membership check (`any` / `none`) over an EMPTY
|
|
237
|
+
* array value is content-free and is skipped.
|
|
238
|
+
*
|
|
239
|
+
* @param rule - The authored rule
|
|
240
|
+
* @param working - The working subject to evaluate against
|
|
241
|
+
* @param evaluator - The shared reason check evaluator
|
|
242
|
+
* @param labels - Optional field-to-label overrides, keyed by dot-joined field
|
|
243
|
+
* @returns A fresh, ordered list of rich premises
|
|
244
|
+
*
|
|
245
|
+
* @example
|
|
246
|
+
* ```ts
|
|
247
|
+
* import { logicalPremises } from '@orkestrel/qualifier'
|
|
248
|
+
* import { atom, createEvaluator, logicalDefinition, rule } from '@orkestrel/reason'
|
|
249
|
+
*
|
|
250
|
+
* const gates = logicalDefinition('gates', 'Gates', [
|
|
251
|
+
* rule('licensed', [atom('licensed', 'equals', false)], atom('blocked', 'equals', true)),
|
|
252
|
+
* ])
|
|
253
|
+
* const evaluator = createEvaluator()
|
|
254
|
+
* const premises = logicalPremises(gates.rules[0], { licensed: false }, evaluator)
|
|
255
|
+
*
|
|
256
|
+
* premises[0]?.met // true
|
|
257
|
+
* ```
|
|
258
|
+
*/
|
|
259
|
+
function logicalPremises(rule, working, evaluator, labels) {
|
|
260
|
+
const output = [];
|
|
261
|
+
for (const premise of rule.premises) for (const atom of (0, _orkestrel_reason.extractAtoms)(premise)) {
|
|
262
|
+
const { check } = atom;
|
|
263
|
+
if ((check.operator === "any" || check.operator === "none") && Array.isArray(check.value) && check.value.length === 0) continue;
|
|
264
|
+
output.push(premiseCheck(check, evaluator.evaluate(check, working), labels));
|
|
265
|
+
}
|
|
266
|
+
return output;
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* Locate an authored {@link Rule} by id.
|
|
270
|
+
*
|
|
271
|
+
* @param definition - The logical definition to search
|
|
272
|
+
* @param id - The rule id
|
|
273
|
+
* @returns The matching rule, or `undefined`
|
|
274
|
+
*
|
|
275
|
+
* @example
|
|
276
|
+
* ```ts
|
|
277
|
+
* import { findRule } from '@orkestrel/qualifier'
|
|
278
|
+
* import { atom, logicalDefinition, rule } from '@orkestrel/reason'
|
|
279
|
+
*
|
|
280
|
+
* const gates = logicalDefinition('gates', 'Gates', [
|
|
281
|
+
* rule('licensed', [atom('licensed', 'equals', false)], atom('blocked', 'equals', true)),
|
|
282
|
+
* ])
|
|
283
|
+
*
|
|
284
|
+
* findRule(gates, 'licensed')?.id // 'licensed'
|
|
285
|
+
* ```
|
|
286
|
+
*/
|
|
287
|
+
function findRule(definition, id) {
|
|
288
|
+
return definition.rules.find((rule) => rule.id === id);
|
|
289
|
+
}
|
|
290
|
+
/**
|
|
291
|
+
* Project one reason result into the internal qualification namespace.
|
|
292
|
+
*
|
|
293
|
+
* @remarks
|
|
294
|
+
* A quantitative result contributes its numeric `value`. A logical result
|
|
295
|
+
* contributes a record of its `conclusion` plus every applied rule's asserted
|
|
296
|
+
* conclusion fields (`extractConclusions`). A mismatched pairing contributes
|
|
297
|
+
* `false`. The projection is pure — it never mutates the subject or the result.
|
|
298
|
+
*
|
|
299
|
+
* @param definition - The pass definition that produced the result
|
|
300
|
+
* @param result - The evaluated reason result
|
|
301
|
+
* @returns The pass's working projection
|
|
302
|
+
*/
|
|
303
|
+
function reasonResultToProjection(definition, result) {
|
|
304
|
+
if (definition.reasoning === "quantitative" && result.reasoning === "quantitative") return result.value;
|
|
305
|
+
if (definition.reasoning !== "logical" || result.reasoning !== "logical") return false;
|
|
306
|
+
const projection = { conclusion: result.conclusion };
|
|
307
|
+
for (const resolved of result.rules) {
|
|
308
|
+
if (!resolved.applied) continue;
|
|
309
|
+
const authored = definition.rules.find((rule) => rule.id === resolved.id);
|
|
310
|
+
if (authored === void 0) continue;
|
|
311
|
+
const conclusions = (0, _orkestrel_reason.extractConclusions)(authored.conclusion);
|
|
312
|
+
for (const [key, value] of Object.entries(conclusions)) projection[key] = value;
|
|
313
|
+
}
|
|
314
|
+
return projection;
|
|
315
|
+
}
|
|
316
|
+
/**
|
|
317
|
+
* Project a quantitative result into a {@link Derivation} audit record.
|
|
318
|
+
*
|
|
319
|
+
* @param id - The pass id
|
|
320
|
+
* @param result - The evaluated quantitative result
|
|
321
|
+
* @returns A fresh derivation
|
|
322
|
+
*/
|
|
323
|
+
function quantitativeResultToDerivation(id, result) {
|
|
324
|
+
return {
|
|
325
|
+
id,
|
|
326
|
+
value: result.value,
|
|
327
|
+
success: result.success,
|
|
328
|
+
trace: [...result.trace],
|
|
329
|
+
errors: [...result.errors]
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
/**
|
|
333
|
+
* Wrap a {@link QualificationContext} under {@link QUALIFICATION_KEY}.
|
|
334
|
+
*
|
|
335
|
+
* @param context - The accumulated projection context
|
|
336
|
+
* @returns A record carrying the context under the reserved key
|
|
337
|
+
*/
|
|
338
|
+
function qualificationToRecord(context) {
|
|
339
|
+
return { [QUALIFICATION_KEY]: context };
|
|
340
|
+
}
|
|
341
|
+
/**
|
|
342
|
+
* Copy-on-write merge one pass projection into the context.
|
|
343
|
+
*
|
|
344
|
+
* @param context - The current projection context
|
|
345
|
+
* @param id - The pass id to project under
|
|
346
|
+
* @param projection - The pass's working projection
|
|
347
|
+
* @returns A fresh context with the projection added
|
|
348
|
+
*/
|
|
349
|
+
function mergeQualificationContext(context, id, projection) {
|
|
350
|
+
return {
|
|
351
|
+
...context,
|
|
352
|
+
[id]: projection
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
/**
|
|
356
|
+
* Join a ruling, its logical rule result, the pass, the pre-projection subject,
|
|
357
|
+
* and an evaluator into a {@link Finding}.
|
|
358
|
+
*
|
|
359
|
+
* @remarks
|
|
360
|
+
* `applied` reflects whether the ruling's rule fired. Premises are re-evaluated
|
|
361
|
+
* against the SAME subject snapshot the pass evaluated, so a conclusion the pass
|
|
362
|
+
* derived can never satisfy its own reported premise. The message interpolates
|
|
363
|
+
* against that snapshot.
|
|
364
|
+
*
|
|
365
|
+
* @param ruling - The authored ruling
|
|
366
|
+
* @param pass - The pass the ruling's rule lives in
|
|
367
|
+
* @param result - The pass's evaluated logical result
|
|
368
|
+
* @param subject - The pre-projection subject snapshot the pass evaluated
|
|
369
|
+
* @param evaluator - The shared reason check evaluator
|
|
370
|
+
* @param labels - Optional field-to-label overrides, keyed by dot-joined field
|
|
371
|
+
* @returns A fresh finding
|
|
372
|
+
*/
|
|
373
|
+
function rulingToFinding(ruling, pass, result, subject, evaluator, labels) {
|
|
374
|
+
const applied = result.rules.find((item) => item.id === ruling.rule)?.applied ?? false;
|
|
375
|
+
const rule = pass.reasoning === "logical" ? findRule(pass, ruling.rule) : void 0;
|
|
376
|
+
const premises = rule === void 0 ? [] : logicalPremises(rule, subject, evaluator, labels);
|
|
377
|
+
return {
|
|
378
|
+
id: ruling.id,
|
|
379
|
+
pass: ruling.pass,
|
|
380
|
+
rule: ruling.rule,
|
|
381
|
+
effect: ruling.effect,
|
|
382
|
+
...ruling.scope === void 0 ? {} : { scope: ruling.scope },
|
|
383
|
+
applied,
|
|
384
|
+
...ruling.message === void 0 ? {} : { message: interpolateMessage(ruling.message, subject) },
|
|
385
|
+
premises
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
/**
|
|
389
|
+
* Derive global eligibility from applied, unscoped findings.
|
|
390
|
+
*
|
|
391
|
+
* @remarks
|
|
392
|
+
* Only findings that both applied and carry no `scope` shape global eligibility.
|
|
393
|
+
* A failed pass contributes a synthetic `referral` (fail-closed), passed through
|
|
394
|
+
* `failed`.
|
|
395
|
+
*
|
|
396
|
+
* @param findings - The resolved findings
|
|
397
|
+
* @param failed - Whether an operational pass failure occurred
|
|
398
|
+
* @returns The most severe global eligibility
|
|
399
|
+
*/
|
|
400
|
+
function deriveFindingEligibility(findings, failed = false) {
|
|
401
|
+
const eligibilities = findings.filter((finding) => finding.applied && finding.scope === void 0).map((finding) => EFFECT_ELIGIBILITIES[finding.effect]);
|
|
402
|
+
if (failed) eligibilities.push("referral");
|
|
403
|
+
return combineEligibilities(eligibilities);
|
|
404
|
+
}
|
|
405
|
+
/**
|
|
406
|
+
* Return the most severe {@link Eligibility} in a list.
|
|
407
|
+
*
|
|
408
|
+
* @param eligibilities - The eligibilities to combine
|
|
409
|
+
* @returns The most severe eligibility, or `'eligible'` for an empty list
|
|
410
|
+
*
|
|
411
|
+
* @example
|
|
412
|
+
* ```ts
|
|
413
|
+
* import { combineEligibilities } from '@orkestrel/qualifier'
|
|
414
|
+
*
|
|
415
|
+
* combineEligibilities(['eligible', 'referral']) // 'referral'
|
|
416
|
+
* ```
|
|
417
|
+
*/
|
|
418
|
+
function combineEligibilities(eligibilities) {
|
|
419
|
+
for (const eligibility of ELIGIBILITY_PRECEDENCE) if (eligibilities.includes(eligibility)) return eligibility;
|
|
420
|
+
return "eligible";
|
|
421
|
+
}
|
|
422
|
+
/**
|
|
423
|
+
* Derive one eligibility per finding scope.
|
|
424
|
+
*
|
|
425
|
+
* @remarks
|
|
426
|
+
* Only applied, scoped findings contribute. Each named scope combines its own
|
|
427
|
+
* findings' impacts by severity. An unmentioned scope is absent (eligible by
|
|
428
|
+
* default at the program layer).
|
|
429
|
+
*
|
|
430
|
+
* @param findings - The resolved findings
|
|
431
|
+
* @returns A fresh record of scope to its combined eligibility
|
|
432
|
+
*/
|
|
433
|
+
function deriveScopeEligibilities(findings) {
|
|
434
|
+
const grouped = {};
|
|
435
|
+
for (const finding of findings) {
|
|
436
|
+
if (!finding.applied || finding.scope === void 0) continue;
|
|
437
|
+
const current = grouped[finding.scope] ?? [];
|
|
438
|
+
grouped[finding.scope] = [...current, EFFECT_ELIGIBILITIES[finding.effect]];
|
|
439
|
+
}
|
|
440
|
+
const scopes = {};
|
|
441
|
+
for (const [scope, eligibilities] of Object.entries(grouped)) scopes[scope] = combineEligibilities(eligibilities);
|
|
442
|
+
return scopes;
|
|
443
|
+
}
|
|
444
|
+
/**
|
|
445
|
+
* Find rulings whose pass or rule does not exist, whose pass is not logical, or
|
|
446
|
+
* a pass id shadowing the reserved {@link QUALIFICATION_KEY}.
|
|
447
|
+
*
|
|
448
|
+
* @param definition - The qualification definition to check
|
|
449
|
+
* @returns A fresh list of reference-error messages
|
|
450
|
+
*/
|
|
451
|
+
function findMissingReferences(definition) {
|
|
452
|
+
const errors = [];
|
|
453
|
+
const passes = new Map(definition.passes.map((pass) => [pass.id, pass]));
|
|
454
|
+
for (const pass of definition.passes) if (pass.id === "qualification") errors.push(`Pass id must not equal reserved key '${QUALIFICATION_KEY}'`);
|
|
455
|
+
for (const ruling of definition.rulings ?? []) {
|
|
456
|
+
const pass = passes.get(ruling.pass);
|
|
457
|
+
if (pass === void 0) {
|
|
458
|
+
errors.push(`Ruling '${ruling.id}' references missing pass '${ruling.pass}'`);
|
|
459
|
+
continue;
|
|
460
|
+
}
|
|
461
|
+
if (pass.reasoning !== "logical") {
|
|
462
|
+
errors.push(`Ruling '${ruling.id}' references non-logical pass '${ruling.pass}'`);
|
|
463
|
+
continue;
|
|
464
|
+
}
|
|
465
|
+
if (findRule(pass, ruling.rule) === void 0) errors.push(`Ruling '${ruling.id}' references missing rule '${ruling.rule}' in pass '${ruling.pass}'`);
|
|
466
|
+
}
|
|
467
|
+
return errors;
|
|
468
|
+
}
|
|
469
|
+
/**
|
|
470
|
+
* Find duplicate ids in a list, once each.
|
|
471
|
+
*
|
|
472
|
+
* @param ids - The ids to scan
|
|
473
|
+
* @returns A fresh list of ids that appear more than once
|
|
474
|
+
*/
|
|
475
|
+
function findDuplicateIds(ids) {
|
|
476
|
+
const seen = /* @__PURE__ */ new Set();
|
|
477
|
+
const duplicates = /* @__PURE__ */ new Set();
|
|
478
|
+
for (const id of ids) if (seen.has(id)) duplicates.add(id);
|
|
479
|
+
else seen.add(id);
|
|
480
|
+
return [...duplicates];
|
|
481
|
+
}
|
|
482
|
+
/**
|
|
483
|
+
* Determine whether a subject already owns the reserved {@link QUALIFICATION_KEY}.
|
|
484
|
+
*
|
|
485
|
+
* @param subject - The subject to check
|
|
486
|
+
* @returns `true` when the subject owns the reserved key
|
|
487
|
+
*/
|
|
488
|
+
function hasReservedKey(subject) {
|
|
489
|
+
return Object.hasOwn(subject, QUALIFICATION_KEY);
|
|
490
|
+
}
|
|
491
|
+
/**
|
|
492
|
+
* Assert a value is a valid qualification {@link Subject}, narrowing it in place.
|
|
493
|
+
*
|
|
494
|
+
* @param value - The candidate subject to validate
|
|
495
|
+
* @throws {@link QualifierError} `'MISMATCH'` when the value is not a record, or
|
|
496
|
+
* when it already carries the reserved `qualification` key
|
|
497
|
+
*/
|
|
498
|
+
function assertSubject(value) {
|
|
499
|
+
if (!(0, _orkestrel_contract.isRecord)(value)) throw new QualifierError("MISMATCH", "Qualification subject must be a record");
|
|
500
|
+
if (hasReservedKey(value)) throw new QualifierError("MISMATCH", `Qualification subject must not contain reserved key '${QUALIFICATION_KEY}'`);
|
|
501
|
+
}
|
|
502
|
+
//#endregion
|
|
503
|
+
//#region src/core/Qualifier.ts
|
|
504
|
+
/**
|
|
505
|
+
* A qualifier — runs ordered passes over one reason engine and returns
|
|
506
|
+
* pre-rating eligibility.
|
|
507
|
+
*
|
|
508
|
+
* @remarks
|
|
509
|
+
* The engine is OWNED when self-created (destroyed on `destroy()`) and borrowed
|
|
510
|
+
* when injected (never destroyed). `qualify` builds its working subject through
|
|
511
|
+
* copy-on-write overlays only — the caller's `subject` is never mutated, and the
|
|
512
|
+
* qualifier's internal projection is discarded after each call.
|
|
513
|
+
*/
|
|
514
|
+
var Qualifier = class {
|
|
515
|
+
#emitter;
|
|
516
|
+
#engine;
|
|
517
|
+
#evaluator;
|
|
518
|
+
#owned;
|
|
519
|
+
#validate;
|
|
520
|
+
#labels;
|
|
521
|
+
#destroyed = false;
|
|
522
|
+
constructor(options) {
|
|
523
|
+
this.#emitter = new _orkestrel_emitter.Emitter({
|
|
524
|
+
on: options?.on,
|
|
525
|
+
error: options?.error
|
|
526
|
+
});
|
|
527
|
+
this.#evaluator = (0, _orkestrel_reason.createEvaluator)();
|
|
528
|
+
this.#owned = options?.engine === void 0;
|
|
529
|
+
this.#engine = options?.engine ?? (0, _orkestrel_reason.createReason)({
|
|
530
|
+
reasoners: [(0, _orkestrel_reason.createQuantitativeReasoner)({ evaluator: this.#evaluator }), (0, _orkestrel_reason.createLogicalReasoner)({ evaluator: this.#evaluator })],
|
|
531
|
+
bail: false
|
|
532
|
+
});
|
|
533
|
+
this.#validate = options?.validate ?? true;
|
|
534
|
+
this.#labels = options?.labels;
|
|
535
|
+
}
|
|
536
|
+
get emitter() {
|
|
537
|
+
return this.#emitter;
|
|
538
|
+
}
|
|
539
|
+
qualify(subject, definition) {
|
|
540
|
+
this.#alive();
|
|
541
|
+
if (this.#validate) {
|
|
542
|
+
const validation = this.validate(definition);
|
|
543
|
+
if (!validation.valid) throw new QualifierError("DEFINITION", `Qualification definition is invalid: ${validation.errors.join(", ")}`, definition.id);
|
|
544
|
+
}
|
|
545
|
+
return this.#qualify(subject, definition);
|
|
546
|
+
}
|
|
547
|
+
validate(definition) {
|
|
548
|
+
this.#alive();
|
|
549
|
+
const errors = [];
|
|
550
|
+
const warnings = [];
|
|
551
|
+
if (definition.id.length === 0) errors.push("Definition id must not be empty");
|
|
552
|
+
if (definition.name.length === 0) errors.push("Definition name must not be empty");
|
|
553
|
+
if (definition.passes.length === 0) warnings.push("Definition has no passes");
|
|
554
|
+
for (const id of findDuplicateIds(definition.passes.map((pass) => pass.id))) errors.push(`Duplicate pass id '${id}'`);
|
|
555
|
+
for (const id of findDuplicateIds((definition.rulings ?? []).map((ruling) => ruling.id))) errors.push(`Duplicate ruling id '${id}'`);
|
|
556
|
+
for (const reference of findMissingReferences(definition)) errors.push(reference);
|
|
557
|
+
return {
|
|
558
|
+
valid: errors.length === 0,
|
|
559
|
+
errors,
|
|
560
|
+
warnings
|
|
561
|
+
};
|
|
562
|
+
}
|
|
563
|
+
destroy() {
|
|
564
|
+
if (this.#destroyed) return;
|
|
565
|
+
if (this.#owned) this.#engine.destroy();
|
|
566
|
+
this.#destroyed = true;
|
|
567
|
+
this.#emitter.emit("destroy");
|
|
568
|
+
this.#emitter.destroy();
|
|
569
|
+
}
|
|
570
|
+
#qualify(subject, definition) {
|
|
571
|
+
assertSubject(subject);
|
|
572
|
+
let context = {};
|
|
573
|
+
let working = { ...subject };
|
|
574
|
+
const findings = [];
|
|
575
|
+
const derivations = [];
|
|
576
|
+
const trace = [];
|
|
577
|
+
const errors = [];
|
|
578
|
+
let failed = false;
|
|
579
|
+
for (const pass of definition.passes) {
|
|
580
|
+
const evaluated = working;
|
|
581
|
+
const result = this.#engine.reason(evaluated, pass);
|
|
582
|
+
trace.push(...result.trace.map((entry) => `${pass.id}: ${entry}`));
|
|
583
|
+
errors.push(...result.errors.map((entry) => `${pass.id}: ${entry}`));
|
|
584
|
+
if (result.reasoning === "quantitative") {
|
|
585
|
+
const derivation = quantitativeResultToDerivation(pass.id, result);
|
|
586
|
+
derivations.push(derivation);
|
|
587
|
+
this.#emitter.emit("derive", derivation);
|
|
588
|
+
}
|
|
589
|
+
if (result.reasoning === "logical") {
|
|
590
|
+
const rulings = (definition.rulings ?? []).filter((ruling) => ruling.pass === pass.id);
|
|
591
|
+
for (const ruling of rulings) {
|
|
592
|
+
const finding = rulingToFinding(ruling, pass, result, evaluated, this.#evaluator, this.#labels);
|
|
593
|
+
findings.push(finding);
|
|
594
|
+
this.#emitter.emit("finding", finding);
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
const projection = reasonResultToProjection(pass, result);
|
|
598
|
+
context = mergeQualificationContext(context, pass.id, projection);
|
|
599
|
+
working = {
|
|
600
|
+
...subject,
|
|
601
|
+
...qualificationToRecord(context)
|
|
602
|
+
};
|
|
603
|
+
if (!result.success) {
|
|
604
|
+
failed = true;
|
|
605
|
+
break;
|
|
606
|
+
}
|
|
607
|
+
if (deriveFindingEligibility(findings) === "ineligible") break;
|
|
608
|
+
}
|
|
609
|
+
const success = !failed && errors.length === 0;
|
|
610
|
+
const qualification = {
|
|
611
|
+
id: definition.id,
|
|
612
|
+
name: definition.name,
|
|
613
|
+
eligibility: deriveFindingEligibility(findings, !success),
|
|
614
|
+
scopes: deriveScopeEligibilities(findings),
|
|
615
|
+
findings,
|
|
616
|
+
derivations,
|
|
617
|
+
success,
|
|
618
|
+
trace,
|
|
619
|
+
errors
|
|
620
|
+
};
|
|
621
|
+
this.#emitter.emit("qualify", qualification);
|
|
622
|
+
return qualification;
|
|
623
|
+
}
|
|
624
|
+
#alive() {
|
|
625
|
+
if (this.#destroyed) throw new QualifierError("DESTROYED", "Qualifier has been destroyed");
|
|
626
|
+
}
|
|
627
|
+
};
|
|
628
|
+
//#endregion
|
|
629
|
+
//#region src/core/factories.ts
|
|
630
|
+
/**
|
|
631
|
+
* Create one qualifier over a reason engine.
|
|
632
|
+
*
|
|
633
|
+
* @remarks
|
|
634
|
+
* A standalone qualifier creates and OWNS one shared quantitative-plus-logical
|
|
635
|
+
* reason engine, destroying it on `destroy()`. An injected `options.engine`
|
|
636
|
+
* remains caller-owned and is never destroyed. Validation is on by default.
|
|
637
|
+
*
|
|
638
|
+
* @param options - Optional injected engine, validation, labels, and emitter hooks
|
|
639
|
+
* @returns A {@link QualifierInterface}
|
|
640
|
+
*
|
|
641
|
+
* @example
|
|
642
|
+
* ```ts
|
|
643
|
+
* import { createQualifier } from '@orkestrel/qualifier'
|
|
644
|
+
*
|
|
645
|
+
* const qualifier = createQualifier()
|
|
646
|
+
* qualifier.qualify({ id: 'risk-1' }, definition)
|
|
647
|
+
* qualifier.destroy()
|
|
648
|
+
* ```
|
|
649
|
+
*/
|
|
650
|
+
function createQualifier(options) {
|
|
651
|
+
return new Qualifier(options);
|
|
652
|
+
}
|
|
653
|
+
/**
|
|
654
|
+
* Build a {@link QualificationDefinition}.
|
|
655
|
+
*
|
|
656
|
+
* @remarks
|
|
657
|
+
* Returns a fresh top-level definition, omitting absent optional keys;
|
|
658
|
+
* `passes` and `rulings` arrays are copied, and record `metadata` is
|
|
659
|
+
* shallow-copied so nested values are not deep-cloned.
|
|
660
|
+
*
|
|
661
|
+
* @param id - The definition id
|
|
662
|
+
* @param name - The display name
|
|
663
|
+
* @param passes - The ordered qualification passes
|
|
664
|
+
* @param input - Optional description, rulings, and metadata
|
|
665
|
+
* @returns A fresh qualification definition
|
|
666
|
+
*
|
|
667
|
+
* @example
|
|
668
|
+
* ```ts
|
|
669
|
+
* import { qualificationDefinition } from '@orkestrel/qualifier'
|
|
670
|
+
*
|
|
671
|
+
* qualificationDefinition('standard', 'Standard', [gates], { rulings: [ruling] })
|
|
672
|
+
* ```
|
|
673
|
+
*/
|
|
674
|
+
function qualificationDefinition(id, name, passes, input) {
|
|
675
|
+
return {
|
|
676
|
+
id,
|
|
677
|
+
name,
|
|
678
|
+
passes: [...passes],
|
|
679
|
+
...input?.description === void 0 ? {} : { description: input.description },
|
|
680
|
+
...input?.rulings === void 0 ? {} : { rulings: [...input.rulings] },
|
|
681
|
+
...input?.metadata === void 0 ? {} : { metadata: { ...input.metadata } }
|
|
682
|
+
};
|
|
683
|
+
}
|
|
684
|
+
/**
|
|
685
|
+
* Build a {@link Ruling} — one authored consequence for one rule in one pass.
|
|
686
|
+
*
|
|
687
|
+
* @param id - The ruling id
|
|
688
|
+
* @param pass - The logical pass id the rule lives in
|
|
689
|
+
* @param rule - The rule id whose firing this ruling reacts to
|
|
690
|
+
* @param effect - The eligibility impact when the rule fires
|
|
691
|
+
* @param input - Optional rating scope and message template
|
|
692
|
+
* @returns A fresh ruling
|
|
693
|
+
*
|
|
694
|
+
* @example
|
|
695
|
+
* ```ts
|
|
696
|
+
* import { rulingDefinition } from '@orkestrel/qualifier'
|
|
697
|
+
*
|
|
698
|
+
* rulingDefinition('license', 'gates', 'licensed', 'restriction', { message: 'A license is required' })
|
|
699
|
+
* ```
|
|
700
|
+
*/
|
|
701
|
+
function rulingDefinition(id, pass, rule, effect, input) {
|
|
702
|
+
return {
|
|
703
|
+
id,
|
|
704
|
+
pass,
|
|
705
|
+
rule,
|
|
706
|
+
effect,
|
|
707
|
+
...input?.scope === void 0 ? {} : { scope: input.scope },
|
|
708
|
+
...input?.message === void 0 ? {} : { message: input.message }
|
|
709
|
+
};
|
|
710
|
+
}
|
|
711
|
+
//#endregion
|
|
712
|
+
exports.DEFAULT_QUALIFIER_VALIDATE = DEFAULT_QUALIFIER_VALIDATE;
|
|
713
|
+
exports.EFFECT_ELIGIBILITIES = EFFECT_ELIGIBILITIES;
|
|
714
|
+
exports.ELIGIBILITY_PRECEDENCE = ELIGIBILITY_PRECEDENCE;
|
|
715
|
+
exports.QUALIFICATION_KEY = QUALIFICATION_KEY;
|
|
716
|
+
exports.Qualifier = Qualifier;
|
|
717
|
+
exports.QualifierError = QualifierError;
|
|
718
|
+
exports.assertSubject = assertSubject;
|
|
719
|
+
exports.combineEligibilities = combineEligibilities;
|
|
720
|
+
exports.createQualifier = createQualifier;
|
|
721
|
+
exports.deriveFindingEligibility = deriveFindingEligibility;
|
|
722
|
+
exports.deriveScopeEligibilities = deriveScopeEligibilities;
|
|
723
|
+
exports.describeComparison = describeComparison;
|
|
724
|
+
exports.describePremise = describePremise;
|
|
725
|
+
exports.describeValue = describeValue;
|
|
726
|
+
exports.findDuplicateIds = findDuplicateIds;
|
|
727
|
+
exports.findMissingReferences = findMissingReferences;
|
|
728
|
+
exports.findRule = findRule;
|
|
729
|
+
exports.hasReservedKey = hasReservedKey;
|
|
730
|
+
exports.interpolateMessage = interpolateMessage;
|
|
731
|
+
exports.isEligibility = isEligibility;
|
|
732
|
+
exports.isQualificationDefinition = isQualificationDefinition;
|
|
733
|
+
exports.isQualificationEffect = isQualificationEffect;
|
|
734
|
+
exports.isQualificationPass = isQualificationPass;
|
|
735
|
+
exports.isQualifierError = isQualifierError;
|
|
736
|
+
exports.isRuling = isRuling;
|
|
737
|
+
exports.logicalPremises = logicalPremises;
|
|
738
|
+
exports.mergeQualificationContext = mergeQualificationContext;
|
|
739
|
+
exports.premiseCheck = premiseCheck;
|
|
740
|
+
exports.qualificationDefinition = qualificationDefinition;
|
|
741
|
+
exports.qualificationToRecord = qualificationToRecord;
|
|
742
|
+
exports.quantitativeResultToDerivation = quantitativeResultToDerivation;
|
|
743
|
+
exports.reasonResultToProjection = reasonResultToProjection;
|
|
744
|
+
exports.rulingDefinition = rulingDefinition;
|
|
745
|
+
exports.rulingToFinding = rulingToFinding;
|
|
746
|
+
|
|
747
|
+
//# sourceMappingURL=index.cjs.map
|