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

15 KiB

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
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
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

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.