@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.
@@ -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, isReasonValidationResult } from "@orkestrel/reason";
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
- /** Default definition validation policy for `createQualifier` / `Qualifier.qualify`. */
5
+ /**
6
+ * Holds the default definition validation policy for `createQualifier` and
7
+ * `Qualifier.qualify`, `true`.
8
+ */
6
9
  var DEFAULT_QUALIFIER_VALIDATE = true;
7
- /** The reserved internal projection namespace a pass's working projection is written under. */
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
- /** Eligibility severity order — most to least severe. */
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
- /** The eligibility impact of each {@link QualificationEffect}; `condition` remains eligible. */
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
- * A coded programmer error thrown by the qualifier layer.
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
- * `MISMATCH` — a subject is not a record or already carries the reserved
29
- * `qualification` key. `DESTROYED` — use of a destroyed qualifier.
30
- * `ENGINE` — the underlying reason engine threw while running a pass (the
31
- * original throw is preserved as `context.cause`).
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
- /** Narrow a caught value to a {@link QualifierError}. */
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
- /** Determine whether a value is an {@link Eligibility} literal. */
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
- /** Determine whether a value is a {@link QualificationEffect} literal. */
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
- * Determine whether a value is an open string-keyed record of {@link Eligibility} values.
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 property is checked, including non-enumerable properties. Inherited and
58
- * symbol-named members are left unchecked because they are outside the record this guard certifies.
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 `true` when `value` is a non-array object whose own string-named values are eligibilities
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
- * Determine whether a value is an open result-side {@link Premise}.
107
+ * Determines whether a value is an open result-side {@link Premise}.
68
108
  *
69
109
  * @remarks
70
- * `expected` is unchecked and may be absent or contain any value because its published type is
71
- * `unknown`. `actual` is unchecked and may be absent or contain any value for the same reason.
72
- * Unknown members are admitted.
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 `true` when every checked premise member follows its published type
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
- * Determine whether a value is an open result-side {@link Finding}.
135
+ * Determines whether a value is an open result-side {@link Finding}.
94
136
  *
95
137
  * @remarks
96
- * Unknown members are admitted. Optional `scope` and `message` members may be absent or
97
- * `undefined`; a present defined value must be a string.
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 `true` when every published finding member is valid
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
- * Determine whether a value is an open result-side {@link Derivation}.
159
+ * Determines whether a value is an open result-side {@link Derivation}.
116
160
  *
117
161
  * @remarks
118
- * Unknown members are admitted. `value` follows the published `number` type, including `NaN` and
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 `true` when every published derivation member is valid
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
- * Determine whether a value is an open {@link QualificationResult} returned by a qualifier.
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
- * admits unknown members and class instances while checking the complete published result closure.
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 `true` when every published qualification-result member is valid
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
- * Determine whether a value is an open {@link QualificationValidationResult} returned by a qualifier.
206
+ * Determines whether a value is a {@link QualificationPass} (a quantitative or logical definition).
158
207
  *
159
208
  * @remarks
160
- * `QualificationValidationResult` is an alias of reasons' validation-result type, so this IS
161
- * reasons' published open result guard by delegation — the alias tracks reasons' shape
162
- * automatically, and a hand-written member list here would silently stop checking a member a
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 `true` when every published qualification-validation-result member is valid
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
- /** Determine whether a value is an exact {@link Ruling} record. */
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
- /** Determine whether a value is an exact {@link QualificationDefinition} record. */
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
- * Interpolate `{{dotted.path}}` tokens in a message template against a subject.
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 ONE key, never dot-split — the split
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 UNRESOLVED path renders as the empty string.
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
- * Describe a {@link Premise} comparison as a display-neutral verb phrase.
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 { describeComparison } from '@orkestrel/qualifier'
304
+ * import { renderComparison } from '@orkestrel/qualifier'
240
305
  *
241
- * describeComparison('above') // 'is more than'
306
+ * renderComparison('above') // 'is more than'
242
307
  * ```
243
308
  */
244
- function describeComparison(comparison) {
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
- * Render a structured or scalar expected value display-neutrally.
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 NOT grouped here (grouping is {@link interpolateMessage}'s
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 { describeValue } from '@orkestrel/qualifier'
339
+ * import { renderValue } from '@orkestrel/qualifier'
275
340
  *
276
- * describeValue([18, 25, 40]) // '18, 25, 40'
277
- * describeValue({ minimum: 18, maximum: 65 }) // '18 and 65'
278
- * describeValue(42) // '42'
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 describeValue(value) {
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
- * Render one {@link Premise} into a display-neutral sentence.
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 BOTH
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 { describePremise } from '@orkestrel/qualifier'
370
+ * import { renderPremise } from '@orkestrel/qualifier'
306
371
  *
307
- * describePremise({ field: 'age', comparison: 'above', expected: 18, actual: 25, met: true })
372
+ * renderPremise({ field: 'age', comparison: 'above', expected: 18, actual: 25, met: true })
308
373
  * // 'age is more than 18 → met'
309
374
  *
310
- * describePremise({ description: 'Applicant is enrolled', met: true })
375
+ * renderPremise({ description: 'Applicant is enrolled', met: true })
311
376
  * // 'Applicant is enrolled → met'
312
377
  * ```
313
378
  */
314
- function describePremise(entry, labels) {
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 ? "" : ` ${describeValue(entry.expected)}`;
320
- return `${label} ${describeComparison(entry.comparison)}${expected} → ${status}`;
384
+ const expected = entry.expected === void 0 ? "" : ` ${renderValue(entry.expected)}`;
385
+ return `${label} ${renderComparison(entry.comparison)}${expected} → ${status}`;
321
386
  }
322
387
  /**
323
- * Build a {@link Premise} from an authored {@link Check} and its evaluated
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 { premiseCheck } from '@orkestrel/qualifier'
334
- * import { check } from '@orkestrel/reason'
398
+ * import { checkToPremise } from '@orkestrel/qualifier'
399
+ * import { createCheck } from '@orkestrel/reason'
335
400
  *
336
- * const authored = check('age', 'above', 18)
337
- * premiseCheck(authored, { field: 'age', actual: 25, met: true })
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 premiseCheck(check, result, labels) {
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
- * Build rich premises for one fired {@link Rule} by walking its premise atoms
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 via `extractAtoms`, and each leaf's `Check` is re-evaluated through
360
- * the injected `evaluator`. A membership check (`any` / `none`) over an EMPTY
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 { logicalPremises } from '@orkestrel/qualifier'
372
- * import { atom, createEvaluator, logicalDefinition, rule } from '@orkestrel/reason'
436
+ * import { ruleToPremises } from '@orkestrel/qualifier'
437
+ * import { createAtom, createEvaluator, createLogicalDefinition, createRule } from '@orkestrel/reason'
373
438
  *
374
- * const gates = logicalDefinition('gates', 'Gates', [
375
- * rule('licensed', [atom('licensed', 'equals', false)], atom('blocked', 'equals', true)),
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 = logicalPremises(gates.rules[0], { licensed: false }, evaluator)
443
+ * const premises = ruleToPremises(gates.rules[0], { licensed: false }, evaluator)
379
444
  *
380
445
  * premises[0]?.met // true
381
446
  * ```
382
447
  */
383
- function logicalPremises(rule, working, evaluator, labels) {
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(premiseCheck(check, evaluator.evaluate(check, working), labels));
453
+ output.push(checkToPremise(check, evaluator.evaluate(check, working), labels));
389
454
  }
390
455
  return output;
391
456
  }
392
457
  /**
393
- * Locate an authored {@link Rule} by id.
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 { atom, logicalDefinition, rule } from '@orkestrel/reason'
467
+ * import { createAtom, createLogicalDefinition, createRule } from '@orkestrel/reason'
403
468
  *
404
- * const gates = logicalDefinition('gates', 'Gates', [
405
- * rule('licensed', [atom('licensed', 'equals', false)], atom('blocked', 'equals', true)),
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
- * Project one reason result into the internal qualification namespace.
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 { factorGroup, quantitativeDefinition, staticFactor } from '@orkestrel/reason'
495
+ * import { createFactorGroup, createQuantitativeDefinition, createStaticFactor } from '@orkestrel/reason'
431
496
  *
432
- * const cap = quantitativeDefinition('cap', 'TIV cap', [
433
- * factorGroup('limit', 'sum', [staticFactor('base', 500_000)]),
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
- * Project a quantitative result into a {@link Derivation} audit record.
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
- * Wrap a {@link QualificationContext} under {@link QUALIFICATION_KEY}.
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
- * Copy-on-write merge one pass projection into the context.
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
- * Join a ruling, its logical rule result, the pass, the pre-projection subject,
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 SAME subject snapshot the pass evaluated, so a conclusion the pass
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 { atom, createEvaluator, logicalDefinition, rule } from '@orkestrel/reason'
609
+ * import { createAtom, createEvaluator, createLogicalDefinition, createRule } from '@orkestrel/reason'
545
610
  *
546
- * const gates = logicalDefinition('gates', 'Gates', [
547
- * rule('licensed', [atom('licensed', 'equals', false)], atom('blocked', 'equals', true)),
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], conclusion: true }], count: 1, success: true, trace: [], errors: [] }
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((item) => item.id === ruling.rule)?.applied ?? false;
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 ? [] : logicalPremises(rule, subject, evaluator, labels);
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
- * Derive global eligibility from applied, unscoped findings.
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 - Whether an operational pass failure occurred
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
- * Return the most severe {@link Eligibility} in a list.
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
- * Derive one eligibility per finding scope.
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
- * Find rulings whose pass or rule does not exist, whose pass is not logical, or
645
- * a pass id shadowing the reserved {@link QUALIFICATION_KEY}.
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 { findMissingReferences, qualificationDefinition, rulingDefinition } from '@orkestrel/qualifier'
653
- * import { atom, logicalDefinition, rule } from '@orkestrel/reason'
718
+ * import { createQualificationDefinition, createRuling, describeMissingReferences } from '@orkestrel/qualifier'
719
+ * import { createAtom, createLogicalDefinition, createRule } from '@orkestrel/reason'
654
720
  *
655
- * const gates = logicalDefinition('gates', 'Gates', [
656
- * rule('licensed', [atom('licensed', 'equals', false)], atom('blocked', 'equals', true)),
721
+ * const gates = createLogicalDefinition('gates', 'Gates', [
722
+ * createRule('licensed', [createAtom('licensed', 'equals', false)], createAtom('blocked', 'equals', true)),
657
723
  * ])
658
- * const definition = qualificationDefinition('standard', 'Standard', [gates], {
659
- * rulings: [rulingDefinition('license', 'gates', 'absent', 'restriction')],
724
+ * const definition = createQualificationDefinition('standard', 'Standard', [gates], {
725
+ * rulings: [createRuling('license', 'gates', 'absent', 'restriction')],
660
726
  * })
661
727
  *
662
- * findMissingReferences(definition)
728
+ * describeMissingReferences(definition)
663
729
  * // ["Ruling 'license' references missing rule 'absent' in pass 'gates'"]
664
730
  * ```
665
731
  */
666
- function findMissingReferences(definition) {
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
- * Determine whether a subject already owns the reserved {@link QUALIFICATION_KEY}.
751
+ * Determines whether a subject already owns the reserved {@link QUALIFICATION_KEY}.
686
752
  *
687
753
  * @param subject - The subject to check
688
- * @returns `true` when the subject owns the reserved key
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
- * Assert a value is a valid qualification {@link Subject}, narrowing it in place.
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
- * Map an engine throw caught while running one pass to a typed {@link QualifierError}.
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 (e.g. `MISSING`) and every non-`ReasonError` throw
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
- * Find logical passes carrying no rulings.
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 { findEmptyLogicalPasses, qualificationDefinition } from '@orkestrel/qualifier'
774
- * import { atom, logicalDefinition, rule } from '@orkestrel/reason'
839
+ * import { createQualificationDefinition, describeEmptyLogicalPasses } from '@orkestrel/qualifier'
840
+ * import { createAtom, createLogicalDefinition, createRule } from '@orkestrel/reason'
775
841
  *
776
- * const gates = logicalDefinition('gates', 'Gates', [
777
- * rule('licensed', [atom('licensed', 'equals', false)], atom('blocked', 'equals', true)),
842
+ * const gates = createLogicalDefinition('gates', 'Gates', [
843
+ * createRule('licensed', [createAtom('licensed', 'equals', false)], createAtom('blocked', 'equals', true)),
778
844
  * ])
779
- * const definition = qualificationDefinition('standard', 'Standard', [gates])
845
+ * const definition = createQualificationDefinition('standard', 'Standard', [gates])
780
846
  *
781
- * findEmptyLogicalPasses(definition) // ["Logical pass 'gates' has no rulings"]
847
+ * describeEmptyLogicalPasses(definition) // ["Logical pass 'gates' has no rulings"]
782
848
  * ```
783
849
  */
784
- function findEmptyLogicalPasses(definition) {
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
- * Find quantitative passes never read by a later pass.
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 { findUnreadDerivations, qualificationDefinition } from '@orkestrel/qualifier'
808
- * import { factorGroup, quantitativeDefinition, staticFactor } from '@orkestrel/reason'
873
+ * import { createQualificationDefinition, describeUnreadDerivations } from '@orkestrel/qualifier'
874
+ * import { createFactorGroup, createQuantitativeDefinition, createStaticFactor } from '@orkestrel/reason'
809
875
  *
810
- * const cap = quantitativeDefinition('cap', 'TIV cap', [
811
- * factorGroup('limit', 'sum', [staticFactor('base', 500_000)]),
876
+ * const cap = createQuantitativeDefinition('cap', 'TIV cap', [
877
+ * createFactorGroup('limit', 'sum', [createStaticFactor('base', 500_000)]),
812
878
  * ])
813
- * const definition = qualificationDefinition('standard', 'Standard', [cap])
879
+ * const definition = createQualificationDefinition('standard', 'Standard', [cap])
814
880
  *
815
- * findUnreadDerivations(definition) // ["Quantitative pass 'cap' is never read by a later pass"]
881
+ * describeUnreadDerivations(definition) // ["Quantitative pass 'cap' is never read by a later pass"]
816
882
  * ```
817
883
  */
818
- function findUnreadDerivations(definition) {
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
- * A qualifier — runs ordered passes over one reason engine and returns
897
- * eligibility.
904
+ * Runs ordered passes over one reason engine and returns eligibility.
898
905
  *
899
906
  * @remarks
900
- * The engine is OWNED when self-created (destroyed on `destroy()`) and borrowed
901
- * when injected (never destroyed). `qualify` builds its working subject through
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 findMissingReferences(definition)) errors.push(reference);
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 findEmptyLogicalPasses(definition)) warnings.push(warning);
955
- for (const warning of findUnreadDerivations(definition)) warnings.push(warning);
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
- * Create one qualifier over a reason engine.
1137
+ * Creates one {@link QualifierInterface} over a reason engine.
1038
1138
  *
1039
1139
  * @remarks
1040
- * A standalone qualifier creates and OWNS one shared quantitative-plus-logical
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. Validation is on by default.
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, createQualifier, deriveFindingEligibility, deriveScopeEligibilities, describeComparison, describePremise, describeValue, findEmptyLogicalPasses, findMissingReferences, findRule, findUnreadDerivations, hasReservedKey, interpolateMessage, isDerivation, isEligibility, isEligibilityRecord, isFinding, isPremise, isQualificationDefinition, isQualificationEffect, isQualificationPass, isQualificationResult, isQualificationValidationResult, isQualifierError, isRuling, logicalPremises, mapEngineError, mergeQualificationContext, premiseCheck, qualificationDefinition, qualificationToRecord, quantitativeResultToDerivation, reasonResultToProjection, rulingDefinition, rulingToFinding };
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