Merge branch 'ca3-detector' into ca3-coord
# Conflicts: # docs/plans/funding.md
This commit is contained in:
commit
ace33b9eef
6 changed files with 671 additions and 0 deletions
|
|
@ -233,3 +233,104 @@ The layer table:
|
|||
The level 3 numbers row:
|
||||
|
||||
| Epoch length | 3,600 DAA s at launch; miners can signal it down to 600 (an FPGA defence, no fork) | floor 600: the slowest compile-ahead (the variant race, 38 s) is 6.3% of the epoch and inside the 600-s seed window; at 600 a per-program FPGA bitstream mines 0% of each epoch, at 3,600 up to 47% (42-min compile) | Measured (Mac compile, 5 October), cited (5090, FPGA compile times), owed (9070 XT compile) |
|
||||
|
||||
## 11. The detector as the signal's trigger (Counter ASIC 3.0 item 4)
|
||||
|
||||
6 October 2026, worker ca3-detector. The share-pattern detector (`tools/observer/detector.mjs`, `tools/observer/README.md` section "Detector") watches the chain for a population of miner ids that behaves like one fixed design: a clique of 3 or more ids whose per-program residual rate vectors correlate above r = 0.8 over a window of 6 closed epochs, with at least one design flag on a member (an excess per-program spread over 10%, no blocks in the first tenth of epochs, a non-uniform nonce pattern, a rate above every known card). It writes `live_state.detector` and `live_events` kind `detector`. The chain does nothing with it; this section is what people do.
|
||||
|
||||
| Rule | Value | Why |
|
||||
|---|---|---|
|
||||
| Trigger | the detector's alert holds for M = 6 net windows (one window per closed epoch; a window without the candidate counts one down) | 6 windows of 6 epochs span 11 epochs: 11 hours at the base, 110 minutes at the floor; one chance clique never holds that long (README: chance 3-cliques about 0.09 per window at 30 ids, and the alert also needs a design flag) |
|
||||
| Recommendation | the project publishes the evidence (`live_state.detector`) and recommends that miners signal the next shorter ladder step: from 3,600 to 2,400, from 2,400 to 1,800, and so on down the ladder of section 1 | one step at a time, because each step's cost to miners is known (section 7) and the signal takes 7 days plus 2 to land (section 2.3), so a second step can follow the next alert |
|
||||
| What the chain does | nothing: 90% of blue blocks over 7 days must carry the new index, then the first day boundary 2 days later activates it (section 2.3) | no consensus change, no release, no fork; the detector is advice |
|
||||
| Reversal | if the alert clears for 7 days at the shorter length, the project recommends signalling back up one step | the shorter epoch costs the chain its difficulty settle share (section 4) and the iGPU tier its compile share (section 7), so it is not kept for nothing |
|
||||
|
||||
What the first step (3,600 to 2,400) costs each tier, from section 7's measured compile-aheads scaled to a 2,400-s epoch, and what the floor costs:
|
||||
|
||||
| Tier | At 2,400 | At 600 (the floor, after three more steps) | What to do before signalling |
|
||||
|---|---|---|---|
|
||||
| Home miner, one NVIDIA card, Windows or Linux | 1 s prepare per 40 min: 0.04% | 0.2% | nothing |
|
||||
| Home miner, one Apple card, macOS | race off 0.5 s: 0.02%; race on 38 s: 1.6% | 0.1%; 6.3% | ship the race default off (section 9) |
|
||||
| Home miner, one AMD RDNA 4 card | 0.31 s plus the compile, owed | owed | measure `prepared` on the 9070 XT (section 9) |
|
||||
| Integrated GPU (AMD or Intel) | 7 to 12 s: 0.3 to 0.5%; 124 s under CPU load: 5.2% | 2%; 21% | per-day dataset reuse in the worker (section 9) |
|
||||
| Rig (app, several cards) | one export per 40 min, cards prepare in parallel | 6x the exports an hour | nothing |
|
||||
| Rig on the installer scripts | a prepare miss costs a restart per 40 min instead of per hour | a miss is 10% of an epoch | prepare-ahead as the only path (section 9) |
|
||||
| Pool user | nothing | nothing | nothing |
|
||||
| Every node's CPU (the VDF) | one core 25% busy | one core 100% busy | peers' proofs for a node with no core to spare (section 3.3) |
|
||||
| The chain | difficulty settles 144 s per epoch: 6% | 24% | the +-15% settle measurement (section 9) |
|
||||
|
||||
What the same step costs the adversary, from section 5.1's rule `max(0, 1 - (compile - 600) / epoch_len)`:
|
||||
|
||||
| Compile time per program | Share of each epoch mined at 3,600 | at 2,400 (first step) | at 1,800 (second step) | at 600 (the floor) |
|
||||
|---|---|---|---|---|
|
||||
| 12 min (PRflow partitioned, a small kernel) | 97% | 95% | 93% | 0% |
|
||||
| 42 min (PRflow monolithic) | 47% | 20% | 0% | 0% |
|
||||
| 160 min (PRflow worst) | 0% | 0% | 0% | 0% |
|
||||
|
||||
Reading: the first step cuts the 42-minute class from half of every epoch to a fifth and the second step removes it; the 12-minute class (a small kernel on a fast flow) survives every step but the floor, so an alert that persists through two steps is the case the floor was reserved for. A soft overlay compiles nothing and is untouched by every step: that lane is section 12.
|
||||
|
||||
## 12. The FPGA lane and the ranking against layer 7 (Counter ASIC 3.0 item 5)
|
||||
|
||||
6 October 2026, worker ca3-detector. No hardware was measured here; every FPGA figure is a product figure or a published measurement with its source and date read, and every derived number is labelled. The GPU side is the measured RTX 5090: 17.5 G dependent 4-byte reads per second at 415 ns (`docs/benchmarks/repro.md` section 2.2, 6 October 2026) at about 326 W (the bench log's 328.6 W peak, approximate).
|
||||
|
||||
### 12.1 The parts
|
||||
|
||||
| Part | HBM | Bandwidth | Stacks, pseudo-channels | Board power | Source (read 6 October 2026) |
|
||||
|---|---|---|---|---|---|
|
||||
| AMD Alveo U55C (XCU55, Virtex UltraScale+) | 16 GB HBM2 | 460 GB/s | 2 stacks; 32 pseudo-channels ("32 independent pseudo-channels") | 150 W maximum total, 115 W typical (TDP) | https://www.amd.com/en/products/accelerators/alveo/u55c/a-u55c-p00g-pq-g.html (through a search summary, the page itself timed out); the CoreEL data sheet https://www.c2s.gov.in/Technical_Data_sheet/new/Alveo_U55C_data_sheet.pdf ("HBM Memory 16GB", "HBM Bandwidth 460 GB/s", "Power (TDP) 115W", 1,304K LUTs, 9,024 DSP slices) |
|
||||
| AMD Alveo U280 (XCU280) | 8 GB HBM2 | 460 GB/s | 2 stacks of 4 GB, each 8 channels of 2 pseudo-channels: 32 pseudo-channels, 32 AXI ports at up to 450 MHz | 225 W total electrical card load | DS963 v1.3 (May 2020) through https://www.digikey.com/en/htmldatasheets/production/3778633/0/0/1/a-u280-a32g-dev-g ; the channel layout from Shuhai (Wang, Huang, Alonso, FCCM 2020, https://arxiv.org/pdf/2005.04324, section II.A and III) |
|
||||
| AMD Versal HBM (VH1582 class) | 32 GB HBM2e | 819 GB/s | 2 stacks (approximate: the series page gives capacity and bandwidth, not the stack count) | not a board; the VHK158 evaluation kit's power is not published as a product figure (approximate: 150 to 250 W for a card around it) | https://www.amd.com/en/products/adaptive-socs-and-fpgas/versal/hbm-series.html through a search summary ("819 GB/s of memory bandwidth and 32 GB of capacity") |
|
||||
| Intel (Altera) Agilex 7 M-series | up to 32 GB HBM2e | 820 GB/s (410 per stack) | 2 stacks | not published as a board figure | the Agilex 7 M-series memory-bandwidth white paper, https://www.intel.com/content/dam/www/central-libraries/us/en/documents/2022-12/agilex-7-fpgas-m-series-memory-bandwidth-white-paper.pdf , through a search summary; the product page redirected to a 404 on 6 October 2026 |
|
||||
|
||||
### 12.2 Random reads in flight per watt
|
||||
|
||||
The metric of the plan: reads in flight = (random reads per second the HBM controller sustains) x (its random-access latency), divided by board watts; the reads per second per watt column is the same quantity without the latency factor and is the one that sets hash rate per watt.
|
||||
|
||||
| Figure | Value | Source and label |
|
||||
|---|---|---|
|
||||
| HBM2 idle read latency on the U280, from the FPGA fabric | page hit 106.7 ns, page closed 122.2 ns, page miss 137.8 ns (48 / 55 / 62 cycles at 450 MHz) | Shuhai, Table IV (measured) |
|
||||
| HBM2 random-access throughput as measured on the U280 at the default address mapping (one bank active per channel, RGBCG) | 2.4 GB/s per AXI channel at 32-byte bursts, 4 KB stride, 256 MB working set = 75 M random reads per second per pseudo-channel, 2.4 G per card over 32 | Shuhai, section IV.C and Figure 7 (measured; the paper's point is that this mapping is the wrong one for random access) |
|
||||
| HBM2 random-access ceiling, bank-bound, with a bank-interleaved mapping | banks per pseudo-channel / row cycle: 8 to 16 banks / 45 ns = 178 to 356 M activates per second per pseudo-channel, 5.7 to 11.4 G per card over 32 | approximate: tRC 45 ns from JEDEC HBM2 as quoted at https://www.overclock.net/threads/the-hbm2-timings-thread.1743510/ and about 48 ns in the MEMSYS 2018 paper the history cites ([L1]); the banks per pseudo-channel from memory; the tFAW and tRRD command limits are not applied, which makes this a ceiling |
|
||||
| Queue depth from the fabric | 32 AXI ports; outstanding reads per port in the AMD HBM IP (PG276) not read today | approximate: at 64 per port the fabric holds 2,048 reads in flight, which at 137.8 ns is 14.9 G per second, above the bank ceiling, so the banks bind, not the queues |
|
||||
| HBM2e parts (Versal HBM, Agilex M) | bandwidth 1.8x the U55C's; the row cycle is the same DRAM | the history's [L1] reading: HBM raises bandwidth, not the row cycle; so the random-read ceiling per stack is the HBM2 ceiling within the clock ratio (approximate) |
|
||||
|
||||
| Design | Random reads per second | Latency | Reads in flight | Board W | Reads in flight per W | Reads per second per W | Against the 5090 (per W) |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| RTX 5090, measured | 17.5 G | 415 ns | 7,260 | 326 (approximate) | 22.3 | 53.7 M | 1.0x |
|
||||
| U55C overlay at Shuhai's measured random rate (default mapping) | 2.4 G | 137.8 ns | 330 | 115 to 150 | 2.2 to 2.9 | 16 to 21 M | 0.30x to 0.39x reads per second per W; 0.10x to 0.13x in flight per W |
|
||||
| U55C overlay at the bank-bound ceiling (bank-interleaved mapping, approximate) | 5.7 to 11.4 G | 137.8 ns | 790 to 1,570 | 115 to 150 | 5.2 to 13.7 | 38 to 99 M | 0.71x to 1.85x reads per second per W; 0.23x to 0.61x in flight per W |
|
||||
| U280 overlay, the same ceilings | as the U55C (the same HBM subsystem) | 137.8 ns | the same | 225 | 1.5 to 7.0 | 11 to 51 M | 0.20x to 0.94x reads per second per W |
|
||||
| Versal HBM or Agilex M overlay (HBM2e, 2 stacks) | the HBM2 ceilings times at most the clock ratio 1.8 (approximate) | about the same | | approximate 150 to 250 | | | under 2x at the ceiling, under 1x at Shuhai's measured rate |
|
||||
|
||||
Reading, with every caveat in the table: at the only measured FPGA random-read rate in the literature found today (Shuhai's 2.4 G per second for a two-stack HBM2 card) a soft-overlay FPGA sits at a third of the 5090 per watt, in the RX 9070 XT's class (2.4 to 2.5 G per second, `docs/bench-log.md`); at the bank-bound ceiling that no published design reaches it could sit between 0.7x and 1.9x per watt, which is why the ceiling row is kept and labelled. The overlay's ALU side is not the bound: a hash is 64 instructions x 8 iterations per lane, 70 G instructions per second at the 5090's 137 MH/s, about 175 soft 32-bit ALUs at 400 MHz on a 1.3 M-LUT part (approximate), and the mixer is not on the hash path. What is owed to turn the ceiling row into a number: one HBM FPGA under a bank-interleaved random-read kernel (a rented U55C or U280 hour, the chase kernel of `docs/benchmarks/repro.md` ported to an AXI master), measured in reads per second and watts; until then the public claim carries the measured row (0.3x to 0.4x per watt) and names the ceiling.
|
||||
|
||||
### 12.3 Compile-ahead: the two FPGA lanes in one table
|
||||
|
||||
| Lane | Compile per program | Share of a 600-s epoch it can mine (section 5.1) | Share of a 3,600-s epoch | Rate against the 5090 per watt | What answers it |
|
||||
|---|---|---|---|---|---|
|
||||
| Hard datapath (a bitstream per program) | 42 to 160 min monolithic, 12 to 18 min partitioned (PRflow, FPT 2019, section 5.1) | 0% | 47% at 42 min, 97% at 12 min | a hard datapath has no overlay overhead, but it still reads the same HBM: bounded by the same 0.3x to 1.9x per watt, before its 0% to 97% duty | layer 9: the epoch length (section 11) |
|
||||
| Soft overlay (a processor on the fabric; the program is data) | none: 0% of every epoch | 100% | 100% | 0.3x to 0.4x per watt measured-basis, up to 1.9x at the unproven bank ceiling | the latency bound itself (the reads per watt), the dataset and cache sizes, the mixer; not the epoch length |
|
||||
|
||||
### 12.4 Layer 9 (epoch length) against layer 7 (the mm8 reserve family): the ranking
|
||||
|
||||
| Adversary class | What layer 9 costs it | What layer 7 costs it |
|
||||
|---|---|---|
|
||||
| Hard-datapath FPGA | its whole duty at the floor (0% of every epoch; 20% at the first step for the 42-min class, section 11) | nothing: it already compiles a datapath per program, and a new family is one more block in that datapath |
|
||||
| Soft-overlay FPGA with HBM | nothing (no compile) | nearly nothing: FPGA DSP blocks do int8 dot products natively (the DSP58 of Versal and the DSP48E2 of UltraScale+ carry int8 multiply-accumulate; approximate, from memory), so an mm8 unit is cheap on the fabric |
|
||||
| On-die 256 MiB recompute chip (`chip-model-v3.md`, 0.92x with the factor) | nothing (it executes the program) | little: int8 matrix blocks are licensable IP at every node (history section 4.3, addition 6) |
|
||||
| Partial-store chip with a custom memory system (item 1) | nothing | little, as above |
|
||||
| One-vendor GPU fleet (the 7.5x AMD gap, item 7) | nothing | it moves the gap: dp4a is 1.17x a step on the 5090, 1.06x on the 9070 XT, 1.6x emulated on Apple (`docs/bench-log.md`, layer 7 row) |
|
||||
|
||||
| User tier | What layer 9 costs it (sections 7 and 11) | What layer 7 costs it (the bench log's layer 7 row, `int8-matrix-family.md`) |
|
||||
|---|---|---|
|
||||
| Home miner, one NVIDIA card (8 to 32 GB), Windows or Linux | 0.04% at 2,400, 0.2% at 600 | nothing relative: dp4a is native, 1.17x a step |
|
||||
| Home miner, one AMD RDNA 4 card | compile owed; 0.31 s plus it | dp4a native at 1.06x a step: falls 10% behind NVIDIA per step, relatively |
|
||||
| Home miner, one Apple card, macOS | 0.02% at 2,400 with the race off; 1.6% with it on | the step is emulated at 1.6x the cost: the Apple tier loses share to every native card for as long as the family is live |
|
||||
| Integrated GPU (AMD, Intel) | 0.3 to 0.5% at 2,400; 5.2% under load until per-day dataset reuse | as the vendor's discrete part (native on AMD, emulated on Intel: not measured) |
|
||||
| Rig | exports per epoch, parallel prepares | as its cards |
|
||||
| Pool user | nothing | nothing directly; the pool's card mix shifts |
|
||||
| Every node (the VDF) | one core 25% busy at 2,400, 100% at 600 | nothing |
|
||||
| The chain | 6% of each epoch in difficulty settle at 2,400, 24% at 600 | nothing |
|
||||
| Reversibility | by the same 90% signal, up or down, in 9 days | a family unlocked by height stays; by signal it can be voted off, but the vendors' relative rates are what they are while it is on |
|
||||
|
||||
Ranking: layer 9 sits above layer 7 in the reserve. Layer 9 removes one adversary class outright (the per-program bitstream, the first adversary of Lyra2REv2 and X16R in the history), costs every GPU tier under 2% at the first step and under 1% with the race off, and is reversible by the same signal that set it. Layer 7 removes no adversary class (every chip and every FPGA has an int8 dot product; the history's "watch ML hardware"), and its cost lands on one honest tier, Apple at 1.6x per emulated op, with a 10% relative shift against AMD. The chip model does not move for either (the on-die recompute chip's cost is the item derivation): layer 9's value is response time against the FPGA lane, layer 7's is a family a 12-op chip lacks, which the overlay and the chip both acquire cheaply. Item 6 (order the reserve by chip-unfriendliness, mm8 last) follows from the same two tables. Decision for the project lead: reserve order layer 9 (epoch length, already live at the base) first, the 32-bit datapath families next, mm8 last; and fund one HBM FPGA hour to replace the ceiling row of 12.2 with a measurement before the public testnet's benchmark page claims a number for this lane.
|
||||
|
|
|
|||
|
|
@ -63,6 +63,15 @@ The fee is 1% of rewards on the official client. Rewards in year one are 963 mil
|
|||
2. A review or audit that is paid for is published whole, pass or fail, and linked from `docs/evidence.md`.
|
||||
3. A bounty is announced only when it is escrowed.
|
||||
4. This plan is revised when a number changes; the git history of this file is the record.
|
||||
5. The chip bounty's trigger is daily issuance in dollars, not a date (Counter ASIC 3.0 item 4b, 6 October 2026): **the bounty is escrowed and the benchmark page is live before daily issuance crosses USD 20,000 a day.** Daily issuance is blocks per day times the subsidy (spec 2.5, `site/lib/emission.mjs`: 3,168,808,781 sompi per DAA second in period 0, so 2,737,851 IGN a day after the 30-day ramp, 273,785 on day 0; 1,368,925 a day in period 1, years 3 and 4), times the price. The history (`docs/analysis/asic-resistance-history.md` section 2.5) puts the first public chip on compute-bound hashes at USD 21,000 to 31,000 of daily issuance (Kadena, Radiant, Handshake) and Vorick's 2018 rule at about USD 55,000 a day; USD 20,000 sits under the lowest observed arrival, so the escrow lands before any chain in that table got its chip. The operating entity watches the number (owed: an "issuance per day in dollars against the USD 20,000 line" row in the 08:00 daily report) and the detector (`tools/observer/detector.mjs`) runs from the public testnet, where issuance in dollars is zero and the clock has not started. The prices at which the line is crossed, so the number is concrete:
|
||||
|
||||
| Daily issuance line | Period 0 (year 1 to 2, after the ramp): price per IGN | Period 1 (years 3 to 4) | Period 2 (years 5 to 6) |
|
||||
|---|---|---|---|
|
||||
| USD 20,000 (the rule) | USD 0.0073 | USD 0.0146 | USD 0.0292 |
|
||||
| USD 30,000 (the top of the compute-bound arrivals) | USD 0.0110 | USD 0.0219 | USD 0.0438 |
|
||||
| USD 55,000 (Vorick) | USD 0.0201 | USD 0.0402 | USD 0.0804 |
|
||||
|
||||
What it means per tier: nothing changes in the protocol at the line; a home miner on any card can read the live benchmark page and the bounty terms, so a chip's existence becomes something its designer is paid to disclose rather than to hide; a pool user sees the same page. If the entity cannot fund the escrow when the line approaches, rule 3 holds (nothing is announced) and the detector plus the epoch-length signal (`docs/plans/epoch-length.md` section 11) are the response that costs no money.
|
||||
|
||||
## The mixer cryptanalysis brief (Counter ASIC 3.0 item 3)
|
||||
|
||||
|
|
|
|||
|
|
@ -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=<x> 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 <id> flagged <flag> (<evidence>)`, `Detector: <n> 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.
|
||||
|
|
|
|||
346
tools/observer/detector.mjs
Normal file
346
tools/observer/detector.mjs
Normal file
|
|
@ -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-<id8> or mac-<id8>) or a worker STATUS line
|
||||
// (label miner-<vendor>-<id8>-<n>). 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 <vendor>-<machine>-<index>).
|
||||
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');
|
||||
}
|
||||
}
|
||||
161
tools/observer/detector.test.mjs
Normal file
161
tools/observer/detector.test.mjs
Normal file
|
|
@ -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']);
|
||||
});
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue