From 3087a01593f78223176a8c261ff6c82da670fa35 Mon Sep 17 00:00:00 2001 From: igneum-labs <337424239+igneum-labs@users.noreply.github.com> Date: Tue, 6 Oct 2026 07:47:21 +0000 Subject: [PATCH] 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 --- tools/observer/README.md | 49 +++++ tools/observer/detector.mjs | 346 +++++++++++++++++++++++++++++++ tools/observer/detector.test.mjs | 161 ++++++++++++++ tools/observer/observer.mjs | 5 + 4 files changed, 561 insertions(+) create mode 100644 tools/observer/detector.mjs create mode 100644 tools/observer/detector.test.mjs diff --git a/tools/observer/README.md b/tools/observer/README.md index 466fe10ed..16b84bcfa 100644 --- a/tools/observer/README.md +++ b/tools/observer/README.md @@ -58,3 +58,52 @@ Created on start if missing. ## Reading it `site/api/stats.mjs`, `site/api/supply.mjs` and `site/api/explorer.mjs` serve `/api/stats`, `/api/supply` and `/api/explorer` (docs/api/public-stats.md). `site/api/live.mjs` serves `/api/live` from these tables in five indexed queries (`proving` from `live_state.proving`; every block carries `shards: [{i, n, state, prover, lag, payout, pgas}]` and `proven`). `LIVE_TABLE_PREFIX` on the API reads a test observer's tables. `site/live.html` polls it every 2 s. The site shows OFFLINE when `live_state.updated_at` is older than 30 s. + +## Detector (Counter ASIC 3.0 item 4a, 6 October 2026) + +`tools/observer/detector.mjs`, hooked into `observer.mjs` with one `setInterval` (every 60 s) and one column, `live_state.detector` (jsonb, the state below). Events go to `live_events` as kind `detector`. The question it answers: does a group of miner ids behave like one fixed design (MoneroCrusher's method, `docs/analysis/asic-resistance-history.md` section 4.3 addition 4)? The chain does nothing with the answer; the answer is the trigger for the epoch-length signal (`docs/plans/epoch-length.md` section 11). Tests: `node --test tools/observer/detector.test.mjs` (a fabricated honest population stays quiet, a fabricated fixed design under three ids alerts after the hold). Dry run against the live tables, read-only: `node tools/observer/detector.mjs --dry` (`--json` for the state). + +### What it reads + +| Input | Where | Note | +|---|---|---| +| Miner id | `live_blocks.vote_key_hash`, first 8 hex | A card mines under 1, 2 or 8 vote keys (`app/igneum-app/src/detect.rs`: 8 on a card with 8 GB or more, 2 on a smaller one, 1 on an iGPU or a Mac), so one machine is several ids; the devnet's 4 machines are 20 to 27 ids. The detector never assumes an id is a machine | +| Program | the epoch, `floor(daa_score / 3600)` (spec 01 section 1.12; `DETECTOR_EPOCH_LEN` follows a signalled change) | one program per epoch; the program id itself is not in the header | +| Work per block | `detail.bits` through `calc_work` (`vendor/igneum-node/consensus/src/processes/difficulty.rs`), as a double | a miner's implied rate in an epoch = its blue blocks' summed work / the epoch's wall seconds, the chain's own `estimate_network_hashes_per_second` rule restricted to one id | +| Nonce | `detail.nonce` (u64, kept as a string) | low and high 4 bits, and whether consecutive nonces of an id increase | +| Card models and rates | `miner_logs`: the app's `GPUs:` line (any upload of the last 7 days) and the workers' `STATUS ... now= MH/s wall` lines (last 6 hours), extracted server-side | the app's own machines only; a stranger's card is not in the intake (owed: the coinbase tag could carry the model from 0.3.12) | + +An epoch is closed when the tip is past its end and settled when the tip is 1,200 DAA past it (colours final); settled epochs are read once and cached, so a run reads at most two epochs (under 7,500 rows) plus one `UPDATE`. Start-up reads the window once (about 25,000 rows in 5,000-row pages). + +### The statistics, per id over the window (`DETECTOR_WINDOW_EPOCHS`, default 6 closed epochs) + +| Statistic | Rule | Flag | What it catches, what it misses | +|---|---|---|---| +| Per-program spread | sd of log implied rate over the epochs the id is present in (30 or more blue blocks), minus the Poisson part (`sqrt(mean 1/blue)`) in quadrature = the excess spread; only for a steady id (no step over 1.65x between consecutive epochs: a step is the machine doing something else, not the program) | `spread` when the excess is over 10% over 4 or more epochs | a design whose cost follows the program (a hard-datapath FPGA, a compute-bound sequencer). Misses the on-die recompute chip: its cost is the item derivation, the same for every program (`chip-model-v3.md`), so its spread is a GPU's | +| Epoch-start share | the id's blue blocks in the first tenth of each epoch (by DAA) over its blocks in the window | `late_start` under 2% with 200 or more blocks (binomial p about 1e-6 at the honest 10%) | a design compiled per program (epoch-length.md section 5.1: 42 to 160 min per bitstream, so it mines nothing in the first minutes). Misses every design that executes the program | +| Nonce pattern | chi-square of the low 4 bits and of the high 4 bits against uniform (15 degrees of freedom), and the fraction of increasing consecutive nonces | `nonce` when either chi-square exceeds 37.70 (p = 0.001) or the increasing fraction leaves [0.35, 0.65] with 200 or more pairs; 32 nonces minimum | a counter from 0, a per-core stride, a design that fixes the high word. Misses a design that draws random starts, as the app does | +| Card band | the window-median implied rate against every band / d for d in {1, 2, 8} (the identity counts), within 30% | `band_high` above every band by 30% (one id faster than any known card: a pool key or a design); `band` when steady and outside every band / d | a pool key trips `band_high` and is honest; the flag is evidence, never the alert | +| Correlation | two-way residuals (log rate minus the id's mean minus the epoch's common factor over the ids present, clipped at +-30%), Pearson over the epochs two ids share (5 or more); an edge at r over 0.8; maximal cliques of 3 or more ids | a clique whose members carry a design flag = `design_candidate`; a clique without = `machine_group` (one card's identities, one operator's machines: honest) | k or more ids moving as one and leaking a signature. Misses a design that is latency-bound like a GPU, draws random nonces and mines from the first second of each epoch: that design is invisible here, which is why the plan calls the detector a response-time tool and not a layer | + +The alert: a `design_candidate` clique that holds for `DETECTOR_HOLD` net windows (default 6: one count up per newly closed epoch with a candidate, one down without), so at the base epoch about 11 hours, at the 600-s floor about 110 minutes. Event texts: `Detector: miner flagged ()`, `Detector: ids move as one machine with design flags ...; held h of M windows`, `Detector ALERT: ...`, `Detector: the alert cleared`. Thresholds live in `DEFAULTS` (`DETECTOR_R`, `DETECTOR_K`, `DETECTOR_HOLD`, `DETECTOR_WINDOW_EPOCHS`, `DETECTOR_EPOCH_LEN` override them). + +### The devnet's honest baseline (read-only dry run, 6 October 2026, 07:5x UTC, window epochs 40 to 45, tip DAA 168,422; the Mac's load average at the run was 3.6 to 4.9 (`uptime`, 08:46 UTC), which does not touch these chain-side numbers in any case) + +Epochs 39 to 45 are the PC 1 outage (the Ember Tune quit, 22:31 UTC on 5 October), so the steady window holds 4 ids: PC 2's RTX 5090 under 2 keys, the M5 Max under 1, the Intel UHD laptop under 1. Network implied rate 125.4 to 130.6 MH/s per epoch against the cards' own STATUS sum of 143.9 (0.89x: reds, pending blocks and template latency are not in the chain figure). + +| id | card | blue blocks | implied MH/s (median) | spread sd | Poisson sd | excess | first-tenth share | nonce chi2 low / high (crit 37.70) | increasing | band match | +|---|---|---|---|---|---|---|---|---|---|---| +| 9915d263 | 5090 (PC 2), 1 of 2 keys | 8,247 | 49.99 | 2.3% | 2.7% | 0% | 10.7% | 23.4 / 22.0 | 0.501 | 5090/2 | +| 00cec3ae | 5090 (PC 2), 2 of 2 | 8,245 | 50.11 | 3.9% | 2.7% | 2.8% | 9.3% | 13.7 / 9.4 | 0.501 | 5090/2 | +| 8fafda27 | M5 Max | 4,133 | 25.35 | 6.5% | 3.8% | 5.2% | 9.8% | 16.9 / 10.8 | 0.496 | M5 Max/1 | +| 4c022439 | Intel UHD | 265 | 1.59 | 13.4% | 15.2% | 0% | 9.1% | 20.2 / 26.0 | 0.512 | Intel UHD/1 | + +Correlation: 6 pairs, max r 0.53 (the two keys of PC 2's card), no edge at 0.8, no group, no flag, no alert. Over all 28 ids with 32 or more nonces in the 24-hour table (40,550 blocks with `detail`): chi-square maximum 26.8 (n = 38) and 26.0 (n = 793), KS maximum 0.232 at n = 42 (p = 0.01 critical 0.251), increasing fraction 0.438 to 0.520; `nonce mod 32` over all blocks chi-square 22.1 at 31 degrees of freedom. So the honest nonce is uniform over the full 64 bits: the serve protocol hands each job a 32-aligned 64-bit `nonce_start` (`proto-cuda/nvrtc/worker.cpp` lines 17 and 1175) and the kernel adds the lane index; the start is drawn at random per job. The 3-epoch window 36 to 38 (22 ids, PC 1 ramping down) had 4 of 28 pairs over r = 0.9: with 3 points a correlation is noise, which is why 5 shared epochs are the minimum. Card bands from the intake (6 hours of STATUS lines, p5 / p50 / p95): 5090 112.6 / 114.8 / 121.7 MH/s (n 781; the bench's 136 to 137 is the card to itself, the app's live rate shares it with the node and the prover); M5 Max 26.5 / 27.3 / 28.5 (674); Intel UHD 1.7 / 1.8 / 2.0 (754); from the 30-hour sample while PC 1 ran: RX 9070 XT 16.8 / 17.0 / 19.1 (355), gfx1036 2.6 / 2.8 / 3.4, M4 Max laptop 8.4 / 19.8 / 21.9 (478). + +The honest population, in one line: excess per-program spread 0 to 5.2% on three card models (the census's 0.8 to 3.2% six-era spread plus the Mac's own load), first-tenth share 9.1 to 10.7%, nonces uniform, pairwise residual correlation under 0.55. + +### False positives, and what the public testnet's first week must add (history check 2) + +Under the null (independent residuals, n = 6 epochs) one pair reads r over 0.8 with p about 0.028 (t = 2.67 on 4 degrees of freedom) and over 0.9 with p about 0.007; chance 3-cliques per window are about C(m, 3) p^3: 0.09 at m = 30 ids, about 100 at m = 300. So the clique alone is never the alert; the candidate also needs a design flag on a member (chance per id: `nonce` about 0.002, `late_start` about 1e-6, `spread` unknown until the per-model baseline exists; the devnet's maximum excess is 5.2% against the 10% line) and 6 net windows. At n = 12 (`DETECTOR_WINDOW_EPOCHS=12`) p(r over 0.8) is about 0.001 and chance 3-cliques at m = 300 are about 0.005 per window: set 12 once the testnet has over 100 ids. The first week must add: (1) the excess spread per card model over 12 or more epochs (the devnet has three models over 6); (2) the nonce layout of third-party miners and pools (a stratum extranonce in the high word is honest and non-uniform, so until each software is baselined a `nonce` flag is evidence, not an alert); (3) bands for cards the project does not own, which needs the card model in the coinbase tag; (4) the `machine_group` count, to see what an 8-key card looks like at scale. + +Consequences per tier: the detector costs a miner nothing (it runs on the observer; one `UPDATE` a minute, two epochs re-read a minute, two regexp scans of the intake every 30 minutes). A home card under 8 keys appears as a `machine_group`, never an alert by itself. A pool's key trips `band_high`, which is informational; a pool that publishes its card mix clears it. The alert's action is the signal of epoch-length.md section 11, whose cost per tier is that document's section 7. diff --git a/tools/observer/detector.mjs b/tools/observer/detector.mjs new file mode 100644 index 000000000..3e67f9362 --- /dev/null +++ b/tools/observer/detector.mjs @@ -0,0 +1,346 @@ +// Share-pattern detector (Counter ASIC 3.0 item 4a, 6 October 2026). Reads what the observer already stores per block +// (live_blocks: vote_key_hash as the miner id, daa_score, timestamp_ms, color, detail.bits, detail.nonce) and what the log +// intake knows about card models (miner_logs: the app's "GPUs:" line and the workers' STATUS lines), and answers one +// question once a minute: does any group of miner ids behave like one fixed design? Written to live_state.detector +// (jsonb) and, on a change, to live_events as kind `detector`. The chain does nothing with it: the detector is the +// trigger for people (docs/plans/epoch-length.md section 11), not for consensus. +// +// Everything that decides is a pure function over rows (aggregate, analyse, cardBands), tested in detector.test.mjs +// with a fabricated fixed-design population and a fabricated honest one. The live path (run) only fetches rows and +// writes the result. `node tools/observer/detector.mjs --dry` runs the live tables read-only and prints the state. +// +// Baseline numbers from the devnet (6 October 2026) and the thresholds' reasons: tools/observer/README.md, section +// "Detector". No em dashes anywhere in this file by the copy law. + +import { readFileSync } from 'node:fs'; +import { homedir } from 'node:os'; + +export const DEFAULTS = { + epochLen: 3600, // DAA s per program (spec 01 section 1.12; epoch-length.md changes this by signal only) + windowEpochs: 6, // the rolling window, closed epochs only (DETECTOR_WINDOW_EPOCHS) + settleDaa: 1200, // an epoch is re-read until the tip is this far past its end (colours settle; merge depth scale) + minBlue: 30, // an id is "present" in an epoch with at least this many blue blocks (Poisson sd 18%) + minEpochsSpread: 4, // epochs an id must be present in for the spread statistic + minEpochsCorr: 5, // epochs two ids must share for a correlation (n = 3 or 4 is noise: see README) + stepMax: 0.5, // |log rate change| between consecutive present epochs above this = an operational step, not a program + excessSpreadMax: 0.10, // excess (above Poisson) per-program spread above 10% is a design flag (honest devnet max 5.1%) + earlyShare: 0.10, // the first tenth of each epoch (DAA) carries a tenth of an honest miner's blocks + earlyShareMin: 0.02, // under 2% with earlyMinBlue blocks = the miner cannot mine the start of an epoch (compile per program) + earlyMinBlue: 200, // P(X <= 4 | n = 200, p = 0.1) is about 1e-6 + nonceMin: 32, // nonces needed for the nonce test + chi2Crit: 37.70, // chi-square, 15 degrees of freedom, p = 0.001 + incMin: 0.35, incMax: 0.65, incMinN: 200, // fraction of increasing consecutive nonces (honest 0.50; a counter 1.0) + bandTol: 0.30, // a chain-implied rate within 30% of a band / divisor matches it + identityDivisors: [1, 2, 8], // the app mines a card under 1, 2 or 8 vote keys (app/igneum-app/src/detect.rs) + winsor: 0.30, // residual log rates are clipped at +-30% before correlating + r: 0.8, // pairwise residual correlation above this = an edge (at n = 6 a true 0.95 reads 0.85 to 0.99 with noise) + k: 3, // a clique of this many ids with design flags = a candidate alert + holdWindows: 6, // windows (one per closed epoch) the candidate must hold before the alert is active; a window without it counts one down + maxIds: 400, // the correlation graph is bounded (ids with the most blue blocks) +}; + +// ---------- the chain's work rule (vendor/igneum-node/consensus/src/processes/difficulty.rs calc_work) ---------- +export function targetFromBits(bits) { + bits = Number(bits); + const size = bits >>> 24; + const word = BigInt(bits & 0x007fffff); + return size <= 3 ? word >> BigInt(8 * (3 - size)) : word << BigInt(8 * (size - 3)); +} +const U256_MAX = (1n << 256n) - 1n; +export function calcWork(bits) { const t = targetFromBits(bits); return ((U256_MAX - t) / (t + 1n)) + 1n; } +// The same quantity as a double for sums (2^256 / (target + 1), exact to 53 bits; the SQL aggregate uses this form) +export function workDouble(bits) { bits = Number(bits); const size = bits >>> 24; const word = bits & 0x007fffff; return 2 ** (256 - 8 * (size - 3)) / word; } +export const epochOf = (daa, L = DEFAULTS.epochLen) => Math.floor(Number(daa) / L); + +// ---------- aggregate: block rows -> per (id, epoch) cells ---------- +// rows: {vote_key_hash, daa_score, timestamp_ms, color, bits, nonce (string, u64)}; any order. Output cells carry what the +// tests need: blue count, summed work (double), blocks in the first tenth of the epoch, and the nonce histograms. +export function aggregate(rows, opts = {}) { + const o = { ...DEFAULTS, ...opts }; + const sorted = [...rows].filter(r => r.vote_key_hash && r.bits != null).sort((a, b) => Number(a.daa_score) - Number(b.daa_score) || Number(a.timestamp_ms) - Number(b.timestamp_ms)); + const epochs = new Map(); const cells = new Map(); const lastNonce = new Map(); + for (const r of sorted) { + const daa = Number(r.daa_score), e = epochOf(daa, o.epochLen), t = Number(r.timestamp_ms); + const ep = epochs.get(e) || { e, t0: Infinity, t1: -Infinity, blocks: 0, blue: 0, work: 0, maxDaa: 0 }; + ep.t0 = Math.min(ep.t0, t); ep.t1 = Math.max(ep.t1, t); ep.blocks++; ep.maxDaa = Math.max(ep.maxDaa, daa); epochs.set(e, ep); + const id = String(r.vote_key_hash).slice(0, 8), key = `${id}:${e}`; + const c = cells.get(key) || { id, e, blue: 0, work: 0, early: 0, n: 0, lo: new Array(16).fill(0), hi: new Array(16).fill(0), inc: 0, pairs: 0 }; + if (r.nonce !== undefined && r.nonce !== null) { + let n = null; try { n = BigInt(String(r.nonce)); } catch { n = null; } + if (n !== null) { + c.n++; c.lo[Number(n & 15n)]++; c.hi[Number((n >> 60n) & 15n)]++; + const prev = lastNonce.get(id); if (prev !== undefined) { c.pairs++; if (n > prev) c.inc++; } lastNonce.set(id, n); + } + } + if (r.color === 'blue') { const w = workDouble(r.bits); c.blue++; c.work += w; ep.blue++; ep.work += w; if (daa % o.epochLen < o.epochLen / 10) c.early++; } + cells.set(key, c); + } + return { epochs, cells }; +} +// Merge cells of the same shape (the live path keeps settled epochs cached and re-reads the open ones) +export function mergeAggregates(list) { + const epochs = new Map(), cells = new Map(); + for (const a of list) { for (const [e, ep] of a.epochs) epochs.set(e, ep); for (const [k, c] of a.cells) cells.set(k, c); } + return { epochs, cells }; +} + +// ---------- small statistics ---------- +const mean = a => a.reduce((x, y) => x + y, 0) / a.length; +const sd = a => { if (a.length < 2) return 0; const m = mean(a); return Math.sqrt(a.reduce((x, y) => x + (y - m) ** 2, 0) / (a.length - 1)); }; +const median = a => { const b = [...a].sort((x, y) => x - y); const h = b.length >> 1; return b.length % 2 ? b[h] : (b[h - 1] + b[h]) / 2; }; +export function chi2Uniform(counts) { const n = counts.reduce((a, b) => a + b, 0); if (!n) return 0; const exp = n / counts.length; return counts.reduce((a, c) => a + (c - exp) ** 2 / exp, 0); } +export function pearson(a, b) { const ma = mean(a), mb = mean(b); let sab = 0, saa = 0, sbb = 0; for (let j = 0; j < a.length; j++) { sab += (a[j] - ma) * (b[j] - mb); saa += (a[j] - ma) ** 2; sbb += (b[j] - mb) ** 2; } return saa > 0 && sbb > 0 ? sab / Math.sqrt(saa * sbb) : 0; } +const round = (x, d = 1) => x === null || x === undefined || !Number.isFinite(x) ? null : Math.round(x * 10 ** d) / 10 ** d; + +// Maximal cliques of size >= k in a small graph (Bron-Kerbosch without pivoting; the graph is bounded by maxIds and +// by the edge rule, and ids with degree under k - 1 are dropped first) +export function cliques(nodes, edges, k) { + const adj = new Map(nodes.map(n => [n, new Set()])); + for (const [a, b] of edges) { adj.get(a).add(b); adj.get(b).add(a); } + const keep = nodes.filter(n => adj.get(n).size >= k - 1); + const out = []; + const bk = (R, P, X) => { + if (!P.size && !X.size) { if (R.length >= k) out.push([...R]); return; } + for (const v of [...P]) { + const nv = adj.get(v); + bk([...R, v], new Set([...P].filter(x => nv.has(x))), new Set([...X].filter(x => nv.has(x)))); + P.delete(v); X.add(v); + if (out.length > 50) return; + } + }; + bk([], new Set(keep), new Set()); + return out.sort((a, b) => b.length - a.length); +} + +// ---------- analyse: cells -> the detector state ---------- +// bands: [{model, p5, p50, p95}] in MH/s (cardBands); prev: the previous state (for the hold counter); tipDaa: the tip +export function analyse(agg, opts = {}, bands = [], prev = null, tipDaa = null) { + const o = { ...DEFAULTS, ...opts }; + const L = o.epochLen; + const all = [...agg.epochs.values()].sort((a, b) => a.e - b.e); + const tip = tipDaa ?? (all.length ? all[all.length - 1].maxDaa : 0); + // closed = the whole epoch is in the past of the tip; settled = colours are final + const closed = all.filter(ep => (ep.e + 1) * L <= tip && ep.blocks >= 0.5 * L); + const win = closed.slice(-o.windowEpochs); + const W = win.map(ep => ep.e); + const secs = new Map(win.map(ep => [ep.e, Math.max(1, (ep.t1 - ep.t0) / 1000)])); + const network = win.map(ep => ({ epoch: ep.e, secs: round(secs.get(ep.e), 0), blue: ep.blue, mhs: round(ep.work / secs.get(ep.e) / 1e6, 1), settled: tip >= (ep.e + 1) * L + o.settleDaa })); + // per id + const byId = new Map(); + for (const c of agg.cells.values()) { if (!W.includes(c.e)) continue; if (!byId.has(c.id)) byId.set(c.id, []); byId.get(c.id).push(c); } + const ids = [...byId.keys()].sort((a, b) => byId.get(b).reduce((x, c) => x + c.blue, 0) - byId.get(a).reduce((x, c) => x + c.blue, 0)).slice(0, o.maxIds); + const logRate = new Map(); // id -> Map(epoch -> log rate) + const miners = new Map(); + for (const id of ids) { + const cs = byId.get(id); + const present = cs.filter(c => c.blue >= o.minBlue).sort((a, b) => a.e - b.e); + const lr = new Map(present.map(c => [c.e, Math.log(c.work / secs.get(c.e))])); + logRate.set(id, lr); + const rates = [...lr.values()]; + const blueTotal = cs.reduce((x, c) => x + c.blue, 0), early = cs.reduce((x, c) => x + c.early, 0); + // steady: no operational step between consecutive present epochs + let steady = present.length >= 2; const steps = []; + for (let i = 1; i < present.length; i++) { const d = lr.get(present[i].e) - lr.get(present[i - 1].e); steps.push(d); if (Math.abs(d) > o.stepMax) steady = false; } + const spreadSd = rates.length >= 2 ? sd(rates) : null; + const poissonSd = present.length ? Math.sqrt(mean(present.map(c => 1 / c.blue))) : null; + const excess = spreadSd === null ? null : Math.sqrt(Math.max(0, spreadSd ** 2 - poissonSd ** 2)); + // nonces, pooled over the window + const lo = new Array(16).fill(0), hi = new Array(16).fill(0); let n = 0, inc = 0, pairs = 0; + for (const c of cs) { n += c.n; inc += c.inc; pairs += c.pairs; for (let i = 0; i < 16; i++) { lo[i] += c.lo[i]; hi[i] += c.hi[i]; } } + const chiLo = chi2Uniform(lo), chiHi = chi2Uniform(hi), incFrac = pairs ? inc / pairs : null; + const flags = [], notes = []; + if (present.length >= o.minEpochsSpread && steady && excess > o.excessSpreadMax) flags.push('spread'); + if (!steady && present.length >= 2) notes.push('unsteady'); + const earlyFrac = blueTotal ? early / blueTotal : null; + if (blueTotal >= o.earlyMinBlue && earlyFrac < o.earlyShareMin) flags.push('late_start'); + if (n >= o.nonceMin && (chiLo > o.chi2Crit || chiHi > o.chi2Crit || (pairs >= o.incMinN && (incFrac < o.incMin || incFrac > o.incMax)))) flags.push('nonce'); + // band: the window-median chain rate against every band / identity divisor + const mhs = rates.length ? Math.exp(median(rates)) / 1e6 : null; + let band = null; + if (mhs !== null && bands.length) { + const matches = []; + for (const b of bands) for (const d of o.identityDivisors) if (mhs >= b.p5 / d * (1 - o.bandTol) && mhs <= b.p95 / d * (1 + o.bandTol)) matches.push(`${b.model}/${d}`); + const top = Math.max(...bands.map(b => b.p95)), bottom = Math.min(...bands.map(b => b.p5)) / Math.max(...o.identityDivisors); + band = { matches, high: mhs > top * (1 + o.bandTol), small: mhs < bottom * (1 - o.bandTol) }; + if (band.high) flags.push('band_high'); + else if (!matches.length && !band.small && steady && present.length >= o.minEpochsSpread) flags.push('band'); + if (band.small) notes.push('below_every_band'); + } + miners.set(id, { + id, epochs_present: present.length, blue: blueTotal, mhs: round(mhs, 2), + steady, max_step_pct: steps.length ? round(Math.max(...steps.map(Math.abs)) * 100, 0) : null, + spread_sd_pct: round(spreadSd === null ? null : spreadSd * 100), poisson_sd_pct: round(poissonSd === null ? null : poissonSd * 100), excess_spread_pct: round(excess === null ? null : excess * 100), + early_share_pct: round(earlyFrac === null ? null : earlyFrac * 100), nonce: { n, chi2_low4: round(chiLo), chi2_high4: round(chiHi), inc_frac: round(incFrac, 3) }, + band, flags, notes, + }); + } + // two-way residuals: id mean and the epoch common factor over the ids present in it, clipped + const idMean = new Map([...logRate].map(([id, lr]) => [id, lr.size ? mean([...lr.values()]) : 0])); + const epochFactor = new Map(W.map(e => { const devs = ids.filter(id => logRate.get(id).has(e)).map(id => logRate.get(id).get(e) - idMean.get(id)); return [e, devs.length ? mean(devs) : 0]; })); + const resid = new Map(ids.map(id => [id, new Map([...logRate.get(id)].map(([e, v]) => [e, Math.max(-o.winsor, Math.min(o.winsor, v - idMean.get(id) - epochFactor.get(e)))]))])); + // correlation graph + const corrIds = ids.filter(id => resid.get(id).size >= o.minEpochsCorr); + const edges = []; let pairsTested = 0, maxR = null; + for (let a = 0; a < corrIds.length; a++) for (let b = a + 1; b < corrIds.length; b++) { + const ra = resid.get(corrIds[a]), rb = resid.get(corrIds[b]); + const common = [...ra.keys()].filter(e => rb.has(e)); + if (common.length < o.minEpochsCorr) continue; + const r = pearson(common.map(e => ra.get(e)), common.map(e => rb.get(e))); + pairsTested++; if (maxR === null || r > maxR) maxR = r; + if (r > o.r) edges.push([corrIds[a], corrIds[b], r]); + } + const groups = cliques(corrIds, edges.map(e => [e[0], e[1]]), o.k).map(members => { + const rs = edges.filter(e => members.includes(e[0]) && members.includes(e[1])).map(e => e[2]); + const designFlags = [...new Set(members.flatMap(id => miners.get(id).flags))]; + return { ids: members, size: members.length, min_r: round(Math.min(...rs), 2), design_flags: designFlags, kind: designFlags.length ? 'design_candidate' : 'machine_group' }; + }); + // the alert: a clique with design flags, held over consecutive windows (one window per newly closed epoch) + const windowEnd = W.length ? W[W.length - 1] : null; + const candidate = groups.find(g => g.kind === 'design_candidate') || null; + const prevAlert = (prev && prev.alert) || { held: 0, window_end: null, active: false, since: null }; + // one count per newly closed epoch: up with a candidate, one down without (a clique at the noise edge may drop a + // pair for one window and come back; the alert needs holdWindows net) + let held = prevAlert.held || 0; + if (W.length >= o.minEpochsCorr && windowEnd !== prevAlert.window_end) held = candidate ? held + 1 : Math.max(0, held - 1); + else if (W.length < o.minEpochsCorr) held = 0; + const active = held >= o.holdWindows; + const alert = { active, held, hold_windows: o.holdWindows, window_end: windowEnd, since: active ? (prevAlert.active ? prevAlert.since : new Date().toISOString()) : null, candidate }; + // events: transitions and new per-id flags + const events = []; + const prevFlags = new Map(((prev && prev.miners) || []).map(m => [m.id, m.flags || []])); + for (const m of miners.values()) for (const f of m.flags) if (!(prevFlags.get(m.id) || []).includes(f)) events.push(`Detector: miner ${m.id} flagged ${f} (${flagEvidence(m, f)})`); + if (candidate && !(prevAlert.candidate && sameIds(prevAlert.candidate.ids, candidate.ids))) events.push(`Detector: ${candidate.size} ids move as one machine with design flags ${candidate.design_flags.join(', ')} (min r ${candidate.min_r}); held ${held} of ${o.holdWindows} windows`); + if (active && !prevAlert.active) events.push(`Detector ALERT: a population behaves like one fixed design: ${candidate.ids.join(', ')} (${candidate.design_flags.join(', ')}, min r ${candidate.min_r}) over ${o.holdWindows} windows of ${o.windowEpochs} epochs; see epoch-length.md section 11`); + if (!active && prevAlert.active) events.push('Detector: the alert cleared'); + const state = { + computed_at: new Date().toISOString(), epoch_len: L, tip_daa: tip, + window: { epochs: W, closed_epochs: closed.length, ids: ids.length, ids_correlated: corrIds.length }, + network, miners: [...miners.values()], + correlation: { pairs_tested: pairsTested, max_r: round(maxR, 2), edges: edges.length, groups }, + alert, thresholds: { r: o.r, k: o.k, hold_windows: o.holdWindows, excess_spread_max_pct: o.excessSpreadMax * 100, early_share_min_pct: o.earlyShareMin * 100, chi2_crit: o.chi2Crit, inc_range: [o.incMin, o.incMax], band_tol_pct: o.bandTol * 100, min_blue: o.minBlue, min_epochs_corr: o.minEpochsCorr }, + bands: bands.map(b => ({ model: b.model, p5: round(b.p5), p50: round(b.p50), p95: round(b.p95), n: b.n })), + }; + return { state, events }; +} +const sameIds = (a, b) => a.length === b.length && a.every(x => b.includes(x)); +function flagEvidence(m, f) { + if (f === 'spread') return `excess per-program spread ${m.excess_spread_pct}% over ${m.epochs_present} epochs, Poisson ${m.poisson_sd_pct}%`; + if (f === 'late_start') return `${m.early_share_pct}% of ${m.blue} blocks in the first tenth of each epoch`; + if (f === 'nonce') return `chi2 low ${m.nonce.chi2_low4}, high ${m.nonce.chi2_high4}, increasing ${m.nonce.inc_frac} over ${m.nonce.n} nonces`; + if (f === 'band_high') return `${m.mhs} MH/s, above every known card`; + if (f === 'band') return `${m.mhs} MH/s matches no known card band at a steady rate`; + return ''; +} + +// ---------- card bands from the log intake ---------- +// rows: {label, line} where line is an app "GPUs:" line (label win- or mac-) or a worker STATUS line +// (label miner---). The worker label's trailing index is the 1-based position in the GPUs list of the +// same machine id (app/igneum-app/src/engine.rs names cards --). +export function modelOf(text) { + const t = String(text); + if (/RTX 5090/.test(t)) return '5090'; + if (/RX 9070 XT|gfx1201/.test(t)) return '9070 XT'; + if (/Apple M5 Max/.test(t)) return 'M5 Max'; + if (/Apple M4 Max/.test(t)) return 'M4 Max'; + if (/Apple M\d/.test(t)) return t.match(/Apple M\d[^,;()]*/)[0].trim(); + if (/gfx1036|Radeon\(TM\) Graphics/.test(t)) return 'gfx1036'; + if (/UHD/.test(t)) return 'Intel UHD'; + const m = /GeForce (RTX \d+[^,;()]*)|Radeon (RX [^,;()]*)/.exec(t); if (m) return (m[1] || m[2]).trim(); + return null; +} +export function cardBands(rows, opts = {}) { + const o = { minUptime: 120, ...opts }; + const gpus = new Map(); // machine id8 -> [model...] + for (const r of rows) { const m = /^(?:win|mac)-([0-9a-f]{8})$/.exec(r.label); const g = / GPUs: (.*)$/.exec(r.line || ''); if (m && g) gpus.set(m[1], g[1].split(';').map(s => modelOf(s))); } + const samples = new Map(); + for (const r of rows) { + const m = /^miner-[a-z]+-([0-9a-f]{8})-(\d+)$/.exec(r.label); if (!m) continue; + const s = /STATUS '[^']+' \[worker\]: (\d+)s .*? now=([\d.]+) MH\/s wall/.exec(r.line || ''); if (!s) continue; + const uptime = Number(s[1]), now = Number(s[2]); if (!(now > 0) || uptime < o.minUptime) continue; + const list = gpus.get(m[1]); const model = (list && list[Number(m[2]) - 1]) || modelOf(r.line) || null; if (!model) continue; + if (!samples.has(model)) samples.set(model, []); samples.get(model).push(now); + } + const q = (a, p) => a[Math.min(a.length - 1, Math.floor(p * a.length))]; + return [...samples].map(([model, v]) => { v.sort((a, b) => a - b); return { model, n: v.length, p5: q(v, 0.05), p50: q(v, 0.5), p95: q(v, 0.95) }; }).sort((a, b) => b.p50 - a.p50); +} + +// ---------- the live path ---------- +// ctx: {sql, TB (blocks table), TS (state table), recordEvent(kind, text), log}; opts from env. Settled epochs are read +// once and cached as cells; the open and unsettled epochs are re-read each run (two epochs at most, under 7,500 rows). +const PAGE = 5000; +export function makeRunner(ctx, opts = {}) { + const o = { ...DEFAULTS, ...opts }; + const cache = new Map(); // epoch -> aggregate of that epoch (settled only) + let bands = [], bandsAt = 0, prev = null; + async function readEpoch(e) { + const rows = []; + for (let off = 0; ; off += PAGE) { + const page = await ctx.sql(`SELECT vote_key_hash, daa_score::bigint AS daa_score, timestamp_ms::bigint AS timestamp_ms, color, (detail->>'bits')::bigint AS bits, detail->>'nonce' AS nonce + FROM ${ctx.TB} WHERE daa_score >= $1 AND daa_score < $2 AND detail IS NOT NULL AND vote_key_hash IS NOT NULL + ORDER BY daa_score, timestamp_ms LIMIT ${PAGE} OFFSET ${off}`, [String(e * o.epochLen), String((e + 1) * o.epochLen)]); + rows.push(...page); if (page.length < PAGE) break; + } + return aggregate(rows, o); + } + async function readBands() { + // The app logs its "GPUs:" line once at start, so it is read from any upload of the last 7 days (rare line, small + // result); the workers' STATUS lines (every 10 s) come from the uploads of the last 6 hours, distinct, because the + // 262 KB rolling uploads overlap. Both queries are server-side regexp extractions: no whole upload crosses the wire. + const gpus = await ctx.sql(`SELECT DISTINCT label, m[1] AS line FROM miner_logs, LATERAL regexp_matches(lines, '([^\n]* GPUs: [^\n]*)', 'g') AS m + WHERE received_at > now() - interval '7 days' AND (label LIKE 'win-%' OR label LIKE 'mac-%')`); + const status = await ctx.sql(`SELECT DISTINCT label, m[1] AS line FROM miner_logs, LATERAL regexp_matches(lines, '([^\n]* STATUS ''[^'']+'' \\[worker\\]: [^\n]*)', 'g') AS m + WHERE received_at > now() - interval '6 hours' AND label LIKE 'miner-%'`); + return cardBands([...gpus, ...status], o); + } + return async function run({ dry = false, tipDaa = null } = {}) { + const tipRow = tipDaa === null ? await ctx.sql(`SELECT max(daa_score)::bigint AS tip FROM ${ctx.TB}`) : null; + const tip = tipDaa ?? Number(tipRow[0].tip || 0); + const tipEpoch = epochOf(tip, o.epochLen); + const wanted = []; for (let e = tipEpoch - o.windowEpochs - 1; e <= tipEpoch; e++) if (e >= 0) wanted.push(e); + const parts = []; + for (const e of wanted) { + const settled = tip >= (e + 1) * o.epochLen + o.settleDaa; + if (settled && cache.has(e)) { parts.push(cache.get(e)); continue; } + const a = await readEpoch(e); if (settled) cache.set(e, a); parts.push(a); + } + for (const e of [...cache.keys()]) if (!wanted.includes(e)) cache.delete(e); + if (Date.now() - bandsAt > 30 * 60_000) { try { bands = await readBands(); bandsAt = Date.now(); } catch (e) { ctx.log && ctx.log('detector bands read failed', e.message); } } + const { state, events } = analyse(mergeAggregates(parts), o, bands, prev, tip); + prev = state; + if (!dry) { + await ctx.sql(`UPDATE ${ctx.TS} SET detector = $1::jsonb WHERE id = 1`, [JSON.stringify(state)]); + for (const t of events) await ctx.recordEvent('detector', t); + } + return { state, events }; + }; +} +export function optsFromEnv(env = process.env) { + const o = {}; + if (env.DETECTOR_WINDOW_EPOCHS) o.windowEpochs = Number(env.DETECTOR_WINDOW_EPOCHS); + if (env.DETECTOR_R) o.r = Number(env.DETECTOR_R); + if (env.DETECTOR_K) o.k = Number(env.DETECTOR_K); + if (env.DETECTOR_HOLD) o.holdWindows = Number(env.DETECTOR_HOLD); + if (env.DETECTOR_EPOCH_LEN) o.epochLen = Number(env.DETECTOR_EPOCH_LEN); + return o; +} + +// ---------- dry run (read-only against the live tables) ---------- +if (process.argv[1] && process.argv[1].endsWith('detector.mjs') && process.argv.includes('--dry')) { + const env = readFileSync(`${homedir()}/.config/igneum/env`, 'utf8'); + const m = /^DATABASE_URL=(.*)$/m.exec(env); if (!m) { console.error('DATABASE_URL not found in ~/.config/igneum/env'); process.exit(1); } + const url = m[1].trim().replace(/^['"]|['"]$/g, ''); const host = new URL(url).hostname.replace('-pooler', ''); + const sql = async (query, params = []) => { if (!/^\s*(select|with)/i.test(query)) throw new Error('dry run: read-only'); const r = await fetch(`https://${host}/sql`, { method: 'POST', headers: { 'Neon-Connection-String': url, 'Content-Type': 'application/json' }, body: JSON.stringify({ query, params }) }); const j = await r.json(); if (!r.ok) throw new Error(j.message || JSON.stringify(j)); return j.rows; }; + const T = (process.env.LIVE_TABLE_PREFIX || '').replace(/[^a-z0-9_]/gi, ''); + const run = makeRunner({ sql, TB: `${T}live_blocks`, TS: `${T}live_state`, recordEvent: async () => { }, log: console.log }, optsFromEnv()); + const { state, events } = await run({ dry: true }); + if (process.argv.includes('--json')) { console.log(JSON.stringify(state, null, 1)); } + else { + console.log(`window epochs ${state.window.epochs.join(' ')} (tip DAA ${state.tip_daa}, ${state.window.ids} ids, ${state.window.ids_correlated} correlated)`); + console.table(state.network); + console.table(state.miners.map(m => ({ id: m.id, epochs: m.epochs_present, blue: m.blue, mhs: m.mhs, steady: m.steady, step_pct: m.max_step_pct, spread_pct: m.spread_sd_pct, poisson_pct: m.poisson_sd_pct, excess_pct: m.excess_spread_pct, early_pct: m.early_share_pct, nonce_n: m.nonce.n, chi2_lo: m.nonce.chi2_low4, chi2_hi: m.nonce.chi2_high4, inc: m.nonce.inc_frac, band: m.band ? (m.band.matches.join('|') || (m.band.high ? 'HIGH' : m.band.small ? 'small' : 'none')) : '-', flags: m.flags.join(',') + (m.notes.length ? ' (' + m.notes.join(',') + ')' : '') }))); + console.log('bands:', state.bands.map(b => `${b.model} ${b.p5}/${b.p50}/${b.p95} MH/s (n ${b.n})`).join('; ') || 'none'); + console.log('correlation:', JSON.stringify(state.correlation)); + console.log('alert:', JSON.stringify(state.alert)); + console.log('events:', events.length ? events : 'none'); + } +} diff --git a/tools/observer/detector.test.mjs b/tools/observer/detector.test.mjs new file mode 100644 index 000000000..d541bdbab --- /dev/null +++ b/tools/observer/detector.test.mjs @@ -0,0 +1,161 @@ +// 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']); +}); diff --git a/tools/observer/observer.mjs b/tools/observer/observer.mjs index 9cc45255d..7147d16f0 100644 --- a/tools/observer/observer.mjs +++ b/tools/observer/observer.mjs @@ -46,6 +46,7 @@ import { readFileSync } from 'node:fs'; import { homedir } from 'node:os'; import { blockSubsidy } from '../../site/lib/emission.mjs'; import { keccak256 } from '../../site/lib/eth.mjs'; +import { makeRunner as makeDetector, optsFromEnv as detectorOpts } from './detector.mjs'; // Counter ASIC 3.0 item 4a: the share-pattern detector const RPC = process.env.IGNEUM_RPC || 'ws://127.0.0.1:28610'; const RETAIN_HOURS = Number(process.env.LIVE_RETAIN_HOURS || 24); @@ -128,6 +129,7 @@ async function setupSchema() { // Explorer: this process's RPC calls per minute, and the hourly coinbase-versus-rule comparison `ALTER TABLE ${TS} ADD COLUMN IF NOT EXISTS rpc_load jsonb`, `ALTER TABLE ${TS} ADD COLUMN IF NOT EXISTS supply_check jsonb`, + `ALTER TABLE ${TS} ADD COLUMN IF NOT EXISTS detector jsonb`, // One row per planned shard of a chain block (spec 7.7 item 8): the plan as the block joins the chain, then the // record's progress. prover is the first 8 hex characters of the record's vote key hash (never the full key, R4.6.2). `CREATE TABLE IF NOT EXISTS ${TP} ( @@ -1138,6 +1140,9 @@ async function main() { setInterval(() => tick(rpc), STATE_EVERY_MS); setInterval(prune, PRUNE_EVERY_MS); setInterval(supplyCheck, SUPPLY_CHECK_EVERY_MS); setTimeout(supplyCheck, 20_000); + // Detector (6 Oct 2026): once a minute, live_state.detector and live_events kind `detector`; tools/observer/detector.mjs + const detector = makeDetector({ sql, TB, TS, recordEvent, log }, detectorOpts()); + setInterval(() => detector().catch(e => log('detector failed', e.message)), 60_000); setTimeout(() => detector().catch(e => log('detector failed', e.message)), 40_000); tick(rpc); prune(); }