igneum/tools/observer/detector.test.mjs
igneum-labs 3087a01593 Counter ASIC 3.0 items 4 and 5: the share-pattern detector on the observer
tools/observer/detector.mjs: per-program implied rate per miner id from blue work over
wall seconds (the chain's own estimate rule restricted to one id), excess spread above
Poisson, epoch-start share, nonce chi-square and increasing-fraction tests, card bands
from the log intake, two-way residual correlations and cliques; a design_candidate clique
held 6 net windows is the alert, written to live_state.detector and live_events kind
detector. One hook in observer.mjs (every 60 s) and one jsonb column. node:test file
with a fabricated fixed design (fires) and a fabricated honest population (quiet); the
live devnet in --dry mode is quiet with its baseline recorded in the README.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 07:47:21 +00:00

161 lines
12 KiB
JavaScript

// node --test tools/observer/detector.test.mjs
// The detector is trusted only after it fires on one known-failed case (a fabricated population that is one fixed
// design) and stays quiet on one known-finished case (a fabricated honest population shaped like the devnet's, plus
// the live devnet in `--dry` mode, README "Detector"). Fixtures are deterministic (SplitMix64).
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { aggregate, analyse, cardBands, calcWork, targetFromBits, workDouble, chi2Uniform, cliques, modelOf, DEFAULTS } from './detector.mjs';
// ---------- fixtures ----------
function rng(seed) { let s = BigInt(seed); return () => { s = (s + 0x9e3779b97f4a7c15n) & 0xffffffffffffffffn; let z = s; z = ((z ^ (z >> 30n)) * 0xbf58476d1ce4e5b9n) & 0xffffffffffffffffn; z = ((z ^ (z >> 27n)) * 0x94d049bb133111ebn) & 0xffffffffffffffffn; z ^= z >> 31n; return z; }; }
const unit = next => Number(next() >> 11n) / 2 ** 53;
function poisson(next, lambda) { if (lambda > 50) { const u1 = unit(next), u2 = unit(next); return Math.max(0, Math.round(lambda + Math.sqrt(lambda) * Math.sqrt(-2 * Math.log(u1 || 1e-12)) * Math.cos(2 * Math.PI * u2))); } let L = Math.exp(-lambda), k = 0, p = 1; do { k++; p *= unit(next); } while (p > L); return k - 1; }
const BITS = 487714602; // a live devnet value (6 October 2026); work about 2.4e8 hashes per block
const WORK = workDouble(BITS);
const L = DEFAULTS.epochLen;
// miners: [{id, mhs: base rate, program: [per-epoch multipliers] | null, nonce: 'random' | 'counter', lateStart?: bool}]
// Epoch e (0-based) spans DAA [e L, (e + 1) L) and 3,600 s of wall time; blocks per miner per epoch ~ Poisson(rate x secs / work)
function population(miners, epochs, seed = 1) {
const next = rng(seed); const rows = []; const counters = new Map();
for (let e = 0; e < epochs; e++) {
const t0 = 1_791_000_000_000 + e * L * 1000;
for (const m of miners) {
const mult = m.program ? m.program[e % m.program.length] : 1;
const lambda = m.mhs * 1e6 * L / WORK * mult;
const n = poisson(next, lambda);
for (let i = 0; i < n; i++) {
let frac = unit(next); if (m.lateStart && frac < 0.15) frac = 0.15 + unit(next) * 0.85;
const daa = e * L + Math.floor(frac * L);
let nonce;
if (m.nonce === 'counter') { const c = (counters.get(m.id) || 0n) + BigInt(1 + Math.floor(unit(next) * 50_000)); counters.set(m.id, c); nonce = c; }
else nonce = next();
rows.push({ vote_key_hash: m.id + 'f'.repeat(56), daa_score: daa, timestamp_ms: t0 + Math.floor(frac * L * 1000), color: unit(next) < 0.97 ? 'blue' : 'red', bits: BITS, nonce: nonce.toString() });
}
}
}
// the open epoch after the last closed one, a few blocks, so the window is the closed epochs
rows.push({ vote_key_hash: miners[0].id + 'f'.repeat(56), daa_score: epochs * L + 5, timestamp_ms: 1_791_000_000_000 + epochs * L * 1000 + 5000, color: 'pending', bits: BITS, nonce: '1' });
return rows;
}
const HONEST = [
{ id: 'aaaa0001', mhs: 50, nonce: 'random' }, { id: 'aaaa0002', mhs: 50, nonce: 'random' },
{ id: 'bbbb0001', mhs: 25, nonce: 'random' }, { id: 'cccc0001', mhs: 1.6, nonce: 'random' },
{ id: 'dddd0001', mhs: 13, nonce: 'random' }, { id: 'dddd0002', mhs: 13, nonce: 'random' }, { id: 'dddd0003', mhs: 13, nonce: 'random' },
];
// one fixed design under three ids: the same per-program response (a compute-bound sequencer whose rate follows the
// program's op mix, +-30%), counting nonces upward from 0, and compiled per program (no blocks in the first 15% of an epoch)
const DESIGN_PROGRAM = [1.0, 1.3, 0.8, 1.2, 0.9, 1.25, 1.1, 0.85, 1.3, 0.95, 1.15, 0.8];
const DESIGN = [
{ id: 'ee000001', mhs: 40, program: DESIGN_PROGRAM, nonce: 'counter', lateStart: true },
{ id: 'ee000002', mhs: 40, program: DESIGN_PROGRAM, nonce: 'counter', lateStart: true },
{ id: 'ee000003', mhs: 40, program: DESIGN_PROGRAM, nonce: 'counter', lateStart: true },
];
const BANDS = [{ model: '5090', n: 100, p5: 101, p50: 115, p95: 125 }, { model: 'M5 Max', n: 100, p5: 20.4, p50: 23.4, p95: 27.7 }, { model: '9070 XT', n: 50, p5: 16.8, p50: 17, p95: 19.1 }, { model: 'Intel UHD', n: 100, p5: 1.8, p50: 2.2, p95: 2.4 }];
// Run the detector epoch by epoch as the observer would (one analyse per closed epoch, prev carried), return the last
function runWindows(rows, opts, bands, fromEpoch, toEpoch) {
let prev = null, out = null;
for (let e = fromEpoch; e <= toEpoch; e++) { out = analyse(aggregate(rows, opts), opts, bands, prev, e * L + 5); prev = out.state; }
return out;
}
// ---------- the work rule ----------
test('calcWork and targetFromBits follow the chain rule', () => {
// Bitcoin's genesis bits: target 0x00ffff << 8 * (0x1d - 3)
assert.equal(targetFromBits(0x1d00ffff), 0xffffn << BigInt(8 * 26));
const w = calcWork(0x1d00ffff);
assert.equal(w, ((1n << 256n) - 1n - targetFromBits(0x1d00ffff)) / (targetFromBits(0x1d00ffff) + 1n) + 1n);
// the double form (2^256 / target) agrees with the floored BigInt form to 1e-8 on a live value (ratio 1 + 3e-9)
assert.ok(Math.abs(workDouble(BITS) / Number(calcWork(BITS)) - 1) < 1e-8);
assert.ok(Math.abs(chi2Uniform([10, 10, 10, 10]) - 0) < 1e-12);
});
// ---------- the known-finished case: an honest population stays quiet ----------
test('an honest population of 7 ids over 12 epochs raises no flag, no clique and no alert', () => {
const rows = population(HONEST, 12, 7);
const out = runWindows(rows, {}, BANDS, 6, 12);
const s = out.state;
assert.equal(s.window.epochs.length, 6);
for (const m of s.miners) assert.deepEqual(m.flags, [], `${m.id} flagged ${m.flags}`);
assert.equal(s.correlation.groups.length, 0, JSON.stringify(s.correlation));
assert.equal(s.alert.active, false); assert.equal(s.alert.held, 0);
// bands: the 50 MH/s ids match 5090/2, the 25 matches M5 Max/1, the 13s match 5090/8
assert.ok(s.miners.find(m => m.id === 'aaaa0001').band.matches.includes('5090/2'));
assert.ok(s.miners.find(m => m.id === 'bbbb0001').band.matches.includes('M5 Max/1'));
assert.ok(s.miners.find(m => m.id === 'dddd0001').band.matches.includes('5090/8'));
// early share near a tenth, nonces uniform, spreads at Poisson
for (const m of s.miners) { assert.ok(m.early_share_pct > 6 && m.early_share_pct < 14, `${m.id} early ${m.early_share_pct}`); assert.ok(m.nonce.chi2_low4 < DEFAULTS.chi2Crit && m.nonce.chi2_high4 < DEFAULTS.chi2Crit); if (m.excess_spread_pct !== null) assert.ok(m.excess_spread_pct < 10, `${m.id} excess ${m.excess_spread_pct}`); }
});
// ---------- the known-failed case: one fixed design under three ids fires ----------
test('a fixed design under 3 ids beside 7 honest ids is flagged, forms a clique and alerts after the hold', () => {
const rows = population([...HONEST, ...DESIGN], 13, 11);
const opts = {};
// window after the first 6 closed epochs: per-id flags and the candidate, held 1
let out = runWindows(rows, opts, BANDS, 6, 6);
const s1 = out.state;
for (const id of ['ee000001', 'ee000002', 'ee000003']) {
const m = s1.miners.find(x => x.id === id);
assert.ok(m.flags.includes('nonce'), `${id} nonce flag: ${JSON.stringify(m.nonce)}`);
assert.ok(m.flags.includes('late_start'), `${id} late start: ${m.early_share_pct}%`);
assert.ok(m.flags.includes('spread'), `${id} spread: excess ${m.excess_spread_pct}% steady ${m.steady}`);
assert.ok(m.band && !m.band.matches.includes('5090/1'));
}
for (const m of s1.miners.filter(x => x.id.startsWith('aaaa') || x.id.startsWith('bbbb') || x.id.startsWith('cccc') || x.id.startsWith('dddd'))) assert.deepEqual(m.flags, [], `${m.id} flagged ${m.flags}`);
const g = s1.correlation.groups.find(x => x.kind === 'design_candidate');
assert.ok(g, 'a design candidate clique: ' + JSON.stringify(s1.correlation));
assert.deepEqual([...g.ids].sort(), ['ee000001', 'ee000002', 'ee000003']);
assert.ok(g.min_r > DEFAULTS.r);
assert.equal(s1.alert.held, 1); assert.equal(s1.alert.active, false);
assert.ok(out.events.some(t => t.includes('move as one machine')));
// the hold: six windows later the alert is active, with the ALERT event once
out = runWindows(rows, opts, BANDS, 6, 11);
assert.equal(out.state.alert.held, 6); assert.equal(out.state.alert.active, true);
assert.ok(out.events.some(t => t.startsWith('Detector ALERT')), out.events.join(' | '));
const again = analyse(aggregate(rows, opts), opts, BANDS, out.state, 12 * L + 5);
assert.equal(again.state.alert.active, true); assert.ok(!again.events.some(t => t.startsWith('Detector ALERT')), 'the ALERT event is not repeated');
});
test('three ids of one honest machine that pause together are a machine_group, not a design candidate', () => {
// one card under 3 ids whose rate halves for two epochs (a shared machine): correlated, no design flag
const shared = [0, 1, 2].map(i => ({ id: `cc00000${i}`, mhs: 13, program: [1, 1, 0.55, 0.55, 1, 1, 1, 1, 1, 1, 1, 1], nonce: 'random' }));
const rows = population([...HONEST, ...shared], 12, 5);
const s = runWindows(rows, {}, BANDS, 6, 12).state;
for (const m of s.miners.filter(x => x.id.startsWith('cc0'))) assert.deepEqual(m.flags, [], `${m.id} ${m.flags}`);
assert.equal(s.alert.active, false); assert.equal(s.alert.held, 0);
assert.ok(s.correlation.groups.every(g => g.kind === 'machine_group'), JSON.stringify(s.correlation.groups));
});
test('a window shorter than minEpochsCorr correlates nothing and a lone flagged id is no alert', () => {
const rows = population([...HONEST, DESIGN[0]], 4, 3);
const s = runWindows(rows, {}, BANDS, 3, 4).state;
assert.equal(s.correlation.pairs_tested, 0);
assert.equal(s.alert.held, 0);
assert.ok(s.miners.find(m => m.id === 'ee000001').flags.includes('nonce'));
});
// ---------- card bands from the log intake ----------
test('cardBands maps worker labels to the GPUs line and reads the STATUS rate', () => {
const rows = [
{ label: 'win-ae432dc7', line: '1791270059 GPUs: NVIDIA GeForce RTX 5090 (CUDA); AMD Radeon(TM) Graphics (OpenCL, gfx1036); AMD Radeon RX 9070 XT (OpenCL, gfx1201)' },
{ label: 'miner-nvidia-ae432dc7-1', line: "1791270351.028 STATUS 'nvidia-ae432dc7-1' [worker]: 181s jobs=1049 accepted=73 hash=97.40 MH/s wall (123.78 MH/s inside jobs) now=123.73 MH/s wall (123.79 MH/s inside jobs, 222 jobs) identities=8" },
{ label: 'miner-nvidia-ae432dc7-1', line: "1791270361.028 STATUS 'nvidia-ae432dc7-1' [worker]: 191s jobs=1049 accepted=73 hash=97.40 MH/s wall (123.78 MH/s inside jobs) now=120.10 MH/s wall (123.79 MH/s inside jobs, 222 jobs) identities=8" },
{ label: 'miner-amd-ae432dc7-3', line: "1791270447.809 STATUS 'amd-ae432dc7-3' [worker]: 271s jobs=2457 accepted=23 hash=19.03 MH/s wall (19.10 MH/s inside jobs) now=19.09 MH/s wall (19.09 MH/s inside jobs, 274 jobs) identities=8" },
{ label: 'miner-amd-ae432dc7-3', line: "1791270000.809 STATUS 'amd-ae432dc7-3' [worker]: 30s jobs=2 accepted=0 hash=1.00 MH/s wall (1.00 MH/s inside jobs) now=5.00 MH/s wall (5.00 MH/s inside jobs, 2 jobs) identities=8" },
{ label: 'miner-other-37ba0461-1', line: "1791269654.125 STATUS 'other-37ba0461-1' [worker]: 21057s jobs=18608 accepted=268 hash=1.85 MH/s wall (1.85 MH/s inside jobs) now=1.84 MH/s wall (1.84 MH/s inside jobs, 27 jobs) identities=1" },
{ label: 'win-37ba0461', line: '1791248508 GPUs: Intel(R) UHD Graphics (OpenCL)' },
];
const b = cardBands(rows);
const by = Object.fromEntries(b.map(x => [x.model, x]));
assert.equal(by['5090'].n, 2); assert.equal(by['5090'].p95, 123.73);
assert.equal(by['9070 XT'].n, 1, 'the 30-s warm-up reading is dropped'); assert.equal(by['9070 XT'].p50, 19.09);
assert.equal(by['Intel UHD'].p50, 1.84);
assert.equal(modelOf('AMD Radeon RX 9070 XT (OpenCL, gfx1201)'), '9070 XT');
assert.equal(modelOf('Apple M5 Max (Metal)'), 'M5 Max');
});
test('cliques finds the triangle and ignores the pendant', () => {
const c = cliques(['a', 'b', 'c', 'd'], [['a', 'b'], ['b', 'c'], ['a', 'c'], ['c', 'd']], 3);
assert.equal(c.length, 1); assert.deepEqual([...c[0]].sort(), ['a', 'b', 'c']);
});