js-rigor: possibilistic validity layer (Cella FVN labels, conflict mass, validification)

Adds an epistemic validity layer in the spirit of the zig-contour fusion
spec: every check result now carries a validity block {label, operator,
regime, sources, conflictMass, validifiedPossibility, nonMaxitive}.

- Relations accept a validity label (default heuristic = unlabeled input).
- Labels propagate through fusion: identity/max preserve the weakest
  source label (max is already valid under arbitrary dependence); min
  (conjunctive: intersection, chain, TTU, multi_hop, parent) is
  approximate at best, surfaces the conflict mass (1 - possibility) that
  was previously dropped, and exposes the arbitrary-regime validification
  min(1, K*gamma); product-style operators (exclusion, defeasible) and
  interior OWA averaging are always heuristic, with nonMaxitive flagged.
- Reliability and validity are now explicitly distinct: reliability stays
  the scalar confidence adaptation; validity tracks the epistemic label.
- The hottest paths attach a shared frozen default block instead of
  allocating (perf A/B shows no regression: ~300k ops/s direct both ways).
- Pre-existing fixes surfaced while wiring: the array-form logical config
  dropped top-level aggregator/owaWeights (average union compiled as max),
  and _createStandardResult dropped unknown fields (validity never
  survived rule results).

New campaign validity-parity.test.js pins the label taxonomy, conflict
mass, validification, weakest-propagation, and the reliability/validity
separation. Suites: rigor 203/0, full 803/741/0.
This commit is contained in:
John Dvorak
2026-08-02 08:57:05 -07:00
parent fb258035f9
commit 58e8b0e030
14 changed files with 408 additions and 11 deletions
+22 -1
View File
@@ -1,3 +1,4 @@
import { buildValidity, normalizeValidity, mergeValidity, DEFAULT_VALIDITY } from '../core/validity.js';
import { Arbiter } from '../core/Arbiter.js';
import { RuleEvaluator } from './RuleEvaluator.js';
import { RuleCollector } from './RuleCollector.js';
@@ -162,6 +163,9 @@ export class AuthorizationChecker {
} else {
result = {
possibility: directRel.possibility,
validity: directRel.validity !== undefined
? buildValidity('identity', [effectiveRelation], [normalizeValidity(directRel.validity)], 1, directRel.possibility)
: DEFAULT_VALIDITY,
// A denied decision (possibility 0) must not leak the
// relation's reliability — the rule-collection path zeroes it.
reliability: directRel.possibility > 0
@@ -344,6 +348,7 @@ export class AuthorizationChecker {
const finalResult = {
possibility: resPossibility || 0,
reliability: res.reliability !== undefined ? res.reliability : 1.0,
validity: res.validity || DEFAULT_VALIDITY,
...(collectValues && res.collectedValues && Array.isArray(res.collectedValues) && { collectedValues: res.collectedValues }),
...(includeMeta && {
meta: {
@@ -373,6 +378,8 @@ export class AuthorizationChecker {
let bestDenyReliability = 0;
let bestAllow = null;
let bestDeny = null;
let bestAllowValidity = null;
let ruleValidityBlocks = [];
let reason = undefined;
let allRuleResults = shouldTrackEvaluation ? [] : null;
let allCollectedValues = collectValues ? [] : null; // Collect values from all evaluated rules
@@ -457,7 +464,9 @@ export class AuthorizationChecker {
maxAllow = resAllowPossibility;
bestAllow = resMeta;
bestAllowReliability = res.reliability !== undefined ? res.reliability : 1.0;
bestAllowValidity = res.validity || null;
}
if (res.validity) ruleValidityBlocks.push(res.validity);
if (resDenyPossibility > maxDeny) {
maxDeny = resDenyPossibility;
@@ -543,9 +552,21 @@ export class AuthorizationChecker {
const remediation = maxAllow === 0
? buildRemediation(remediationOptions.length > 0 ? { status: 'required', options: remediationOptions } : null, { status: 'required' })
: null;
const result = {
// Possibilistic validity of the decision: the winning rule's block when
// a single rule drove it, otherwise the weakest-merge of all
// contributors (a heuristic/banned contributor downgrades the whole).
let finalValidity = DEFAULT_VALIDITY;
if (ruleValidityBlocks.length === 1) {
finalValidity = ruleValidityBlocks[0];
} else if (ruleValidityBlocks.length > 1) {
finalValidity = mergeValidity(ruleValidityBlocks);
} else if (maxAllow > 0 && bestAllowValidity) {
finalValidity = bestAllowValidity;
}
const result = {
possibility: maxAllow,
reliability: maxAllow > 0 ? bestAllowReliability : maxDeny > 0 ? bestDenyReliability : 0,
validity: finalValidity,
...(includeMeta && {
meta: {
allow: bestAllow,
+28
View File
@@ -1,3 +1,4 @@
import { buildValidity, normalizeValidity, DEFAULT_VALIDITY } from '../core/validity.js';
import { OWAFusion, getOWAWeightsFromRule } from '../utils/OWAFusion.js';
import { buildRemediation, extractRemediation, mergeRemediationOptions } from './remediation.js';
@@ -143,6 +144,9 @@ export class CompiledEvaluator {
const result = {
possibility: relationStrength,
reliability: directRel.reliability !== undefined ? directRel.reliability : 1.0,
validity: directRel.validity !== undefined
? buildValidity('identity', [relName], [normalizeValidity(directRel.validity)], 1, relationStrength)
: DEFAULT_VALIDITY,
possibility_allow: relationStrength,
possibility_deny: 0,
...(includeMeta && {
@@ -407,6 +411,7 @@ export class CompiledEvaluator {
const possibilities = [];
const reliabilities = [];
const metas = [];
const resChildrenValidity = [];
const remediationOptions = [];
const allCollectedValues = collectValues ? [] : null;
@@ -416,6 +421,7 @@ export class CompiledEvaluator {
if (!res || typeof res.possibility !== 'number') {
res = { possibility: 0, meta: { reason: 'missing_rule' }, collectedValues: [] };
}
if (res.validity) resChildrenValidity.push(res.validity);
mergeRemediationOptions(remediationOptions, extractRemediation(res));
@@ -477,9 +483,15 @@ export class CompiledEvaluator {
const maxP = Math.max(...possibilities);
unionReliability = reliabilities[possibilities.indexOf(maxP)] ?? 1.0;
}
const unionOperator = (compiled.aggregator || 'max') === 'max' && (!compiled.owaWeights || (compiled.owaWeights.length <= 1 || (compiled.owaWeights[0] === 1 && compiled.owaWeights.slice(1).every(w => w === 0))))
? 'max' : 'owa';
const unionSourceLabels = (resChildrenValidity || []).map(v => v?.label).filter(Boolean);
const unionSources = [];
for (const v of resChildrenValidity || []) for (const s of v?.sources || []) if (!unionSources.includes(s)) unionSources.push(s);
return {
possibility: result.value,
reliability: unionReliability,
validity: buildValidity(unionOperator, unionSources, unionSourceLabels, possibilities.length, result.value),
...(collectValues && { collectedValues: allCollectedValues }),
...(includeMeta && {
meta: {
@@ -523,10 +535,12 @@ export class CompiledEvaluator {
// (no children); treat it as a one-element conjunction, matching the
// union path's `compiled.children || [compiled]` fallback.
const children = compiled.children || [compiled];
const resChildrenValidity = [];
for (const child of children) {
const childVisited = new Set(visited);
const res = this.evaluate(child, userId, userKey, objectId, objectKey, childVisited, currentRelation, options);
if (res.validity) resChildrenValidity.push(res.validity);
possibilities.push(res.possibility);
reliabilities.push(res.reliability !== undefined ? res.reliability : 1.0);
metas.push(includeMeta ? res.meta : null);
@@ -587,9 +601,13 @@ export class CompiledEvaluator {
const minP = Math.min(...possibilities);
intersectionReliability = reliabilities[possibilities.indexOf(minP)] ?? 1.0;
}
const interSourceLabels = (resChildrenValidity || []).map(v => v?.label).filter(Boolean);
const interSources = [];
for (const v of resChildrenValidity || []) for (const s of v?.sources || []) if (!interSources.includes(s)) interSources.push(s);
return {
possibility: result.value,
reliability: intersectionReliability,
validity: buildValidity('min', interSources, interSourceLabels, possibilities.length, result.value),
...(collectValues && { collectedValues: allCollectedValues }),
...(includeMeta && {
meta: {
@@ -668,6 +686,7 @@ export class CompiledEvaluator {
// Both legs contribute to the decision, so their reliabilities
// multiply (consistent with TTU/chain path semantics).
reliability: (a.reliability !== undefined ? a.reliability : 1.0) * (b.reliability !== undefined ? b.reliability : 1.0),
validity: buildValidity('product', [a.validity?.sources?.[0], b.validity?.sources?.[0]].filter(Boolean), [a.validity?.label, b.validity?.label].filter(Boolean), 2, possibility),
...(collectValues && { collectedValues: allCollectedValues }),
...(includeMeta && {
meta: {
@@ -698,6 +717,7 @@ export class CompiledEvaluator {
const possibilities = [];
const reliabilities = [];
const metas = [];
const listChildrenValidity = [];
const allCollectedValues = collectValues ? [] : null;
const directRules = compiled._optimized.direct;
@@ -712,6 +732,9 @@ export class CompiledEvaluator {
const possibility = directRel ? directRel.possibility : 0;
possibilities.push(possibility);
reliabilities.push(directRel ? (directRel.reliability !== undefined ? directRel.reliability : 1.0) : 1.0);
if (directRel) {
listChildrenValidity.push(buildValidity('identity', [direct.relation], [normalizeValidity(directRel.validity !== undefined ? directRel.validity : null)], 1, possibility));
}
if (includeMeta) {
const _src = directRel ? (directRel.source || 'persistent') : null;
const _allowMeta = directRel ? {
@@ -808,9 +831,14 @@ export class CompiledEvaluator {
listReliability = reliabilities[possibilities.indexOf(maxP)] ?? 1.0;
}
const listOperator = aggregator === 'min' ? 'min' : (aggregator === 'max' ? 'max' : 'owa');
const listLabels = listChildrenValidity.map(v => v.label).filter(Boolean);
const listSources = [];
for (const v of listChildrenValidity) for (const s of v.sources || []) if (!listSources.includes(s)) listSources.push(s);
return {
possibility: result.value,
reliability: listReliability,
validity: buildValidity(listOperator, listSources, listLabels, possibilities.length, result.value),
...(collectValues && { collectedValues: allCollectedValues }),
...(includeMeta && {
meta: {
+11 -8
View File
@@ -140,14 +140,17 @@ export class RuleCompiler {
type: 'logical',
op: logicalKey,
children,
aggregator: logicalConfig?.aggregator,
owaWeights: logicalConfig?.owaWeights,
_owaSparseCount: logicalConfig?._owaSparseCount,
reliabilityWeighting: logicalConfig?.reliabilityWeighting || false,
useBilattice: logicalConfig?.useBilattice || false,
capacityType: logicalConfig?.capacityType,
epistemicMode: logicalConfig?.epistemicMode,
negate: logicalConfig?.negate || false
// The array form ({ union: [a, b], aggregator: 'average' }) carries the
// operator options on the TOP-level config; the object form carries
// them on the union object itself. Honor both.
aggregator: logicalConfig?.aggregator ?? config.aggregator,
owaWeights: logicalConfig?.owaWeights ?? config.owaWeights,
_owaSparseCount: logicalConfig?._owaSparseCount ?? config._owaSparseCount,
reliabilityWeighting: logicalConfig?.reliabilityWeighting ?? config.reliabilityWeighting ?? false,
useBilattice: logicalConfig?.useBilattice ?? config.useBilattice ?? false,
capacityType: logicalConfig?.capacityType ?? config.capacityType,
epistemicMode: logicalConfig?.epistemicMode ?? config.epistemicMode,
negate: logicalConfig?.negate ?? config.negate ?? false
};
return compiled;
+14
View File
@@ -1,4 +1,5 @@
import { OWAFusion } from '../../utils/OWAFusion.js';
import { buildValidity, normalizeValidity, DEFAULT_VALIDITY } from '../../core/validity.js';
import { BilatticeOrderings } from '../../qualitative/BilatticeOrderings.js';
import { QualitativeCapacity } from '../../qualitative/QualitativeCapacity.js';
import { QualitativeScale } from '../../qualitative/QualitativeScale.js';
@@ -134,11 +135,24 @@ export class BaseRule {
* Create standardized rule result with collected values
* @protected
*/
_defaultValidity() {
return DEFAULT_VALIDITY;
}
_validity(operator, sources = [], sourceLabels = [], K = 1, possibility = 0) {
return buildValidity({ operator, sources, sourceLabels, K, possibility });
}
_relationValidity(rel) {
return normalizeValidity(rel && rel.validity !== undefined ? rel.validity : null);
}
_createStandardResult(authResult, collectedValues = []) {
const result = {
// AUTHORIZATION
possibility: authResult.possibility || 0,
reliability: authResult.reliability !== undefined ? authResult.reliability : 1.0,
validity: authResult.validity || DEFAULT_VALIDITY,
// BINARY MODE FIELDS
possibility_allow: authResult.possibility_allow !== undefined ? authResult.possibility_allow : (authResult.possibility || 0),
+1
View File
@@ -350,6 +350,7 @@ export class ChainRule extends BaseRule {
const authResult = {
possibility: finalPossibility,
reliability: finalReliability,
validity: this._validity('min', normalizedRule.steps.map(s => s.relation).filter(Boolean), [], normalizedRule.steps.length, finalPossibility),
...(includeMeta && {
meta: finalPossibility > 0 ? {
ruleType: 'chain',
+3
View File
@@ -71,6 +71,9 @@ export class DirectRule extends BaseRule {
const authResult = {
possibility: relationStrength,
reliability: directRel.reliability !== undefined ? directRel.reliability : 1.0,
validity: directRel.validity !== undefined
? this._validity('identity', [relName], [this._relationValidity(directRel)], 1, relationStrength)
: this._defaultValidity(),
possibility_allow: relationStrength, // For binary mode
possibility_deny: 0, // DirectRule doesn't deny
...(includeMeta && {
@@ -1,5 +1,6 @@
import { BaseRule } from './BaseRule.js';
import { OWAFusion, getOWAWeightsFromRule } from '../../utils/OWAFusion.js';
import { buildValidity } from '../../core/validity.js';
import { Arbiter } from '../../core/Arbiter.js';
import { BilatticeOrderings } from '../../qualitative/BilatticeOrderings.js';
import { QualitativeCapacity } from '../../qualitative/QualitativeCapacity.js';
@@ -859,6 +860,7 @@ export class LogicalOperators extends BaseRule {
return {
possibility: Math.max(0, Math.min(1, possibility)),
reliability,
validity: buildValidity('product', [], [], 2, Math.max(0, Math.min(1, possibility))),
collectedValues: allCollectedValues,
meta: {
...resultMeta,
+1
View File
@@ -217,6 +217,7 @@ export class MultiHopRule extends BaseRule {
const authResult = {
possibility: finalPossibility,
reliability: finalReliability,
validity: this._validity('min', [relation], [], Math.max(1, bestPath ? bestPath.hops : 1), finalPossibility),
...(includeMeta && {
meta: allowMeta
}),
@@ -100,6 +100,8 @@ export class RelationalComparatorRule extends BaseRule {
else comparisonOutput.meta = { fullEvaluationTrace: evaluationMeta };
}
comparisonOutput.validity = this._validity('identity',
[leftOperand?.rule?.relation, rightOperand?.rule?.relation].filter(Boolean), [], 1, comparisonOutput.possibility || 0);
return comparisonOutput;
} catch (error) {
return {
@@ -500,6 +500,7 @@ export class TupleToUsersetRule extends BaseRule {
return {
possibility: maxPossibility,
reliability: maxReliability,
validity: this._validity('min', [(ruleMetaBase && ruleMetaBase.tuplesetRelation) || null, (ruleMetaBase && ruleMetaBase.computedRelation) || null].filter(Boolean), [], 2, maxPossibility),
reason: reasons.includes('cycle') ? 'cycle' : (maxPossibility > 0 ? 'tuple_to_userset_found' : 'no_sufficient_tuple_to_userset_path')
};
}
@@ -565,6 +566,7 @@ export class TupleToUsersetRule extends BaseRule {
return {
possibility: fusionResult.value,
reliability: fusedReliability,
validity: this._validity('min', [(ruleMetaBase && ruleMetaBase.tuplesetRelation) || null, (ruleMetaBase && ruleMetaBase.computedRelation) || null].filter(Boolean), [], 2, fusionResult.value),
...(includeMeta && { meta: finalMeta }),
...(collectValues && { collectedValues: fusionResult.value > 0 ? collectedValues : [] }),
reason: reasons.includes('cycle') ? 'cycle' : (fusionResult.value > 0 ? 'tuple_to_userset_found' : 'no_sufficient_tuple_to_userset_path')