4fd4e20bd0
Systemic reliability gap found by the probe sweep: the compiled evaluation paths never emitted the reliability the engine computes. - Compiled _evaluateDirect omitted the relation's reliability, and the chain/multi_hop rules hardcoded reliability: 1.0 — so check() results reported 1.0 for any rule whose decision came through a chain, multi_hop, union, intersection, exclusion, or defeasible combination. - The chain and multi_hop traversals now track per-path reliability (product of edge reliabilities) and report the winning path's value; the compiled and fallback logical operators (union/intersection/exclusion, direct_list fast path, early exits) report the selected child's reliability (max/min child or OWA trace index; exclusion multiplies both legs), and normal-mode defeasible combines base x requires x defeater reliabilities. - The checker's logical fast path dropped collectedValues from union/ intersection/exclusion results; it now passes them through. - MultiHopRule.valueManager was read off relationManager where the real arbiter keeps it on the arbiter — collectValues: true on a multi_hop rule with a value-carrying edge crashed the evaluation (error result, silent denial). Now resolved at the arbiter level with a relationManager fallback for stubs. Campaign pins: reliability per kind (chain/multi_hop product, union/intersection selected child, exclusion/defeasible product), and multi_hop value collection through persistent and partial contexts.
334 lines
12 KiB
JavaScript
334 lines
12 KiB
JavaScript
/**
|
|
* rigor/relational-comparator-router.test.js — js-rigor property tests for
|
|
* RelationalComparatorRouter._isQualitativeRule.
|
|
*
|
|
* The router dispatches to either the numeric or qualitative implementation
|
|
* based on the rule config. Properties:
|
|
* - rule.qualitative === true → qualitative (regardless of other fields)
|
|
* - any operand scaleName → qualitative
|
|
* - any operand decaySteps/baseBlurSteps (defined, non-null) → qualitative
|
|
* - rule.marginSteps (defined, non-null) → qualitative
|
|
* - otherwise → numeric
|
|
*
|
|
* getImplementationType is a pure pass-through to _isQualitativeRule.
|
|
*/
|
|
import { describe, it } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { rigor } from '@rigor/core';
|
|
import { RelationalComparatorRouter } from '../../src/authorization/rules/RelationalComparatorRouter.js';
|
|
|
|
const SCALE_NAMES = ['five-point', 'ternary', 'ordinal-7'];
|
|
|
|
/**
|
|
* Build a stub arbiter that satisfies the rule's constructor.
|
|
* Neither _evaluateRule is called by these tests, but the constructor
|
|
* stores references that the rule may touch on dispatch.
|
|
*/
|
|
function makeStubArbiter() {
|
|
return {
|
|
nodeIdByKey: new Map(),
|
|
keyByNodeId: new Map(),
|
|
relations: [],
|
|
nodes: new Map()
|
|
};
|
|
}
|
|
|
|
function makeRouter() {
|
|
// _evaluateRule is what we test; pass a stub arbiter so the
|
|
// constructor doesn't crash. We never call _evaluateRule in these
|
|
// tests — we test _isQualitativeRule and getImplementationType
|
|
// directly.
|
|
return new RelationalComparatorRouter(makeStubArbiter(), {});
|
|
}
|
|
|
|
describe('RelationalComparatorRouter._isQualitativeRule (rigor)', () => {
|
|
it('rule.qualitative=true always wins', async () => {
|
|
async function check(rule) {
|
|
const router = makeRouter();
|
|
const result = router._isQualitativeRule(rule);
|
|
if (!result) {
|
|
throw new Error(
|
|
`expected qualitative=true to win, got numeric. rule=${JSON.stringify(rule)}`
|
|
);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
const report = await rigor.campaign(
|
|
[
|
|
rigor.fn('check', check,
|
|
rigor.args(
|
|
rigor.gen.object({
|
|
qualitative: rigor.gen.constant(true),
|
|
// random other fields that should not matter
|
|
left: rigor.gen.option(rigor.gen.object({
|
|
scaleName: rigor.gen.option(rigor.gen.enum(SCALE_NAMES))
|
|
})),
|
|
right: rigor.gen.option(rigor.gen.object({
|
|
scaleName: rigor.gen.option(rigor.gen.enum(SCALE_NAMES))
|
|
})),
|
|
marginSteps: rigor.gen.option(rigor.gen.int(0, 5)),
|
|
fallbackBehavior: rigor.gen.option(rigor.gen.enum(['allow', 'deny']))
|
|
})
|
|
)
|
|
)
|
|
],
|
|
rigor.crucible([
|
|
rigor.invariant('qualitative-wins', ({ error, errorMessage }) => !error && !errorMessage)
|
|
])
|
|
).run({ effort: 800 , artifacts: { dir: '', persist: 'never' }});
|
|
|
|
if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
|
|
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'qualitative-wins');
|
|
assert.ok(inv);
|
|
assert.equal(inv.passed, true,
|
|
`qualitative=true did not win in ${inv.failureCount} cases`);
|
|
});
|
|
|
|
it('scaleName on either operand triggers qualitative', async () => {
|
|
async function check(operand, side) {
|
|
const router = makeRouter();
|
|
const rule = {
|
|
left: side === 'left' ? operand : {},
|
|
right: side === 'right' ? operand : {}
|
|
};
|
|
const result = router._isQualitativeRule(rule);
|
|
if (!result) {
|
|
throw new Error(
|
|
`expected scaleName on ${side} to trigger qualitative, got numeric. ` +
|
|
`operand=${JSON.stringify(operand)}`
|
|
);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
const report = await rigor.campaign(
|
|
[
|
|
rigor.fn('check', check,
|
|
rigor.args(
|
|
rigor.gen.object({
|
|
scaleName: rigor.gen.enum(SCALE_NAMES)
|
|
}),
|
|
rigor.gen.enum(['left', 'right'])
|
|
)
|
|
)
|
|
],
|
|
rigor.crucible([
|
|
rigor.invariant('scaleName-triggers', ({ error, errorMessage }) => !error && !errorMessage)
|
|
])
|
|
).run({ effort: 500 , artifacts: { dir: '', persist: 'never' }});
|
|
|
|
if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
|
|
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'scaleName-triggers');
|
|
assert.ok(inv);
|
|
assert.equal(inv.passed, true,
|
|
`scaleName did not trigger qualitative in ${inv.failureCount} cases`);
|
|
});
|
|
|
|
it('decaySteps/baseBlurSteps on either operand triggers qualitative', async () => {
|
|
async function check(operand, side) {
|
|
const router = makeRouter();
|
|
const rule = {
|
|
left: side === 'left' ? operand : {},
|
|
right: side === 'right' ? operand : {}
|
|
};
|
|
const result = router._isQualitativeRule(rule);
|
|
if (!result) {
|
|
throw new Error(
|
|
`expected ${JSON.stringify(Object.keys(operand))} on ${side} to trigger qualitative`
|
|
);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
const report = await rigor.campaign(
|
|
[
|
|
rigor.fn('check', check,
|
|
rigor.args(
|
|
rigor.gen.oneOf([
|
|
rigor.gen.object({ decaySteps: rigor.gen.int(0, 10) }),
|
|
rigor.gen.object({ baseBlurSteps: rigor.gen.int(0, 10) })
|
|
]),
|
|
rigor.gen.enum(['left', 'right'])
|
|
)
|
|
)
|
|
],
|
|
rigor.crucible([
|
|
rigor.invariant('decay-blur-triggers', ({ error, errorMessage }) => !error && !errorMessage)
|
|
])
|
|
).run({ effort: 500 , artifacts: { dir: '', persist: 'never' }});
|
|
|
|
if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
|
|
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'decay-blur-triggers');
|
|
assert.ok(inv);
|
|
assert.equal(inv.passed, true,
|
|
`decaySteps/baseBlurSteps did not trigger qualitative in ${inv.failureCount} cases`);
|
|
});
|
|
|
|
it('marginSteps triggers qualitative (when defined and non-null)', async () => {
|
|
async function check(marginSteps) {
|
|
const router = makeRouter();
|
|
const rule = { marginSteps };
|
|
const result = router._isQualitativeRule(rule);
|
|
if (marginSteps === undefined || marginSteps === null) {
|
|
// marginSteps not present — should not trigger
|
|
if (result) throw new Error(`marginSteps=${marginSteps} should not trigger qualitative`);
|
|
} else {
|
|
if (!result) throw new Error(`marginSteps=${marginSteps} should trigger qualitative`);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
const report = await rigor.campaign(
|
|
[
|
|
rigor.fn('check', check,
|
|
rigor.args(
|
|
rigor.gen.option(rigor.gen.int(0, 10))
|
|
)
|
|
)
|
|
],
|
|
rigor.crucible([
|
|
rigor.invariant('marginSteps-correct', ({ error, errorMessage }) => !error && !errorMessage)
|
|
])
|
|
).run({ effort: 800 , artifacts: { dir: '', persist: 'never' }});
|
|
|
|
if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
|
|
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'marginSteps-correct');
|
|
assert.ok(inv);
|
|
assert.equal(inv.passed, true,
|
|
`marginSteps handling was wrong in ${inv.failureCount} cases`);
|
|
});
|
|
|
|
it('plain numeric rule (no qualitative flags) routes to numeric', async () => {
|
|
async function check(leftValue, rightValue, comparator) {
|
|
const router = makeRouter();
|
|
const rule = {
|
|
left: { rule: { type: 'direct' }, extractValue: true },
|
|
right: { rule: { type: 'direct' }, extractValue: true },
|
|
comparator,
|
|
leftValue, // injected for testing only — production ignores
|
|
rightValue
|
|
};
|
|
const result = router._isQualitativeRule(rule);
|
|
if (result) {
|
|
throw new Error(
|
|
`expected numeric, got qualitative. rule=${JSON.stringify(rule)}`
|
|
);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
const report = await rigor.campaign(
|
|
[
|
|
rigor.fn('check', check,
|
|
rigor.args(
|
|
rigor.gen.int(-1000, 1000),
|
|
rigor.gen.int(-1000, 1000),
|
|
rigor.gen.enum(['>', '>=', '<', '<=', '==', '!='])
|
|
)
|
|
)
|
|
],
|
|
rigor.crucible([
|
|
rigor.invariant('plain-numeric', ({ error, errorMessage }) => !error && !errorMessage)
|
|
])
|
|
).run({ effort: 800 , artifacts: { dir: '', persist: 'never' }});
|
|
|
|
if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
|
|
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'plain-numeric');
|
|
assert.ok(inv);
|
|
assert.equal(inv.passed, true,
|
|
`plain numeric rule routed to qualitative in ${inv.failureCount} cases`);
|
|
});
|
|
|
|
it('getImplementationType matches _isQualitativeRule', async () => {
|
|
async function check(rule) {
|
|
const router = makeRouter();
|
|
const fromPrivate = router._isQualitativeRule(rule);
|
|
const fromPublic = router.getImplementationType(rule);
|
|
const expected = fromPrivate ? 'qualitative' : 'numeric';
|
|
if (fromPublic !== expected) {
|
|
throw new Error(
|
|
`getImplementationType=${fromPublic} but _isQualitativeRule=${fromPrivate}`
|
|
);
|
|
}
|
|
return fromPublic;
|
|
}
|
|
|
|
const report = await rigor.campaign(
|
|
[
|
|
rigor.fn('check', check,
|
|
rigor.args(
|
|
rigor.gen.object({
|
|
qualitative: rigor.gen.option(rigor.gen.boolean()),
|
|
left: rigor.gen.option(rigor.gen.object({
|
|
scaleName: rigor.gen.option(rigor.gen.enum(SCALE_NAMES)),
|
|
decaySteps: rigor.gen.option(rigor.gen.int(0, 10))
|
|
})),
|
|
right: rigor.gen.option(rigor.gen.object({
|
|
scaleName: rigor.gen.option(rigor.gen.enum(SCALE_NAMES)),
|
|
decaySteps: rigor.gen.option(rigor.gen.int(0, 10))
|
|
})),
|
|
marginSteps: rigor.gen.option(rigor.gen.int(0, 10))
|
|
})
|
|
)
|
|
)
|
|
],
|
|
rigor.crucible([
|
|
rigor.invariant('getImplType-consistent', ({ error, errorMessage }) => !error && !errorMessage)
|
|
])
|
|
).run({ effort: 1500 , artifacts: { dir: '', persist: 'never' }});
|
|
|
|
if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
|
|
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'getImplType-consistent');
|
|
assert.ok(inv);
|
|
assert.equal(inv.passed, true,
|
|
`getImplementationType disagreed with _isQualitativeRule in ${inv.failureCount} cases`);
|
|
});
|
|
|
|
it('_hasValidQualitativeProperty: undefined/null → false, anything else → true', async () => {
|
|
async function check(value) {
|
|
const router = makeRouter();
|
|
const result = router._hasValidQualitativeProperty(value);
|
|
if (value === undefined || value === null) {
|
|
if (result) throw new Error(`_hasValidQualitativeProperty(${value}) should be false`);
|
|
} else {
|
|
if (!result) throw new Error(`_hasValidQualitativeProperty(${value}) should be true`);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
const report = await rigor.campaign(
|
|
[
|
|
rigor.fn('check', check,
|
|
rigor.args(
|
|
// Exclude undefined/null from explicit inputs — the
|
|
// generator treats them as "absent". The campaign will
|
|
// also exercise the absent path via undefined when the
|
|
// record is shrunk to {}.
|
|
rigor.gen.option(
|
|
rigor.gen.oneOf([
|
|
rigor.gen.int(),
|
|
rigor.gen.float(),
|
|
rigor.gen.boolean(),
|
|
rigor.gen.string(),
|
|
rigor.gen.constant(0),
|
|
rigor.gen.constant(''),
|
|
rigor.gen.constant(false)
|
|
])
|
|
)
|
|
)
|
|
)
|
|
],
|
|
rigor.crucible([
|
|
rigor.invariant('hasValidProperty', ({ error, errorMessage }) => !error && !errorMessage)
|
|
])
|
|
).run({ effort: 1000 , artifacts: { dir: '', persist: 'never' }});
|
|
|
|
if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
|
|
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'hasValidProperty');
|
|
assert.ok(inv);
|
|
assert.equal(inv.passed, true,
|
|
`_hasValidQualitativeProperty contract violated in ${inv.failureCount} cases`);
|
|
});
|
|
});
|