Aluode's picture
Upload 21 files
e85709c verified
Raw
History Blame Contribute Delete
14.7 kB
/* Slapstack Playroom core — faithful JS port of SlapstackBet6 math.
FIELDS: [x, y, theta, su, sv, f, phase, r, g, b]
Verified against bet6_open.py / bet6_bp_binding.py / bet6_multimodal.py
by tests_node.js before being embedded in the playroom. */
"use strict";
const TAU = Math.PI * 2;
function wrapPi(d) {
return ((d + Math.PI) % TAU + TAU) % TAU - Math.PI;
}
function rotApply(rho, x, y) {
const c = Math.cos(rho), s = Math.sin(rho);
return [c * x - s * y, s * x + c * y];
}
/* Exact Sim(2) action on atom parameters (the Bet 5 algebra):
xy -> s R xy + t, theta -> theta + rho, sigma -> s sigma, f -> f/s.
Envelope-relative phase and color are INVARIANT. */
function transformAtoms(atoms, xi) {
const [tx, ty, rho, lam] = xi;
const s = Math.exp(lam);
const out = new Array(atoms.length);
for (let i = 0; i < atoms.length; i++) {
const a = atoms[i];
const [rx, ry] = rotApply(rho, a[0], a[1]);
out[i] = [
s * rx + tx, s * ry + ty,
a[2] + rho,
a[3] * s, a[4] * s,
a[5] / s,
a[6],
a[7], a[8], a[9],
];
}
return out;
}
/* Sim(2)-invariant intrinsic signature: identity lives here. */
function signature(atoms) {
return atoms.map(a => [
Math.log(a[3] * a[5]),
Math.log(a[3] / a[4]),
Math.cos(a[6]), Math.sin(a[6]),
a[7], a[8], a[9],
]);
}
/* Two pose-vote hypotheses per correspondence (pi-ambiguity fix):
H0: rho = d_theta, phi_obs == phi_tmpl
H1: rho = d_theta + pi, phi_obs == -phi_tmpl */
function poseVotes2pi(obs, tmpl, sigPhase = 0.35) {
const s = Math.pow((obs[3] / tmpl[3]) * (obs[4] / tmpl[4]) * (tmpl[5] / obs[5]), 1 / 3);
const dTheta = obs[2] - tmpl[2];
const out = [];
for (let H = 0; H < 2; H++) {
const rho = wrapPi(dTheta + H * Math.PI);
const phiExp = H === 0 ? tmpl[6] : -tmpl[6];
const dphi = wrapPi(obs[6] - phiExp);
const pc = -0.5 * dphi * dphi / (sigPhase * sigPhase);
const [rx, ry] = rotApply(rho, tmpl[0], tmpl[1]);
out.push([[obs[0] - s * rx, obs[1] - s * ry, rho, Math.log(s)], pc]);
}
return out;
}
/* ------- small dense linear algebra on 4x4 (row-major flat arrays) ------- */
function mat4Inv(m) {
// Gauss-Jordan, fine for well-conditioned SPD 4x4s here.
const a = m.map(r => r.slice());
const inv = [[1,0,0,0],[0,1,0,0],[0,0,1,0],[0,0,0,1]];
for (let col = 0; col < 4; col++) {
let piv = col;
for (let r = col + 1; r < 4; r++)
if (Math.abs(a[r][col]) > Math.abs(a[piv][col])) piv = r;
[a[col], a[piv]] = [a[piv], a[col]];
[inv[col], inv[piv]] = [inv[piv], inv[col]];
const d = a[col][col];
for (let j = 0; j < 4; j++) { a[col][j] /= d; inv[col][j] /= d; }
for (let r = 0; r < 4; r++) {
if (r === col) continue;
const f = a[r][col];
for (let j = 0; j < 4; j++) { a[r][j] -= f * a[col][j]; inv[r][j] -= f * inv[col][j]; }
}
}
return inv;
}
function mat4Det(m) {
const a = m.map(r => r.slice());
let det = 1;
for (let col = 0; col < 4; col++) {
let piv = col;
for (let r = col + 1; r < 4; r++)
if (Math.abs(a[r][col]) > Math.abs(a[piv][col])) piv = r;
if (piv !== col) { [a[col], a[piv]] = [a[piv], a[col]]; det = -det; }
det *= a[col][col];
if (a[col][col] === 0) return 0;
for (let r = col + 1; r < 4; r++) {
const f = a[r][col] / a[col][col];
for (let j = col; j < 4; j++) a[r][j] -= f * a[col][j];
}
}
return det;
}
function mat4Vec(m, v) {
return [0,1,2,3].map(i => m[i][0]*v[0]+m[i][1]*v[1]+m[i][2]*v[2]+m[i][3]*v[3]);
}
function matAdd(A, B, wB = 1) {
return A.map((row, i) => row.map((x, j) => x + wB * B[i][j]));
}
/* Greedy mode-seeking init (angle-aware), port of _density_peaks. */
function densityPeaks(votes, weights, M, radius = 0.45) {
const scale = [0.15, 0.15, 0.30, 0.20];
const n = votes.length;
const dens = new Float64Array(n);
for (let i = 0; i < n; i++) {
let acc = 0;
for (let k = 0; k < n; k++) {
const d0 = (votes[k][0] - votes[i][0]) / scale[0];
const d1 = (votes[k][1] - votes[i][1]) / scale[1];
const d2 = wrapPi(votes[k][2] - votes[i][2]) / scale[2];
const d3 = (votes[k][3] - votes[i][3]) / scale[3];
acc += weights[k] * Math.exp(-0.5 * (d0*d0 + d1*d1 + d2*d2 + d3*d3));
}
dens[i] = acc;
}
const peaks = [];
const alive = new Uint8Array(n).fill(1);
for (let m = 0; m < M; m++) {
let best = -1, bestD = -Infinity;
for (let i = 0; i < n; i++)
if (alive[i] && dens[i] > bestD) { bestD = dens[i]; best = i; }
if (best < 0) break;
peaks.push(votes[best].slice());
for (let i = 0; i < n; i++) {
const dxy = Math.hypot(votes[i][0] - votes[best][0], votes[i][1] - votes[best][1]);
const dr = Math.abs(wrapPi(votes[i][2] - votes[best][2]));
if (dxy + dr <= radius) alive[i] = 0;
}
}
return peaks;
}
/* Loopy BP binding of obs atoms to K templates at unknown poses.
Port of bet6_open.bp_bind: candidates carry both pi-hypotheses,
cavity messages, damping, branch-aligned rotation fusion.
Options: clampPose — array of length K; if clampPose[k] is a pose xi,
object k's pose is held fixed (conditioning-as-intervention) and only
the assignment beliefs re-equilibrate around it. */
function bpBind(templates, obs, opts = {}) {
const iters = opts.iters ?? 40;
const damping = opts.damping ?? 0.5;
const cavity = opts.cavity ?? true;
const sigVar = opts.sigVar ?? 0.08;
const outLL = opts.outLL ?? -14.0;
const clampPose = opts.clampPose ?? null;
const hiddenMask = opts.hiddenMask ?? null; // per-atom: true = no evidence
const onIter = opts.onIter ?? null;
const K = templates.length;
const sigT = templates.map(signature);
const sigO = signature(obs);
const N = obs.length;
const Vdiag = [0.03 * 0.03, 0.03 * 0.03, 0.05 * 0.05, 0.05 * 0.05];
const Vinv = [[1/Vdiag[0],0,0,0],[0,1/Vdiag[1],0,0],[0,0,1/Vdiag[2],0],[0,0,0,1/Vdiag[3]]];
const Vmat = [[Vdiag[0],0,0,0],[0,Vdiag[1],0,0],[0,0,Vdiag[2],0],[0,0,0,Vdiag[3]]];
const P0inv = [[1e-2,0,0,0],[0,1e-2,0,0],[0,0,1e-2,0],[0,0,0,1e-2]];
// ---- candidate generation: 3 signature-nearest per template, 2 hypotheses
const cands = [], votes = [], base = [];
for (let i = 0; i < N; i++) {
const c = [], v = [], b = [];
if (!(hiddenMask && hiddenMask[i])) {
for (let k = 0; k < K; k++) {
const d2 = sigT[k].map(st => {
let acc = 0;
for (let q = 0; q < 7; q++) { const d = st[q] - sigO[i][q]; acc += d * d; }
return acc;
});
const order = d2.map((d, j) => [d, j]).sort((p, q) => p[0] - q[0]).slice(0, 3);
for (const [dj, j] of order) {
for (const [xi, pc] of poseVotes2pi(obs[i], templates[k][j])) {
c.push([k, j]); v.push(xi); b.push(-0.5 * dj / sigVar + pc);
}
}
}
}
cands.push(c); votes.push(v); base.push(b);
}
// ---- beliefs seeded from the identity+phase channel
let B = [];
for (let i = 0; i < N; i++) {
const ll = base[i].concat([outLL]);
const mx = Math.max(...ll);
let e = ll.map(x => Math.exp(x - mx));
const s = e.reduce((a, x) => a + x, 0);
B.push(e.map(x => x / s));
}
// ---- pose init: density peak of each object's votes (or the clamp)
let mu = [];
for (let k = 0; k < K; k++) {
if (clampPose && clampPose[k]) { mu.push(clampPose[k].slice()); continue; }
const vk = [], wk = [];
for (let i = 0; i < N; i++)
for (let ci = 0; ci < cands[i].length; ci++)
if (cands[i][ci][0] === k) { vk.push(votes[i][ci]); wk.push(B[i][ci]); }
mu.push(vk.length ? densityPeaks(vk, wk, 1)[0] : [0, 0, 0, 0]);
}
let Sig = mu.map(() => [[1,0,0,0],[0,1,0,0],[0,0,1,0],[0,0,0,1]]);
for (let it = 0; it < iters; it++) {
// pose fusion
const Lam = [], eta = [];
for (let k = 0; k < K; k++) { Lam.push(P0inv.map(r => r.slice())); eta.push([0,0,0,0]); }
for (let i = 0; i < N; i++) {
for (let ci = 0; ci < cands[i].length; ci++) {
const k = cands[i][ci][0];
const v = votes[i][ci].slice();
v[2] = mu[k][2] + wrapPi(v[2] - mu[k][2]);
const w = B[i][ci];
Lam[k] = matAdd(Lam[k], Vinv, w);
const Vv = mat4Vec(Vinv, v);
for (let q = 0; q < 4; q++) eta[k][q] += w * Vv[q];
}
}
Sig = Lam.map(mat4Inv);
for (let k = 0; k < K; k++) {
if (clampPose && clampPose[k]) {
mu[k] = clampPose[k].slice();
Sig[k] = [[1e-6,0,0,0],[0,1e-6,0,0],[0,0,1e-6,0],[0,0,0,1e-6]];
} else {
mu[k] = mat4Vec(Sig[k], eta[k]);
mu[k][2] = wrapPi(mu[k][2]);
}
}
// assignment update with cavity
const newB = [];
for (let i = 0; i < N; i++) {
const nc = cands[i].length;
const ll = new Array(nc + 1);
for (let ci = 0; ci < nc; ci++) {
const k = cands[i][ci][0];
const v = votes[i][ci].slice();
v[2] = mu[k][2] + wrapPi(v[2] - mu[k][2]);
let mC, Sk;
if (cavity && !(clampPose && clampPose[k])) {
const Lc = matAdd(Lam[k], Vinv, -B[i][ci]);
const Vv = mat4Vec(Vinv, v);
const ecav = [0,1,2,3].map(q => eta[k][q] - B[i][ci] * Vv[q]);
const Sc = mat4Inv(Lc);
mC = mat4Vec(Sc, ecav);
Sk = Sc;
} else {
mC = mu[k]; Sk = Sig[k];
}
const r = [v[0] - mC[0], v[1] - mC[1], wrapPi(v[2] - mC[2]), v[3] - mC[3]];
const Cov = matAdd(Sk, Vmat, 1);
const Ci = mat4Inv(Cov);
const Cr = mat4Vec(Ci, r);
const quad = r[0]*Cr[0] + r[1]*Cr[1] + r[2]*Cr[2] + r[3]*Cr[3];
ll[ci] = base[i][ci] - 0.5 * quad - 0.5 * Math.log(mat4Det(Cov));
}
ll[nc] = outLL;
const mx = Math.max(...ll);
let e = ll.map(x => Math.exp(x - mx));
const s = e.reduce((a, x) => a + x, 0);
e = e.map(x => x / s);
newB.push(e.map((x, q) => damping * x + (1 - damping) * B[i][q]));
}
B = newB;
if (onIter) onIter(it, marginals(), mu, Sig);
}
function marginals() {
const marg = [];
for (let i = 0; i < N; i++) {
if (cands[i].length === 0) {
// no evidence: assignment belief reverts to the prior (uniform),
// not to a confident "outlier" — this is what permanence means.
marg.push(new Array(K + 1).fill(1 / (K + 1)));
continue;
}
const m = new Array(K + 1).fill(0);
for (let ci = 0; ci < cands[i].length; ci++) m[cands[i][ci][0]] += B[i][ci];
m[K] = B[i][cands[i].length];
marg.push(m);
}
return marg;
}
return { marg: marginals(), mu, Sig, cands, votes, B };
}
/* Numpy-matching reference render (verification + full-res compositor).
pre[c] += color_c * env * carrier ; out = sigmoid(2 * pre).
Returns {pre: Float32Array(3*H*H)} pre-sigmoid field. */
function renderPre(atoms, H, pre) {
pre = pre || new Float32Array(3 * H * H);
const lim = 3.2; // envelope support cut, in sigmas
for (const a of atoms) {
const [ax, ay, th, su, sv, f, ph, r, g, b] = a;
const ct = Math.cos(th), st = Math.sin(th);
const rad = lim * Math.max(su, sv);
// pixel bbox: x in [-1,1] maps to col (H-1)*(x+1)/2
const x0 = Math.max(0, Math.floor((ax - rad + 1) / 2 * (H - 1)));
const x1 = Math.min(H - 1, Math.ceil((ax + rad + 1) / 2 * (H - 1)));
const y0 = Math.max(0, Math.floor((ay - rad + 1) / 2 * (H - 1)));
const y1 = Math.min(H - 1, Math.ceil((ay + rad + 1) / 2 * (H - 1)));
for (let py = y0; py <= y1; py++) {
const Y = -1 + 2 * py / (H - 1);
const dy = Y - ay;
for (let px = x0; px <= x1; px++) {
const X = -1 + 2 * px / (H - 1);
const dx = X - ax;
const u = ct * dx + st * dy;
const v = -st * dx + ct * dy;
const eArg = 0.5 * ((u / su) * (u / su) + (v / sv) * (v / sv));
if (eArg > lim * lim / 2 * 1.6) continue;
const env = Math.exp(-eArg);
const car = Math.cos(TAU * f * u + ph);
const ec = env * car;
const idx = py * H + px;
pre[idx] += r * ec;
pre[H * H + idx] += g * ec;
pre[2 * H * H + idx] += b * ec;
}
}
}
return pre;
}
function sigmoidField(pre, H, out) {
out = out || new Uint8ClampedArray(4 * H * H);
const n = H * H;
for (let i = 0; i < n; i++) {
out[4 * i] = 255 / (1 + Math.exp(-2 * pre[i]));
out[4 * i + 1] = 255 / (1 + Math.exp(-2 * pre[n + i]));
out[4 * i + 2] = 255 / (1 + Math.exp(-2 * pre[2 * n + i]));
out[4 * i + 3] = 255;
}
return out;
}
/* Ownership field: P(k|pixel) through the atoms' actual envelopes,
energy-weighted. Port of bet6_open.ownership_field. */
function ownershipField(obs, marg, K, H) {
const O = [];
for (let k = 0; k <= K; k++) O.push(new Float32Array(H * H));
const lim = 3.2;
for (let i = 0; i < obs.length; i++) {
const a = obs[i];
const energy = Math.hypot(a[7], a[8], a[9]);
const ct = Math.cos(a[2]), st = Math.sin(a[2]);
const rad = lim * Math.max(a[3], a[4]);
const x0 = Math.max(0, Math.floor((a[0] - rad + 1) / 2 * (H - 1)));
const x1 = Math.min(H - 1, Math.ceil((a[0] + rad + 1) / 2 * (H - 1)));
const y0 = Math.max(0, Math.floor((a[1] - rad + 1) / 2 * (H - 1)));
const y1 = Math.min(H - 1, Math.ceil((a[1] + rad + 1) / 2 * (H - 1)));
for (let py = y0; py <= y1; py++) {
const Y = -1 + 2 * py / (H - 1);
const dy = Y - a[1];
for (let px = x0; px <= x1; px++) {
const X = -1 + 2 * px / (H - 1);
const dx = X - a[0];
const u = ct * dx + st * dy;
const v = -st * dx + ct * dy;
const env = Math.exp(-0.5 * ((u / a[3]) ** 2 + (v / a[4]) ** 2));
const idx = py * H + px;
for (let k = 0; k <= K; k++) O[k][idx] += marg[i][k] * energy * env;
}
}
}
const P = O.map(() => new Float32Array(H * H));
const ent = new Float32Array(H * H);
const support = new Uint8Array(H * H);
for (let idx = 0; idx < H * H; idx++) {
let tot = 0;
for (let k = 0; k <= K; k++) tot += O[k][idx];
support[idx] = tot > 0.05 ? 1 : 0;
let e = 0;
for (let k = 0; k <= K; k++) {
const p = O[k][idx] / (tot + 1e-6);
P[k][idx] = p;
e -= p * Math.log2(p + 1e-12);
}
ent[idx] = e;
}
return { P, ent, support };
}
/* Assignment entropy per atom, in bits. */
function atomEntropy(marg) {
return marg.map(m => {
let e = 0;
for (const p of m) e -= p * Math.log2(p + 1e-12);
return e;
});
}
if (typeof module !== "undefined") {
module.exports = {
wrapPi, transformAtoms, signature, poseVotes2pi, densityPeaks,
bpBind, renderPre, sigmoidField, ownershipField, atomEntropy,
mat4Inv, mat4Det,
};
}