igneum/docs/plans/counter-asic-3-node.md

77 lines
15 KiB
Markdown

# Counter ASIC 3.0: the node side (program class v4 as a second height switch)
6 October 2026, worker "ca3-v4-node". Branches: `ca3-v4-node` (main repository: igneum-pow, the workers, the fast-time gate) and `ca3-v4-node` (the fork, from the 0.3.13 tip bb43e9a8). The 2.0 twin of `docs/plans/counter-asic-2-node.md`: every piece of its section 1 has its v4 twin below; v2 and v3 are byte for byte what they were (the pinned packs, `igneum-pow/tests/packs.rs`). The class: `mx8+sh256x27` (`docs/analysis/latency-shadow-2026-10-06.md`), gated here as THE class v4 candidate on the project lead's word of 16:50 UTC. Nothing is published; the Mac's app is untouched (its miner stays paused); the live devnet is untouched. Gate rows: `docs/plans/counter-asic-3-gate/node-gates.md`.
## 1. What changed, where
| Piece | What |
|---|---|
| igneum-pow `generator.rs` | `ProgramClass::V4`, `V4_CLASS = LoadClass { shadow: Some(ShadowClass { instrs: 256, reps: 27 }), ..V3_CLASS }` (name `mx8+sh256x27`), `GENERATOR_VERSION_V4 = 4`, `generate_era_generator` (the era draw of class v3 over `V4_CLASS`, generator 4 stamped) used by `generate_from_seed_bytes_program_class` for `(V4, Some(era))`; `ProgramClass::{load_class, generator_version, from_generator, name, parse}` take v4; `ProgramClass::has_era` (v3 and v4); a v4 program's id is `program_id(4, seed, attempt)` (`Program::program_id`), its class `Program::program_class` = V4 from generator 4. Tests: `program_classes` (v4 round trip), new `program_class_v4_is_class_v3_with_the_shadow_block` (the base instructions, attempt and era draw equal class v3's of the same seed and era; shadow 256 x 27, 55,296 per hash; the id differs; the hash differs; v2's id `bcc1248b10cc90f2` unchanged) |
| igneum-pow `lib.rs` | exports `GENERATOR_VERSION_V4`, `V4_CLASS` |
| igneum-pow `emit.rs` | program.h: `IGNEUM_GENERATOR 4`, `IGNEUM_PROGRAM_CLASS "v4"`, `IGNEUM_ERA_SEED_HEX` (the v3 comment lines are byte for byte the 0.3.11 ones, so the pinned v3 packs do not change); the shadow lines (`IGNEUM_SHADOW_INSTRS`, `IGNEUM_SHADOW_REPS`, the `for (sh ...)` block in every kernel body) come from the class as before; program.json `"program_class": "v4"` |
| igneum-pow `packcheck.rs` | `verify_pack_texts_chain` accepts generator 2, 3 or 4 (`PackFault::WrongClass` otherwise, "2, 3 or 4" in the message); the era rule applies to every class with `has_era` (v3 and v4); test `program_class_and_era_are_checked` extended (a v4 pack with its era and shadow lines passes when v4 is wanted, is refused when v2 or v3 is wanted; a v3 or v2 pack is refused when v4 is wanted; the v4 era mismatch and the missing-era cases; the v3 header unchanged) |
| igneum-pow `main.rs` | `--program-class v2|v3|v4` |
| `proto-cuda/nvrtc/packfile.h` | `pf_load` accepts generator 2, 3 or 4 and names the class from it (`"v4"`); `pf_pack_class_ok` applies the era rule to every class that is not v2; `pf_class_token` unchanged (reads `class=v4`). `emu/packfile-test.c`: 7 new checks (a generator 4 pack loads as v4 with its era; v3 refuses it; it refuses another era; v4 refuses a v3 pack; the `class=v4` token; a v3 class line on a generator 4 pack is refused); `emu/packfile-test.sh` 0 failures on the Mac |
| `proto-cuda/nvrtc/worker.cpp`, `proto-opencl/host.c` | no text change: both read the class and era through `packfile.h` (`pairIsClass`, `takeClassTokens`), so a pair's identity carries `v4` and its era, a v4 job against a resident v3 pair of the same seeds answers `need` and `program class mismatch`, and the prepared v4 pair wins; `host.c` compiles clean (`cc -fsyntax-only`) against the new header |
| `proto-metal/main.swift` | `isPackClass` (v3 or v4) and `packClassOf(generator)`: `servePackProgram` and `servePackDataset` accept generator 2, 3 or 4 and refuse the rest in plain words; a `class=v4` line is compiled only from the prepared pack (its `program_bound.metal` carries the shadow block) and its day built from the pack's `memhard.metal`, keyed by (day, class, era); an inline `class=v4` job with no prepared pair answers `need` and the class-mismatch error (2.0's G4b path); a class that is not v2, v3 or v4 is refused |
| fork `consensus/core/src/igneum.rs` | `ProgramClass::V4` (generator 4, `"v4"`), `has_era`, `dataset_class` (V4 -> V3: the v4 day cache is the v3 day cache), `program_class_for_epoch_at(epoch, N4, N5, L)` with two switches (v4 from the first epoch at or above N5, else v3 from N4, else v2; N5 at or below N4 is v4 from N5), `program_class_first_epoch_at`, `program_class_v4_first_epoch_at`, the process-wide `install_program_class_v4_activation` / `program_class_v4_activation_daa` / `program_class_v4_first_epoch`; `PowEpochInfo.program_class_v4_activation_daa` (serde default never); the switch test sweeps both heights |
| fork `consensus/core/src/config/params.rs` | `program_class_v4_activation_daa` in `Params` and `OverrideParams` (never on devnet, simnet, mainnet; 0 on the testnet), `override_params`, `From<Params>`, the digest (unconditionally, right after `program_class_v3_activation_daa`), `install_program_class_v4_activation`, `program_class_v4_first_epoch`; tests: `override_params_carry_the_program_class_v4_activation`, the digest test's 15th edit, the pinned devnet digest re-pinned (section 2), `fast_time_60x_file_is_the_devnet_at_60x` (every field) |
| fork `kaspad/src/daemon.rs` | `Program class v4 from the override file: active from epoch E (DAA score N5 rounded up to the epoch boundary at L*E, epochs of L DAA)` beside the v3 line; `install_program_class_v4_activation` next to the v3 install |
| fork `consensus/pow/src/igneum.rs` | `EpochSeeds::day_key` keys the day cache on `(day, class.dataset_class())`, so a v4 epoch after a v3 one builds no new cache; `era_bytes` for every class with an era; `pow_class_of` maps V4; `build_day` builds the dataset class's day. Test `program_class_v4_seeds_hash_the_shadow_program_over_the_v3_day_cache` (one cache for v3 and v4 seeds of one day; the v4 program is generator 4, `V4_CLASS` with the era inside, the base instructions and attempt class v3's; the engine's pow equals the standalone epoch's; another era keeps the id and changes the program) |
| fork `consensus/src/consensus/mod.rs` | `get_pow_epoch_info` reports `program_class_v4_activation_daa`; the class of this and the next epoch come from `program_class_for_epoch` (both switches) |
| fork `consensus/src/pipeline/header_processor/pre_ghostdag_validation.rs`, `processes/pruning_proof/igneum_pow.rs` | unchanged: `program_class_at(daa)` answers v4 under the installed switches; the era seed is read for every class after v2 |
| fork `rpc/core/src/model/message.rs`, `rpc/grpc/core/proto/rpc.proto` (field 19), `rpc/grpc/core/src/convert/message.rs` | `RpcPowEpochInfo.program_class_v4_activation_daa` (serde default never); proto `optional uint64 programClassV4ActivationDaa = 19` so a 0.3.13 node, which sends nothing, reads as never and not as 0 (v4 from genesis); `program_class` and `next_program_class` already carry a generator number, so 4 needs no field |
| fork `igneum/miner/src/main.rs` | the v4 activation installed from the template beside the v3 one (`node program class v4 activation: ...`); `class_tokens`: a v3 or v4 epoch's job and prepare lines end with `class=<v3|v4> era=<hex>`; `seeds.txt` carries `program_class v4` and `era_seed_hex`; `write_pack_checked` checks class and era through `verify_pack_dir_chain` (generator 4 accepted); the "program and 256 MiB cache ready" line prints `class v4` and the id; `next_pair` takes the next epoch's class from the template (`next_program_class` 4 at the boundary) |
| `app/igneum-app/src/engine.rs` | confirmed, no change: `miner_args` pushes `--prepare-packs` for every worker (line 1478, since 0.3.11; the unit test `every_worker_gets_the_prepare_directory_in_the_platform_form`), so the Mac's Metal worker takes a v4 pack the way it takes a v3 one |
| `infra/fast-time/override-60x.json` | `program_class_v4_activation_daa` at never right after the v3 field. Two faults of the base file fixed on the way: the proving v1 block was in the file TWICE (two merges each appended it; the node refuses a duplicate field, so neither class harness could start a node from the file as committed), and the four 0.3.12 and 0.3.13 fields (`proving_v1_fresh_rule_daa`, `exec_restart_number`, `exec_restart_hash`, `exec_restart_trust_daa`) were missing (the fork's every-field test skips on the PCs, where the file is not beside the node). New CI check `tools/ci/override-json-check.sh` (every override file parses with no duplicate key; self-tested on a duplicate), registered in `.github/workflows/ci.yml` |
| `infra/fast-time/class-v4.mjs`, `infra/fast-time/README.md` | the G4 gate (section 3): class-v3.mjs's shape with two switches (`--v3-activation 60`, `--activation 150|never`), ports 29660, suffix 966, `/tmp/igneum-fast-time-v4`; the known-failed case is `--activation never` |
## 2. The epoch-boundary rule with two switches
One epoch has one program, so both switches key on the EPOCH: epoch `e` is v4 when `L * e >= N5`, else v3 when `L * e >= N4`, else v2 (`program_class_for_epoch_at`); each height rounds UP to the next boundary and never splits an epoch. A block's class is a function of its DAA score alone (`program_class_at`).
| N4 | N5 | L | epochs | Source |
|---|---|---|---|---|
| 60 | 150 | 60 | e0 v2; e1, e2 v3; e3 (DAA 180) v4 | the gate run, section 3; the unit test |
| 60 | 181 | 60 | e3 v3; e4 (DAA 240) v4 | the unit test |
| 154,800 | 180,001 | 3,600 | e50 v3; e51 (183,600) v4 | the unit test (the devnet shape: v3 live from epoch 43, v4 at tip + 14,400 rounded up) |
| 150 | 120 | 60 | e2 v4 (N5 below N4: v4 from N5) | the unit test |
| any | never | any | the 2.0 answers, unchanged | the unit test sweeps N4 0..399 with N5 never |
| any | 0 | any | v4 from genesis (the testnet) | the unit test |
The digest: `program_class_v4_activation_daa` enters `consensus_digest` unconditionally right after the v3 field, so the digest flips the moment a binary carrying the field runs, as the v3 field did on 0.3.11. Measured on the fork (the pinned test `consensus_digest_keeps_the_0_3_5_value_until_the_fee_switch_is_set`): the devnet digest with no override file moves from `c562d70e1428c9789823cc40067623b4767f7c555ce7ff4ea11c1498f013ef6c` (0.3.11 to 0.3.13) to `3c50502174b031b9afe8aad3758aa55eb32e3ee20ffa6f9760b2e958547c0690`. Consequence: a node on this binary is refused at the handshake by every 0.3.13 node and refuses them, which is the rollout's order step 1 (every node carries the object before any node reaches the height); the cut that carries it is a one-sweep binary rollout like 0.3.11's, and the Mac's app, PC 2 and the seeds all move in that sweep, nothing earlier.
The day cache: the v4 dataset is the v3 dataset (the same mixer x8 construction, growth rule and era layout; the shadow block touches no load and no item), so the node's engine keys its day caches on `(day, dataset_class)` and a v4 epoch reuses the v3 cache across the N5 boundary: no 1 GiB rebuild on the node at the switch (the kaspa-pow test: `cached_days` 1, `cache_builds` 1 for v3 and v4 seeds of one day). The Metal worker keeps separate (day, class, era) entries, so it builds the v4 day once at the boundary from the pack's own `memhard.metal` (the same bytes; about 41 ms on the GPU by the 2.0 figure).
## 3. The gates
See `docs/plans/counter-asic-3-gate/node-gates.md` for the rows in the rollout's section 7 shape and the summary files. The results, in short:
| Gate | State | The line that decides it |
|---|---|---|
| G4 | GREEN | run 1: 3 of 3 switch lines, 181 / 128 blocks across DAA 180, ids agree on all three miners, rejected 0/0/0, one sink 308/308/308; run 2 (`--activation never`): `SUMMARY FAIL: NO v4 epoch seen`, exit 1 |
| G4b | GREEN | three `prepared ... class v4` lines (430, 139, 175 ms), every swap with no pause, 123 blocks accepted on v4, re-check mismatched 0, need 0, no mismatch, no exit 42 or 44 |
| G5 | GREEN | the two Windows workers e563126e... and 7fce1249... with the resource block, the Mac worker 30c70754..., the job's igneumd.exe 140be25e..., every sha256 listed |
| G6 | GREEN | PC 2 job build-20261006-155958, every stage ok, 392 s; kaspa-pow 15 with the v4 engine test, no flake |
## 4. Tests
| Where | What | State |
|---|---|---|
| igneum-pow `cargo test --release` | 60 unit (the v4 class test, the v4 pack checks) + 7 derive + 4 mixer + 19 packs (the pinned v2 and v3 packs byte-identical) + 7 scratch | pass (Mac, 6 Oct 2026, 15:44Z) |
| `proto-cuda/nvrtc/emu/packfile-test.sh` | 13 + 7 checks: the generator rule with 4, the v4 pack with its era, the token matcher | 0 failures (Mac) |
| `proto-opencl/host.c` | `cc -fsyntax-only` against the new packfile.h | clean (Mac) |
| `proto-metal/main.swift` | `swiftc -O` (the G4b worker) | built (Mac) |
| fork `cargo test --release -p kaspa-consensus-core` | the two-switch rounding sweep, the v4 params, the digest's 15th edit, the pinned digest, the fast-time every-field test | pass (Mac, 109 + 7, 2 ignored; PC 2 job build-20261006-155958: 109 + 7) |
| fork `cargo test --release -p kaspa-pow --features igneum-pow` | the v4 engine test beside the v3 one | pass (Mac, 15; PC 2 job: 15, the feature on through unification with igneum-miner) |
| PC 2 `build-job.mjs` suites | `kaspa-consensus kaspa-consensus-core igneum-exec kaspa-pow igneum-miner kaspa-p2p-flows` + `igneum-app` | G6 row |
## 5. Unverified, and what is owed
- The AMD vendor (G1 on the RX 9070 XT) and G2 (1,000 random hashes per card re-hashed on the CPU) are the hash side's gates, not this worker's; the OpenCL worker's generator-4 rule was checked by the C test and a syntax check, not by a live worker on a v4 pack (as 2.0's rollout read before its G1).
- The Metal worker's rate in G4b (18.6 MH/s wall) was taken under a load average of 5 to 12 with CPU miners beside it: not a measurement (the measured v4 rate is the shadow analysis's 26.5 to 26.9 MH/s on packbench under the measure lock).
- The era walk for era >= 1 and the pruning-proof era witness: unchanged from 2.0 section 7 (the v4 switch adds nothing there).
- `next_pair` at an epoch boundary that is also an era boundary: the 2.0 note stands for v4 as for v3.
- Wire: `RpcPowEpochInfo` gained one Borsh field (wRPC, versioned by `GetBlockTemplateResponse`) and one optional proto field; a 0.3.13 miner against this node reads the v4 height as never (the field absent) and would key epochs on the v3 switch alone, so every miner moves in the same binary sweep as the node (the rollout order of 2.0).
- The build job has no `features` key for test units (G6 row): the feature was on through unification; adding the key needs the PC's installed app to honour it, a 0.3.14 app item.
- The 48 GiB APFS clone of the 0.3.13 target dir (`vendor/igneum-node-ca3v4/target-ca3v4`, untracked) can be deleted after the cut.