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