Files
core/src/core/ValueManager.js
T
John Dvorak f8f6c5cb1b
CI / test (push) Successful in 5m45s
CI / benchmark (push) Successful in 43s
CI / publish (push) Has been skipped
cleanup: remove dead code, stale shipped scaffolding, internal docs
Dead code with zero callers (deprecation notes promised removal):
- RelationCSR index: always-off option (useRelationCsrIndex), never
  enabled in production, wired through RelationManager/RelationUpdates/
  RelationLookup. Removed the module and all wiring.
- getAggregatedBlurredValue (RelationManager) and aggregateBlurredValues
  (ValueManager): @deprecated shims, zero callers.
- QualitativeRelationalComparatorRule._aggregateBlurredValues:
  @deprecated shim, zero callers.

Kept compareRelationValues: non-deprecated public API, coherent and
clock-threaded, just currently callerless.

Stale scaffolding shipping in the published artifact (files: src/):
- src/ast/tests/* and src/ast/examples/*: orphaned duplicates of
  tests/ast/, zero references anywhere, 11 files in the tarball.
  Removed; the live copies live in tests/ast/.

Internal docs moved out of the shipped surface (1266 lines) to
docs/internal/: VALUE_OPTIMIZATION_SUMMARY, rules API_SPECIFICATION,
ast README, qualitative README — repo-kept, not packaged.

Tarball .md count: 11 -> 1. Rigor 251/251, full suite 853/791/0.
2026-08-02 21:03:37 -07:00

1247 lines
43 KiB
JavaScript

/**
* ValueManager - Manages value intervals, blurring, and decay for relation values
*
* This module handles the sophisticated logic of treating all values as intervals
* that blur over time based on decaying possibility. It provides lazy recalculation
* and caching to ensure efficient access to current value states.
*
* NEW: Uses statistical distributions from all instances of a relation type to
* inform intelligent, data-driven blurring.
*/
import { PriorityQueueFast } from './PriorityQueueFast.js';
import { UnifiedKeyManager } from './UnifiedKeyManager.js';
import { defaultCacheFactory } from './cache.js';
// Default decay configuration
const DEFAULT_DECAY_CONFIG = {
valueDecayRate: 0.1,
possibilityDecayRate: 0.1,
decayPeriod: 'HOUR',
valueBlurDirection: 'neutral',
possibilityDecayDirection: 'down',
baseBlurAmount: 1.0,
minPossibility: 0.01,
epsilon: 0.0001,
// New distribution-based settings
useDistributionBlur: true, // Use statistical distribution for blur
distributionBlurMode: 'adaptive', // 'fixed', 'adaptive', or 'confidence'
confidenceLevel: 0.95, // For confidence interval blur
minSampleSize: 5 // Minimum samples before using distribution
};
// Period conversions to milliseconds
const PERIOD_TO_MS = {
SECOND: 1000,
MINUTE: 1000 * 60,
HOUR: 1000 * 60 * 60,
DAY: 1000 * 60 * 60 * 24,
WEEK: 1000 * 60 * 60 * 24 * 7,
MONTH: 1000 * 60 * 60 * 24 * 30,
YEAR: 1000 * 60 * 60 * 24 * 365
};
export class ValueManager {
constructor(arbiter) {
this.arbiter = arbiter;
// Initialize unified key manager
this.keyManager = new UnifiedKeyManager();
// Global decay configurations by relation type
this.decayConfigs = new Map();
// Cache factory with fallback for minimal/mock arbiters that do not
// expose cacheFactory (e.g. unit-test MockArbiter instances).
const cacheFactory = (typeof arbiter.cacheFactory === 'function')
? arbiter.cacheFactory
: defaultCacheFactory;
// Cache for computed blurred intervals to avoid redundant calculations
this.blurredValueCache = cacheFactory(5000, {
onEvict: (key, value) => {
// Track evictions for debugging
this.blurredValueCacheStats = this.blurredValueCacheStats || { evictions: 0 };
this.blurredValueCacheStats.evictions++;
},
sampleSize: 6,
sketchEpsilon: 0.01,
sketchDelta: 0.01
});
// Cache for relation type statistics
this.distributionCache = cacheFactory(1000, {
onEvict: (key, value) => {
// Track evictions for debugging
this.distributionCacheStats = this.distributionCacheStats || { evictions: 0 };
this.distributionCacheStats.evictions++;
},
sampleSize: 4,
sketchEpsilon: 0.02,
sketchDelta: 0.01
});
this.distributionCacheMaxAge = 5 * 60 * 1000; // 5 minutes
// Default configuration
this.defaultConfig = { ...DEFAULT_DECAY_CONFIG };
// TTL configuration (separate from decay)
this.ttlConfigs = new Map(); // Key: relationType, Value: ttlMs
this.defaultTTL = 24 * 60 * 60 * 1000; // 24 hours default TTL
// Staleness Management
this.staleValuesQueue = new PriorityQueueFast();
// Map to quickly find stale items in the queue for updates
this.staleValueItemsIndex = new Map(); // Key: stale cache key (id of StaleValueItem), Value: StaleValueItem
}
/**
* Convert string key to integer for PriorityQueueFast
* @private
*/
_stringToInt(key) {
return this.keyManager.getStringId(key);
}
/**
* Set decay configuration for a specific relation type
* @param {string} relationType - The relation type (e.g., 'balance', 'price')
* @param {Object} config - Decay configuration
*/
setDecayConfig(relationType, config) {
this.decayConfigs.set(relationType, {
...this.defaultConfig,
...config
});
// Invalidate distribution cache for this type
this.distributionCache.delete(relationType);
}
/**
* Get decay configuration for a relation type
* @param {string} relationType - The relation type
* @returns {Object} Decay configuration
*/
getDecayConfig(relationType) {
return this.decayConfigs.get(relationType) || this.defaultConfig;
}
/**
* Set global default decay configuration
* @param {Object} config - Default decay configuration
*/
setDefaultDecayConfig(config) {
this.defaultConfig = {
...this.defaultConfig,
...config
};
}
/**
* Set TTL for a specific relation type
* @param {string} relationType - The relation type (e.g., 'balance', 'price')
* @param {number} ttlMs - TTL in milliseconds
*/
setTTL(relationType, ttlMs) {
this.ttlConfigs.set(relationType, ttlMs);
}
/**
* Get TTL for a relation type
* @param {string} relationType - The relation type
* @returns {number} TTL in milliseconds
*/
getTTL(relationType) {
return this.ttlConfigs.get(relationType) || this.defaultTTL;
}
/**
* Check if a relation value has expired based on TTL
* @private
*/
_isValueExpired(relation, now = null) {
const ttl = this.getTTL(relation.rel);
const ts = now !== null && now !== undefined ? now : Date.now();
const timestamp = relation.changed_last_at || relation.updated_last_at || ts;
const age = ts - timestamp;
return age > ttl;
}
/**
* Get or calculate the blurred value interval for a relation
* @param {Object} relation - The relation object
* @param {number|null} [now] - Caller-pinned clock; the wall clock is
* only the fallback for unpinned callers.
* @returns {Object} { interval: {min, max}, possibility: number, reliability: number }
*/
getBlurredValue(relation, now = null) {
// If relation has no value, return null interval
if (relation.value === undefined || relation.value === null) {
return {
interval: null,
possibility: 0,
reliability: relation.reliability || 1.0
};
}
// Check TTL first - if expired, return null interval
if (this._isValueExpired(relation, now)) {
return {
interval: null,
possibility: 0,
reliability: relation.reliability || 1.0
};
}
const possibility = relation.possibility !== undefined ? relation.possibility : 1.0;
return {
interval: { min: relation.value, max: relation.value },
possibility,
reliability: relation.reliability || 1.0
};
}
/**
* Get decayed relation with separated value blurring and possibility decay
* This is the main interface that should be used by other components
* @param {Object} relation - The relation object
* @returns {Object} { pointValue, blurredInterval, currentPossibility, originalPossibility, reliability }
*/
getDecayedRelation(relation, now = null) {
if (relation.value === undefined || relation.value === null) {
return {
pointValue: null,
blurredInterval: null,
currentPossibility: 0,
originalPossibility: relation.possibility !== undefined ? relation.possibility : 1.0,
reliability: relation.reliability || 1.0,
decayApplied: false,
stateId: relation.stateId
};
}
const possibility = relation.possibility !== undefined ? relation.possibility : 1.0;
return {
pointValue: relation.value,
blurredInterval: { min: relation.value, max: relation.value },
currentPossibility: possibility,
originalPossibility: possibility,
reliability: relation.reliability || 1.0,
decayApplied: false,
stateId: relation.stateId
};
}
/**
* Calculate separated decay for value blurring and possibility
* @private
*/
_calculateSeparatedDecay(relation, now = null) {
const pointValue = relation.value;
const initialPossibility = relation.possibility !== undefined ? relation.possibility : 1.0;
const reliability = relation.reliability !== undefined ? relation.reliability : 1.0;
const timestamp = relation.changed_last_at || relation.updated_last_at ||
(now !== null && now !== undefined ? now : Date.now());
// Get decay configuration
const config = this._getRelationDecayConfig(relation);
// Calculate age
const evalNow = now !== null && now !== undefined ? now : Date.now();
const ageMs = Math.max(0, evalNow - timestamp);
const periodMs = PERIOD_TO_MS[config.decayPeriod.toUpperCase()] || PERIOD_TO_MS.HOUR;
const ageInPeriod = ageMs / periodMs;
// No decay if age is 0 or decay rates are 0
if (ageInPeriod === 0 || (config.valueDecayRate === 0 && config.possibilityDecayRate === 0)) {
return {
pointValue,
blurredInterval: { min: pointValue, max: pointValue },
currentPossibility: initialPossibility,
originalPossibility: initialPossibility,
reliability,
decayApplied: false
};
}
// Calculate possibility decay with better neutral handling
const possibilityDecayFactor = this._calculateDecayFactor(ageInPeriod, config.possibilityDecayRate, config.decayType);
const decayedPossibility = this._applyImprovedPossibilityDecay(
initialPossibility,
possibilityDecayFactor,
config.possibilityDecayDirection,
ageInPeriod
);
// Calculate value blur (independent of possibility)
let blurredInterval;
if (config.useDistributionBlur) {
blurredInterval = this._calculateDistributionBasedBlur(
relation,
pointValue,
initialPossibility,
decayedPossibility,
config
);
} else {
// Use age-based blur instead of possibility-based
const blurMagnitude = ageInPeriod * config.baseBlurAmount * config.valueDecayRate;
blurredInterval = this._calculateBlurredInterval(
pointValue,
blurMagnitude,
config.valueBlurDirection,
config.epsilon
);
}
// Check minimum possibility threshold
const finalPossibility = decayedPossibility < config.minPossibility ? 0 : decayedPossibility;
return {
pointValue,
blurredInterval: finalPossibility > 0 ? blurredInterval : null,
currentPossibility: finalPossibility,
originalPossibility: initialPossibility,
reliability,
decayApplied: ageInPeriod > 0
};
}
/**
* Improved possibility decay with better neutral handling
* @private
*/
_applyImprovedPossibilityDecay(initialPossibility, decayFactor, direction, ageInPeriod) {
let decayed;
switch (direction) {
case 'up':
// Decay towards 1 (increasing confidence over time)
decayed = 1 - (1 - initialPossibility) * decayFactor;
break;
case 'down':
// Decay towards 0 (decreasing confidence over time)
decayed = initialPossibility * decayFactor;
break;
case 'stable':
// No decay
decayed = initialPossibility;
break;
case 'neutral':
default:
// IMPROVED: Better neutral decay that doesn't drop too quickly
// Use a gentler decay curve that approaches but doesn't rapidly fall to 0.5
const targetPossibility = 0.5;
const decayStrength = Math.min(0.8, ageInPeriod * 0.05); // Cap max decay effect
decayed = initialPossibility + (targetPossibility - initialPossibility) * decayStrength;
break;
}
return Math.max(0, Math.min(1, decayed));
}
/**
* Get blurred values for multiple relations (batch operation)
* @param {Array} relations - Array of relation objects
* @returns {Array} Array of blurred value results
*/
getBlurredValuesBatch(relations) {
return relations.map(rel => this.getBlurredValue(rel));
}
/**
* Invalidate cache for specific relations
* @param {Array} relationUniqueKeysWithOldStateIds - Array of { relationUniqueKey, oldStateId, accessTime (optional) }
*/
invalidateCache(relationUniqueKeysWithOldStateIds) {
if (!relationUniqueKeysWithOldStateIds) {
// Clear entire cache and stale queue
this.blurredValueCache.clear();
this.distributionCache.clear(); // Keep this? Or make it separate. For now, clear.
this.staleValuesQueue.clear(); // Assuming PQ has clear() or re-init
this.staleValueItemsIndex.clear();
} else {
const now = Date.now();
for (const itemToInvalidate of relationUniqueKeysWithOldStateIds) {
// itemToInvalidate is { relationUniqueKey, oldStateId, accessTime (optional) }
const oldCacheKey = this._generateCacheKeyForStaleness(itemToInvalidate.relationUniqueKey, itemToInvalidate.oldStateId);
if (this.blurredValueCache.has(oldCacheKey)) {
const cachedDataForOldState = this.blurredValueCache.get(oldCacheKey);
this._deleteFromCache(this.blurredValueCache, oldCacheKey); // Remove from active cache
// Add to stale queue
if (!this.staleValueItemsIndex.has(oldCacheKey)) {
const staleItem = {
id: oldCacheKey, // ID for the stale queue is the old cache key
relationUniqueKey: itemToInvalidate.relationUniqueKey,
itemType: 'relationValue',
lastAccessTime: itemToInvalidate.accessTime || cachedDataForOldState?.recalculatedAt || now,
staleSinceTime: now,
originalRelationStateId: itemToInvalidate.oldStateId
};
const intKey = this._stringToInt(oldCacheKey);
this.staleValuesQueue.push(intKey, now);
this.staleValueItemsIndex.set(oldCacheKey, staleItem);
}
}
}
}
}
_deleteFromCache(cache, key) {
if (key === null || key === undefined || !cache) return;
if (typeof cache.delete === 'function') {
cache.delete(key);
return;
}
if (typeof cache._deleteKey === 'function') {
cache._deleteKey(key);
return;
}
if (cache.cache && typeof cache.cache.delete === 'function') {
cache.cache.delete(key);
}
}
/**
* Calculate statistics for a relation type based on its local context
* Uses a weighted combination of global baseline and local similarity-based samples
* @private
* @param {Object} relation - The relation object for context
* @returns {Object | null} Calculated statistics or null
*/
_calculateRelationTypeStatistics(relation) {
const relationType = relation.rel;
const srcNodeId = relation.src.id || relation.src;
const dstNodeId = relation.dst.id || relation.dst;
// Generate a cache key specific to this local context using numeric IDs
const srcId = this.keyManager.getStringId(srcNodeId);
const dstId = this.keyManager.getStringId(dstNodeId);
const relationTypeId = this.keyManager.getStringId(relationType);
const cacheKey = `local_stats_type:${relationTypeId}_src:${srcId}_dst:${dstId}`;
const cached = this.distributionCache.get(cacheKey);
if (cached && (Date.now() - cached.calculatedAt) < this.distributionCacheMaxAge) {
return cached.stats;
}
// Weighted samples collection
const weightedSamples = new Map(); // key -> {value, weight}
// Configuration for weighting
const baseWeight = 1.0; // Weight for global samples
const localBoostFactor = 2.0; // Multiplier for local samples
const similarityPower = 2.0; // Exponent to emphasize high similarity scores
// 1. Get global baseline sample
const globalRelations = this.arbiter.relationManager.getRawValueRelationsByName(
relationType,
relation // Exclude the current relation
) || [];
// Add global samples with base weight
globalRelations.forEach(rel => {
const srcId = this.keyManager.getStringId(rel.src);
const dstId = this.keyManager.getStringId(rel.dst);
const key = this.keyManager.createCompositeKey(srcId, rel.rel, dstId);
weightedSamples.set(key, {
value: rel.value,
weight: baseWeight
});
});
// 2. Get local samples from source and destination contexts
const defaultSimilarityOptions = {
count: 5, // Number of similar nodes to consider
efSearch: 50 // Search effort for ANN
};
// Process local context samples with similarity-based weights
const processLocalSamples = (localResults) => {
localResults.forEach(result => {
const rel = result.relation;
const similarityScore = result.similarityScore;
const srcId = this.keyManager.getStringId(rel.src);
const dstId = this.keyManager.getStringId(rel.dst);
const key = this.keyManager.createCompositeKey(srcId, rel.rel, dstId);
// Calculate weight based on similarity
// Higher similarity = higher weight, with exponential emphasis
const similarityWeight = Math.pow(similarityScore, similarityPower);
const weight = baseWeight + (localBoostFactor * similarityWeight);
// If already in samples, use the higher weight
const existing = weightedSamples.get(key);
if (!existing || existing.weight < weight) {
weightedSamples.set(key, {
value: rel.value,
weight: weight
});
}
});
};
// Fetch and process source context
const srcLocalResults = this.arbiter.relationManager.getRawValueRelationsForLocalContext(
srcNodeId,
'src',
relationType,
relation,
defaultSimilarityOptions
) || [];
processLocalSamples(srcLocalResults);
// Fetch and process destination context
const dstLocalResults = this.arbiter.relationManager.getRawValueRelationsForLocalContext(
dstNodeId,
'dst',
relationType,
relation,
defaultSimilarityOptions
) || [];
processLocalSamples(dstLocalResults);
// Convert to arrays for statistical calculation
const samples = Array.from(weightedSamples.values());
if (samples.length === 0) {
this.distributionCache.set(cacheKey, { stats: null, calculatedAt: Date.now() });
return null;
}
// Calculate weighted statistics
const stats = this._calculateWeightedStatistics(samples);
// Cache the statistics
this.distributionCache.set(cacheKey, {
stats,
calculatedAt: Date.now()
});
return stats;
}
/**
* Calculate statistics from weighted samples
* @private
* @param {Array} samples - Array of {value, weight} objects
* @returns {Object} Statistical summary
*/
_calculateWeightedStatistics(samples) {
const n = samples.length;
if (n === 0) return null;
// Calculate total weight
const totalWeight = samples.reduce((sum, s) => sum + s.weight, 0);
// Weighted mean
const weightedSum = samples.reduce((sum, s) => sum + s.value * s.weight, 0);
const mean = weightedSum / totalWeight;
// Weighted variance and standard deviation
const weightedSquaredDiff = samples.reduce((sum, s) =>
sum + s.weight * Math.pow(s.value - mean, 2), 0
);
const variance = weightedSquaredDiff / totalWeight;
const stdDev = Math.sqrt(variance);
// For percentiles, we need to sort by value and accumulate weights
const sortedSamples = [...samples].sort((a, b) => a.value - b.value);
// Calculate weighted percentiles
const getWeightedPercentile = (p) => {
const targetWeight = p * totalWeight;
let accumulatedWeight = 0;
for (let i = 0; i < sortedSamples.length; i++) {
accumulatedWeight += sortedSamples[i].weight;
if (accumulatedWeight >= targetWeight) {
// Linear interpolation for smoother percentiles
if (i === 0) return sortedSamples[0].value;
if (accumulatedWeight === targetWeight) {
return (sortedSamples[i].value + sortedSamples[i + 1]?.value || sortedSamples[i].value) / 2;
}
// Interpolate between current and previous value
const prevWeight = accumulatedWeight - sortedSamples[i].weight;
const fraction = (targetWeight - prevWeight) / sortedSamples[i].weight;
if (i > 0) {
return sortedSamples[i - 1].value +
fraction * (sortedSamples[i].value - sortedSamples[i - 1].value);
}
return sortedSamples[i].value;
}
}
return sortedSamples[n - 1].value;
};
// Calculate key percentiles
const median = getWeightedPercentile(0.5);
const q1 = getWeightedPercentile(0.25);
const q3 = getWeightedPercentile(0.75);
// Weighted MAD (Median Absolute Deviation)
const deviations = samples.map(s => ({
value: Math.abs(s.value - median),
weight: s.weight
}));
const sortedDeviations = deviations.sort((a, b) => a.value - b.value);
const mad = getWeightedPercentile(0.5); // This won't work correctly, we need to use sortedDeviations
// Calculate MAD properly
const getMadFromDeviations = () => {
const targetWeight = 0.5 * totalWeight;
let accumulatedWeight = 0;
for (const dev of sortedDeviations) {
accumulatedWeight += dev.weight;
if (accumulatedWeight >= targetWeight) {
return dev.value;
}
}
return sortedDeviations[sortedDeviations.length - 1].value;
};
const actualMad = getMadFromDeviations();
// Calculate effective sample size (for confidence intervals)
const effectiveSampleSize = Math.pow(totalWeight, 2) /
samples.reduce((sum, s) => sum + Math.pow(s.weight, 2), 0);
return {
count: n,
effectiveSampleSize: Math.round(effectiveSampleSize),
totalWeight,
mean,
median,
stdDev,
variance,
mad: actualMad,
min: sortedSamples[0].value,
max: sortedSamples[n - 1].value,
q1,
q3,
iqr: q3 - q1,
p5: getWeightedPercentile(0.05),
p95: getWeightedPercentile(0.95),
p10: getWeightedPercentile(0.10),
p90: getWeightedPercentile(0.90),
skewness: (mean - median) / (stdDev || 1),
range: sortedSamples[n - 1].value - sortedSamples[0].value,
// Additional info about sample composition
globalSampleWeight: samples.filter(s => s.weight === 1.0).length,
localSampleWeight: samples.filter(s => s.weight > 1.0).length
};
}
/**
* Calculate the blurred value interval for a relation
* @private
*/
_calculateBlurredValue(relation, now = null) {
const pointValue = relation.value;
const initialPossibility = relation.possibility !== undefined ? relation.possibility : 1.0;
const reliability = relation.reliability !== undefined ? relation.reliability : 1.0;
const timestamp = relation.changed_last_at || relation.updated_last_at ||
(now !== null && now !== undefined ? now : Date.now());
// Get decay configuration
const config = this._getRelationDecayConfig(relation);
// Calculate age
const evalNow = now !== null && now !== undefined ? now : Date.now();
const ageMs = Math.max(0, evalNow - timestamp); // Ensure non-negative
const periodMs = PERIOD_TO_MS[config.decayPeriod.toUpperCase()] || PERIOD_TO_MS.HOUR;
const ageInPeriod = ageMs / periodMs;
// No decay if age is 0 or decay rates are 0
if (ageInPeriod === 0 || (config.valueDecayRate === 0 && config.possibilityDecayRate === 0)) {
return {
interval: { min: pointValue, max: pointValue },
possibility: initialPossibility,
reliability: reliability
};
}
// Calculate possibility decay
const possibilityDecayFactor = this._calculateDecayFactor(ageInPeriod, config.possibilityDecayRate, config.decayType);
const decayedPossibility = this._applyPossibilityDecay(
initialPossibility,
possibilityDecayFactor,
config.possibilityDecayDirection
);
// Calculate value blur
let blurredInterval;
if (config.useDistributionBlur) {
// Use distribution-based blur
blurredInterval = this._calculateDistributionBasedBlur(
relation,
pointValue,
initialPossibility,
decayedPossibility,
config
);
} else {
// Use traditional fixed blur
const blurMagnitude = this._calculateBlurMagnitudeFromPossibility(
initialPossibility,
decayedPossibility,
config.baseBlurAmount
);
blurredInterval = this._calculateBlurredInterval(
pointValue,
blurMagnitude,
config.valueBlurDirection,
config.epsilon
);
}
// Check minimum possibility threshold
const finalPossibility = decayedPossibility < config.minPossibility ? 0 : decayedPossibility;
return {
interval: finalPossibility > 0 ? blurredInterval : null,
possibility: finalPossibility,
reliability: reliability // Reliability doesn't decay in this model
};
}
/**
* Calculate blur based on statistical distribution
* @private
*/
_calculateDistributionBasedBlur(relation, pointValue, initialPossibility, decayedPossibility, config) {
const stats = this._calculateRelationTypeStatistics(relation);
// Fallback to fixed blur if insufficient data
if (!stats || stats.count < config.minSampleSize) {
const blurMagnitude = this._calculateBlurMagnitudeFromPossibility(
initialPossibility,
decayedPossibility,
config.baseBlurAmount
);
return this._calculateBlurredInterval(
pointValue,
blurMagnitude,
config.valueBlurDirection,
config.epsilon
);
}
// Calculate lost possibility proportion
const lostPossibilityProportion = initialPossibility > 0
? Math.max(0, initialPossibility - decayedPossibility) / initialPossibility
: 0;
let blurMagnitude;
switch (config.distributionBlurMode) {
case 'fixed':
// Use a fixed proportion of the standard deviation
blurMagnitude = stats.stdDev * lostPossibilityProportion;
break;
case 'adaptive':
// Adaptive blur based on value's position in distribution
const valuePercentile = this._getValuePercentile(pointValue, stats);
// Use different blur strategies based on position
if (valuePercentile < 0.1 || valuePercentile > 0.9) {
// Extreme values: use larger blur (IQR-based)
blurMagnitude = stats.iqr * lostPossibilityProportion;
} else if (valuePercentile < 0.25 || valuePercentile > 0.75) {
// Moderate outliers: use MAD-based blur
blurMagnitude = stats.mad * 2 * lostPossibilityProportion;
} else {
// Central values: use standard deviation
blurMagnitude = stats.stdDev * lostPossibilityProportion;
}
break;
case 'confidence':
// Use confidence interval based blur
const z = this._getZScore(config.confidenceLevel);
// Use effective sample size for weighted samples
const effectiveN = stats.effectiveSampleSize || stats.count;
const standardError = stats.stdDev / Math.sqrt(effectiveN);
blurMagnitude = z * standardError * lostPossibilityProportion;
break;
default:
// Default to standard deviation
blurMagnitude = stats.stdDev * lostPossibilityProportion;
}
// Apply direction-aware blur
let interval;
const direction = config.valueBlurDirection;
if (direction === 'auto') {
// Auto-detect direction based on distribution skewness and value position
const relativePosition = (pointValue - stats.median) / (stats.iqr || 1);
if (stats.skewness > 0.5 && relativePosition > 0) {
// Right-skewed distribution, value above median: blur upward
interval = { min: pointValue, max: pointValue + blurMagnitude * 1.5 };
} else if (stats.skewness < -0.5 && relativePosition < 0) {
// Left-skewed distribution, value below median: blur downward
interval = { min: pointValue - blurMagnitude * 1.5, max: pointValue };
} else {
// Symmetric blur
interval = {
min: pointValue - blurMagnitude,
max: pointValue + blurMagnitude
};
}
} else {
// Use configured direction
switch (direction) {
case 'up':
interval = { min: pointValue, max: pointValue + blurMagnitude };
break;
case 'down':
interval = { min: pointValue - blurMagnitude, max: pointValue };
break;
case 'stable':
const epsilonBlur = blurMagnitude > 0 ? config.epsilon / 2 : 0;
interval = { min: pointValue - epsilonBlur, max: pointValue + epsilonBlur };
break;
case 'neutral':
default:
interval = {
min: pointValue - blurMagnitude,
max: pointValue + blurMagnitude
};
break;
}
}
// Constrain interval to observed range (with some margin)
const rangeMargin = stats.range * 0.1; // 10% margin beyond observed range
interval.min = Math.max(interval.min, stats.min - rangeMargin);
interval.max = Math.min(interval.max, stats.max + rangeMargin);
return interval;
}
/**
* Get percentile position of a value in distribution
* @private
*/
_getValuePercentile(value, stats) {
if (value <= stats.min) return 0;
if (value >= stats.max) return 1;
// Simple linear interpolation between known percentiles
if (value <= stats.p5) return 0.05 * (value - stats.min) / (stats.p5 - stats.min);
if (value <= stats.q1) return 0.05 + 0.2 * (value - stats.p5) / (stats.q1 - stats.p5);
if (value <= stats.median) return 0.25 + 0.25 * (value - stats.q1) / (stats.median - stats.q1);
if (value <= stats.q3) return 0.5 + 0.25 * (value - stats.median) / (stats.q3 - stats.median);
if (value <= stats.p95) return 0.75 + 0.2 * (value - stats.q3) / (stats.p95 - stats.q3);
return 0.95 + 0.05 * (value - stats.p95) / (stats.max - stats.p95);
}
/**
* Get Z-score for confidence level
* @private
*/
_getZScore(confidenceLevel) {
// Common confidence levels
const zScores = {
0.90: 1.645,
0.95: 1.96,
0.99: 2.576
};
return zScores[confidenceLevel] || 1.96; // Default to 95%
}
/**
* Calculate decay factor using different decay types
* @private
*/
_calculateDecayFactor(ageInPeriod, decayRate, decayType = 'rational') {
switch (decayType) {
case 'exponential':
// Exponential decay: e^(-decayRate * ageInPeriod)
return Math.exp(-decayRate * ageInPeriod);
case 'linear':
// Linear decay: 1 - decayRate * ageInPeriod (clamped to 0)
return Math.max(0, 1 - decayRate * ageInPeriod);
case 'quadratic':
// Quadratic decay: 1 / (1 + decayRate * ageInPeriod^2)
return 1 / (1 + decayRate * Math.pow(ageInPeriod, 2));
case 'rational':
default:
// Rational decay: 1 / (1 + decayRate * ageInPeriod)
return 1 / (1 + decayRate * ageInPeriod);
}
}
/**
* Apply possibility decay based on direction
* @private
*/
_applyPossibilityDecay(initialPossibility, decayFactor, direction) {
let decayed;
switch (direction) {
case 'up':
// Decay towards 1 (increasing confidence over time)
decayed = 1 - (1 - initialPossibility) * decayFactor;
break;
case 'down':
// Decay towards 0 (decreasing confidence over time)
decayed = initialPossibility * decayFactor;
break;
case 'stable':
// No decay
decayed = initialPossibility;
break;
case 'neutral':
default:
// Decay towards 0.5 (uncertainty)
decayed = 0.5 + (initialPossibility - 0.5) * decayFactor;
break;
}
return Math.max(0, Math.min(1, decayed));
}
/**
* Calculate blurred interval based on possibility loss (traditional method)
* @private
*/
_calculateBlurredInterval(pointValue, blurMagnitude, blurDirection, epsilon) {
// Apply blur based on direction
let interval;
switch (blurDirection) {
case 'up':
// Blur expands upward
interval = { min: pointValue, max: pointValue + blurMagnitude };
break;
case 'down':
// Blur expands downward
interval = { min: pointValue - blurMagnitude, max: pointValue };
break;
case 'stable':
// Minimal blur (just epsilon for numerical stability)
const epsilonBlur = epsilon / 2;
interval = { min: pointValue - epsilonBlur, max: pointValue + epsilonBlur };
break;
case 'neutral':
default:
// Blur expands symmetrically
interval = {
min: pointValue - blurMagnitude / 2,
max: pointValue + blurMagnitude / 2
};
break;
}
return interval;
}
_calculateBlurMagnitudeFromPossibility(initialPossibility, decayedPossibility, baseBlurAmount) {
if (!Number.isFinite(initialPossibility) || initialPossibility <= 0) {
return baseBlurAmount;
}
const lostProportion = Math.max(0, initialPossibility - decayedPossibility) / initialPossibility;
return baseBlurAmount * lostProportion;
}
/**
* Get decay configuration for a specific relation
* @private
*/
_getRelationDecayConfig(relation) {
// First check if relation has custom decay config
if (relation.decayConfig) {
return {
...this.defaultConfig,
...relation.decayConfig
};
}
// Then check relation type config
const typeConfig = this.decayConfigs.get(relation.rel);
if (typeConfig) {
return typeConfig;
}
// Fall back to default
return this.defaultConfig;
}
/**
* Generate cache key for a relation
* @private
*/
_generateCacheKey(relation) {
// Use composite key with stateId for better performance
const baseKey = this.arbiter.keyManager.createCompositeKey(
relation.src,
relation.rel,
relation.dst
);
return `${baseKey}_${relation.stateId}`;
}
_generateCacheKeyForStaleness(relationUniqueKey, stateId) {
// relationUniqueKey is like "srcId_relName_dstId"
return `${relationUniqueKey}_${stateId}`;
}
/**
* Check if cached value is still valid for the given relation
* @private
*/
_isCacheValid(cached, relation) {
// PRIMARY CHECK: Relation's StateId must match what the cache entry was based on
if (cached.originalRelationStateId !== relation.stateId) return false;
// Optional: Further checks like max cache age, though stateId is the main version control
const cacheAge = Date.now() - cached.recalculatedAt;
// Example: const maxCacheAgeForcedRefresh = 24 * 60 * 60 * 1000; // 1 day
// if (cacheAge > maxCacheAgeForcedRefresh) return false;
return true;
}
/**
* Updates the lastAccessTime for an item if it's in the stale queue.
* @private
*/
_updateAccessForStaleItem(cacheKeyForCurrentVersion) {
// This function's purpose is to update lastAccessTime if an item,
// currently considered stale (i.e., its *old* version is in staleValuesQueue),
// is accessed via its *current* version.
// This is complex because stale queue items are keyed by their *old* stateId.
// For simplicity, we might not need this if access to current version doesn't affect staleness priority of old version.
// However, if the intent is "most recently accessed (entity) but oldest stale (version)", this could be relevant.
// For now, let's assume access to current version does not directly re-prioritize an old version in stale queue.
// The priority queue already handles lastAccessTime of the stale item itself.
}
/**
* Refreshes a specified number of stale values from the priority queue.
* "Refreshes" means attempting to compute and cache the value for the *current*
* version of the relation that the stale item pertained to.
* @param {number} countToRefresh - The maximum number of stale items to refresh.
* @returns {number} The number of items still remaining in the stale queue.
*/
refreshStaleValues(countToRefresh) {
let refreshedOps = 0;
for (let i = 0; i < countToRefresh; i++) {
if (this.staleValuesQueue.isEmpty()) {
break;
}
const intKey = this.staleValuesQueue.pop();
const cacheKey = this.keyManager.getIdString(intKey);
if (!cacheKey) {
console.warn(`[ValueManager] No cacheKey found for intKey ${intKey}`);
continue;
}
const staleItem = this.staleValueItemsIndex.get(cacheKey);
if (!staleItem) {
console.warn(`[ValueManager] No staleItem found for cacheKey ${cacheKey}`);
continue;
}
this.staleValueItemsIndex.delete(cacheKey); // Remove from index
// staleItem.relationUniqueKey is "srcId_relName_dstId"
const keyParts = staleItem.relationUniqueKey.split('_');
const srcId = parseInt(keyParts[0], 10);
const relName = keyParts[1];
const dstId = parseInt(keyParts[2], 10);
// Fetch the *current* version of the relation
const currentRelation = this.arbiter.relationManager.getDirectRelation(srcId, relName, dstId);
if (currentRelation) {
// Generate the cache key for the current version of this relation
const currentCacheKey = this._generateCacheKey(currentRelation);
// Check if we already have an up-to-date calculation for the current version
// or if the current version is newer than the one that caused staleness.
// This avoids recomputing if another process already updated it.
if (!this.blurredValueCache.has(currentCacheKey) ||
(this.blurredValueCache.get(currentCacheKey).originalRelationStateId !== currentRelation.stateId)) {
const newBlurredValue = this._calculateSeparatedDecay(currentRelation);
this.blurredValueCache.set(currentCacheKey, {
interval: newBlurredValue.blurredInterval,
possibility: newBlurredValue.currentPossibility,
recalculatedAt: Date.now(),
valueSnapshot: currentRelation.value,
timestampSnapshot: currentRelation.changed_last_at || currentRelation.updated_last_at,
originalRelationStateId: currentRelation.stateId,
decayApplied: newBlurredValue.decayApplied
});
}
refreshedOps++;
} else {
// The relation might have been deleted entirely. Nothing to refresh.
}
}
return this.staleValuesQueue.size();
}
/**
* Aggregate multiple interval values using interval arithmetic
* @param {Array} values - Array of { interval, possibility, reliability }
* @param {string} aggregator - Aggregation method: 'max', 'min', 'sum', 'average'
* @returns {Object} Aggregated { interval, possibility, reliability }
*/
aggregateCrispValues(values, aggregator = 'max') {
// Filter out null intervals
const validValues = values.filter(v => v.interval !== null);
if (validValues.length === 0) {
return {
interval: null,
possibility: 0,
reliability: 0
};
}
if (validValues.length === 1) {
return validValues[0];
}
let aggregatedInterval;
let aggregatedPossibility;
let aggregatedReliability;
switch (aggregator) {
case 'max':
// For max, take the interval with highest max value
const maxIdx = validValues.reduce((maxI, v, i) =>
v.interval.max > validValues[maxI].interval.max ? i : maxI, 0);
aggregatedInterval = validValues[maxIdx].interval;
aggregatedPossibility = validValues[maxIdx].possibility;
aggregatedReliability = validValues[maxIdx].reliability;
break;
case 'min':
// For min, take the interval with lowest min value
const minIdx = validValues.reduce((minI, v, i) =>
v.interval.min < validValues[minI].interval.min ? i : minI, 0);
aggregatedInterval = validValues[minIdx].interval;
aggregatedPossibility = validValues[minIdx].possibility;
aggregatedReliability = validValues[minIdx].reliability;
break;
case 'sum':
// Sum intervals: [sum of mins, sum of maxs]
aggregatedInterval = {
min: validValues.reduce((sum, v) => sum + v.interval.min, 0),
max: validValues.reduce((sum, v) => sum + v.interval.max, 0)
};
// Average possibility and multiply reliabilities
aggregatedPossibility = validValues.reduce((sum, v) => sum + v.possibility, 0) / validValues.length;
aggregatedReliability = validValues.reduce((prod, v) => prod * v.reliability, 1.0);
break;
case 'average':
default:
// Average intervals: [avg of mins, avg of maxs]
aggregatedInterval = {
min: validValues.reduce((sum, v) => sum + v.interval.min, 0) / validValues.length,
max: validValues.reduce((sum, v) => sum + v.interval.max, 0) / validValues.length
};
// Average possibility and reliability
aggregatedPossibility = validValues.reduce((sum, v) => sum + v.possibility, 0) / validValues.length;
aggregatedReliability = validValues.reduce((sum, v) => sum + v.reliability, 0) / validValues.length;
break;
}
return {
interval: aggregatedInterval,
possibility: aggregatedPossibility,
reliability: aggregatedReliability
};
}
/**
* Compare two intervals using a comparator
* @param {Object} leftInterval - { min, max }
* @param {Object} rightInterval - { min, max }
* @param {string} comparator - '>', '>=', '<', '<=', '==', '!='
* @param {number} epsilon - Epsilon for equality comparison
* @returns {number} Possibility (0-1) that comparison holds
*/
compareIntervals(leftInterval, rightInterval, comparator, epsilon = null) {
if (!leftInterval || !rightInterval) {
return 0; // No comparison possible with null intervals
}
const eps = epsilon || this.defaultConfig.epsilon;
// Calculate difference interval D = L - R
const D_min = leftInterval.min - rightInterval.max;
const D_max = leftInterval.max - rightInterval.min;
const D_length = D_max - D_min;
// Handle degenerate case
if (D_length <= 0) {
const midL = (leftInterval.min + leftInterval.max) / 2;
const midR = (rightInterval.min + rightInterval.max) / 2;
switch (comparator) {
case '>': return midL > midR ? 1 : 0;
case '>=': return midL >= midR ? 1 : 0;
case '<': return midL < midR ? 1 : 0;
case '<=': return midL <= midR ? 1 : 0;
case '==': return Math.abs(midL - midR) < eps ? 1 : 0;
case '!=': return Math.abs(midL - midR) >= eps ? 1 : 0;
default: return 0;
}
}
// Calculate possibility based on comparator
switch (comparator) {
case '>':
if (D_min > 0) return 1;
if (D_max <= 0) return 0;
return D_max / D_length;
case '>=':
if (D_min >= 0) return 1;
if (D_max < 0) return 0;
return D_max / D_length;
case '<':
if (D_max < 0) return 1;
if (D_min >= 0) return 0;
return -D_min / D_length;
case '<=':
if (D_max <= 0) return 1;
if (D_min > 0) return 0;
return -D_min / D_length;
case '==':
const overlap_min = Math.max(D_min, -eps);
const overlap_max = Math.min(D_max, eps);
if (overlap_max <= overlap_min) return 0;
return (overlap_max - overlap_min) / D_length;
case '!=':
const eq_overlap_min = Math.max(D_min, -eps);
const eq_overlap_max = Math.min(D_max, eps);
if (eq_overlap_max <= eq_overlap_min) return 1;
return 1 - (eq_overlap_max - eq_overlap_min) / D_length;
default:
console.warn('Unknown comparator:', comparator);
return 0;
}
}
}