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

29 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; re-run on 7c22d0d with the id assertion: PASS, every v4 epoch's id (3 of 3 miners) equals the CLI's class v4 id and differs from the same-seed v3 id (e3 30544487d1289d6e against 1ae1c9f0eda0cef5); its failed case (--id-check-against v4) fails on exactly that check
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

3a. The program id carries the class (the coordinator's blocker, 7c22d0d)

The hash lane found the seven gate packs under proto-cuda/packs-ca3-v4 carrying the v3 control's program id 73bcbfe8ccf988f1. The cause was not the chain seam (the fork's kaspa-pow test asserts a v3 and a v4 program of one seed and era have different ids, generator 4 in the preimage) but the CLI's --era path: stamp_era in igneum-pow/src/main.rs stamped generator 3 on any class, and program_id(3, seed, attempt) is class-independent inside a generator (spec 01 section 1.4.6, the 2.0 rule: every era of one epoch seed shares one id), so a mx8+sh256x27 pack exported under an era read as a class v3 pack with the v3 id. A worker given such a pack would mine the v4 program under the v3 id and class token.

Piece What
igneum-pow generator.rs era_generator_of(base) (4 when the base minus its era is V4_CLASS, else 3), used by generate_era; ProgramClass::of_load_class (V2, V3_CLASS, V4_CLASS, else none); the v4 test asserts the --era path and the chain path make the same v4 program, generator 4, and a measurement class (mx8+sh256x13) stays generator 3
igneum-pow main.rs stamp_era and show stamp from the class; show honours --program-class and --era-hex (the harness's same-seed v3 and v4 ids come from it)
igneum-pow packcheck.rs, proto-cuda/nvrtc/packfile.h, proto-metal/main.swift the shadow block marks class v4: a generator 3 pack with IGNEUM_SHADOW_INSTRS is refused ("a class v4 program is generator 4 (export the pack as class v4)"), a generator 4 pack without it is refused; a generator 2 pack with a shadow (the ladder of packs-ca3-shadow, class-bearing ids) stays loadable; tests in packcheck.rs and emu/packfile-test.c (4 new checks, 0 failures)
proto-cuda/packs-ca3-v4/* (7) re-exported with the same seeds, day and eras: generator 4, class "v4", id c120d7963abdcd96 (one id per epoch seed across its eras); every other line of every file byte-identical (kernels, memhard, program*.metal, every vector value); measured on the re-exported packs with proto-metal/packbench --batches 60 --batch-log2 24 --group 256 under with-lock.sh run (16:30 to 16:37Z, load average 90 from other agents' builds, a fingerprint is load-independent): v4-devnet-epoch0 f410c731b6bc2d31, v4-era-0 b115c410e08be6ca, v4-era-1 edc2b18fc67e9d1c, v4-era-2 604ed87109570559, v4-era-3 9541e2a41dde2ee6, v4-era-4 a9ffa2b67bd2e366, v4-era-5 1f34e9c465945249, every one equal to the hash lane's table (hash-gates.md); the control mx8-devnet-epoch0 untouched
infra/fast-time/class-v4.mjs two new checks: every v4 epoch's id (the three miners agreeing) equals the CLI's class v4 id of that seed and era and differs from the CLI's class v3 id of the same seed and era; --id-check-against v4 is the assertion's own failed case
the fork no change: the chain path stamped 4 from the first commit; the miner's job line names the class, the workers key a pair on (seeds, class, era), and the id is a pack label, not an identity, which is why the pack rule above is the guard

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.

6. PROPOSED: miner-signalled class activation (precondition 2 of the cut)

Status: PROPOSED spec text for spec 01 (a new section 1.12.2 beside the epoch rule) and spec 02; implemented behind the override on the fork branch ca3-v4-node (consensus/src/processes/class_signal.rs, the pure rule in consensus/core/src/igneum.rs), gated by the fast-time runs of section 6.4. Not in docs/spec until the project lead adopts it. The 2.0 fixed height stays, as the floor.

6.1 The rule

  1. The carrier: the block header's version field, 2 bytes little-endian. The low byte is the block version as before (2 on every Igneum network; block_version_of). The high byte is the producer's OBJECT VERSION, the highest program class the node that built the template runs: 4 for this binary (CLASS_SIGNAL_V4), 0 on every block made before it (signalled_version(2, 4) = 0x0402; class_signal_of). Why the header and not the coinbase extra data: the tally is read in header validation and by a node that holds headers only (a pruning-proof sync, a headers-first IBD), the header is what GHOSTDAG orders and what the finality rule already reads for its weight (the vote key hash is a header field, finality.rs), and the bytes are already there: no new field, no new hash preimage, no proto change for the header. A node before this binary rejects any header whose version is not exactly 2 (check_header_version), which is why the signalling binary ships in the same one-sweep rollout as the digest flip it already needs (section 2); from this binary on, only the low byte is checked, so a later object (5, 6) can be signalled to it without another header rule.
  2. The weight: blue blocks, the finality rule's convention. For epoch e the anchor is its seed block S_e, the last selected-chain block whose DAA score is below L e - lead (the block the epoch seed is already taken from, HeaderProcessor::epoch_seed; so an epoch's seed and its class are decided at the same block of the same chain). The window is the W DAA below and including S_e: the selected chain is walked down from S_e, every chain block's mergeset blues counted once (the compute_weights walk of finality.rs, bounded by the merge depth), a blue block counted when its DAA score lies in (daa(S_e) - W, daa(S_e)], and signalling when its object byte is at least 4. Share = signalling / total, in basis points.
  3. The decision, per epoch, monotone. Epoch e is class v4 when (a) L e >= N6, the floor (the fixed height, rounded up to the epoch boundary exactly as the v3 switch is: program_class_for_epoch_at), or (b) epoch e - 1 was v4, or (c) the window ending at S_e is FULL (daa(S_e) >= W) and its share is at least 9,500 bps. A signal moves the class one step: the rule answers v4 only where the floor rule answers v3 (below the v3 switch the answer is v2 whatever is signalled). The decision is memoised per seed block, so a fork of the chain has its own entries and one tally is paid per epoch per process. The epoch-boundary rounding of the v2 to v3 switch is unchanged: a class never changes inside an epoch, every block's class is its epoch's.
  4. The window W: 86,400 DAA (one day of blocks at 1 block/s), program_class_v4_signal_window_daa in the override file, in the digest right after the floor; 0 = signalling off, the floor alone (2.0's rule, byte for byte). Why a day: the share must mean "the fleet that mines, not the fleet that happens to be up this hour", and a day covers every box's daily pattern (the rented boxes come and go by the hour); it is long enough that 95 percent cannot be reached by a burst and short enough that the flip lands within a day of the last upgrade; the finality rule's 30-day window answers a different question (who may vote) and would hold the class for a month after the fleet was ready. On the fast-time profile W is 120 (two epochs of 60).
  5. The threshold: 9,500 bps (95 percent), a constant (CLASS_SIGNAL_THRESHOLD_BPS), not a file field, so no file can lower it; 95 percent of the blue blocks of a day is 95 percent of the hash rate of that day, the coordinator's figure; a box that cannot mine v4 (an old worker, section 2's wire note) is at most 5 percent of the hash rate at the flip, and the floor catches the rest.
  6. The floor N6: program_class_v4_activation_daa, set at the publish as DAA + 14,400 rounded up to the epoch boundary (the 2.0 rule for N4), checked N6 - DAA >= 10,800. A stalled signal (a fleet that never reaches 95 percent) cannot hold the class forever: at N6 v4 holds regardless. never is allowed in the file and means no floor (the signal alone decides; not for the devnet publish).
  7. What a node reports. PowEpochInfo and the template's powEpoch carry programClassV4SignalWindowDaa, programClassV4SignalBps (the share of the window ending at the SINK, the live tally the next decision is heading for), programClassSignal (this node's byte) and programClassV4SignalEpoch (the epoch v4 was decided by signal on this chain, when it has been); gRPC fields 20 to 23. The daemon prints Program class v4 signal window from the override file: W DAA ending at each epoch's seed block, threshold 9500 bps of blue blocks; the fixed height is the floor beside the floor line, and Program class v4 by miner signal: epoch E (share X bps over W DAA ending at seed block S, threshold 9500 bps, N of M blue blocks) once per flip.
  8. The miner. Nothing: the node builds the template header (the miner varies the nonce), so the signal is the node's binary; the miner takes the class of the epoch and the next from the template as before (next_program_class is the next epoch's decision once its seed block is known, else this epoch's class; a boundary that decides otherwise costs one refused pair and one prepare, the 2.0 era-boundary shape). IGNEUM_CLASS_SIGNAL=<n> on devnet and simnet only lowers a node's byte (the fast-time gate's non-signalling node); it is not read on mainnet or the testnet.

6.2 The devnet object changes shape

Two fields join the live object: program_class_v4_activation_daa is now the FLOOR (its name and its rounding unchanged: the 2.0 fixed-height form), and program_class_v4_signal_window_daa is the window (86,400 on the devnet; 0 turns signalling off). Both enter the digest (unconditionally, right after the v3 field), so the digest moves on the binary rollout once more; the pinned devnet digest of the fork's test is re-pinned to the value of this binary (section 6.4). The publish object and the rehearsal object are in docs/plans/counter-asic-3-rehearsal.md section 2. Defaults: devnet and mainnet 86,400, testnet and simnet 0 (the testnet is v4 from genesis by its floor of 0; the simnet keeps 2.0's rule).

6.3 What it does not cover (owed)

Item Why What is done about it
A class-signal witness in the pruning-proof format a node that synced from a proof holds no headers below its pruning point, so an epoch whose window reaches below it cannot be tallied; the node then takes the floor rule for that epoch and logs it (class_signal.rs), which can disagree with a full-history node for the epochs between a signal flip and the floor the same class as the era witness of 2.0 section 7 (MissingEraSeed); the floor bounds the exposure to at most N6 - flip; the witness (the per-epoch decision beside the epoch seed in the proof) is the next node item
The first tally after a restart one walk of W chain blocks' mergesets per epoch per process, memoised; a day of blocks is about 86,400 header reads, under a second on the Mac's store measured in the fast-time runs below at W = 120 only; the devnet figure is owed from the rehearsal
A byte above 4 accepted and counted as a v4 signal (a later object contains v4); a v5 rule would count bytes at or above 5 nothing now

6.4 The fast-time gate (three cases and the failed case)

infra/fast-time/class-v4-signal.mjs: three nodes on override-60x.json (v3 from DAA 60, window 120, the floor at --floor), each node's byte set by IGNEUM_CLASS_SIGNAL (--signal a,b,c), one real CPU miner each, the id assertion of G4 on every v4 epoch.

Case Run SUMMARY Summary file
Two of three signal (--signal 4,4,3 --expect no-flip --epochs 7) 17:26:4x to 17:34:57Z (load average about 9) PASS: no v4 epoch over epochs 0 to 7; the share at the sink read 6,166 to 7,583 bps epoch by epoch (two CPU miners of three, the third's blocks byte 3); on the chain 286 blocks byte 4, 138 byte 3, genesis byte 0 (6,729 bps); no node printed the signal line; 0 rejected; one sink d845fdd016a25425 at 424/424/424; the floor line on 3 of 3 (never) class-v4-signal-no-flip.json
All three signal (--signal 4,4,4 --expect flip) 17:34:5x to 17:39:33Z PASS: the share 10,000 bps from epoch 1; the class flipped at epoch 3 (DAA 180), the first epoch whose seed block has a full 120-DAA window below it, before the floor (never); 3 of 3 nodes printed Program class v4 by miner signal: epoch 3 (share 10000 bps over 120 DAA ending at seed block 13629115abf4b06bd9b07dfee66c7aab817cef268a34c02f8da6884d52f26017, threshold 9500 bps, 120 of 120 blue blocks) with the same epoch and share; 181 / 124 blocks across DAA 180; 0 rejected; one sink 322e4a57824ab7fd at 304/304/304; the id assertion on epochs 3, 4, 5: the three miners' v4 id equals the CLI's class v4 id and differs from the same-seed v3 id (e3 e48e6be7c6699824 against 6847355c88e8215f) class-v4-signal-flip.json
Nobody signals, the floor at 300 (--signal 3,3,3 --floor 300 --expect floor) 17:39:3x to 17:46:28Z PASS: the share 0 bps throughout (424 blocks byte 3); no signal line on any node; the class flipped at epoch 5 (DAA 300), the floor, and not before; the floor line active from epoch 5 on 3 of 3; 301 / 124 blocks; 0 rejected; one sink aa8755bf4c1cc9e2 at 424/424/424; the id assertion on epochs 5 to 7 (e5 99f8a29e69d425fd against 48b8b87ff3a7e9ae) class-v4-signal-floor.json
The known-failed case (--signal 4,4,3 --expect flip --epochs 7) 17:20:34 to 17:27:04Z FAIL as it must: no flip at 6,250 to 7,288 bps, and the harness reports FAILED CHECK on eight checks (template_switched_to_v4, switched_at_the_first_full_window_epoch, switched_before_the_floor, signal_line_on_every_node_same_epoch, signal_share_at_or_above_threshold, blocks_on_both_sides, the two id checks), exit 1 class-v4-signal-failed-case.json

The first pass of the three good cases (17:01 to 17:20Z) had the same chain behaviour and failed on one harness check of its own (chain_carries_the_bytes demanded every byte on the chain be a node's and genesis carries byte 0); the check was corrected to allow the one genesis block and the three cases re-run above. Every number here is a count, a line or an id; the Mac's load average (about 9, other agents' builds) moves the wall times only.

Fork suites on the signalling change (0562a7f2): kaspa-consensus-core 110 + 7 (the signal-rule test, the window in the params, digest and fast-time file tests; the pinned devnet digest re-pinned from 3c505021... to 7f2e49beabc253f327c5ac6bb457a674ea7f527af2971c95d3bdf65ef8bcf977), kaspa-consensus lib 98 (the template test now reads the low byte and the signal byte), kaspa-pow with the feature 15; PC 2: section G6 of node-gates.md.