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')
+3 -1
View File
@@ -45,7 +45,8 @@ export class RelationUpdates {
decayConfig: options?.decayConfig,
updated_last_at: currentTime,
changed_last_at: options?.changed_last_at !== undefined ? options.changed_last_at : currentTime,
stateId: newStateId
stateId: newStateId,
validity: options?.validity !== undefined ? options.validity : null
};
this.manager.arbiter.relations.push(relationObj);
@@ -599,6 +600,7 @@ export class RelationUpdates {
oldRelation.reliability = newOptions?.reliability !== undefined ? newOptions.reliability : oldRelation.reliability;
oldRelation.value = newOptions?.value !== undefined ? newOptions.value : oldRelation.value;
oldRelation.decayConfig = newOptions?.decayConfig !== undefined ? newOptions.decayConfig : oldRelation.decayConfig;
oldRelation.validity = newOptions?.validity !== undefined ? newOptions.validity : oldRelation.validity;
oldRelation.updated_last_at = currentTime;
// Honor an explicit changed_last_at override like the add path does,
// but only on writes that would refresh anyway: a write that does not
+172
View File
@@ -0,0 +1,172 @@
/**
* src/core/validity.js possibilistic validity metadata (Cella FVN / Martin).
*
* Every check result carries a validity block describing how the decision
* value was produced, in the spirit of the zig-contour fusion spec:
*
* label weakest validity label of the contributing sources, downgraded
* by the fusion operator's class:
* identity/max : operator is valid (max is already valid
* and normalized under arbitrary dependence);
* the fused label = weakest source label
* min (conjunctive) : unvalidified ranking -> the raw min is
* reported as the decision, the validified
* value min(1, K*gamma_min) is exposed
* separately, and the conflict mass is
* surfaced, never silently dropped
* product-style : exclusion P(A)*(1-P(B)) and defeasible
* when*(1-unless) use the product operator,
* which has no linear validification under
* arbitrary dependence (Cella Thm 4) ->
* always heuristic
* owa (interior) : convex mixtures of plausibilities are not
* maxitive -> scoring heuristic, never a
* valid possibility
* regime dependence assumption (always 'arbitrary' unless declared)
* operator the fusion operator actually applied
* conflictMass 1 - fused_possibility: the conjunctive suppression (the
* scalar analog of contour sub-normalization; 0 for disjunctive)
* validifiedPossibility the arbitrary-regime validification
* min(1, K*gamma_min) for conjunctive results (null otherwise)
* sources the base relations that fed the decision
* nonMaxitive true for interior OWA/averaging operators
*
* The taxonomy follows the contour spec: finite_sample | anytime | conformal |
* approximate | heuristic | unknown. Unlabeled relations default to heuristic.
*/
export const VALIDITY_LABELS = ['finite_sample', 'anytime', 'conformal', 'approximate', 'heuristic', 'unknown'];
// Shared immutable default for unlabeled decisions (all-heuristic): the
// hottest paths attach this constant instead of allocating per check.
export const DEFAULT_VALIDITY = Object.freeze({
label: 'heuristic',
operator: 'identity',
regime: 'arbitrary',
sources: [],
conflictMass: 0,
validifiedPossibility: null,
nonMaxitive: false
});
export const VALIDITY_RANK = { finite_sample: 0, anytime: 1, conformal: 2, approximate: 3, heuristic: 4, unknown: 5 };
export function normalizeValidity(label) {
if (label === undefined || label === null) return 'heuristic';
const l = String(label);
return VALIDITY_RANK[l] !== undefined ? l : 'unknown';
}
/** Weakest (least valid) of a set of labels. */
export function weakestValidity(labels) {
// Track the weakest (highest-rank) label; every label ranks >= 0.
let worst = 'finite_sample';
for (const l of labels) {
const n = normalizeValidity(l);
if (VALIDITY_RANK[n] > VALIDITY_RANK[worst]) worst = n;
}
return worst;
}
/**
* Operator validity classes:
* 'identity' a single source; fused label = source label
* 'max' disjunctive; valid by construction, label = weakest source
* 'min' conjunctive; unvalidified ranking; label downgraded to
* approximate unless already weaker
* 'product' banned operator (Thm 4); always heuristic
* 'owa' interior OWA averaging; non-maxitive; always heuristic
*/
export function operatorValidityClass(operator) {
switch (operator) {
case 'identity':
case 'max':
return { downgrade: false, nonMaxitive: false };
case 'min':
return { downgrade: true, nonMaxitive: false };
case 'product':
return { downgrade: true, alwaysHeuristic: true, nonMaxitive: false };
case 'owa':
return { downgrade: true, alwaysHeuristic: true, nonMaxitive: true };
default:
return { downgrade: true, alwaysHeuristic: true, nonMaxitive: false };
}
}
/**
* Build the validity block for a fused decision.
*
* @param {object} opts
* operator 'identity' | 'max' | 'min' | 'product' | 'owa'
* sources array of base relation names that fed the decision
* sourceLabels array of validity labels (parallel to sources, or a single
* 'heuristic' when the engine does not track them)
* K number of fused sources (for the min validification)
* possibility the raw fused possibility
*/
export function buildValidity(optsOrOp, sourcesArg = [], sourceLabelsArg = [], KArg = 1, possibilityArg = 0) {
// Accept both the object form ({ operator, sources, ... }) and the
// positional form (operator, sources, sourceLabels, K, possibility).
const opts = (typeof optsOrOp === 'object' && optsOrOp !== null)
? optsOrOp
: { operator: optsOrOp, sources: sourcesArg, sourceLabels: sourceLabelsArg, K: KArg, possibility: possibilityArg };
const { operator = 'identity', sources = [], sourceLabels = [], K = 1, possibility = 0 } = opts;
const cls = operatorValidityClass(operator);
const base = sourceLabels.length > 0 ? weakestValidity(sourceLabels) : 'heuristic';
let label = base;
if (cls.downgrade) {
if (cls.alwaysHeuristic) {
label = 'heuristic';
} else {
// unvalidified conjunctive ranking: approximate at best
if (VALIDITY_RANK[base] < VALIDITY_RANK.approximate) label = 'approximate';
}
}
const block = {
label,
operator,
regime: 'arbitrary',
sources: [...sources],
nonMaxitive: cls.nonMaxitive
};
if (operator === 'min') {
const k = Math.max(1, K);
block.conflictMass = Math.max(0, 1 - possibility);
block.validifiedPossibility = Math.min(1, k * possibility);
} else if (operator === 'identity' || operator === 'max') {
block.conflictMass = 0;
block.validifiedPossibility = null;
} else {
block.conflictMass = null;
block.validifiedPossibility = null;
}
return block;
}
/** Merge several rule-level validity blocks into one (weakest label, unioned sources). */
export function mergeValidity(blocks) {
const present = (blocks || []).filter(Boolean);
if (present.length === 0) {
return buildValidity({});
}
const labels = present.map(b => b.label);
const sources = [];
for (const b of present) {
for (const s of b.sources || []) {
if (!sources.includes(s)) sources.push(s);
}
}
// If ANY contributor is a banned-operator heuristic, the merged result is
// heuristic too (the weakest operator wins, not just the weakest source).
const anyBanned = present.some(b => b.operator === 'product' || b.nonMaxitive);
let label = weakestValidity(labels);
if (anyBanned && VALIDITY_RANK[label] < VALIDITY_RANK.heuristic) label = 'heuristic';
return {
label,
operator: present.length === 1 ? present[0].operator : 'mixed',
regime: 'arbitrary',
sources,
nonMaxitive: present.some(b => b.nonMaxitive),
conflictMass: present.some(b => b.operator === 'min') ? Math.max(0, 1 - Math.max(...present.map(b => b.conflictMass ?? 0))) : null,
validifiedPossibility: null
};
}
+2 -1
View File
@@ -296,7 +296,8 @@ describe('Binary (threshold) mode parity (rigor)', () => {
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'binary-normal-agreement');
assert.ok(inv, 'invariant missing');
assert.equal(inv.passed, true, `binary parity violated in ${inv.failureCount} cases`);
assert.equal(inv.passed, true, `binary parity violated in ${inv.failureCount} cases: ` +
JSON.stringify((report.failures || []).slice(0, 2).map(f => ({ name: f.name, msg: f.message, seq: (f.sequence || []).map(s => s.args) }))));
});
it('MUTATION FRESHNESS: binary and normal agree after every mutation with caching enabled', async () => {
+145
View File
@@ -0,0 +1,145 @@
/**
* rigor/validity-parity.test.js possibilistic validity metadata.
*
* Pins the Cella-FVN-inspired validity layer:
* - every check result carries a validity block {label, operator, regime,
* sources, conflictMass, validifiedPossibility, nonMaxitive}
* - unlabeled relations default to heuristic; a labeled relation
* propagates its label through identity/max fusion
* - max (disjunctive) fusion preserves the weakest source label
* - interior OWA/averaging is non-maxitive and always heuristic
* - min (conjunctive) fusion is approximate at best, surfaces the
* conflict mass (1 - possibility), and exposes the arbitrary-regime
* validification min(1, K*possibility)
* - product-style operators (exclusion, defeasible) are always heuristic
* - reliability and validity are distinct: reliability stays the scalar
* confidence; validity tracks the epistemic label
*/
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { rigor } from '@rigor/core';
import { Arbiter } from '../../src/index.js';
import { buildValidity, mergeValidity, weakestValidity, DEFAULT_VALIDITY } from '../../src/core/validity.js';
const child = (r) => ({ type: 'direct', relation: r });
function mk() {
const a = new Arbiter();
a.addNode('u:0', 'user');
a.addNode('d:0', 'doc');
a.addNode('g:0', 'group');
return a;
}
describe('Possibilistic validity metadata (rigor)', () => {
it('FIXED: labels, operators, conflict mass, validification per kind', () => {
// direct unlabeled -> heuristic identity
{
const a = mk();
a.setRelationConfig('t', { type: 'direct', relation: 'r1' });
a.addRelation('u:0', 'r1', 'd:0', { possibility: 0.8 });
const r = a.check('u:0', 't', 'd:0');
assert.equal(r.validity.label, 'heuristic');
assert.equal(r.validity.operator, 'identity');
assert.deepEqual(r.validity.sources, ['r1']);
assert.equal(r.validity.conflictMass, 0);
}
// labeled direct propagates its label
{
const a = mk();
a.setRelationConfig('t', { type: 'direct', relation: 'r1' });
a.addRelation('u:0', 'r1', 'd:0', { possibility: 0.8, validity: 'finite_sample' });
assert.equal(a.check('u:0', 't', 'd:0').validity.label, 'finite_sample');
}
// max fusion preserves the weakest source label
{
const a = mk();
a.setRelationConfig('t', { union: [child('r1'), child('r2')] });
a.addRelation('u:0', 'r1', 'd:0', { possibility: 0.8, validity: 'finite_sample' });
a.addRelation('u:0', 'r2', 'd:0', { possibility: 0.5, validity: 'conformal' });
const r = a.check('u:0', 't', 'd:0');
assert.equal(r.validity.label, 'conformal', 'weakest label wins through max');
assert.equal(r.validity.operator, 'max');
assert.equal(r.validity.nonMaxitive, false);
}
// interior OWA is non-maxitive heuristic
{
const a = mk();
a.setRelationConfig('t', { union: [child('r1'), child('r2')], aggregator: 'average' });
a.addRelation('u:0', 'r1', 'd:0', { possibility: 0.8, validity: 'finite_sample' });
a.addRelation('u:0', 'r2', 'd:0', { possibility: 0.5, validity: 'finite_sample' });
const r = a.check('u:0', 't', 'd:0');
assert.equal(r.validity.operator, 'owa');
assert.equal(r.validity.nonMaxitive, true);
assert.equal(r.validity.label, 'heuristic', 'averaging never claims validity');
}
// min fusion: approximate, conflict mass, validification
{
const a = mk();
a.setRelationConfig('t', { intersection: [child('r1'), child('r2')] });
a.addRelation('u:0', 'r1', 'd:0', { possibility: 0.8, validity: 'finite_sample' });
a.addRelation('u:0', 'r2', 'd:0', { possibility: 0.5, validity: 'finite_sample' });
const r = a.check('u:0', 't', 'd:0');
assert.equal(r.validity.operator, 'min');
assert.equal(r.validity.label, 'approximate', 'unvalidified conjunctive is approximate at best');
assert.ok(Math.abs(r.validity.conflictMass - (1 - 0.5)) < 1e-9, 'conflict mass = 1 - possibility');
assert.equal(r.validity.validifiedPossibility, 1, 'min(1, K*gamma) with K=2, gamma=0.5');
}
// product operators are heuristic even with labeled sources
{
const a = mk();
a.setRelationConfig('t', { exclusion: [child('r1'), child('r2')] });
a.addRelation('u:0', 'r1', 'd:0', { possibility: 0.8, validity: 'finite_sample' });
a.addRelation('u:0', 'r2', 'd:0', { possibility: 0.5, validity: 'finite_sample' });
assert.equal(a.check('u:0', 't', 'd:0').validity.label, 'heuristic');
a.setRelationConfig('t2', { type: 'defeasible', when: child('r1'), unless: child('r2') });
assert.equal(a.check('u:0', 't2', 'd:0').validity.label, 'heuristic');
}
// chain: conjunctive ranking with conflict surfacing
{
const a = mk();
a.setRelationConfig('t', { type: 'chain', steps: [{ relation: 'member_of', direction: 'out' }, { relation: 'viewer', direction: 'out' }] });
a.addRelation('u:0', 'member_of', 'g:0', { possibility: 0.9 });
a.addRelation('g:0', 'viewer', 'd:0', { possibility: 0.6 });
const r = a.check('u:0', 't', 'd:0');
assert.equal(r.validity.operator, 'min');
assert.ok(Math.abs(r.validity.conflictMass - 0.4) < 1e-9, 'chain conflict mass');
}
// reliability and validity stay distinct
{
const a = mk();
a.setRelationConfig('t', { type: 'direct', relation: 'r1' });
a.addRelation('u:0', 'r1', 'd:0', { possibility: 0.8, reliability: 0.42, validity: 'finite_sample' });
const r = a.check('u:0', 't', 'd:0');
assert.equal(r.reliability, 0.42, 'reliability unchanged');
assert.equal(r.validity.label, 'finite_sample', 'validity independent of reliability');
}
});
it('PROPERTY CAMPAIGN: helper semantics (weakest, merge, default identity)', async () => {
const result = await rigor.campaign(
[rigor.fn('helpers', (labels) => {
const weakest = weakestValidity(labels);
const merged = mergeValidity(labels.map(l => ({
label: l, operator: 'max', regime: 'arbitrary', sources: ['r'], nonMaxitive: false, conflictMass: 0, validifiedPossibility: null
})));
const single = mergeValidity([{
label: labels[0], operator: 'max', regime: 'arbitrary', sources: ['r'], nonMaxitive: false, conflictMass: 0, validifiedPossibility: null
}]);
return {
weakest: weakestValidity([labels[0], weakest]),
mergedWeakest: merged.label === weakest,
singlePass: single === undefined ? false : single.label === labels[0],
defaultIsFrozen: Object.isFrozen(DEFAULT_VALIDITY),
buildPositional: buildValidity('min', ['a'], ['finite_sample'], 2, 0.5).validifiedPossibility === 1
};
}, rigor.args(rigor.gen.array(rigor.gen.oneOf(['finite_sample', 'anytime', 'conformal', 'approximate', 'heuristic', 'unknown']), 1, 4)))],
rigor.crucible([
rigor.invariant('helper invariants', ({ error, errorMessage, actual }) => !error && !errorMessage && Object.values(actual).every(Boolean))
])
).run({ effort: 200, seed: 'validity-helpers-2026', artifacts: { dir: '', persist: 'never' } });
const inv = result.crucibleVerdict?.invariants?.find(i => i.name === 'helper invariants');
assert.ok(inv && inv.passed, `validity helpers violated in ${inv?.failureCount} cases`);
});
});