Files
ECC/scripts/lib/agent-proximity/projection.js
T

306 lines
11 KiB
JavaScript

'use strict';
/**
* 2D projection of the pairwise proximity channels for the control-plane view.
*
* Input: one row per agent pair, the shipped channel vector
* x = [x_tree, x_overlap, x_dep] each in [0, 1]
* (distance.js: treeRisk, overlapRisk, dependencyRisk).
*
* Pipeline (COMPETITION-AND-VISION section 4, "Normalization and projection"):
* 1. z-score each channel against a rolling window of pair samples,
* 2. clip the tails at the 2.5th and 97.5th percentile of that window,
* 3. map back to [0, 1],
* 4. apply the static channel weights (same omega as the noisy-OR),
* 5. PCA over the weighted matrix, keep the first two components.
*
* Agent positions are the risk-weighted centroid of the projected points of
* the pairs the agent belongs to. Nothing here changes the risk or the
* advisory: the projection is a display, not a decision.
*
* No runtime dependencies. The eigen-decomposition is a Jacobi sweep over the
* 3x3 covariance matrix, which is exact enough for a display.
*/
const CHANNEL_ORDER = ['tree', 'overlap', 'dependency'];
const CHANNEL_LABELS = { tree: 'x_tree', overlap: 'x_overlap', dependency: 'x_dep' };
const PROJECTION_DEFAULTS = {
windowSize: 512,
minWindowForZscore: 8,
clipPercentiles: [2.5, 97.5],
components: 2
};
function finite(x) {
return Number.isFinite(x) ? x : 0;
}
function mean(values) {
if (values.length === 0) return 0;
let s = 0;
for (const v of values) s += v;
return s / values.length;
}
function stddev(values, mu) {
if (values.length < 2) return 0;
let s = 0;
for (const v of values) s += (v - mu) * (v - mu);
return Math.sqrt(s / (values.length - 1));
}
/**
* Linear-interpolated percentile (p in [0, 100]) of a numeric array.
*/
function percentile(values, p) {
const sorted = values.filter(Number.isFinite).slice().sort((a, b) => a - b);
if (sorted.length === 0) return 0;
if (sorted.length === 1) return sorted[0];
const rank = (Math.min(100, Math.max(0, p)) / 100) * (sorted.length - 1);
const lo = Math.floor(rank);
const hi = Math.ceil(rank);
if (lo === hi) return sorted[lo];
return sorted[lo] + (sorted[hi] - sorted[lo]) * (rank - lo);
}
/**
* Rolling window of pair channel samples. Each push records one sample vector;
* the window keeps the newest `size` samples. `stats()` returns, per channel,
* the mean, standard deviation and clip bounds (in z units) used to normalize.
*/
function createProjectionWindow(options = {}) {
const size = Number.isFinite(options.windowSize) && options.windowSize > 0 ? Math.floor(options.windowSize) : PROJECTION_DEFAULTS.windowSize;
const [pLo, pHi] = Array.isArray(options.clipPercentiles) && options.clipPercentiles.length === 2 ? options.clipPercentiles : PROJECTION_DEFAULTS.clipPercentiles;
const samples = [];
return {
size,
push(vector) {
const row = CHANNEL_ORDER.map((_, i) => finite(vector[i]));
samples.push(row);
if (samples.length > size) samples.splice(0, samples.length - size);
return samples.length;
},
get length() {
return samples.length;
},
stats() {
const per = CHANNEL_ORDER.map((channel, i) => {
const column = samples.map(row => row[i]);
const mu = mean(column);
const sigma = stddev(column, mu);
const z = sigma > 0 ? column.map(v => (v - mu) / sigma) : column.map(() => 0);
return {
channel,
mean: mu,
stddev: sigma,
clipLow: percentile(z, pLo),
clipHigh: percentile(z, pHi)
};
});
return { samples: samples.length, percentiles: [pLo, pHi], channels: per };
},
reset() {
samples.length = 0;
}
};
}
/**
* z-score one sample against the window stats, clip to the percentile bounds,
* map back to [0, 1]. A channel with zero variance maps to 0.5.
*/
function normalizeSample(vector, stats) {
return CHANNEL_ORDER.map((_, i) => {
const s = stats.channels[i];
const v = finite(vector[i]);
if (!(s.stddev > 0)) return 0.5;
const z = (v - s.mean) / s.stddev;
const lo = s.clipLow;
const hi = s.clipHigh;
if (!(hi > lo)) return 0.5;
const clipped = Math.min(hi, Math.max(lo, z));
return (clipped - lo) / (hi - lo);
});
}
/**
* Jacobi eigen-decomposition of a small symmetric matrix. Returns eigenvalues
* (descending) and the matching unit eigenvectors (as columns).
*/
function symmetricEigen(matrix) {
const n = matrix.length;
const a = matrix.map(row => row.slice());
const v = Array.from({ length: n }, (_, i) => Array.from({ length: n }, (_, j) => (i === j ? 1 : 0)));
for (let sweep = 0; sweep < 64; sweep += 1) {
let off = 0;
for (let p = 0; p < n; p += 1) for (let q = p + 1; q < n; q += 1) off += a[p][q] * a[p][q];
if (off < 1e-18) break;
for (let p = 0; p < n; p += 1) {
for (let q = p + 1; q < n; q += 1) {
if (Math.abs(a[p][q]) < 1e-14) continue;
const theta = (a[q][q] - a[p][p]) / (2 * a[p][q]);
const t = Math.sign(theta || 1) / (Math.abs(theta) + Math.sqrt(theta * theta + 1));
const c = 1 / Math.sqrt(t * t + 1);
const s = t * c;
for (let k = 0; k < n; k += 1) {
const akp = a[k][p];
const akq = a[k][q];
a[k][p] = c * akp - s * akq;
a[k][q] = s * akp + c * akq;
}
for (let k = 0; k < n; k += 1) {
const apk = a[p][k];
const aqk = a[q][k];
a[p][k] = c * apk - s * aqk;
a[q][k] = s * apk + c * aqk;
}
for (let k = 0; k < n; k += 1) {
const vkp = v[k][p];
const vkq = v[k][q];
v[k][p] = c * vkp - s * vkq;
v[k][q] = s * vkp + c * vkq;
}
}
}
}
const order = Array.from({ length: n }, (_, i) => i).sort((i, j) => a[j][j] - a[i][i]);
return {
values: order.map(i => a[i][i]),
vectors: order.map(i => v.map(row => row[i]))
};
}
/**
* PCA over a row matrix. Returns the scores for the first `components`
* components, the loadings (unit eigenvectors) and the explained variance.
* Fewer than two rows, or zero total variance, yields all-zero scores.
*/
function pca(rows, components = PROJECTION_DEFAULTS.components) {
const n = rows.length;
const dims = n > 0 ? rows[0].length : CHANNEL_ORDER.length;
const k = Math.max(1, Math.min(components, dims));
const centre = Array.from({ length: dims }, (_, d) => mean(rows.map(r => r[d])));
const zeroScores = rows.map(() => new Array(k).fill(0));
if (n < 2) {
return { scores: zeroScores, loadings: [], explainedVariance: new Array(k).fill(0), centre };
}
const cov = Array.from({ length: dims }, () => new Array(dims).fill(0));
for (const row of rows) {
for (let i = 0; i < dims; i += 1) {
for (let j = i; j < dims; j += 1) {
cov[i][j] += (row[i] - centre[i]) * (row[j] - centre[j]);
}
}
}
for (let i = 0; i < dims; i += 1) for (let j = i; j < dims; j += 1) {
cov[i][j] /= n - 1;
cov[j][i] = cov[i][j];
}
const total = cov.reduce((s, row, i) => s + row[i], 0);
if (!(total > 1e-12)) {
return { scores: zeroScores, loadings: [], explainedVariance: new Array(k).fill(0), centre };
}
const eig = symmetricEigen(cov);
const loadings = eig.vectors.slice(0, k);
const scores = rows.map(row => loadings.map(vec => vec.reduce((s, w, d) => s + w * (row[d] - centre[d]), 0)));
const explainedVariance = eig.values.slice(0, k).map(val => Math.max(0, val) / total);
return { scores, loadings, explainedVariance, centre };
}
function channelVector(channels) {
return CHANNEL_ORDER.map(key => finite(channels && channels[key]));
}
/**
* Project a set of pair links ({ a, b, risk, channels }) to 2D.
*
* The window is optional; when given, each link's channel vector is pushed
* into it and the normalization uses the window stats (rolling z-score plus
* tail clip). Without a window, or while the window holds fewer than
* `minWindowForZscore` samples, the raw [0, 1] channel values are used and the
* result says so (`normalization: 'raw'`).
*
* @returns {{ pairs, agents, normalization, window, pca }}
*/
function projectPairs(links, options = {}) {
const list = Array.isArray(links) ? links.filter(l => l && l.a !== undefined && l.b !== undefined) : [];
const weights = { tree: 0.25, overlap: 1.0, dependency: 0.9, ...(options.channelWeights || {}) };
const window = options.window || null;
const minWindow = Number.isFinite(options.minWindowForZscore) ? options.minWindowForZscore : PROJECTION_DEFAULTS.minWindowForZscore;
const raw = list.map(l => channelVector(l.channels));
if (window && options.sample !== false) for (const vec of raw) window.push(vec);
let stats = null;
let normalization = 'raw';
let normalized = raw;
if (window && window.length >= minWindow) {
stats = window.stats();
normalized = raw.map(vec => normalizeSample(vec, stats));
normalization = 'zscore-clipped';
}
const weighted = normalized.map(vec => vec.map((v, i) => v * finite(weights[CHANNEL_ORDER[i]])));
const result = pca(weighted, options.components || PROJECTION_DEFAULTS.components);
const pairs = list.map((l, i) => ({
a: l.a,
b: l.b,
risk: finite(l.risk),
level: l.level || null,
channels: Object.fromEntries(CHANNEL_ORDER.map((key, d) => [CHANNEL_LABELS[key], raw[i][d]])),
normalized: Object.fromEntries(CHANNEL_ORDER.map((key, d) => [CHANNEL_LABELS[key], normalized[i][d]])),
point: result.scores[i]
}));
// Agent position: risk-weighted centroid of its pair points. A floor keeps
// a clear pair from vanishing, so every agent with a pair gets a position.
const byAgent = new Map();
for (const pair of pairs) {
const w = 0.05 + pair.risk;
for (const id of [pair.a, pair.b]) {
const acc = byAgent.get(id) || { sum: pair.point.map(() => 0), w: 0, pairs: 0, maxRisk: 0 };
pair.point.forEach((x, d) => {
acc.sum[d] += x * w;
});
acc.w += w;
acc.pairs += 1;
acc.maxRisk = Math.max(acc.maxRisk, pair.risk);
byAgent.set(id, acc);
}
}
const agents = [...byAgent.entries()].map(([agentId, acc]) => ({
agentId,
point: acc.sum.map(x => (acc.w > 0 ? x / acc.w : 0)),
pairs: acc.pairs,
maxRisk: acc.maxRisk
}));
return {
method: 'pca',
channels: CHANNEL_ORDER.map(key => CHANNEL_LABELS[key]),
weights: Object.fromEntries(CHANNEL_ORDER.map(key => [CHANNEL_LABELS[key], finite(weights[key])])),
normalization,
window: stats ? { samples: stats.samples, percentiles: stats.percentiles, channels: stats.channels.map(c => ({ ...c, channel: CHANNEL_LABELS[c.channel] })) } : { samples: window ? window.length : 0, percentiles: PROJECTION_DEFAULTS.clipPercentiles, channels: [] },
pca: {
loadings: result.loadings.map(vec => Object.fromEntries(CHANNEL_ORDER.map((key, d) => [CHANNEL_LABELS[key], vec[d]]))),
explainedVariance: result.explainedVariance
},
pairs,
agents
};
}
module.exports = {
PROJECTION_DEFAULTS,
CHANNEL_ORDER,
CHANNEL_LABELS,
percentile,
createProjectionWindow,
normalizeSample,
pca,
projectPairs,
_internal: { symmetricEigen, mean, stddev }
};