@orkestrel/qualifier 0.0.1 → 0.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/src/core/index.cjs +312 -85
- package/dist/src/core/index.cjs.map +1 -1
- package/dist/src/core/index.d.cts +188 -14
- package/dist/src/core/index.d.ts +188 -14
- package/dist/src/core/index.js +311 -86
- package/dist/src/core/index.js.map +1 -1
- package/package.json +2 -2
package/dist/src/core/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { arrayOf, isFiniteNumber, isRecord, isString, literalOf, recordOf, resolveField } from "@orkestrel/contract";
|
|
2
|
-
import { createEvaluator, createLogicalReasoner, createQuantitativeReasoner, createReason, extractAtoms, extractConclusions, formatField, isBounds, isLogicalDefinition, isQuantitativeDefinition } from "@orkestrel/reason";
|
|
2
|
+
import { createEvaluator, createLogicalReasoner, createQuantitativeReasoner, createReason, extractAtoms, extractConclusions, findDuplicates, formatField, isBounds, isLogicalDefinition, isQuantitativeDefinition, isReasonError } from "@orkestrel/reason";
|
|
3
3
|
import { Emitter } from "@orkestrel/emitter";
|
|
4
4
|
//#region src/core/constants.ts
|
|
5
5
|
/** Default definition validation policy for `createQualifier` / `Qualifier.qualify`. */
|
|
@@ -27,6 +27,8 @@ var EFFECT_ELIGIBILITIES = Object.freeze({
|
|
|
27
27
|
* `DEFINITION` — a qualification definition failed semantic validation.
|
|
28
28
|
* `MISMATCH` — a subject is not a record or already carries the reserved
|
|
29
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`).
|
|
30
32
|
*/
|
|
31
33
|
var QualifierError = class extends Error {
|
|
32
34
|
code;
|
|
@@ -65,7 +67,6 @@ function isRuling(value) {
|
|
|
65
67
|
}
|
|
66
68
|
/** Determine whether a value is an exact {@link QualificationDefinition} record. */
|
|
67
69
|
function isQualificationDefinition(value) {
|
|
68
|
-
if (!isRecord(value)) return false;
|
|
69
70
|
return recordOf({
|
|
70
71
|
id: isString,
|
|
71
72
|
name: isString,
|
|
@@ -298,6 +299,26 @@ function findRule(definition, id) {
|
|
|
298
299
|
* @param definition - The pass definition that produced the result
|
|
299
300
|
* @param result - The evaluated reason result
|
|
300
301
|
* @returns The pass's working projection
|
|
302
|
+
*
|
|
303
|
+
* @example
|
|
304
|
+
* ```ts
|
|
305
|
+
* import { reasonResultToProjection } from '@orkestrel/qualifier'
|
|
306
|
+
* import { factorGroup, quantitativeDefinition, staticFactor } from '@orkestrel/reason'
|
|
307
|
+
*
|
|
308
|
+
* const cap = quantitativeDefinition('cap', 'TIV cap', [
|
|
309
|
+
* factorGroup('limit', 'sum', [staticFactor('base', 500_000)]),
|
|
310
|
+
* ])
|
|
311
|
+
* const result = {
|
|
312
|
+
* reasoning: 'quantitative' as const,
|
|
313
|
+
* value: 500_000,
|
|
314
|
+
* groups: [],
|
|
315
|
+
* count: 1,
|
|
316
|
+
* success: true,
|
|
317
|
+
* trace: [],
|
|
318
|
+
* errors: [],
|
|
319
|
+
* }
|
|
320
|
+
* reasonResultToProjection(cap, result) // 500000
|
|
321
|
+
* ```
|
|
301
322
|
*/
|
|
302
323
|
function reasonResultToProjection(definition, result) {
|
|
303
324
|
if (definition.reasoning === "quantitative" && result.reasoning === "quantitative") return result.value;
|
|
@@ -318,6 +339,15 @@ function reasonResultToProjection(definition, result) {
|
|
|
318
339
|
* @param id - The pass id
|
|
319
340
|
* @param result - The evaluated quantitative result
|
|
320
341
|
* @returns A fresh derivation
|
|
342
|
+
*
|
|
343
|
+
* @example
|
|
344
|
+
* ```ts
|
|
345
|
+
* import { quantitativeResultToDerivation } from '@orkestrel/qualifier'
|
|
346
|
+
*
|
|
347
|
+
* const result = { reasoning: 'quantitative' as const, value: 500_000, groups: [], count: 1, success: true, trace: ['cap: 500000'], errors: [] }
|
|
348
|
+
* quantitativeResultToDerivation('cap', result)
|
|
349
|
+
* // { id: 'cap', value: 500000, success: true, trace: ['cap: 500000'], errors: [] }
|
|
350
|
+
* ```
|
|
321
351
|
*/
|
|
322
352
|
function quantitativeResultToDerivation(id, result) {
|
|
323
353
|
return {
|
|
@@ -333,6 +363,13 @@ function quantitativeResultToDerivation(id, result) {
|
|
|
333
363
|
*
|
|
334
364
|
* @param context - The accumulated projection context
|
|
335
365
|
* @returns A record carrying the context under the reserved key
|
|
366
|
+
*
|
|
367
|
+
* @example
|
|
368
|
+
* ```ts
|
|
369
|
+
* import { qualificationToRecord } from '@orkestrel/qualifier'
|
|
370
|
+
*
|
|
371
|
+
* qualificationToRecord({ cap: 500_000 }) // { qualification: { cap: 500000 } }
|
|
372
|
+
* ```
|
|
336
373
|
*/
|
|
337
374
|
function qualificationToRecord(context) {
|
|
338
375
|
return { [QUALIFICATION_KEY]: context };
|
|
@@ -344,6 +381,14 @@ function qualificationToRecord(context) {
|
|
|
344
381
|
* @param id - The pass id to project under
|
|
345
382
|
* @param projection - The pass's working projection
|
|
346
383
|
* @returns A fresh context with the projection added
|
|
384
|
+
*
|
|
385
|
+
* @example
|
|
386
|
+
* ```ts
|
|
387
|
+
* import { mergeQualificationContext } from '@orkestrel/qualifier'
|
|
388
|
+
*
|
|
389
|
+
* mergeQualificationContext({ cap: 500_000 }, 'excess', 250_000)
|
|
390
|
+
* // { cap: 500000, excess: 250000 }
|
|
391
|
+
* ```
|
|
347
392
|
*/
|
|
348
393
|
function mergeQualificationContext(context, id, projection) {
|
|
349
394
|
return {
|
|
@@ -368,6 +413,21 @@ function mergeQualificationContext(context, id, projection) {
|
|
|
368
413
|
* @param evaluator - The shared reason check evaluator
|
|
369
414
|
* @param labels - Optional field-to-label overrides, keyed by dot-joined field
|
|
370
415
|
* @returns A fresh finding
|
|
416
|
+
*
|
|
417
|
+
* @example
|
|
418
|
+
* ```ts
|
|
419
|
+
* import { rulingToFinding } from '@orkestrel/qualifier'
|
|
420
|
+
* import { atom, createEvaluator, logicalDefinition, rule } from '@orkestrel/reason'
|
|
421
|
+
*
|
|
422
|
+
* const gates = logicalDefinition('gates', 'Gates', [
|
|
423
|
+
* rule('licensed', [atom('licensed', 'equals', false)], atom('blocked', 'equals', true)),
|
|
424
|
+
* ])
|
|
425
|
+
* const ruling = { id: 'license', pass: 'gates', rule: 'licensed', effect: 'restriction' as const }
|
|
426
|
+
* const result = { reasoning: 'logical' as const, conclusion: true, rules: [{ id: 'licensed', applied: true, premises: [true], conclusion: true }], count: 1, success: true, trace: [], errors: [] }
|
|
427
|
+
* const finding = rulingToFinding(ruling, gates, result, { licensed: false }, createEvaluator())
|
|
428
|
+
*
|
|
429
|
+
* finding.applied // true
|
|
430
|
+
* ```
|
|
371
431
|
*/
|
|
372
432
|
function rulingToFinding(ruling, pass, result, subject, evaluator, labels) {
|
|
373
433
|
const applied = result.rules.find((item) => item.id === ruling.rule)?.applied ?? false;
|
|
@@ -395,6 +455,14 @@ function rulingToFinding(ruling, pass, result, subject, evaluator, labels) {
|
|
|
395
455
|
* @param findings - The resolved findings
|
|
396
456
|
* @param failed - Whether an operational pass failure occurred
|
|
397
457
|
* @returns The most severe global eligibility
|
|
458
|
+
*
|
|
459
|
+
* @example
|
|
460
|
+
* ```ts
|
|
461
|
+
* import { deriveFindingEligibility } from '@orkestrel/qualifier'
|
|
462
|
+
*
|
|
463
|
+
* const finding = { id: 'license', pass: 'gates', rule: 'licensed', effect: 'restriction' as const, applied: true, premises: [] }
|
|
464
|
+
* deriveFindingEligibility([finding]) // 'ineligible'
|
|
465
|
+
* ```
|
|
398
466
|
*/
|
|
399
467
|
function deriveFindingEligibility(findings, failed = false) {
|
|
400
468
|
const eligibilities = findings.filter((finding) => finding.applied && finding.scope === void 0).map((finding) => EFFECT_ELIGIBILITIES[finding.effect]);
|
|
@@ -428,6 +496,14 @@ function combineEligibilities(eligibilities) {
|
|
|
428
496
|
*
|
|
429
497
|
* @param findings - The resolved findings
|
|
430
498
|
* @returns A fresh record of scope to its combined eligibility
|
|
499
|
+
*
|
|
500
|
+
* @example
|
|
501
|
+
* ```ts
|
|
502
|
+
* import { deriveScopeEligibilities } from '@orkestrel/qualifier'
|
|
503
|
+
*
|
|
504
|
+
* const finding = { id: 'coastal', pass: 'wind', rule: 'coastal', effect: 'restriction' as const, scope: 'wind', applied: true, premises: [] }
|
|
505
|
+
* deriveScopeEligibilities([finding]) // { wind: 'ineligible' }
|
|
506
|
+
* ```
|
|
431
507
|
*/
|
|
432
508
|
function deriveScopeEligibilities(findings) {
|
|
433
509
|
const grouped = {};
|
|
@@ -446,6 +522,22 @@ function deriveScopeEligibilities(findings) {
|
|
|
446
522
|
*
|
|
447
523
|
* @param definition - The qualification definition to check
|
|
448
524
|
* @returns A fresh list of reference-error messages
|
|
525
|
+
*
|
|
526
|
+
* @example
|
|
527
|
+
* ```ts
|
|
528
|
+
* import { findMissingReferences, qualificationDefinition, rulingDefinition } from '@orkestrel/qualifier'
|
|
529
|
+
* import { atom, logicalDefinition, rule } from '@orkestrel/reason'
|
|
530
|
+
*
|
|
531
|
+
* const gates = logicalDefinition('gates', 'Gates', [
|
|
532
|
+
* rule('licensed', [atom('licensed', 'equals', false)], atom('blocked', 'equals', true)),
|
|
533
|
+
* ])
|
|
534
|
+
* const definition = qualificationDefinition('standard', 'Standard', [gates], {
|
|
535
|
+
* rulings: [rulingDefinition('license', 'gates', 'absent', 'restriction')],
|
|
536
|
+
* })
|
|
537
|
+
*
|
|
538
|
+
* findMissingReferences(definition)
|
|
539
|
+
* // ["Ruling 'license' references missing rule 'absent' in pass 'gates'"]
|
|
540
|
+
* ```
|
|
449
541
|
*/
|
|
450
542
|
function findMissingReferences(definition) {
|
|
451
543
|
const errors = [];
|
|
@@ -466,23 +558,17 @@ function findMissingReferences(definition) {
|
|
|
466
558
|
return errors;
|
|
467
559
|
}
|
|
468
560
|
/**
|
|
469
|
-
* Find duplicate ids in a list, once each.
|
|
470
|
-
*
|
|
471
|
-
* @param ids - The ids to scan
|
|
472
|
-
* @returns A fresh list of ids that appear more than once
|
|
473
|
-
*/
|
|
474
|
-
function findDuplicateIds(ids) {
|
|
475
|
-
const seen = /* @__PURE__ */ new Set();
|
|
476
|
-
const duplicates = /* @__PURE__ */ new Set();
|
|
477
|
-
for (const id of ids) if (seen.has(id)) duplicates.add(id);
|
|
478
|
-
else seen.add(id);
|
|
479
|
-
return [...duplicates];
|
|
480
|
-
}
|
|
481
|
-
/**
|
|
482
561
|
* Determine whether a subject already owns the reserved {@link QUALIFICATION_KEY}.
|
|
483
562
|
*
|
|
484
563
|
* @param subject - The subject to check
|
|
485
564
|
* @returns `true` when the subject owns the reserved key
|
|
565
|
+
*
|
|
566
|
+
* @example
|
|
567
|
+
* ```ts
|
|
568
|
+
* import { hasReservedKey } from '@orkestrel/qualifier'
|
|
569
|
+
*
|
|
570
|
+
* hasReservedKey({ id: 's1', qualification: {} }) // true
|
|
571
|
+
* ```
|
|
486
572
|
*/
|
|
487
573
|
function hasReservedKey(subject) {
|
|
488
574
|
return Object.hasOwn(subject, QUALIFICATION_KEY);
|
|
@@ -493,16 +579,198 @@ function hasReservedKey(subject) {
|
|
|
493
579
|
* @param value - The candidate subject to validate
|
|
494
580
|
* @throws {@link QualifierError} `'MISMATCH'` when the value is not a record, or
|
|
495
581
|
* when it already carries the reserved `qualification` key
|
|
582
|
+
*
|
|
583
|
+
* @example
|
|
584
|
+
* ```ts
|
|
585
|
+
* import { assertSubject } from '@orkestrel/qualifier'
|
|
586
|
+
*
|
|
587
|
+
* assertSubject({ id: 's1' }) // narrows to Subject
|
|
588
|
+
* ```
|
|
496
589
|
*/
|
|
497
590
|
function assertSubject(value) {
|
|
498
591
|
if (!isRecord(value)) throw new QualifierError("MISMATCH", "Qualification subject must be a record");
|
|
499
592
|
if (hasReservedKey(value)) throw new QualifierError("MISMATCH", `Qualification subject must not contain reserved key '${QUALIFICATION_KEY}'`);
|
|
500
593
|
}
|
|
594
|
+
/**
|
|
595
|
+
* Map an engine throw caught while running one pass to a typed {@link QualifierError}.
|
|
596
|
+
*
|
|
597
|
+
* @remarks
|
|
598
|
+
* A reason `ReasonError('INVALID')` maps to `DEFINITION` (the engine rejected the
|
|
599
|
+
* pass as an invalid definition); `ReasonError('DESTROYED')` maps to `DESTROYED`;
|
|
600
|
+
* every other `ReasonError` code (e.g. `MISSING`) and every non-`ReasonError` throw
|
|
601
|
+
* (including a `bail: true` reasoner rethrow) maps to `ENGINE`. The original throw
|
|
602
|
+
* is preserved as `context.cause`.
|
|
603
|
+
*
|
|
604
|
+
* @param error - The value caught from the engine's `reason` call
|
|
605
|
+
* @param pass - The id of the pass that was running
|
|
606
|
+
* @returns A fresh, typed {@link QualifierError}
|
|
607
|
+
*
|
|
608
|
+
* @example
|
|
609
|
+
* ```ts
|
|
610
|
+
* import { mapEngineError } from '@orkestrel/qualifier'
|
|
611
|
+
* import { ReasonError } from '@orkestrel/reason'
|
|
612
|
+
*
|
|
613
|
+
* mapEngineError(new ReasonError('MISSING', 'No reasoner registered'), 'cap').code // 'ENGINE'
|
|
614
|
+
* ```
|
|
615
|
+
*/
|
|
616
|
+
function mapEngineError(error, pass) {
|
|
617
|
+
if (isReasonError(error)) {
|
|
618
|
+
if (error.code === "INVALID") return new QualifierError("DEFINITION", `Pass '${pass}' failed engine validation: ${error.message}`, {
|
|
619
|
+
pass,
|
|
620
|
+
cause: error
|
|
621
|
+
});
|
|
622
|
+
if (error.code === "DESTROYED") return new QualifierError("DESTROYED", `Qualifier engine was destroyed during pass '${pass}'`, {
|
|
623
|
+
pass,
|
|
624
|
+
cause: error
|
|
625
|
+
});
|
|
626
|
+
return new QualifierError("ENGINE", `Pass '${pass}' engine failure: ${error.message}`, {
|
|
627
|
+
pass,
|
|
628
|
+
cause: error
|
|
629
|
+
});
|
|
630
|
+
}
|
|
631
|
+
return new QualifierError("ENGINE", `Pass '${pass}' engine failure: ${error instanceof Error ? error.message : String(error)}`, {
|
|
632
|
+
pass,
|
|
633
|
+
cause: error
|
|
634
|
+
});
|
|
635
|
+
}
|
|
636
|
+
/**
|
|
637
|
+
* Find logical passes carrying no rulings.
|
|
638
|
+
*
|
|
639
|
+
* @remarks
|
|
640
|
+
* A logical pass with no matching ruling can never contribute a finding, so its
|
|
641
|
+
* rules only ever influence later passes' reads — this is very likely an authoring
|
|
642
|
+
* oversight.
|
|
643
|
+
*
|
|
644
|
+
* @param definition - The qualification definition to check
|
|
645
|
+
* @returns A fresh list of warning messages
|
|
646
|
+
*
|
|
647
|
+
* @example
|
|
648
|
+
* ```ts
|
|
649
|
+
* import { findEmptyLogicalPasses, qualificationDefinition } from '@orkestrel/qualifier'
|
|
650
|
+
* import { atom, logicalDefinition, rule } from '@orkestrel/reason'
|
|
651
|
+
*
|
|
652
|
+
* const gates = logicalDefinition('gates', 'Gates', [
|
|
653
|
+
* rule('licensed', [atom('licensed', 'equals', false)], atom('blocked', 'equals', true)),
|
|
654
|
+
* ])
|
|
655
|
+
* const definition = qualificationDefinition('standard', 'Standard', [gates])
|
|
656
|
+
*
|
|
657
|
+
* findEmptyLogicalPasses(definition) // ["Logical pass 'gates' has no rulings"]
|
|
658
|
+
* ```
|
|
659
|
+
*/
|
|
660
|
+
function findEmptyLogicalPasses(definition) {
|
|
661
|
+
const warnings = [];
|
|
662
|
+
for (const pass of definition.passes) {
|
|
663
|
+
if (pass.reasoning !== "logical") continue;
|
|
664
|
+
if (!(definition.rulings ?? []).some((ruling) => ruling.pass === pass.id)) warnings.push(`Logical pass '${pass.id}' has no rulings`);
|
|
665
|
+
}
|
|
666
|
+
return warnings;
|
|
667
|
+
}
|
|
668
|
+
/**
|
|
669
|
+
* Find quantitative passes never read by a later pass.
|
|
670
|
+
*
|
|
671
|
+
* @remarks
|
|
672
|
+
* A quantitative pass's projected value lives under `qualification.<id>` — if no
|
|
673
|
+
* later logical premise or quantitative factor field reads that key, the
|
|
674
|
+
* derivation is dead weight. Reads are collected per later pass: a logical pass's
|
|
675
|
+
* premise atoms (conclusion atoms are writes, not reads), and a quantitative
|
|
676
|
+
* pass's field/lookup/range factor sources plus factor checks.
|
|
677
|
+
*
|
|
678
|
+
* @param definition - The qualification definition to check
|
|
679
|
+
* @returns A fresh list of warning messages
|
|
680
|
+
*
|
|
681
|
+
* @example
|
|
682
|
+
* ```ts
|
|
683
|
+
* import { findUnreadDerivations, qualificationDefinition } from '@orkestrel/qualifier'
|
|
684
|
+
* import { factorGroup, quantitativeDefinition, staticFactor } from '@orkestrel/reason'
|
|
685
|
+
*
|
|
686
|
+
* const cap = quantitativeDefinition('cap', 'TIV cap', [
|
|
687
|
+
* factorGroup('limit', 'sum', [staticFactor('base', 500_000)]),
|
|
688
|
+
* ])
|
|
689
|
+
* const definition = qualificationDefinition('standard', 'Standard', [cap])
|
|
690
|
+
*
|
|
691
|
+
* findUnreadDerivations(definition) // ["Quantitative pass 'cap' is never read by a later pass"]
|
|
692
|
+
* ```
|
|
693
|
+
*/
|
|
694
|
+
function findUnreadDerivations(definition) {
|
|
695
|
+
const warnings = [];
|
|
696
|
+
definition.passes.forEach((pass, index) => {
|
|
697
|
+
if (pass.reasoning !== "quantitative") return;
|
|
698
|
+
const key = formatField(["qualification", pass.id]);
|
|
699
|
+
const reads = /* @__PURE__ */ new Set();
|
|
700
|
+
for (const later of definition.passes.slice(index + 1)) {
|
|
701
|
+
if (later.reasoning === "logical") for (const rule of later.rules) for (const premise of rule.premises) for (const atom of extractAtoms(premise)) reads.add(formatField(atom.check.field));
|
|
702
|
+
if (later.reasoning === "quantitative") for (const group of later.groups) for (const factor of group.factors) {
|
|
703
|
+
if (factor.source.origin === "field" || factor.source.origin === "lookup" || factor.source.origin === "range") reads.add(formatField(factor.source.field));
|
|
704
|
+
for (const check of factor.checks ?? []) reads.add(formatField(check.field));
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
if (!reads.has(key)) warnings.push(`Quantitative pass '${pass.id}' is never read by a later pass`);
|
|
708
|
+
});
|
|
709
|
+
return warnings;
|
|
710
|
+
}
|
|
711
|
+
/**
|
|
712
|
+
* Build a {@link QualificationDefinition}.
|
|
713
|
+
*
|
|
714
|
+
* @remarks
|
|
715
|
+
* Returns a fresh top-level definition, omitting absent optional keys;
|
|
716
|
+
* `passes` and `rulings` arrays are copied, and record `metadata` is
|
|
717
|
+
* shallow-copied so nested values are not deep-cloned.
|
|
718
|
+
*
|
|
719
|
+
* @param id - The definition id
|
|
720
|
+
* @param name - The display name
|
|
721
|
+
* @param passes - The ordered qualification passes
|
|
722
|
+
* @param input - Optional description, rulings, and metadata
|
|
723
|
+
* @returns A fresh qualification definition
|
|
724
|
+
*
|
|
725
|
+
* @example
|
|
726
|
+
* ```ts
|
|
727
|
+
* import { qualificationDefinition } from '@orkestrel/qualifier'
|
|
728
|
+
*
|
|
729
|
+
* qualificationDefinition('standard', 'Standard', [gates], { rulings: [ruling] })
|
|
730
|
+
* ```
|
|
731
|
+
*/
|
|
732
|
+
function qualificationDefinition(id, name, passes, input) {
|
|
733
|
+
return {
|
|
734
|
+
id,
|
|
735
|
+
name,
|
|
736
|
+
passes: [...passes],
|
|
737
|
+
...input?.description === void 0 ? {} : { description: input.description },
|
|
738
|
+
...input?.rulings === void 0 ? {} : { rulings: [...input.rulings] },
|
|
739
|
+
...input?.metadata === void 0 ? {} : { metadata: { ...input.metadata } }
|
|
740
|
+
};
|
|
741
|
+
}
|
|
742
|
+
/**
|
|
743
|
+
* Build a {@link Ruling} — one authored consequence for one rule in one pass.
|
|
744
|
+
*
|
|
745
|
+
* @param id - The ruling id
|
|
746
|
+
* @param pass - The logical pass id the rule lives in
|
|
747
|
+
* @param rule - The rule id whose firing this ruling reacts to
|
|
748
|
+
* @param effect - The eligibility impact when the rule fires
|
|
749
|
+
* @param input - Optional selection scope and message template
|
|
750
|
+
* @returns A fresh ruling
|
|
751
|
+
*
|
|
752
|
+
* @example
|
|
753
|
+
* ```ts
|
|
754
|
+
* import { rulingDefinition } from '@orkestrel/qualifier'
|
|
755
|
+
*
|
|
756
|
+
* rulingDefinition('license', 'gates', 'licensed', 'restriction', { message: 'A license is required' })
|
|
757
|
+
* ```
|
|
758
|
+
*/
|
|
759
|
+
function rulingDefinition(id, pass, rule, effect, input) {
|
|
760
|
+
return {
|
|
761
|
+
id,
|
|
762
|
+
pass,
|
|
763
|
+
rule,
|
|
764
|
+
effect,
|
|
765
|
+
...input?.scope === void 0 ? {} : { scope: input.scope },
|
|
766
|
+
...input?.message === void 0 ? {} : { message: input.message }
|
|
767
|
+
};
|
|
768
|
+
}
|
|
501
769
|
//#endregion
|
|
502
770
|
//#region src/core/Qualifier.ts
|
|
503
771
|
/**
|
|
504
772
|
* A qualifier — runs ordered passes over one reason engine and returns
|
|
505
|
-
*
|
|
773
|
+
* eligibility.
|
|
506
774
|
*
|
|
507
775
|
* @remarks
|
|
508
776
|
* The engine is OWNED when self-created (destroyed on `destroy()`) and borrowed
|
|
@@ -549,10 +817,18 @@ var Qualifier = class {
|
|
|
549
817
|
const warnings = [];
|
|
550
818
|
if (definition.id.length === 0) errors.push("Definition id must not be empty");
|
|
551
819
|
if (definition.name.length === 0) errors.push("Definition name must not be empty");
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
820
|
+
definition.passes.forEach((pass, index) => {
|
|
821
|
+
if (!isQualificationPass(pass)) errors.push(`Pass at index ${index} is not a valid qualification pass`);
|
|
822
|
+
});
|
|
823
|
+
(definition.rulings ?? []).forEach((ruling, index) => {
|
|
824
|
+
if (!isRuling(ruling)) errors.push(`Ruling at index ${index} is not a valid ruling`);
|
|
825
|
+
});
|
|
826
|
+
for (const id of findDuplicates(definition.passes)) errors.push(`Duplicate pass id '${id}'`);
|
|
827
|
+
for (const id of findDuplicates(definition.rulings ?? [])) errors.push(`Duplicate ruling id '${id}'`);
|
|
555
828
|
for (const reference of findMissingReferences(definition)) errors.push(reference);
|
|
829
|
+
if (definition.passes.length === 0) warnings.push("Definition has no passes");
|
|
830
|
+
for (const warning of findEmptyLogicalPasses(definition)) warnings.push(warning);
|
|
831
|
+
for (const warning of findUnreadDerivations(definition)) warnings.push(warning);
|
|
556
832
|
return {
|
|
557
833
|
valid: errors.length === 0,
|
|
558
834
|
errors,
|
|
@@ -576,8 +852,14 @@ var Qualifier = class {
|
|
|
576
852
|
const errors = [];
|
|
577
853
|
let failed = false;
|
|
578
854
|
for (const pass of definition.passes) {
|
|
855
|
+
this.#alive();
|
|
579
856
|
const evaluated = working;
|
|
580
|
-
|
|
857
|
+
let result;
|
|
858
|
+
try {
|
|
859
|
+
result = this.#engine.reason(evaluated, pass);
|
|
860
|
+
} catch (error) {
|
|
861
|
+
throw mapEngineError(error, pass.id);
|
|
862
|
+
}
|
|
581
863
|
trace.push(...result.trace.map((entry) => `${pass.id}: ${entry}`));
|
|
582
864
|
errors.push(...result.errors.map((entry) => `${pass.id}: ${entry}`));
|
|
583
865
|
if (result.reasoning === "quantitative") {
|
|
@@ -606,17 +888,18 @@ var Qualifier = class {
|
|
|
606
888
|
if (deriveFindingEligibility(findings) === "ineligible") break;
|
|
607
889
|
}
|
|
608
890
|
const success = !failed && errors.length === 0;
|
|
609
|
-
const
|
|
891
|
+
const scopes = deriveScopeEligibilities(findings);
|
|
892
|
+
const qualification = Object.freeze({
|
|
610
893
|
id: definition.id,
|
|
611
894
|
name: definition.name,
|
|
612
895
|
eligibility: deriveFindingEligibility(findings, !success),
|
|
613
|
-
scopes:
|
|
614
|
-
findings,
|
|
615
|
-
derivations,
|
|
896
|
+
scopes: Object.freeze(scopes),
|
|
897
|
+
findings: Object.freeze(findings),
|
|
898
|
+
derivations: Object.freeze(derivations),
|
|
616
899
|
success,
|
|
617
|
-
trace,
|
|
618
|
-
errors
|
|
619
|
-
};
|
|
900
|
+
trace: Object.freeze(trace),
|
|
901
|
+
errors: Object.freeze(errors)
|
|
902
|
+
});
|
|
620
903
|
this.#emitter.emit("qualify", qualification);
|
|
621
904
|
return qualification;
|
|
622
905
|
}
|
|
@@ -649,65 +932,7 @@ var Qualifier = class {
|
|
|
649
932
|
function createQualifier(options) {
|
|
650
933
|
return new Qualifier(options);
|
|
651
934
|
}
|
|
652
|
-
/**
|
|
653
|
-
* Build a {@link QualificationDefinition}.
|
|
654
|
-
*
|
|
655
|
-
* @remarks
|
|
656
|
-
* Returns a fresh top-level definition, omitting absent optional keys;
|
|
657
|
-
* `passes` and `rulings` arrays are copied, and record `metadata` is
|
|
658
|
-
* shallow-copied so nested values are not deep-cloned.
|
|
659
|
-
*
|
|
660
|
-
* @param id - The definition id
|
|
661
|
-
* @param name - The display name
|
|
662
|
-
* @param passes - The ordered qualification passes
|
|
663
|
-
* @param input - Optional description, rulings, and metadata
|
|
664
|
-
* @returns A fresh qualification definition
|
|
665
|
-
*
|
|
666
|
-
* @example
|
|
667
|
-
* ```ts
|
|
668
|
-
* import { qualificationDefinition } from '@orkestrel/qualifier'
|
|
669
|
-
*
|
|
670
|
-
* qualificationDefinition('standard', 'Standard', [gates], { rulings: [ruling] })
|
|
671
|
-
* ```
|
|
672
|
-
*/
|
|
673
|
-
function qualificationDefinition(id, name, passes, input) {
|
|
674
|
-
return {
|
|
675
|
-
id,
|
|
676
|
-
name,
|
|
677
|
-
passes: [...passes],
|
|
678
|
-
...input?.description === void 0 ? {} : { description: input.description },
|
|
679
|
-
...input?.rulings === void 0 ? {} : { rulings: [...input.rulings] },
|
|
680
|
-
...input?.metadata === void 0 ? {} : { metadata: { ...input.metadata } }
|
|
681
|
-
};
|
|
682
|
-
}
|
|
683
|
-
/**
|
|
684
|
-
* Build a {@link Ruling} — one authored consequence for one rule in one pass.
|
|
685
|
-
*
|
|
686
|
-
* @param id - The ruling id
|
|
687
|
-
* @param pass - The logical pass id the rule lives in
|
|
688
|
-
* @param rule - The rule id whose firing this ruling reacts to
|
|
689
|
-
* @param effect - The eligibility impact when the rule fires
|
|
690
|
-
* @param input - Optional rating scope and message template
|
|
691
|
-
* @returns A fresh ruling
|
|
692
|
-
*
|
|
693
|
-
* @example
|
|
694
|
-
* ```ts
|
|
695
|
-
* import { rulingDefinition } from '@orkestrel/qualifier'
|
|
696
|
-
*
|
|
697
|
-
* rulingDefinition('license', 'gates', 'licensed', 'restriction', { message: 'A license is required' })
|
|
698
|
-
* ```
|
|
699
|
-
*/
|
|
700
|
-
function rulingDefinition(id, pass, rule, effect, input) {
|
|
701
|
-
return {
|
|
702
|
-
id,
|
|
703
|
-
pass,
|
|
704
|
-
rule,
|
|
705
|
-
effect,
|
|
706
|
-
...input?.scope === void 0 ? {} : { scope: input.scope },
|
|
707
|
-
...input?.message === void 0 ? {} : { message: input.message }
|
|
708
|
-
};
|
|
709
|
-
}
|
|
710
935
|
//#endregion
|
|
711
|
-
export { DEFAULT_QUALIFIER_VALIDATE, EFFECT_ELIGIBILITIES, ELIGIBILITY_PRECEDENCE, QUALIFICATION_KEY, Qualifier, QualifierError, assertSubject, combineEligibilities, createQualifier, deriveFindingEligibility, deriveScopeEligibilities, describeComparison, describePremise, describeValue,
|
|
936
|
+
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, isEligibility, isQualificationDefinition, isQualificationEffect, isQualificationPass, isQualifierError, isRuling, logicalPremises, mapEngineError, mergeQualificationContext, premiseCheck, qualificationDefinition, qualificationToRecord, quantitativeResultToDerivation, reasonResultToProjection, rulingDefinition, rulingToFinding };
|
|
712
937
|
|
|
713
938
|
//# sourceMappingURL=index.js.map
|