From f390f43adba1050d09d933105e673a1bb2dab158 Mon Sep 17 00:00:00 2001 From: cyber-pc <165458555+cyber-pc@users.noreply.github.com> Date: Mon, 3 Aug 2026 00:15:06 +0700 Subject: [PATCH 01/46] Adapt the score_bpp9000 with the ant colony. --- src/mining/score_bpp9000.h | 186 +++++++++++++++++--- src/score.h | 38 +++-- test/score.cpp | 336 +++++++++++++++++++++++++++++++++++++ 3 files changed, 525 insertions(+), 35 deletions(-) diff --git a/src/mining/score_bpp9000.h b/src/mining/score_bpp9000.h index b7f308b4..9960f07c 100644 --- a/src/mining/score_bpp9000.h +++ b/src/mining/score_bpp9000.h @@ -21,6 +21,17 @@ static bool isCanonicalBpp9000Nonce(const unsigned char* nonce) && (nonce[2] == 0); } +// Same rule for the ant colony, except that K is a real degree of freedom there: the walk restores +// K = nonce[2] as its explore-step count, so K is range-checked instead of pinned to 0. Values above +// numberOfMutations are rejected +static bool isCanonicalAntNonce(const unsigned char* nonce, unsigned long long numberOfMutations) +{ + return (getAlgoType(nonce) == AlgoType::Bpp9000) + && (nonce[1] >= 1) + && (nonce[1] <= MAX_LUT_ENTRIES_PER_STEP) + && (nonce[2] <= numberOfMutations); +} + template struct ScoreBpp9000 { @@ -79,13 +90,26 @@ struct ScoreBpp9000 kEvolution, }; - // Rollback/snapshot state: just the per-neuron LUT. + // An ANN is its per-neuron LUT: maxNumberOfNeurons rows of lutSize entries + // resourceTestingDigest, written to snapshots, sent to miners... struct ANN + { + unsigned char lut[maxNumberOfNeurons * lutSize]; + }; + + // padding LUT for a SIMD + struct PaddedLut { alignas(64) unsigned char lut[maxNumberOfNeurons * lutStride]; }; - ANN currentANN; - ANN prevANN; + + PaddedLut currentANN; + PaddedLut prevANN; + + // LUT that produced the score returned by the walk, in working layout. The ant colony stores this + // as the child's inherited state, so a child branches from the best-ever LUT rather than the last + // one walked; read it with getBestANN(). + PaddedLut bestANN; struct InitValue { @@ -403,7 +427,8 @@ struct ScoreBpp9000 #endif } - // Sliding-window self-clocked score via the window-batched SIMD kernel (AVX-512 or AVX2, bit-exact). + // Sliding-window self-clocked score via the window-batched SIMD kernel + // Apply on the curANN unsigned int score() { // PROFILE_NAMED_SCOPE("bpp9000:score"); @@ -972,44 +997,102 @@ struct ScoreBpp9000 currentANN.lut[storageIdx] = newTrit; } - // Seed the ANN: root LUT from the pubkey alone (each computor's fixed root); mutation seeds from - // pubkey+nonce (nonce[0..2] are the algo/L/K knobs, excluded from the RNG). Returns the start score. - unsigned int initializeANN(const unsigned char* publicKey, const unsigned char* nonce, const unsigned char* pRandom2Pool) + // Derive the root LUT material + void deriveRootLut(const unsigned char* publicKey, const unsigned char* pRandom2Pool) { - // PROFILE_NAMED_SCOPE("bpp9000:initializeANN"); unsigned char rootHash[32]; KangarooTwelve(publicKey, 32, rootHash, 32); random2(rootHash, pRandom2Pool, (unsigned char*)&initValue.lutInit, lutInitPaddedBytes); + } + // Derive the mutation-walk seeds. nonce[0..2] are the algo/L/K knobs and stay excluded from the RNG, + // so K and L can be chosen freely without reseeding the walk. anchorTickDigest == nullptr for the + // standalone walk; the ant colony binds a child's walk to the tick it anchors on. + void deriveMutationSeeds( + const unsigned char* publicKey, + const unsigned char* nonce, + const unsigned char* anchorTickDigest, + const unsigned char* pRandom2Pool) + { unsigned char searchHash[32]; - unsigned char combined[64]; + unsigned char combined[96]; copyMem(combined, publicKey, 32); copyMem(combined + 32, nonce, 32); combined[32] = 0; combined[33] = 0; combined[34] = 0; - KangarooTwelve(combined, 64, searchHash, 32); + unsigned int combinedSize = 64; + if (anchorTickDigest != nullptr) + { + copyMem(combined + 64, anchorTickDigest, 32); + combinedSize = 96; + } + KangarooTwelve(combined, combinedSize, searchHash, 32); random2(searchHash, pRandom2Pool, (unsigned char*)&initValue.mutationSeed, mutationSeedPaddedBytes); + } + + // Store the LUT densely by updated-neuron position k (row k): row k holds neuron + // updatedNeuronIndices[k]'s LUT. RNG draw into initValue.lutInit unchanged (bit-exact). + void applyRootLut(PaddedLut& target) + { + // The loop below writes only rows [0, numberOfUpdatedNeurons) columns [0, lutSize), and the + // SIMD path loads whole rows, so the rest has to be initialised rather than left as whatever + // the buffer previously held. + setMem(&target, sizeof(target), 0); - // Store the LUT densely by updated-neuron position k (row k): row k holds neuron - // updatedNeuronIndices[k]'s LUT. RNG draw into initValue.lutInit unchanged (bit-exact). for (unsigned long long k = 0; k < numberOfUpdatedNeurons; ++k) { const unsigned long long n = updatedNeuronIndices[k]; for (unsigned long long line = 0; line < lutSize; ++line) { - currentANN.lut[k * lutStride + line] = (unsigned char)(initValue.lutInit[n * lutSize + line] % 3); + target.lut[k * lutStride + line] = (unsigned char)(initValue.lutInit[n * lutSize + line] % 3); } } + } + + // Working layout to ANN remove the stride padding. + void compact(const PaddedLut& src, ANN& out) const + { + for (unsigned long long k = 0; k < maxNumberOfNeurons; ++k) + { + copyMem(out.lut + k * lutSize, src.lut + k * lutStride, lutSize); + } + } + + // Restores the stride and zeroes the padding. + void expand(const ANN& src, PaddedLut& out) const + { + setMem(&out, sizeof(out), 0); + for (unsigned long long k = 0; k < maxNumberOfNeurons; ++k) + { + copyMem(out.lut + k * lutStride, src.lut + k * lutSize, lutSize); + } + } + + // The LUT behind the score the last walk returned. + void getBestANN(ANN& out) const + { + compact(bestANN, out); + } + + // Seed the ANN: root LUT from the pubkey alone (each computor's fixed root); mutation seeds from + // pubkey+nonce (nonce[0..2] are the algo/L/K knobs, excluded from the RNG). Returns the start score. + unsigned int initializeANN( + const unsigned char* publicKey, + const unsigned char* nonce, + const unsigned char* pRandom2Pool) + { + // PROFILE_NAMED_SCOPE("bpp9000:initializeANN"); + deriveRootLut(publicKey, pRandom2Pool); + deriveMutationSeeds(publicKey, nonce, nullptr, pRandom2Pool); + applyRootLut(currentANN); return score(); } - // Anti-attractor search: L mutations/step; accept worse-or-equal for the first K steps (explore), - // then better-or-equal (exploit); one-step rollback; keep and return the best score found. - unsigned int computeScore(const unsigned char* publicKey, const unsigned char* nonce, const unsigned char* pRandom2Pool) + // Miner-chosen number of LUT entries rewritten per step, clamped to the verifiable range. + static unsigned int lutEntriesPerStep(const unsigned char* nonce) { - // PROFILE_NAMED_SCOPE("bpp9000:computeScore"); unsigned int L = nonce[1]; if (L < 1) { @@ -1019,11 +1102,17 @@ struct ScoreBpp9000 { L = MAX_LUT_ENTRIES_PER_STEP; } - // Explore disabled pre-ant-colony (K=0); restore K = nonce[2] when ants return. - const unsigned long long K = 0; + return L; + } - unsigned int cur = initializeANN(publicKey, nonce, pRandom2Pool); + // Anti-attractor walk starting from the LUT already in currentANN: L mutations/step; accept + // worse-or-equal for the first K steps (explore), then better-or-equal (exploit); one-step rollback. + // Returns the best score found and leaves the LUT that produced it in bestANN. + unsigned int computeScoreFromCurrent(unsigned int L, unsigned long long K, unsigned int startScore) + { + unsigned int cur = startScore; unsigned int best = cur; + copyMem(&bestANN, ¤tANN, sizeof(bestANN)); for (unsigned long long s = 0; s < numberOfMutations; ++s) { @@ -1058,11 +1147,68 @@ struct ScoreBpp9000 if (cur < best) { best = cur; + copyMem(&bestANN, ¤tANN, sizeof(bestANN)); } } return best; } + // Anti-attractor search: L mutations/step; accept worse-or-equal for the first K steps (explore), + // then better-or-equal (exploit); one-step rollback; keep and return the best score found. + unsigned int computeScore( + const unsigned char* publicKey, + const unsigned char* nonce, + const unsigned char* pRandom2Pool) + { + // PROFILE_NAMED_SCOPE("bpp9000:computeScore"); + const unsigned int L = lutEntriesPerStep(nonce); + // Explore disabled for the standalone algorithm (K=0); the ant colony passes K = nonce[2]. + const unsigned long long K = 0; + + const unsigned int cur = initializeANN(publicKey, nonce, pRandom2Pool); + + return computeScoreFromCurrent(L, K, cur); + } + + // Ant colony: the network every one of an identity's lineages starts from. Written to a buffer the + // caller owns, so two roots can be derived on one engine without the first silently becoming the + // second - a child scored against the wrong root would differ only in resourceTestingDigest. + // Uses currentANN as its working buffer, so it destroys whatever the engine was holding. Callers + // derive a root and then score from it, which overwrites currentANN anyway. + void deriveRootANN(const unsigned char* publicKey, const unsigned char* pRandom2Pool, ANN& out) + { + deriveRootLut(publicKey, pRandom2Pool); + applyRootLut(currentANN); + compact(currentANN, out); + } + + // Ant colony: score a child by inheriting the parent's LUT and walking it with the child's own seeds + unsigned int computeScoreFromParent( + const ANN& parentANN, + const unsigned char* publicKey, + const unsigned char* nonce, + const unsigned char* anchorTickDigest, + const unsigned char* pRandom2Pool) + { + // The canonical rule + if (!isCanonicalAntNonce(nonce, numberOfMutations)) + { + return INVALID_SCORE_VALUE; + } + + // Get the ANN from parent, also init the new mutation starting point + expand(parentANN, currentANN); + deriveMutationSeeds(publicKey, nonce, anchorTickDigest, pRandom2Pool); + + // Both knobs are already in range: the check above is what puts them there. + const unsigned int L = lutEntriesPerStep(nonce); + const unsigned long long K = nonce[2]; + + const unsigned int cur = score(); + + return computeScoreFromCurrent(L, K, cur); + } + int getLastOutput(unsigned char* requestedOutput, int requestedSizeInBytes) { return 0; diff --git a/src/score.h b/src/score.h index 99a6523a..14adec6e 100644 --- a/src/score.h +++ b/src/score.h @@ -18,20 +18,9 @@ enum ScoreStatus ScoreStatusTaskNotLoaded, }; -template -struct ScoreFunction +namespace score_engine { - score_engine::ScoreEngine< - score_engine::NeuraxonParams< - NEURAXON_NUMBER_OF_INPUT_NEURONS, - NEURAXON_NUMBER_OF_OUTPUT_NEURONS, - NEURAXON_NUMBER_OF_TICKS, - NEURAXON_NUMBER_OF_NEIGHBORS, - NEURAXON_POPULATION_THRESHOLD, - NEURAXON_NUMBER_OF_MUTATIONS, - NEURAXON_SOLUTION_THRESHOLD_DEFAULT>, - - score_engine::Bpp9000Params< + using Bpp9000ParamsT = Bpp9000Params< BPP9000_NUMBER_OF_INPUT_NEURONS, BPP9000_NUMBER_OF_OUTPUT_NEURONS, BPP9000_SEQUENCE_LENGTH, @@ -40,8 +29,27 @@ struct ScoreFunction BPP9000_NUMBER_OF_NEIGHBORS, BPP9000_POPULATION_THRESHOLD, BPP9000_NUMBER_OF_MUTATIONS, - BPP9000_SOLUTION_THRESHOLD_DEFAULT> - > _computeBuffer[solutionBufferCount]; + BPP9000_SOLUTION_THRESHOLD_DEFAULT>; + + using NeuraxonParamsT = NeuraxonParams< + NEURAXON_NUMBER_OF_INPUT_NEURONS, + NEURAXON_NUMBER_OF_OUTPUT_NEURONS, + NEURAXON_NUMBER_OF_TICKS, + NEURAXON_NUMBER_OF_NEIGHBORS, + NEURAXON_POPULATION_THRESHOLD, + NEURAXON_NUMBER_OF_MUTATIONS, + NEURAXON_SOLUTION_THRESHOLD_DEFAULT>; + + // The bpp9000 scorer the ant colony branches on; exposes ANN (the inheritable per-neuron LUT). + using ScoreBpp9000T = ScoreBpp9000; + + using ScoreEngineT = ScoreEngine; +} + +template +struct ScoreFunction +{ + score_engine::ScoreEngineT _computeBuffer[solutionBufferCount]; volatile char random2PoolLock; unsigned char state[score_engine::STATE_SIZE]; diff --git a/test/score.cpp b/test/score.cpp index 451edc2a..0b4fbf97 100644 --- a/test/score.cpp +++ b/test/score.cpp @@ -506,5 +506,341 @@ TEST(TestQubicScoreFunction, Bpp9000Profile) } #endif +// ============================================================================= +// Ant-colony related +namespace +{ +using AntCfg = ProductionConfig; +using AntEngine = score_engine::ScoreBpp9000; + +// Pool + synthetic task + loaded engine. Every ant test starts from one of these. +template +struct AntFixtureT +{ + std::vector pool; + std::vector taskBytes; + std::unique_ptr> engine; +}; +using AntFixture = AntFixtureT; + +template +static bool makeAntFixtureT(AntFixtureT& f) +{ + std::vector seeds; + std::vector pubkeys; + std::vector nonces; + loadSamples(seeds, pubkeys, nonces, 1); + if (seeds.empty()) + { + ADD_FAILURE() << "missing/short " << SAMPLES_FILE_NAME; + return false; + } + generatePool(seeds[0], f.pool); + + f.taskBytes = readBinaryFile(TASK_FILE_NAME); + if (f.taskBytes.size() <= sizeof(score_task_file::TaskFileHeader)) + { + ADD_FAILURE() << "missing/short " << TASK_FILE_NAME; + return false; + } + const TaskBlocks tb = taskSubview(f.taskBytes); + f.engine = makeEngine(tb.topo, tb.data); + return f.engine != nullptr; +} + +static bool makeAntFixture(AntFixture& f) +{ + return makeAntFixtureT(f); +} + +static m256i makePubkey(unsigned char tag) +{ + m256i k = m256i::zero(); + k.m256i_u8[0] = tag; + k.m256i_u8[31] = (unsigned char)(tag * 3 + 1); + return k; +} + +// Canonical ant nonce: nonce[0] selects bpp9000, nonce[1] = L, nonce[2] = K, rest is the walk seed. +static m256i makeAntNonce(unsigned char L, unsigned char K, unsigned char tag) +{ + m256i n = m256i::zero(); + n.m256i_u8[0] = (unsigned char)score_engine::AlgoType::Bpp9000; + n.m256i_u8[1] = L; + n.m256i_u8[2] = K; + n.m256i_u8[3] = tag; + n.m256i_u8[17] = (unsigned char)(tag ^ 0x5A); + return n; +} + +// Find a nonce whose walk from this parent actually improves on the parent's score. Needed only by +// tests that compare two children of the SAME parent +static bool findImprovingNonce(AntEngine& engine, const AntEngine::ANN& parent, const m256i& pk, + const m256i& anchor, const unsigned char* pool, m256i& outNonce) +{ + for (unsigned char tag = 1; tag <= 6; ++tag) + { + const m256i n = makeAntNonce(6, 5, (unsigned char)(50 + tag)); + const unsigned int sc = engine.computeScoreFromParent(parent, pk.m256i_u8, n.m256i_u8, + anchor.m256i_u8, pool); + if (sc != score_engine::INVALID_SCORE_VALUE) + { + outNonce = n; + return true; + } + } + return false; +} +} + +// Make sure the score at the full computeScore flow have the same score with +// the engine start directly from the best/final LUT +TEST(TestQubicScoreAntColony, BestAnnReproducesReturnedScore) +{ + AntFixture f; + ASSERT_TRUE(makeAntFixture(f)); + + const m256i pk = makePubkey(1); + const m256i nonce = makeAntNonce(3, 0, 11); + + const unsigned int best = f.engine->computeScore(pk.m256i_u8, nonce.m256i_u8, f.pool.data()); + // Re-score the LUT the walk kept, taken out and put back through the public form - this also + // exercises the compact/expand round trip the tree relies on. + AntEngine::ANN bestLut; + f.engine->getBestANN(bestLut); + f.engine->expand(bestLut, f.engine->currentANN); + EXPECT_EQ(f.engine->score(), best); +} + +// Make sure the score at the full computeScoreFromParent flow have the same score with +// the engine start directly from the best/final LUT +TEST(TestQubicScoreAntColony, BestAnnReproducesScoreFromParent) +{ + AntFixture f; + ASSERT_TRUE(makeAntFixture(f)); + + const m256i pk = makePubkey(2); + const m256i nonce = makeAntNonce(4, 2, 23); + const m256i anchor = makePubkey(9); + + AntEngine::ANN root; + f.engine->deriveRootANN(pk.m256i_u8, f.pool.data(), root); + + const unsigned int childScore = f.engine->computeScoreFromParent( + root, pk.m256i_u8, nonce.m256i_u8, anchor.m256i_u8, f.pool.data()); + + // Re-score the LUT the walk kept, taken out and put back through the public form - this also + // exercises the compact/expand round trip the tree relies on. + AntEngine::ANN bestLut; + f.engine->getBestANN(bestLut); + f.engine->expand(bestLut, f.engine->currentANN); + EXPECT_EQ(f.engine->score(), childScore); +} + +// An ANN is exactly its LUT: no storage padding escapes the engine, so hashing or shipping one is +// just sizeof(ANN) and a future change to lutStride cannot alter a digest or the wire format. +TEST(TestQubicScoreAntColony, AnnCarriesOnlyTheLut) +{ + static_assert(sizeof(AntEngine::ANN) == AntCfg::populationThreshold * AntEngine::lutSize, + "ANN must be the LUT and nothing else"); + static_assert(sizeof(AntEngine::ANN) < sizeof(AntEngine::PaddedLut), + "the working layout is the padded one, not the other way round"); + + AntFixture f; + ASSERT_TRUE(makeAntFixture(f)); + + const m256i pk = makePubkey(3); + AntEngine::ANN root; + f.engine->deriveRootANN(pk.m256i_u8, f.pool.data(), root); + + // Every byte handed out is a trit; nothing from the padded rows leaked in. + for (unsigned long long i = 0; i < sizeof(root.lut); ++i) + { + ASSERT_LT(root.lut[i], 3) << "byte " << i << " of the returned ANN is not a trit"; + } + + // Scribbling on the working layout's padding cannot change what comes out of it. + AntEngine::PaddedLut working; + f.engine->expand(root, working); + for (unsigned long long k = 0; k < AntEngine::maxNumberOfNeurons; ++k) + { + for (unsigned long long b = AntEngine::lutSize; b < AntEngine::lutStride; ++b) + { + working.lut[k * AntEngine::lutStride + b] = (unsigned char)(0xA5 + k + b); + } + } + AntEngine::ANN again; + f.engine->compact(working, again); + EXPECT_EQ(memcmp(&again, &root, sizeof(root)), 0) << "storage padding reached the ANN"; +} + +// expand/compact must be lossless, since every parent read from the tree goes through expand and +// every child written back goes through compact. +TEST(TestQubicScoreAntColony, AnnSurvivesExpandAndCompact) +{ + AntFixture f; + ASSERT_TRUE(makeAntFixture(f)); + + AntEngine::ANN original; + f.engine->deriveRootANN(makePubkey(44).m256i_u8, f.pool.data(), original); + + AntEngine::PaddedLut working; + f.engine->expand(original, working); + AntEngine::ANN restored; + f.engine->compact(working, restored); + + EXPECT_EQ(memcmp(&restored, &original, sizeof(original)), 0) << "expand/compact is not lossless"; +} + +TEST(TestQubicScoreAntColony, RootAnnIsDeterministicAndPerIdentity) +{ + AntFixture f; + ASSERT_TRUE(makeAntFixture(f)); + + const m256i pkA = makePubkey(4); + const m256i pkB = makePubkey(5); + + AntEngine::ANN a1; + AntEngine::ANN a2; + AntEngine::ANN b1; + + f.engine->deriveRootANN(pkA.m256i_u8, f.pool.data(), a1); + // Deriving another identity's root overwrites initValue.lutInit, which is the state a1 came from. + f.engine->deriveRootANN(pkB.m256i_u8, f.pool.data(), b1); + f.engine->deriveRootANN(pkA.m256i_u8, f.pool.data(), a2); + + EXPECT_EQ(memcmp(&a1, &a2, sizeof(a1)), 0) << "root depends on engine state"; + EXPECT_NE(memcmp(&a1, &b1, sizeof(a1)), 0) << "two identities share a root"; +} + +// A child is a function of (parent, pubkey, nonce, anchor). Same inputs must give the same score AND +// the same inherited LUT; a different parent must not give the same child. +TEST(TestQubicScoreAntColony, ChildIsDeterministicAndInheritsParent) +{ + AntFixture f; + ASSERT_TRUE(makeAntFixture(f)); + + const m256i pk = makePubkey(6); + const m256i nonce = makeAntNonce(5, 3, 41); + const m256i anchor = makePubkey(12); + + AntEngine::ANN parentA; + AntEngine::ANN parentB; + f.engine->deriveRootANN(pk.m256i_u8, f.pool.data(), parentA); + f.engine->deriveRootANN(makePubkey(7).m256i_u8, f.pool.data(), parentB); + + const unsigned int s1 = f.engine->computeScoreFromParent( + parentA, pk.m256i_u8, nonce.m256i_u8, anchor.m256i_u8, f.pool.data()); + AntEngine::ANN child1; + f.engine->getBestANN(child1); + + const unsigned int s2 = f.engine->computeScoreFromParent( + parentA, pk.m256i_u8, nonce.m256i_u8, anchor.m256i_u8, f.pool.data()); + + EXPECT_EQ(s1, s2) << "same inputs gave different scores"; + AntEngine::ANN child2; + f.engine->getBestANN(child2); + EXPECT_EQ(memcmp(&child1, &child2, sizeof(child1)), 0) << "same inputs gave a different child LUT"; + + f.engine->computeScoreFromParent(parentB, pk.m256i_u8, nonce.m256i_u8, anchor.m256i_u8, f.pool.data()); + AntEngine::ANN child3; + f.engine->getBestANN(child3); + EXPECT_NE(memcmp(&child1, &child3, sizeof(child1)), 0) << "the parent LUT was not inherited"; +} + +// The anchor digest is part of the child's walk seed, so the same nonce on the same parent must not +// produce the same child at a different anchor. +TEST(TestQubicScoreAntColony, ChildDependsOnAnchorDigest) +{ + AntFixture f; + ASSERT_TRUE(makeAntFixture(f)); + + const m256i pk = makePubkey(8); + const m256i anchorA = makePubkey(20); + const m256i anchorB = makePubkey(21); + + AntEngine::ANN parent; + f.engine->deriveRootANN(pk.m256i_u8, f.pool.data(), parent); + + m256i nonce; + ASSERT_TRUE(findImprovingNonce(*f.engine, parent, pk, anchorA, f.pool.data(), nonce)) + << "no nonce improved on this parent, so bestANN would not move and the comparison below " + "would be vacuous"; + + f.engine->computeScoreFromParent(parent, pk.m256i_u8, nonce.m256i_u8, anchorA.m256i_u8, f.pool.data()); + AntEngine::ANN c1; + f.engine->getBestANN(c1); + + f.engine->computeScoreFromParent(parent, pk.m256i_u8, nonce.m256i_u8, anchorB.m256i_u8, f.pool.data()); + AntEngine::ANN c2; + f.engine->getBestANN(c2); + + EXPECT_NE(memcmp(&c1, &c2, sizeof(c1)), 0) << "anchor digest does not reach the walk"; +} + +// Non-canonical nonces are refused by the scorer itself, so no caller can score first and check after. +TEST(TestQubicScoreAntColony, NonCanonicalNonceIsRejected) +{ + AntFixture f; + ASSERT_TRUE(makeAntFixture(f)); + + const m256i pk = makePubkey(10); + const m256i anchor = makePubkey(30); + AntEngine::ANN parentA; + AntEngine::ANN parentB; + f.engine->deriveRootANN(pk.m256i_u8, f.pool.data(), parentA); + f.engine->deriveRootANN(makePubkey(11).m256i_u8, f.pool.data(), parentB); + + constexpr unsigned char maxK = (unsigned char)AntCfg::numberOfMutations; + const m256i good = makeAntNonce(3, 5, 62); + + // A rejected nonce and a timed-out walk + f.engine->computeScoreFromParent(parentA, pk.m256i_u8, good.m256i_u8, anchor.m256i_u8, f.pool.data()); + AntEngine::ANN afterA; + f.engine->getBestANN(afterA); + + // L below range, L above range, K above numberOfMutations, wrong algorithm slot. + static constexpr unsigned int numberOfBadNonces = 4; + m256i bad[numberOfBadNonces]; + bad[0] = makeAntNonce(0, 0, 64); + bad[1] = makeAntNonce((unsigned char)(score_engine::MAX_LUT_ENTRIES_PER_STEP + 1), 0, 65); + bad[2] = makeAntNonce(3, (unsigned char)(maxK + 1), 66); + bad[3] = makeAntNonce(3, 0, 67); + bad[3].m256i_u8[0] = (unsigned char)score_engine::AlgoType::Neuraxon; + + for (unsigned int i = 0; i < numberOfBadNonces; i++) + { + // The bad nonce is early rejected in computeScoreFromParent() + EXPECT_EQ(f.engine->computeScoreFromParent(parentB, pk.m256i_u8, bad[i].m256i_u8, anchor.m256i_u8, f.pool.data()), + score_engine::INVALID_SCORE_VALUE) << "non-canonical nonce " << i << " accepted"; + + AntEngine::ANN now; + f.engine->getBestANN(now); + EXPECT_EQ(memcmp(&now, &afterA, sizeof(now)), 0) << "rejected nonce " << i << " still ran the walk"; + } + + // Now after bad nonce, we feed good nonce, we expect this is ok + f.engine->computeScoreFromParent(parentB, pk.m256i_u8, good.m256i_u8, anchor.m256i_u8, f.pool.data()); + AntEngine::ANN afterB; + f.engine->getBestANN(afterB); + EXPECT_NE(memcmp(&afterB, &afterA, sizeof(afterB)), 0) << "canonical nonce was not scored"; +} + + +// L and K boundaries of the canonical rule, checked as a pure predicate so no walk is needed. +TEST(TestQubicScoreAntColony, NonceCanonicalRuleBoundaries) +{ + constexpr unsigned long long mutations = AntCfg::numberOfMutations; + constexpr unsigned char maxL = (unsigned char)score_engine::MAX_LUT_ENTRIES_PER_STEP; + constexpr unsigned char maxK = (unsigned char)mutations; + + EXPECT_TRUE(score_engine::isCanonicalAntNonce(makeAntNonce(1, 0, 70).m256i_u8, mutations)); + EXPECT_TRUE(score_engine::isCanonicalAntNonce(makeAntNonce(maxL, 0, 71).m256i_u8, mutations)); + EXPECT_TRUE(score_engine::isCanonicalAntNonce(makeAntNonce(3, maxK, 72).m256i_u8, mutations)); + + EXPECT_FALSE(score_engine::isCanonicalAntNonce(makeAntNonce(0, 0, 73).m256i_u8, mutations)); + EXPECT_FALSE(score_engine::isCanonicalAntNonce(makeAntNonce((unsigned char)(maxL + 1), 0, 74).m256i_u8, mutations)); + EXPECT_FALSE(score_engine::isCanonicalAntNonce(makeAntNonce(3, (unsigned char)(maxK + 1), 75).m256i_u8, mutations)); +} From 01bb6aef9ffd62176fd0fe54ddd3abf86a498e77 Mon Sep 17 00:00:00 2001 From: cyber-pc <165458555+cyber-pc@users.noreply.github.com> Date: Mon, 3 Aug 2026 15:42:43 +0700 Subject: [PATCH 02/46] Change the score queue support a generic task base. --- src/qubic.cpp | 26 ++-- src/score.h | 158 ++++++++++++++--------- test/score.cpp | 340 +++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 454 insertions(+), 70 deletions(-) diff --git a/src/qubic.cpp b/src/qubic.cpp index 7b5368ba..f8f3184e 100644 --- a/src/qubic.cpp +++ b/src/qubic.cpp @@ -214,6 +214,17 @@ static ScoreFunction< NUMBER_OF_SOLUTION_PROCESSORS > * score = nullptr; static unsigned char* gBpp9000TaskBuffer = nullptr; + +// The payload is the { publicKey, miningSeed, nonce } +static_assert(3 * sizeof(m256i) <= ScoreFunction::TASK_PAYLOAD_MAX, + "A legacy solution must fit the task queue payload"); + +static void scoreLegacySolutionTask(unsigned long long processorNumber, void* payload) +{ + const m256i* data = (const m256i*)payload; + (*score)(processorNumber, data[0], data[1], data[2]); +} + static volatile char solutionsLock = 0; static unsigned long long* minerSolutionFlags = NULL; static volatile m256i minerPublicKeys[MAX_NUMBER_OF_MINERS + 1]; @@ -1896,7 +1907,7 @@ static void requestProcessor(void* ProcedureArgument) if (solutionProcessorFlags[processorNumber]) { PROFILE_NAMED_SCOPE("requestProcessor(): solution processing"); - score->tryProcessSolution(processorNumber); + score->tryProcessOneTask(processorNumber); } if (requestQueueElementTail == requestQueueElementHead) @@ -3247,7 +3258,7 @@ static void processTick(unsigned long long processorNumber) if (!(minerSolutionFlags[flagIndices[0] >> 6] & (1ULL << (flagIndices[0] & 63))) || !(minerSolutionFlags[flagIndices[1] >> 6] & (1ULL << (flagIndices[1] & 63)))) { - score->addTask(transaction->sourcePublicKey, solution_miningSeed, solution_nonce); + score->addTask(scoreLegacySolutionTask, data, sizeof(data)); } } } @@ -3258,15 +3269,10 @@ static void processTick(unsigned long long processorNumber) PROFILE_SCOPE_END(); { - // Process solutions in this tick and store in cache. In parallel, score->tryProcessSolution() is called by - // request processors to speed up solution processing. + // Process solutions in this tick and store in cache. In parallel, request processors call + // score->tryProcessOneTask() from their idle path to speed this up. PROFILE_NAMED_SCOPE("processTick(): process solutions"); - score->startProcessTaskQueue(); - while (!score->isTaskQueueProcessed()) - { - score->tryProcessSolution(processorNumber); - } - score->stopProcessTaskQueue(); + score->runUntilDone(processorNumber); } solutionTotalExecutionTicks = __rdtsc() - solutionProcessStartTick; // for tracking the time processing solutions diff --git a/src/score.h b/src/score.h index 14adec6e..432ff82e 100644 --- a/src/score.h +++ b/src/score.h @@ -49,8 +49,14 @@ namespace score_engine template struct ScoreFunction { +private: + // The engine scratch buffers and the locks guarding them. Private on purpose: a work function run + // by the task queue cannot reach them, so it cannot take a slot lock and then call a method that + // takes the same one. Every route into the engine locks exactly once, inside this class. score_engine::ScoreEngineT _computeBuffer[solutionBufferCount]; + volatile char solutionEngineLock[solutionBufferCount]; +public: volatile char random2PoolLock; unsigned char state[score_engine::STATE_SIZE]; unsigned char externalPoolVec[score_engine::POOL_VEC_PADDING_SIZE]; @@ -72,8 +78,6 @@ struct ScoreFunction m256i currentRandomSeed; - volatile char solutionEngineLock[solutionBufferCount]; - #if USE_SCORE_CACHE volatile char scoreCacheLock; ScoreCache scoreCache; @@ -245,22 +249,39 @@ struct ScoreFunction unsigned long long stackSize = 0; #endif - // Multithreaded solutions verification: - // This module mainly serve tick processor in qubic core node, thus the queue size is limited at NUMBER_OF_TRANSACTIONS_PER_TICK - // for future use for somewhere else, you can only increase the size. + // Multithreaded solutions verification. + // + // A task is a (work function, payload) pair rather than a fixed tuple, so different kinds of + // scoring work can share one queue and one drain: the queue arbitrates nothing except who runs + // next. The payload is COPIED in, so the caller may reuse or discard its buffer immediately - a + // pointer here would make every caller responsible for keeping data alive across a drain. + // + // The work function is responsible for taking whatever locks it needs, including + // solutionEngineLock. The queue must not take it: solutionEngineLock is a non-reentrant spinlock + // and operator() takes it itself, so a queue that pre-acquired would deadlock any work function + // that reuses operator(). + typedef void (*WorkFunc)(unsigned long long processorNumber, void* payload); + + static constexpr unsigned int TASK_PAYLOAD_MAX = 128; + +private: + static constexpr unsigned int TASK_QUEUE_CAPACITY = NUMBER_OF_TRANSACTIONS_PER_TICK; + + struct Task + { + WorkFunc func; + // 8-byte aligned: m256i is a plain union accessed with unaligned intrinsics, so it needs no more. + unsigned long long payload[TASK_PAYLOAD_MAX / sizeof(unsigned long long)]; + }; volatile char taskQueueLock = 0; - struct - { - m256i publicKey[NUMBER_OF_TRANSACTIONS_PER_TICK]; - m256i miningSeed[NUMBER_OF_TRANSACTIONS_PER_TICK]; - m256i nonce[NUMBER_OF_TRANSACTIONS_PER_TICK]; - } taskQueue; + Task taskQueue[TASK_QUEUE_CAPACITY]; unsigned int _nTask; unsigned int _nProcessing; unsigned int _nFinished; - bool _nIsTaskQueueReady; + volatile bool _nIsTaskQueueReady; +public: void resetTaskQueue() { ACQUIRE(taskQueueLock); @@ -271,81 +292,98 @@ struct ScoreFunction RELEASE(taskQueueLock); } - // add task to the queue - // queue size is limited at NUMBER_OF_TRANSACTIONS_PER_TICK - void addTask(m256i publicKey, m256i miningSeed, m256i nonce) + // Copies size bytes of data. Returns false if the queue is full or the payload does not fit. + bool addTask(WorkFunc func, const void* data, unsigned int size) { - ACQUIRE(taskQueueLock); - if (_nTask < NUMBER_OF_TRANSACTIONS_PER_TICK) + if (size > TASK_PAYLOAD_MAX) { - unsigned int index = _nTask++; - taskQueue.publicKey[index] = publicKey; - taskQueue.miningSeed[index] = miningSeed; - taskQueue.nonce[index] = nonce; + return false; } - RELEASE(taskQueueLock); - } - - void startProcessTaskQueue() - { + bool added = false; ACQUIRE(taskQueueLock); - _nIsTaskQueueReady = true; + if (_nTask < TASK_QUEUE_CAPACITY) + { + Task& t = taskQueue[_nTask++]; + t.func = func; + copyMem(t.payload, data, size); + added = true; + } RELEASE(taskQueueLock); + return added; } - void stopProcessTaskQueue() + // Outcome of one dispatch attempt, so a caller waiting for the batch does not need a second + // lock acquisition just to ask whether it is over. + enum TaskDispatchResult { - ACQUIRE(taskQueueLock); - _nIsTaskQueueReady = false; - RELEASE(taskQueueLock); - } + TaskRan, // a task was taken and executed + TaskNonePending, // nothing left to take, but tasks are still running elsewhere + TaskAllDone // every queued task has finished + }; - // get a task, can call on any thread - bool getTask(m256i* publicKey, m256i* miningSeed, m256i* nonce) + // Run one task if any is pending. Called from request processors' idle path and from the drain. + TaskDispatchResult tryProcessOneTask(unsigned long long processorNumber) { if (!_nIsTaskQueueReady) { - return false; + // No thing to process + return TaskNonePending; } - bool result = false; + + WorkFunc func = nullptr; + unsigned long long payload[TASK_PAYLOAD_MAX / sizeof(unsigned long long)]; + TaskDispatchResult result = TaskNonePending; + ACQUIRE(taskQueueLock); - if (_nProcessing < _nTask) + if (_nFinished >= _nTask) { - unsigned int index = _nProcessing++; - *publicKey = taskQueue.publicKey[index]; - *miningSeed = taskQueue.miningSeed[index]; - *nonce = taskQueue.nonce[index]; - result = true; + result = TaskAllDone; } - else + else if (_nIsTaskQueueReady && _nProcessing < _nTask) { - result = false; + const Task& t = taskQueue[_nProcessing++]; + func = t.func; + copyMem(payload, t.payload, TASK_PAYLOAD_MAX); + result = TaskRan; } RELEASE(taskQueueLock); - return result; - } - void finishTask() - { + + if (func == nullptr) + { + return result; + } + func(processorNumber, payload); + ACQUIRE(taskQueueLock); _nFinished++; RELEASE(taskQueueLock); + return result; } - bool isTaskQueueProcessed() + // Open the queue and work it down. The caller participates rather than spinning idle, and returns + // only once every task has finished - including those running on other threads + void runUntilDone(unsigned long long processorNumber) { - return _nFinished == _nTask; - } + ACQUIRE(taskQueueLock); + _nIsTaskQueueReady = true; + RELEASE(taskQueueLock); - void tryProcessSolution(unsigned long long processorNumber) - { - m256i publicKey; - m256i miningSeed; - m256i nonce; - bool res = this->getTask(&publicKey, &miningSeed, &nonce); - if (res) + // Wait for task queue finish + for (;;) { - (*this)(processorNumber, publicKey, miningSeed, nonce); - this->finishTask(); + const TaskDispatchResult result = tryProcessOneTask(processorNumber); + if (result == TaskAllDone) + { + break; + } + if (result == TaskNonePending) + { + _mm_pause(); + } } + + ACQUIRE(taskQueueLock); + _nIsTaskQueueReady = false; + RELEASE(taskQueueLock); } }; diff --git a/test/score.cpp b/test/score.cpp index 0b4fbf97..2e1ff2e4 100644 --- a/test/score.cpp +++ b/test/score.cpp @@ -7,6 +7,7 @@ #include "../src/public_settings.h" #include "../src/mining/score_bpp9000.h" #include "../src/mining/task_file.h" +#include "../src/score.h" #include "score_bpp9000_reference.h" #include "score_params.h" @@ -23,7 +24,9 @@ #include #include #include +#include #include +#include using namespace score_params; using namespace test_utils; @@ -844,3 +847,340 @@ TEST(TestQubicScoreAntColony, NonceCanonicalRuleBoundaries) EXPECT_FALSE(score_engine::isCanonicalAntNonce(makeAntNonce(3, (unsigned char)(maxK + 1), 75).m256i_u8, mutations)); } + +// --------------------------------------------------------------------------- +// ScoreFunction task queue. + +typedef ScoreFunction<1> TaskQueueScoreFunction; + +static constexpr unsigned int TASK_QUEUE_PROBE_CAPACITY = 256; + +// What the work functions record, so a test can see which tasks ran and what they received. +struct TaskQueueProbe +{ + std::atomic runCount[TASK_QUEUE_PROBE_CAPACITY]; + std::atomic altRunCount; + std::atomic payloadMismatches; + std::atomic started; + std::atomic finished; + + void reset() + { + for (unsigned int i = 0; i < TASK_QUEUE_PROBE_CAPACITY; i++) + { + runCount[i].store(0); + } + altRunCount.store(0); + payloadMismatches.store(0); + started.store(0); + finished.store(0); + } +}; + +struct TaskQueuePayload +{ + TaskQueueProbe* probe; + unsigned int id; + unsigned int patternSize; + unsigned char pattern[64]; +}; +static_assert(sizeof(TaskQueuePayload) <= TaskQueueScoreFunction::TASK_PAYLOAD_MAX, + "TaskQueuePayload must fit one queue slot"); + +// Bigger than one slot, but starts with a valid payload so a wrongly accepted task records the run +// instead of dereferencing garbage. +struct TaskQueueOversizedPayload +{ + TaskQueuePayload base; + unsigned char extra[TaskQueueScoreFunction::TASK_PAYLOAD_MAX]; +}; + +static TaskQueueProbe gTaskQueueProbe; +static std::unique_ptr gTaskQueueOwner; +static std::atomic gTaskQueueHelpersStop; + +static TaskQueuePayload makeTaskQueuePayload(unsigned int id, unsigned int patternSize = sizeof(TaskQueuePayload::pattern)) +{ + TaskQueuePayload task; + setMem(&task, sizeof(task), 0); + task.probe = &gTaskQueueProbe; + task.id = id; + task.patternSize = patternSize; + // Fill the pattern with id+i, so it varies by task and by position + for (unsigned int i = 0; i < patternSize; i++) + { + task.pattern[i] = (unsigned char)(id + i); + } + return task; +} + +// Records the run and checks the payload survived the copy into and out of the queue. +static void countTaskRun(unsigned long long, void* payload) +{ + const TaskQueuePayload* task = (const TaskQueuePayload*)payload; + if (task->id >= TASK_QUEUE_PROBE_CAPACITY) + { + // Surfaces as a failed test rather than a write past runCount. + task->probe->payloadMismatches.fetch_add(1); + return; + } + if (task->patternSize > sizeof(task->pattern)) + { + // A scalar that did not survive the copy is itself a mismatch, and it must not be trusted as + // the loop bound below. + task->probe->payloadMismatches.fetch_add(1); + return; + } + for (unsigned int i = 0; i < task->patternSize; i++) + { + if (task->pattern[i] != (unsigned char)(task->id + i)) + { + task->probe->payloadMismatches.fetch_add(1); + break; + } + } + task->probe->runCount[task->id].fetch_add(1); +} + +class TestQubicScoreTaskQueue : public ::testing::Test +{ +protected: + void SetUp() override + { + if (gTaskQueueOwner.get() == nullptr) + { + gTaskQueueOwner.reset(new TaskQueueScoreFunction()); + } + gTaskQueueOwner->resetTaskQueue(); + gTaskQueueProbe.reset(); + gTaskQueueHelpersStop.store(false); + } + + TaskQueueScoreFunction& queue() + { + return *gTaskQueueOwner; + } +}; + +// Task run once. Normal case +TEST_F(TestQubicScoreTaskQueue, EveryTaskRunsExactlyOnce) +{ + const unsigned int taskCount = TASK_QUEUE_PROBE_CAPACITY; + for (unsigned int i = 0; i < taskCount; i++) + { + const TaskQueuePayload task = makeTaskQueuePayload(i); + EXPECT_TRUE(queue().addTask(countTaskRun, &task, sizeof(task))); + } + + // Try to process every task in queue until all done + queue().runUntilDone(0); + + for (unsigned int i = 0; i < taskCount; i++) + { + // Each task is expected run once + EXPECT_EQ(gTaskQueueProbe.runCount[i].load(), 1u) << "task " << i; + } +} + +// Mixed mutiple size of tasks +TEST_F(TestQubicScoreTaskQueue, PayloadArrivesIntact) +{ + // Bytes a task of this pattern length hands to addTask. + const auto taskQueuePayloadBytes = [](unsigned int patternSize) -> unsigned int + { + return (unsigned int)offsetof(TaskQueuePayload, pattern) + patternSize; + }; + + const unsigned int patternSizes[] = { 0, sizeof(TaskQueuePayload::pattern) }; + const unsigned int sizeCount = (unsigned int)(sizeof(patternSizes) / sizeof(patternSizes[0])); + const unsigned int perSize = 4; + + unsigned int id = 0; + for (unsigned int s = 0; s < sizeCount; s++) + { + for (unsigned int i = 0; i < perSize; i++) + { + const TaskQueuePayload task = makeTaskQueuePayload(id, patternSizes[s]); + EXPECT_TRUE(queue().addTask(countTaskRun, &task, taskQueuePayloadBytes(patternSizes[s]))); + id++; + } + } + + // Try to process every task in queue until all done + queue().runUntilDone(0); + + EXPECT_EQ(gTaskQueueProbe.payloadMismatches.load(), 0u); + for (unsigned int i = 0; i < id; i++) + { + EXPECT_EQ(gTaskQueueProbe.runCount[i].load(), 1u) << "task " << i; + } +} + +// A payload larger than one slot must be refused, not truncated into the slot or written past it. +TEST_F(TestQubicScoreTaskQueue, OversizedPayloadIsRejected) +{ + TaskQueueOversizedPayload oversized; + setMem(&oversized, sizeof(oversized), 0); + oversized.base = makeTaskQueuePayload(0); + + EXPECT_FALSE(queue().addTask(countTaskRun, &oversized, sizeof(oversized))); + + // Nothing was queued, so the drain has nothing to run. + queue().runUntilDone(0); + EXPECT_EQ(gTaskQueueProbe.runCount[0].load(), 0u); +} + +// The queue is bounded. Filling it until addTask refuses shows where the bound is, and that going +// past it fails instead of writing off the end of the array. +TEST_F(TestQubicScoreTaskQueue, QueueRejectsOverflow) +{ + unsigned long long accepted = 0; + for (unsigned long long i = 0; i < NUMBER_OF_TRANSACTIONS_PER_TICK + 16; i++) + { + const TaskQueuePayload task = makeTaskQueuePayload(0); + const bool added = queue().addTask(countTaskRun, &task, sizeof(task)); + if (!added) + { + break; + } + accepted++; + } + + EXPECT_EQ(accepted, NUMBER_OF_TRANSACTIONS_PER_TICK); +} + +// The drain must return only after every task has finished, including the ones other threads picked +// up. Returning once the last task was merely taken would leave work still running. +TEST_F(TestQubicScoreTaskQueue, DrainWaitsForTasksRunningOnOtherThreads) +{ + // Stays in flight long enough that a drain returning on tasks taken, rather than tasks finished, + // would be visible. + const TaskQueueScoreFunction::WorkFunc slowTaskRun = [](unsigned long long, void* payload) + { + const TaskQueuePayload* task = (const TaskQueuePayload*)payload; + task->probe->started.fetch_add(1); + std::this_thread::sleep_for(std::chrono::milliseconds(2)); + task->probe->finished.fetch_add(1); + }; + + const unsigned int taskCount = 64; + const unsigned int helperCount = 4; + for (unsigned int i = 0; i < taskCount; i++) + { + const TaskQueuePayload task = makeTaskQueuePayload(i); + EXPECT_TRUE(queue().addTask(slowTaskRun, &task, sizeof(task))); + } + + // Create another threads for process some tasks in queues + std::vector helpers; + for (unsigned int t = 0; t < helperCount; t++) + { + const unsigned long long helperProcessorNumber = t + 1; + helpers.emplace_back([helperProcessorNumber]() + { + // What a request processor does: keep offering to run queued work until told to stop. + while (!gTaskQueueHelpersStop.load()) + { + gTaskQueueOwner->tryProcessOneTask(helperProcessorNumber); + } + }); + } + + // Mark the task queue ready and process remained task + queue().runUntilDone(0); + const unsigned int finishedOnReturn = gTaskQueueProbe.finished.load(); + + gTaskQueueHelpersStop.store(true); + for (unsigned int t = 0; t < helperCount; t++) + { + helpers[t].join(); + } + + // Expect all task are done + EXPECT_EQ(finishedOnReturn, taskCount); + EXPECT_EQ(gTaskQueueProbe.started.load(), taskCount); +} + +// Tasks are queued before the drain opens the queue. Until it does, a helper must pick up nothing, so +// a half-built batch is never started. +TEST_F(TestQubicScoreTaskQueue, ClosedQueueHandsOutNothing) +{ + const unsigned int taskCount = 8; + for (unsigned int i = 0; i < taskCount; i++) + { + const TaskQueuePayload task = makeTaskQueuePayload(i); + EXPECT_TRUE(queue().addTask(countTaskRun, &task, sizeof(task))); + } + + // Try to run many task but no thing run because the queue is not ready + for (unsigned int i = 0; i < 32; i++) + { + queue().tryProcessOneTask(0); + } + for (unsigned int i = 0; i < taskCount; i++) + { + EXPECT_EQ(gTaskQueueProbe.runCount[i].load(), 0u) << "task " << i << " ran before the drain"; + } + + // Process all items + queue().runUntilDone(0); + for (unsigned int i = 0; i < taskCount; i++) + { + EXPECT_EQ(gTaskQueueProbe.runCount[i].load(), 1u) << "task " << i; + } +} + +// Every tick resets the queue and refills it, so a second batch must behave like the first. It will +// not if reset leaves any of the three counters behind. +TEST_F(TestQubicScoreTaskQueue, QueueIsReusableAfterReset) +{ + const unsigned int taskCount = 16; + for (unsigned int batch = 0; batch < 2; batch++) + { + queue().resetTaskQueue(); + gTaskQueueProbe.reset(); + + for (unsigned int i = 0; i < taskCount; i++) + { + const TaskQueuePayload task = makeTaskQueuePayload(i); + EXPECT_TRUE(queue().addTask(countTaskRun, &task, sizeof(task))); + } + + queue().runUntilDone(0); + + for (unsigned int i = 0; i < taskCount; i++) + { + EXPECT_EQ(gTaskQueueProbe.runCount[i].load(), 1u) << "batch " << batch << " task " << i; + } + } +} + +// Each task carries its own work function, so one batch can mix kinds. This is what lets a second +// caller share the queue without changing it. +TEST_F(TestQubicScoreTaskQueue, OneBatchCarriesDifferentWorkFunctions) +{ + // A second work function, so a batch can be shown to carry more than one kind of task. + const TaskQueueScoreFunction::WorkFunc countAltTaskRun = [](unsigned long long, void* payload) + { + const TaskQueuePayload* task = (const TaskQueuePayload*)payload; + task->probe->altRunCount.fetch_add(1); + }; + + const unsigned int pairCount = 32; + for (unsigned int i = 0; i < pairCount; i++) + { + const TaskQueuePayload counted = makeTaskQueuePayload(i); + EXPECT_TRUE(queue().addTask(countTaskRun, &counted, sizeof(counted))); + const TaskQueuePayload alt = makeTaskQueuePayload(i); + EXPECT_TRUE(queue().addTask(countAltTaskRun, &alt, sizeof(alt))); + } + + queue().runUntilDone(0); + + for (unsigned int i = 0; i < pairCount; i++) + { + EXPECT_EQ(gTaskQueueProbe.runCount[i].load(), 1u) << "task " << i; + } + EXPECT_EQ(gTaskQueueProbe.altRunCount.load(), pairCount); +} + From 85b5db59138c108212e2086c920ce78a665b00f1 Mon Sep 17 00:00:00 2001 From: cyber-pc <165458555+cyber-pc@users.noreply.github.com> Date: Tue, 4 Aug 2026 09:14:01 +0700 Subject: [PATCH 03/46] Replace ACQUIRE/RELEASE with LockGuard. --- src/score.h | 102 ++++++++++++++++++++++++++-------------------------- 1 file changed, 50 insertions(+), 52 deletions(-) diff --git a/src/score.h b/src/score.h index 432ff82e..0779afa1 100644 --- a/src/score.h +++ b/src/score.h @@ -93,9 +93,8 @@ struct ScoreFunction } currentRandomSeed = randomSeed; // persist the initial random seed to be able to send it back on system info response - ACQUIRE(random2PoolLock); + LockGuard guard(random2PoolLock); copyMem(poolVec, externalPoolVec, score_engine::POOL_VEC_PADDING_SIZE); - RELEASE(random2PoolLock); } // Load the task blocks into every compute buffer; returns false if any leaf rejects them. @@ -148,12 +147,11 @@ struct ScoreFunction void saveScoreCache(int epoch, CHAR16* directory = NULL) { #if USE_SCORE_CACHE - ACQUIRE(scoreCacheLock); + LockGuard guard(scoreCacheLock); SCORE_CACHE_FILE_NAME[sizeof(SCORE_CACHE_FILE_NAME) / sizeof(SCORE_CACHE_FILE_NAME[0]) - 4] = epoch / 100 + L'0'; SCORE_CACHE_FILE_NAME[sizeof(SCORE_CACHE_FILE_NAME) / sizeof(SCORE_CACHE_FILE_NAME[0]) - 3] = (epoch % 100) / 10 + L'0'; SCORE_CACHE_FILE_NAME[sizeof(SCORE_CACHE_FILE_NAME) / sizeof(SCORE_CACHE_FILE_NAME[0]) - 2] = epoch % 10 + L'0'; scoreCache.save(SCORE_CACHE_FILE_NAME, directory); - RELEASE(scoreCacheLock); #endif } @@ -162,12 +160,13 @@ struct ScoreFunction { bool success = true; #if USE_SCORE_CACHE - ACQUIRE(scoreCacheLock); - SCORE_CACHE_FILE_NAME[sizeof(SCORE_CACHE_FILE_NAME) / sizeof(SCORE_CACHE_FILE_NAME[0]) - 4] = epoch / 100 + L'0'; - SCORE_CACHE_FILE_NAME[sizeof(SCORE_CACHE_FILE_NAME) / sizeof(SCORE_CACHE_FILE_NAME[0]) - 3] = (epoch % 100) / 10 + L'0'; - SCORE_CACHE_FILE_NAME[sizeof(SCORE_CACHE_FILE_NAME) / sizeof(SCORE_CACHE_FILE_NAME[0]) - 2] = epoch % 10 + L'0'; - success = scoreCache.load(SCORE_CACHE_FILE_NAME); - RELEASE(scoreCacheLock); + { + LockGuard guard(scoreCacheLock); + SCORE_CACHE_FILE_NAME[sizeof(SCORE_CACHE_FILE_NAME) / sizeof(SCORE_CACHE_FILE_NAME[0]) - 4] = epoch / 100 + L'0'; + SCORE_CACHE_FILE_NAME[sizeof(SCORE_CACHE_FILE_NAME) / sizeof(SCORE_CACHE_FILE_NAME[0]) - 3] = (epoch % 100) / 10 + L'0'; + SCORE_CACHE_FILE_NAME[sizeof(SCORE_CACHE_FILE_NAME) / sizeof(SCORE_CACHE_FILE_NAME[0]) - 2] = epoch % 10 + L'0'; + success = scoreCache.load(SCORE_CACHE_FILE_NAME); + } #endif return success; } @@ -195,12 +194,8 @@ struct ScoreFunction m256i getLastOutput(const unsigned long long processor_Number) { - ACQUIRE(solutionEngineLock[processor_Number]); - - m256i result = _computeBuffer[processor_Number].getLastOutput(); - - RELEASE(solutionEngineLock[processor_Number]); - return result; + LockGuard guard(solutionEngineLock[processor_Number]); + return _computeBuffer[processor_Number].getLastOutput(); } // main score function unsigned int operator()(const unsigned long long processor_Number, const m256i& publicKey, const m256i& miningSeed, const m256i& nonce) @@ -230,11 +225,11 @@ struct ScoreFunction #endif const int solutionBufIdx = (int)(processor_Number % solutionBufferCount); - ACQUIRE(solutionEngineLock[solutionBufIdx]); - - score = computeScore(solutionBufIdx, publicKey, nonce); - - RELEASE(solutionEngineLock[solutionBufIdx]); + { + // Scoped so the cache write below happens with the engine slot released. + LockGuard guard(solutionEngineLock[solutionBufIdx]); + score = computeScore(solutionBufIdx, publicKey, nonce); + } #if USE_SCORE_CACHE scoreCache.addEntry(publicKey, miningSeed, nonce, scoreCacheIndex, score); #endif @@ -284,12 +279,11 @@ struct ScoreFunction public: void resetTaskQueue() { - ACQUIRE(taskQueueLock); + LockGuard guard(taskQueueLock); _nTask = 0; _nProcessing = 0; _nFinished = 0; _nIsTaskQueueReady = false; - RELEASE(taskQueueLock); } // Copies size bytes of data. Returns false if the queue is full or the payload does not fit. @@ -299,17 +293,16 @@ struct ScoreFunction { return false; } - bool added = false; - ACQUIRE(taskQueueLock); - if (_nTask < TASK_QUEUE_CAPACITY) + + LockGuard guard(taskQueueLock); + if (_nTask >= TASK_QUEUE_CAPACITY) { - Task& t = taskQueue[_nTask++]; - t.func = func; - copyMem(t.payload, data, size); - added = true; + return false; } - RELEASE(taskQueueLock); - return added; + Task& t = taskQueue[_nTask++]; + t.func = func; + copyMem(t.payload, data, size); + return true; } // Outcome of one dispatch attempt, so a caller waiting for the batch does not need a second @@ -334,19 +327,21 @@ struct ScoreFunction unsigned long long payload[TASK_PAYLOAD_MAX / sizeof(unsigned long long)]; TaskDispatchResult result = TaskNonePending; - ACQUIRE(taskQueueLock); - if (_nFinished >= _nTask) - { - result = TaskAllDone; - } - else if (_nIsTaskQueueReady && _nProcessing < _nTask) + // The task itself must run with the lock released { - const Task& t = taskQueue[_nProcessing++]; - func = t.func; - copyMem(payload, t.payload, TASK_PAYLOAD_MAX); - result = TaskRan; + LockGuard guard(taskQueueLock); + if (_nFinished >= _nTask) + { + result = TaskAllDone; + } + else if (_nIsTaskQueueReady && _nProcessing < _nTask) + { + const Task& t = taskQueue[_nProcessing++]; + func = t.func; + copyMem(payload, t.payload, TASK_PAYLOAD_MAX); + result = TaskRan; + } } - RELEASE(taskQueueLock); if (func == nullptr) { @@ -354,9 +349,10 @@ struct ScoreFunction } func(processorNumber, payload); - ACQUIRE(taskQueueLock); - _nFinished++; - RELEASE(taskQueueLock); + { + LockGuard guard(taskQueueLock); + _nFinished++; + } return result; } @@ -364,9 +360,10 @@ struct ScoreFunction // only once every task has finished - including those running on other threads void runUntilDone(unsigned long long processorNumber) { - ACQUIRE(taskQueueLock); - _nIsTaskQueueReady = true; - RELEASE(taskQueueLock); + { + LockGuard guard(taskQueueLock); + _nIsTaskQueueReady = true; + } // Wait for task queue finish for (;;) @@ -382,8 +379,9 @@ struct ScoreFunction } } - ACQUIRE(taskQueueLock); - _nIsTaskQueueReady = false; - RELEASE(taskQueueLock); + { + LockGuard guard(taskQueueLock); + _nIsTaskQueueReady = false; + } } }; From 00af78762c20ab694bc622ebdc30f694b3fb9b4d Mon Sep 17 00:00:00 2001 From: cyber-pc <165458555+cyber-pc@users.noreply.github.com> Date: Tue, 4 Aug 2026 11:32:40 +0700 Subject: [PATCH 04/46] Improve the canonical nonce check --- src/score.h | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/score.h b/src/score.h index 0779afa1..c2ec227c 100644 --- a/src/score.h +++ b/src/score.h @@ -202,10 +202,17 @@ struct ScoreFunction { PROFILE_SCOPE(); - // TODO: When neuraxon's going, this check need to be modified - if (!score_engine::isCanonicalBpp9000Nonce(nonce.m256i_u8)) + switch (score_engine::getAlgoType(nonce.m256i_u8)) { - return score_engine::INVALID_SCORE_VALUE; + case score_engine::AlgoType::Bpp9000: + if (!score_engine::isCanonicalBpp9000Nonce(nonce.m256i_u8)) + { + return score_engine::INVALID_SCORE_VALUE; + } + break; + default: + // Unsupported algo + return score_engine::INVALID_SCORE_VALUE; } if (isZero(miningSeed) || miningSeed != currentRandomSeed) From 50e8c655cf11dea5ecb6361e27bf9b7da431eecc Mon Sep 17 00:00:00 2001 From: cyber-pc <165458555+cyber-pc@users.noreply.github.com> Date: Tue, 4 Aug 2026 15:11:44 +0700 Subject: [PATCH 05/46] Add message and transaction for ant. --- src/Qubic.vcxproj | 1 + src/Qubic.vcxproj.filters | 3 + src/logging/logging.h | 18 +++ src/mining/mining.h | 38 ++++++ src/network_messages/all.h | 1 + src/network_messages/ant_colony_message.h | 132 ++++++++++++++++++++ src/network_messages/broadcast_message.h | 1 + src/network_messages/network_message_type.h | 6 + 8 files changed, 200 insertions(+) create mode 100644 src/network_messages/ant_colony_message.h diff --git a/src/Qubic.vcxproj b/src/Qubic.vcxproj index 942c974b..0005c94a 100644 --- a/src/Qubic.vcxproj +++ b/src/Qubic.vcxproj @@ -75,6 +75,7 @@ + diff --git a/src/Qubic.vcxproj.filters b/src/Qubic.vcxproj.filters index 160c5365..c27fd38d 100644 --- a/src/Qubic.vcxproj.filters +++ b/src/Qubic.vcxproj.filters @@ -50,6 +50,9 @@ network_messages + + network_messages + network_messages diff --git a/src/logging/logging.h b/src/logging/logging.h index f8e389e6..d7eeda00 100644 --- a/src/logging/logging.h +++ b/src/logging/logging.h @@ -66,6 +66,7 @@ struct Peer; #define CUSTOM_MESSAGE_OP_END_DISTRIBUTE_DIVIDENDS 6217575821008457285ULL //END_DDIV #define CUSTOM_MESSAGE_OP_START_EPOCH 4850183582582395987ULL // STA_EPOC #define CUSTOM_MESSAGE_OP_END_EPOCH 4850183582582591045ULL //END_EPOC +#define CUSTOM_MESSAGE_ANT_SOLUTION 6146374810954124865ULL // ANT_SOLU /* * STRUCTS FOR LOGGING */ @@ -191,6 +192,23 @@ struct DummyCustomMessage char _terminator; // Only data before "_terminator" are logged }; +// On-chain outcome of one ant-colony solution transaction (accepted or rejected), +// identified by its dedup key (sourcePublicKey, parentRef, nonce). +struct AntSolutionLogMessage +{ + unsigned long long _type; // CUSTOM_MESSAGE_ANT_SOLUTION + m256i sourcePublicKey; + m256i nonce; + unsigned int parentTickOffset; + unsigned int parentSolutionIndexInTick; + unsigned int anchorTick; + unsigned int score; + // ValidityResult of the commit + unsigned int result; + + char _terminator; // Only data before "_terminator" are logged +}; + struct Burning { m256i sourcePublicKey; diff --git a/src/mining/mining.h b/src/mining/mining.h index c4796371..bee203f9 100644 --- a/src/mining/mining.h +++ b/src/mining/mining.h @@ -353,3 +353,41 @@ struct CustomMiningStats }; static CustomMiningStats gDogeMiningStats; + +// Ant colony solution transaction +constexpr int ANT_COLONY_MINING_SOLUTION_INPUT_TYPE = 12; +struct AntColonyMiningSolutionTransaction : public Transaction +{ + static constexpr unsigned char transactionType() + { + return ANT_COLONY_MINING_SOLUTION_INPUT_TYPE; + } + + static constexpr long long minAmount() + { + return SOLUTION_SECURITY_DEPOSIT; // same anti-spam deposit as legacy + } + + static constexpr unsigned short minInputSize() + { + return sizeof(parentTickOffset) + sizeof(parentSolutionIndexInTick) + sizeof(anchorTick) + sizeof(claimedScore) + sizeof(nonce); // 4 + 4 + 4 + 4 + 32 = 48 bytes + } + + static bool isSolutionTransaction(const Transaction* tx) + { + return isZero(tx->destinationPublicKey) + && tx->inputType == transactionType() + && tx->amount >= minAmount() + && tx->inputSize == minInputSize(); + } + + unsigned int parentTickOffset; // epoch-relative tick of the parent ref + unsigned int parentSolutionIndexInTick; // dense within-tick index of the parent ref + unsigned int anchorTick; // tick whose digest the solution anchored to (sibling-floor clock + freshness) + // The score the submitter claims this solution reaches. The deposit is refunded only when it + // matches the score the node computes + unsigned int claimedScore; + m256i nonce; + unsigned char signature[SIGNATURE_SIZE]; +}; +static_assert(sizeof(AntColonyMiningSolutionTransaction) == sizeof(Transaction) + 4 + 4 + 4 + 4 + 32 + SIGNATURE_SIZE, "AntColonyMiningSolutionTransaction unexpected padding"); diff --git a/src/network_messages/all.h b/src/network_messages/all.h index edd8e66e..21424292 100644 --- a/src/network_messages/all.h +++ b/src/network_messages/all.h @@ -17,3 +17,4 @@ #include "transactions.h" #include "system_info.h" #include "revenue_data.h" +#include "ant_colony_message.h" diff --git a/src/network_messages/ant_colony_message.h b/src/network_messages/ant_colony_message.h new file mode 100644 index 00000000..d61b9b91 --- /dev/null +++ b/src/network_messages/ant_colony_message.h @@ -0,0 +1,132 @@ +#pragma once + +#include "common_def.h" + +// Asks for the current frontier of parents it can branch a child from +// Paginated via fromIndex / nextIndex. +struct RequestAntMineableParents +{ + // Record index to resume scanning from (0 on the first call). + unsigned int fromIndex; + static constexpr unsigned char type() + { + return REQUEST_ANT_MINEABLE_PARENTS; + } +}; +static_assert(sizeof(RequestAntMineableParents) == 4, "RequestAntMineableParents unexpected size"); + +// Max mineable-parent entries returned per response. Miners page through the +// rest via the nextIndex cursor. +constexpr unsigned int ANT_MINEABLE_PARENTS_PER_RESPONSE = 64; + +// Max records scanned per request +constexpr unsigned int ANT_MINEABLE_PARENTS_SCAN_BUDGET = 1024; + +// One mineable parent. parentTickOffset/parentSolutionIndexInTick is the ref a child sets as its own +// parentRef. The score is an error count, so smaller is better: the child must score strictly below +// parentScore and strictly below siblingFloor (the best sibling more than N ticks earlier, computed +// for a child anchoring at the current tick). +struct AntMineableParent +{ + unsigned int parentTickOffset; + unsigned int parentSolutionIndexInTick; + unsigned int parentScore; + unsigned int siblingFloor; + unsigned int anchorTick; // the parent's own anchor tick number + unsigned int depth; +}; +static_assert(sizeof(AntMineableParent) == 24, "AntMineableParent unexpected size"); + +// Metadata header only; followed by count * AntMineableParent (count * itemSize +// bytes). itemSize lets the receiver validate the payload without hardcoding the +// entry size. +struct RespondAntMineableParentsHeader +{ + // Number of AntMineableParent entries that follow this header. + unsigned int count; + // Size in bytes of one AntMineableParent entry. + unsigned int itemSize; + // Resume cursor for the next request; 0 means no more records. + unsigned int nextIndex; + static constexpr unsigned char type() + { + return RESPOND_ANT_MINEABLE_PARENTS; + } +}; +static_assert(sizeof(RespondAntMineableParentsHeader) == 12, "RespondAntMineableParentsHeader unexpected size"); + +// RespondAntAnnStateHeader.status values. +constexpr unsigned char ANT_ANN_STATUS_OK = 0; // ANN bytes follow the header +constexpr unsigned char ANT_ANN_STATUS_NOT_FOUND = 1; // parentRef has no record +constexpr unsigned char ANT_ANN_STATUS_IS_ROOT = 2; // ROOT_REF; no ANN payload - miner derives its own per-identity root + +// Operator-signed. The request payload is followed by SIGNATURE_SIZE bytes signed by +// operatorPublicKey. +struct RequestAntAnnState +{ + // Monotonic per-operator nonce, the same rule processSpecialCommand uses + unsigned long long everIncreasingNonce; + unsigned int parentRefTickOffset; + unsigned int parentRefSolutionIndexInTick; + static constexpr unsigned char type() + { + return REQUEST_ANT_ANN_STATE; + } +}; +static_assert(sizeof(RequestAntAnnState) == 16, "RequestAntAnnState unexpected size"); + +// Metadata header only; when status is Ok or IsRoot, annSizeBytes bytes of packed +// ANN follow the header (annSizeBytes is 0 otherwise). Kept ANN-agnostic here to +// avoid a heavy include; the receiver uses annSizeBytes to read the trailing blob. +struct RespondAntAnnStateHeader +{ + unsigned int parentRefTickOffset; + unsigned int parentRefSolutionIndexInTick; + // Bytes of packed ANN that follow this header (0 unless status is Ok/IsRoot). + unsigned int annSizeBytes; + unsigned char status; + unsigned char padding[3]; + static constexpr unsigned char type() + { + return RESPOND_ANT_ANN_STATE; + } +}; +static_assert(sizeof(RespondAntAnnStateHeader) == 16, "RespondAntAnnStateHeader unexpected size"); + +struct RequestAntEpochContext +{ + static constexpr unsigned char type() + { + return REQUEST_ANT_EPOCH_CONTEXT; + } +}; + +// Per-epoch ant-colony parameters a miner needs to start building solutions: +// the score threshold, the freshness window, the per-identity root seed (spectrum digest), +// and pool occupancy. The anchor digest is not included; a miner derives it from the standard +// protocol as K12(anchorTick || transactionDigest), with transactionDigest taken from the +// anchor tick's quorum votes (REQUEST_QUORUM_TICK). +#pragma pack(push, 1) +struct RespondAntEpochContext +{ + // per-identity root seed (epoch-start spectrum digest); each root = K12(pubkey || this) + m256i spectrumDigest; + // score threshold for this epoch + unsigned int threshold; + // ANT_FRESHNESS_WINDOW_TICKS (N): publish within N of the anchor tick; siblings within N coexist + unsigned int freshnessWindow; + // accepted solutions so far this epoch + unsigned int solutionCount; + // free slots in the live ANN pool + unsigned int freeAnnSlotsCount; + // epoch this context is for + unsigned short epoch; + unsigned short padding; + + static constexpr unsigned char type() + { + return RESPOND_ANT_EPOCH_CONTEXT; + } +}; +#pragma pack(pop) +static_assert(sizeof(RespondAntEpochContext) == 52, "RespondAntEpochContext unexpected size"); diff --git a/src/network_messages/broadcast_message.h b/src/network_messages/broadcast_message.h index fe4c6c5d..a06a52e9 100644 --- a/src/network_messages/broadcast_message.h +++ b/src/network_messages/broadcast_message.h @@ -5,6 +5,7 @@ #define MESSAGE_TYPE_SOLUTION 0 #define MESSAGE_TYPE_CUSTOM_MINING_TASK 1 #define MESSAGE_TYPE_CUSTOM_MINING_SOLUTION 2 +#define MESSAGE_TYPE_ANT_SOLUTION 3 // TODO: documentation needed: // "A General Message type used to send/receive messages from/to peers." -> right? diff --git a/src/network_messages/network_message_type.h b/src/network_messages/network_message_type.h index a9b3993b..ca0a8d89 100644 --- a/src/network_messages/network_message_type.h +++ b/src/network_messages/network_message_type.h @@ -51,6 +51,12 @@ enum NetworkMessageType : unsigned char BROADCAST_CUSTOM_MINING_SOLUTION = 69, REQUEST_REVENUE_DATA = 70, RESPOND_REVENUE_DATA = 71, + REQUEST_ANT_MINEABLE_PARENTS = 72, + RESPOND_ANT_MINEABLE_PARENTS = 73, + REQUEST_ANT_ANN_STATE = 74, + RESPOND_ANT_ANN_STATE = 75, + REQUEST_ANT_EPOCH_CONTEXT = 76, + RESPOND_ANT_EPOCH_CONTEXT = 77, ORACLE_MACHINE_QUERY = 190, // only on communication channel Core node <-> OM node ORACLE_MACHINE_REPLY = 191, // only on communication channel Core node <-> OM node OC_MACHINE_INVOCATION = 192, // only on communication channel Core node <-> OC machine From 037670a08603870a6f6c7913202bf38998d07aa9 Mon Sep 17 00:00:00 2001 From: cyber-pc <165458555+cyber-pc@users.noreply.github.com> Date: Tue, 4 Aug 2026 16:25:41 +0700 Subject: [PATCH 06/46] Move the code relate to update miner ranking to separate function. --- src/qubic.cpp | 275 ++++++++++++++++++++++++++------------------------ 1 file changed, 142 insertions(+), 133 deletions(-) diff --git a/src/qubic.cpp b/src/qubic.cpp index f8f3184e..4b901d27 100644 --- a/src/qubic.cpp +++ b/src/qubic.cpp @@ -2487,6 +2487,146 @@ static bool ranksBelow(unsigned int scoreA, unsigned int tickA, unsigned int sco return tickA > tickB; } +// Tick-processor only: the competitor sort between the two minerScoreArrayLock sections runs +// unlocked on purpose. +static void updateMinerRankingAndFutureComputors( + const m256i& sourcePublicKey, + unsigned int newScore, + unsigned int newTick) +{ + ACQUIRE(minerScoreArrayLock); + bool minerEntryChanged = false; + unsigned int minerIndex; + for (minerIndex = 0; minerIndex < numberOfMiners; minerIndex++) + { + if (sourcePublicKey == minerPublicKeys[minerIndex]) + { + if (newScore < minerScores[minerIndex]) + { + minerScores[minerIndex] = newScore; + minerBestScoreTicks[minerIndex] = newTick; + minerEntryChanged = true; + } + + break; + } + } + if (minerIndex == numberOfMiners) + { + if (numberOfMiners < MAX_NUMBER_OF_MINERS) + { + minerPublicKeys[numberOfMiners] = sourcePublicKey; + minerBestScoreTicks[numberOfMiners] = newTick; + minerScores[numberOfMiners++] = newScore; + minerEntryChanged = true; + } + else + { + // The table is full. Entries beyond the computor block are kept sorted, so the + // worst-ranked one sits at the end and is replaced only if the newcomer outranks it. + const unsigned int worstIndex = numberOfMiners - 1; + if (ranksBelow(minerScores[worstIndex], minerBestScoreTicks[worstIndex], newScore, newTick)) + { + minerPublicKeys[worstIndex] = sourcePublicKey; + minerScores[worstIndex] = newScore; + minerBestScoreTicks[worstIndex] = newTick; + minerIndex = worstIndex; + minerEntryChanged = true; + } + } + } + + if (minerEntryChanged) + { + const m256i tmpPublicKey = minerPublicKeys[minerIndex]; + const unsigned int tmpScore = minerScores[minerIndex]; + const unsigned int tmpTick = minerBestScoreTicks[minerIndex]; + while (minerIndex > (unsigned int)(minerIndex < NUMBER_OF_COMPUTORS ? 0 : NUMBER_OF_COMPUTORS) + && ranksBelow(minerScores[minerIndex - 1], minerBestScoreTicks[minerIndex - 1], minerScores[minerIndex], minerBestScoreTicks[minerIndex])) + { + minerPublicKeys[minerIndex] = minerPublicKeys[minerIndex - 1]; + minerScores[minerIndex] = minerScores[minerIndex - 1]; + minerBestScoreTicks[minerIndex] = minerBestScoreTicks[minerIndex - 1]; + minerPublicKeys[--minerIndex] = tmpPublicKey; + minerScores[minerIndex] = tmpScore; + minerBestScoreTicks[minerIndex] = tmpTick; + } + } + + // combine 225 worst current computors with 225 best candidates + for (unsigned int i = 0; i < NUMBER_OF_COMPUTORS - QUORUM; i++) + { + competitorPublicKeys[i] = minerPublicKeys[QUORUM + i]; + competitorScores[i] = minerScores[QUORUM + i]; + competitorTicks[i] = minerBestScoreTicks[QUORUM + i]; + competitorComputorStatuses[i] = true; + + if (NUMBER_OF_COMPUTORS + i < numberOfMiners) + { + competitorPublicKeys[i + (NUMBER_OF_COMPUTORS - QUORUM)] = minerPublicKeys[NUMBER_OF_COMPUTORS + i]; + competitorScores[i + (NUMBER_OF_COMPUTORS - QUORUM)] = minerScores[NUMBER_OF_COMPUTORS + i]; + competitorTicks[i + (NUMBER_OF_COMPUTORS - QUORUM)] = minerBestScoreTicks[NUMBER_OF_COMPUTORS + i]; + } + else + { + competitorScores[i + (NUMBER_OF_COMPUTORS - QUORUM)] = NO_MINER_SCORE; + competitorTicks[i + (NUMBER_OF_COMPUTORS - QUORUM)] = 0; + } + competitorComputorStatuses[i + (NUMBER_OF_COMPUTORS - QUORUM)] = false; + } + RELEASE(minerScoreArrayLock); + + // bubble sorting -> top 225 from competitorPublicKeys have computors and candidates which are the best from that subset + for (unsigned int i = NUMBER_OF_COMPUTORS - QUORUM; i < (NUMBER_OF_COMPUTORS - QUORUM) * 2; i++) + { + int j = i; + const m256i tmpPublicKey = competitorPublicKeys[j]; + const unsigned int tmpScore = competitorScores[j]; + const unsigned int tmpTick = competitorTicks[j]; + const bool tmpComputorStatus = false; + while (j + && ranksBelow(competitorScores[j - 1], competitorTicks[j - 1], competitorScores[j], competitorTicks[j])) + { + competitorPublicKeys[j] = competitorPublicKeys[j - 1]; + competitorScores[j] = competitorScores[j - 1]; + competitorTicks[j] = competitorTicks[j - 1]; + competitorComputorStatuses[j] = competitorComputorStatuses[j - 1]; + competitorPublicKeys[--j] = tmpPublicKey; + competitorScores[j] = tmpScore; + competitorTicks[j] = tmpTick; + competitorComputorStatuses[j] = tmpComputorStatus; + } + } + + minimumComputorScore = competitorScores[NUMBER_OF_COMPUTORS - QUORUM - 1]; + + unsigned char candidateCounter = 0; + for (unsigned int i = 0; i < (NUMBER_OF_COMPUTORS - QUORUM) * 2; i++) + { + if (!competitorComputorStatuses[i]) + { + minimumCandidateScore = competitorScores[i]; + candidateCounter++; + } + } + if (candidateCounter < NUMBER_OF_COMPUTORS - QUORUM) + { + minimumCandidateScore = minimumComputorScore; + } + + ACQUIRE(minerScoreArrayLock); + for (unsigned int i = 0; i < QUORUM; i++) + { + system.futureComputors[i] = minerPublicKeys[i]; + } + RELEASE(minerScoreArrayLock); + + for (unsigned int i = QUORUM; i < NUMBER_OF_COMPUTORS; i++) + { + system.futureComputors[i] = competitorPublicKeys[i - QUORUM]; + } +} + static void processTickTransactionSolution(const MiningSolutionTransaction* transaction, const unsigned long long processorNumber) { PROFILE_SCOPE(); @@ -2568,140 +2708,9 @@ static void processTickTransactionSolution(const MiningSolutionTransaction* tran // accepted solutions const unsigned int newScore = solutionScore * gScoreMultiplier[selectedAlgo]; const unsigned int newTick = system.tick; - - ACQUIRE(minerScoreArrayLock); - bool minerEntryChanged = false; - unsigned int minerIndex; - for (minerIndex = 0; minerIndex < numberOfMiners; minerIndex++) - { - if (transaction->sourcePublicKey == minerPublicKeys[minerIndex]) - { - if (newScore < minerScores[minerIndex]) - { - minerScores[minerIndex] = newScore; - minerBestScoreTicks[minerIndex] = newTick; - minerEntryChanged = true; - } - - break; - } - } - if (minerIndex == numberOfMiners) - { - if (numberOfMiners < MAX_NUMBER_OF_MINERS) - { - minerPublicKeys[numberOfMiners] = transaction->sourcePublicKey; - minerBestScoreTicks[numberOfMiners] = newTick; - minerScores[numberOfMiners++] = newScore; - minerEntryChanged = true; - } - else - { - // The table is full. Entries beyond the computor block are kept sorted, so the - // worst-ranked one sits at the end and is replaced only if the newcomer outranks it. - const unsigned int worstIndex = numberOfMiners - 1; - if (ranksBelow(minerScores[worstIndex], minerBestScoreTicks[worstIndex], newScore, newTick)) - { - minerPublicKeys[worstIndex] = transaction->sourcePublicKey; - minerScores[worstIndex] = newScore; - minerBestScoreTicks[worstIndex] = newTick; - minerIndex = worstIndex; - minerEntryChanged = true; - } - } - } - - if (minerEntryChanged) - { - const m256i tmpPublicKey = minerPublicKeys[minerIndex]; - const unsigned int tmpScore = minerScores[minerIndex]; - const unsigned int tmpTick = minerBestScoreTicks[minerIndex]; - while (minerIndex > (unsigned int)(minerIndex < NUMBER_OF_COMPUTORS ? 0 : NUMBER_OF_COMPUTORS) - && ranksBelow(minerScores[minerIndex - 1], minerBestScoreTicks[minerIndex - 1], minerScores[minerIndex], minerBestScoreTicks[minerIndex])) - { - minerPublicKeys[minerIndex] = minerPublicKeys[minerIndex - 1]; - minerScores[minerIndex] = minerScores[minerIndex - 1]; - minerBestScoreTicks[minerIndex] = minerBestScoreTicks[minerIndex - 1]; - minerPublicKeys[--minerIndex] = tmpPublicKey; - minerScores[minerIndex] = tmpScore; - minerBestScoreTicks[minerIndex] = tmpTick; - } - } - - // combine 225 worst current computors with 225 best candidates - for (unsigned int i = 0; i < NUMBER_OF_COMPUTORS - QUORUM; i++) - { - competitorPublicKeys[i] = minerPublicKeys[QUORUM + i]; - competitorScores[i] = minerScores[QUORUM + i]; - competitorTicks[i] = minerBestScoreTicks[QUORUM + i]; - competitorComputorStatuses[i] = true; - - if (NUMBER_OF_COMPUTORS + i < numberOfMiners) - { - competitorPublicKeys[i + (NUMBER_OF_COMPUTORS - QUORUM)] = minerPublicKeys[NUMBER_OF_COMPUTORS + i]; - competitorScores[i + (NUMBER_OF_COMPUTORS - QUORUM)] = minerScores[NUMBER_OF_COMPUTORS + i]; - competitorTicks[i + (NUMBER_OF_COMPUTORS - QUORUM)] = minerBestScoreTicks[NUMBER_OF_COMPUTORS + i]; - } - else - { - competitorScores[i + (NUMBER_OF_COMPUTORS - QUORUM)] = NO_MINER_SCORE; - competitorTicks[i + (NUMBER_OF_COMPUTORS - QUORUM)] = 0; - } - competitorComputorStatuses[i + (NUMBER_OF_COMPUTORS - QUORUM)] = false; - } - RELEASE(minerScoreArrayLock); - - // bubble sorting -> top 225 from competitorPublicKeys have computors and candidates which are the best from that subset - for (unsigned int i = NUMBER_OF_COMPUTORS - QUORUM; i < (NUMBER_OF_COMPUTORS - QUORUM) * 2; i++) - { - int j = i; - const m256i tmpPublicKey = competitorPublicKeys[j]; - const unsigned int tmpScore = competitorScores[j]; - const unsigned int tmpTick = competitorTicks[j]; - const bool tmpComputorStatus = false; - while (j - && ranksBelow(competitorScores[j - 1], competitorTicks[j - 1], competitorScores[j], competitorTicks[j])) - { - competitorPublicKeys[j] = competitorPublicKeys[j - 1]; - competitorScores[j] = competitorScores[j - 1]; - competitorTicks[j] = competitorTicks[j - 1]; - competitorComputorStatuses[j] = competitorComputorStatuses[j - 1]; - competitorPublicKeys[--j] = tmpPublicKey; - competitorScores[j] = tmpScore; - competitorTicks[j] = tmpTick; - competitorComputorStatuses[j] = tmpComputorStatus; - } - } - - minimumComputorScore = competitorScores[NUMBER_OF_COMPUTORS - QUORUM - 1]; - - unsigned char candidateCounter = 0; - for (unsigned int i = 0; i < (NUMBER_OF_COMPUTORS - QUORUM) * 2; i++) - { - if (!competitorComputorStatuses[i]) - { - minimumCandidateScore = competitorScores[i]; - candidateCounter++; - } - } - if (candidateCounter < NUMBER_OF_COMPUTORS - QUORUM) - { - minimumCandidateScore = minimumComputorScore; - } - - ACQUIRE(minerScoreArrayLock); - for (unsigned int i = 0; i < QUORUM; i++) - { - system.futureComputors[i] = minerPublicKeys[i]; - } - RELEASE(minerScoreArrayLock); - - for (unsigned int i = QUORUM; i < NUMBER_OF_COMPUTORS; i++) - { - system.futureComputors[i] = competitorPublicKeys[i - QUORUM]; - } + updateMinerRankingAndFutureComputors(transaction->sourcePublicKey, newScore, newTick); } - } + } } else { From 28a3df686fe5aa6810c81506a66cc32fd01da190 Mon Sep 17 00:00:00 2001 From: cyber-pc <165458555+cyber-pc@users.noreply.github.com> Date: Wed, 5 Aug 2026 10:21:22 +0700 Subject: [PATCH 07/46] Add the ant colony tree and validity rules --- src/Qubic.vcxproj | 3 + src/Qubic.vcxproj.filters | 9 + src/mining/ant_colony.h | 721 ++++++++++++++++++++++++++++++++ src/mining/ant_colony_bpp9000.h | 8 + src/mining/mining.h | 5 + src/mining/trit_pack.h | 51 +++ src/public_settings.h | 9 + src/qubic.cpp | 1 - test/ant_colony.cpp | 260 ++++++++++++ test/test.vcxproj | 2 + test/trit_pack.cpp | 137 ++++++ 11 files changed, 1205 insertions(+), 1 deletion(-) create mode 100644 src/mining/ant_colony.h create mode 100644 src/mining/ant_colony_bpp9000.h create mode 100644 src/mining/trit_pack.h create mode 100644 test/ant_colony.cpp create mode 100644 test/trit_pack.cpp diff --git a/src/Qubic.vcxproj b/src/Qubic.vcxproj index 0005c94a..524cf2d8 100644 --- a/src/Qubic.vcxproj +++ b/src/Qubic.vcxproj @@ -66,12 +66,15 @@ + + + diff --git a/src/Qubic.vcxproj.filters b/src/Qubic.vcxproj.filters index c27fd38d..88632333 100644 --- a/src/Qubic.vcxproj.filters +++ b/src/Qubic.vcxproj.filters @@ -188,6 +188,15 @@ mining + + mining + + + mining + + + mining + logging diff --git a/src/mining/ant_colony.h b/src/mining/ant_colony.h new file mode 100644 index 00000000..300bdb60 --- /dev/null +++ b/src/mining/ant_colony.h @@ -0,0 +1,721 @@ +#pragma once + +#include "platform/assert.h" +#include "platform/concurrency.h" +#include "platform/m256.h" +#include "platform/memory.h" +#include "platform/memory_util.h" +#include "contract_core/pre_qpi_def.h" +#include "qpi/qpi.h" +#include "qpi/impl/qpi_hash_map_impl.h" +#include "public_settings.h" +#include "mining.h" +#include "trit_pack.h" + +// (tickOffset, solutionIndexInTick), epoch-relative tick plus the solution transaction's index in tick +struct SolutionRef +{ + unsigned int tickOffset; // RELATIVE to the epoch start, never an absolute system tick + unsigned int solutionIndexInTick; + + bool operator==(const SolutionRef& other) const + { + return (tickOffset == other.tickOffset) && (solutionIndexInTick == other.solutionIndexInTick); + } + + bool isRoot() const + { + return (tickOffset == 0) && (solutionIndexInTick == 0xFFFFFFFFu); + } +}; + +// A root of all trees +static constexpr SolutionRef ROOT_REF = { 0u, 0xFFFFFFFFu }; + +// A solution is uniquely identified by (pubkey, parentRef, nonce). +struct AntDedupKey +{ + m256i pubkey; + m256i nonce; + SolutionRef parentRef; + + bool operator==(const AntDedupKey& other) const + { + return (pubkey == other.pubkey) && (nonce == other.nonce) && (parentRef == other.parentRef); + } +}; + +struct AntSolutionRecord +{ + m256i pubkey; + m256i nonce; + SolutionRef parentRef; // this solution's parent, or ROOT_REF + SolutionRef selfRef; // this solution's own address (RELATIVE tick inside) + unsigned int score; // error count, lower is better + unsigned int anchorTick; // ABSOLUTE. tick whose digest seeded the RNG; clock for the sibling floor + unsigned int depth; // a child of the root is depth 1; the root itself is never stored + unsigned int childAnnHash; // K12 of the canonical ANN at commit; digest-fold input + unsigned int annStateSlot; // index into the ANN pool; always equals the record index + unsigned int nextSiblingIdx; // next child of the same parent, NO_SIBLING terminates +}; +static constexpr unsigned int NO_SIBLING = 0xFFFFFFFFu; +static constexpr unsigned int WORST_SCORE = 0xFFFFFFFFu; +static constexpr long long ANT_INVALID_INDEX = -1; +static_assert(sizeof(AntSolutionRecord) == 104, "AntSolutionRecord unexpected padding"); + +// tickOffset -> the run of records committed in that tick, so findIndexBySolutionRef() resolves a +// SolutionRef without scanning the store. The run is unbroken because the store is append-only and +// a tick's solutions all commit while that tick is processed +struct AntTickSlot +{ + unsigned int startIdx; // this tick's first record + unsigned int count; // records this tick produced; written last, so it gates the run +}; + +// Results and diagnostics +enum ValidityResult +{ + Valid, + RejectParentNotRegistered, + RejectStale, // anchor in the future, or published more than N ticks after it + RejectWrongTree, // parent belongs to a different identity + RejectBelowThreshold, // score above the per-epoch error bound + RejectLeParent, // did not strictly beat the parent + RejectBelowSiblingFloor, // did not strictly beat the best sibling anchored more than N earlier + RejectRecordsCapFull, + RejectTickOutOfRange, + RejectReplay, // (pubkey, parentRef, nonce) already committed this epoch + RejectDedupFull, + RejectMinerIndexFull, // more than MAX_NUMBER_OF_MINERS identities hold a tree this epoch +}; + +struct AntColonyDiagnostics +{ + unsigned long long rejectParentNotRegistered; + unsigned long long rejectStale; + unsigned long long rejectWrongTree; + unsigned long long rejectThreshold; + unsigned long long rejectLeParent; + unsigned long long rejectSiblingFloor; + unsigned long long rejectRecordsCapFull; + unsigned long long rejectTickOutOfRange; + unsigned long long rejectReplay; + unsigned long long rejectDedupFull; + unsigned long long rejectMinerIndexFull; + + unsigned long long acceptedSolutions; + unsigned long long treeDepthMax; + unsigned long long treeSizeCurrent; + + void reset() + { + setMem(this, sizeof(*this), 0); + } + + void count(ValidityResult r) + { + switch (r) + { + case ValidityResult::RejectParentNotRegistered: rejectParentNotRegistered++; break; + case ValidityResult::RejectStale: rejectStale++; break; + case ValidityResult::RejectWrongTree: rejectWrongTree++; break; + case ValidityResult::RejectBelowThreshold: rejectThreshold++; break; + case ValidityResult::RejectLeParent: rejectLeParent++; break; + case ValidityResult::RejectBelowSiblingFloor: rejectSiblingFloor++; break; + case ValidityResult::RejectRecordsCapFull: rejectRecordsCapFull++; break; + case ValidityResult::RejectTickOutOfRange: rejectTickOutOfRange++; break; + case ValidityResult::RejectReplay: rejectReplay++; break; + case ValidityResult::RejectDedupFull: rejectDedupFull++; break; + case ValidityResult::RejectMinerIndexFull: rejectMinerIndexFull++; break; + default: break; + } + } +}; + +// A proposed child, reduced to what the admission rules read. Deliberately narrower than +// AntCommitInput so validateChild() stays a pure predicate. +struct ChildCandidate +{ + m256i pubkey; + unsigned int score; // error count, lower is better + unsigned int anchorTick; // ABSOLUTE + unsigned int publishTick; // ABSOLUTE +}; + +// Carries BOTH tick bases. selfRef/parentRef hold epoch-RELATIVE ticks, anchorTick/publishTick are +// ABSOLUTE system ticks. They are all unsigned int and all named "tick", so comparing one against +// the other compiles silently and is meaningless - on mainnet that is ~2,000,000 against ~70,000,000. +struct AntCommitInput +{ + m256i pubkey; + m256i nonce; + SolutionRef parentRef; + SolutionRef selfRef; + unsigned int anchorTick; // ABSOLUTE + unsigned int publishTick; // ABSOLUTE +}; + +// The keyed structures are twice the population they index. +static constexpr unsigned long long ANT_DEDUP_SIZE = 2ULL * ANT_MAX_NODES_PER_EPOCH; +// At most one key per record, and records are capped, so load stays at or below 50% and set() cannot fail. +static constexpr unsigned long long ANT_CHILD_HEAD_BY_PARENT_SIZE = 2ULL * ANT_MAX_NODES_PER_EPOCH; +static_assert(ANT_CHILD_HEAD_BY_PARENT_SIZE >= 2ULL * ANT_MAX_NODES_PER_EPOCH, + "child-head-by-parent map must stay at or below 50% load so its set() cannot fail"); +// One entry per identity holding a tree. Unlike the map above, nothing caps how many identities +// submit, so this one CAN fill - commit() fails closed with RejectMinerIndexFull. +static constexpr unsigned long long ANT_CHILD_HEAD_BY_MINER_SIZE = 2ULL * MAX_NUMBER_OF_MINERS; + +// Anchor digests for recent ticks, indexed by tick & (size - 1). Smallest power of two holding +// 2*(N+1) entries so a lookup inside the freshness window can never be aliased by a newer tick. +static constexpr unsigned int antAnchorRingSize(unsigned int window) +{ + unsigned int size = 1; + while (size < 2u * (window + 1u)) + { + size <<= 1; + } + return size; +} +static constexpr unsigned int ANT_ANCHOR_RING_SIZE = antAnchorRingSize(ANT_FRESHNESS_WINDOW_TICKS); +static constexpr unsigned int ANT_ANCHOR_TICK_NONE = 0xFFFFFFFFU; + +struct AnchorRing +{ + unsigned int ticks[ANT_ANCHOR_RING_SIZE]; + m256i digests[ANT_ANCHOR_RING_SIZE]; +}; + +// --------------------------------------------------------------------------------------------- + +template +class AntColony +{ +public: + // The ANN state will depend on score type + using Ann = typename ScoreT::ANN; + + // In-store form of Ann: 2 bits per trit + using PackedAnn = score_engine::PackedTrits; + static_assert(sizeof(PackedAnn) == PackedAnn::groupCount * sizeof(unsigned long long), + "PackedAnn must not be padded"); + // Catches sizing from a scorer's padded genome (bpp9000: lutSize 27 vs PaddedLut 32). + static_assert(PackedAnn::tritCount == sizeof(Ann), "PackedAnn must cover exactly one ANN"); + + static constexpr unsigned long long ANT_RECORDS_BYTES = + (unsigned long long)ANT_MAX_NODES_PER_EPOCH * sizeof(AntSolutionRecord); + static constexpr unsigned long long ANT_ANN_POOL_BYTES = + (unsigned long long)ANT_MAX_NODES_PER_EPOCH * sizeof(PackedAnn); + + bool init(); + void deinit(); + + // Wipe the whole tree. A new epoch starts empty and reseeded. + void reset(); + + void beginEpoch(const m256i& rootSeed) + { + reset(); + _rootSeed = rootSeed; + } + + const m256i& rootSeed() const + { + return _rootSeed; + } + + void setErrorThreshold(unsigned int t) + { + _errorThreshold = t; + } + + unsigned int errorThreshold() const + { + return _errorThreshold; + } + + unsigned int solutionCount() const + { + return _solutionCount; + } + + const AntColonyDiagnostics& stats() const + { + return _stats; + } + + // Anchor digests. Both take an ABSOLUTE system tick, never an epoch-relative tickOffset. + // Called from tick processor only + void recordAnchorDigest(unsigned int tick, const m256i& digest); + // Can be called from any processors + bool getAnchorDigest(unsigned int tick, m256i& digest) const; + + // Tree access + + // nullptr when idx is out of range + const AntSolutionRecord* recordAt(long long idx) const + { + if (idx < 0 || (unsigned long long)idx >= _solutionCount) + { + return nullptr; + } + return &_records[idx]; + } + + // Unpacks a stored network into the caller's buffer. ROOT is never a record, so callers must + // handle parentRef.isRoot() before reaching here. + bool annOfNonRoot(const AntSolutionRecord& rec, Ann& out) const + { + if (rec.annStateSlot >= _solutionCount) + { + return false; + } + _annPool[rec.annStateSlot].unpack(out.lut); + return true; + } + + long long findIndexBySolutionRef(const SolutionRef& ref) const; + + // Constraint specific functions + + // Resolves a parent for scoring. outParentRec is null for ROOT_REF, the caller derives the + // per-identity root from the submitter's pubkey instead. + ValidityResult tryGetParent(const SolutionRef& parentRef, + const AntSolutionRecord** outParentRec) const; + + // Admission rules for a proposed child: freshness, tree ownership, threshold, parent, sibling + // floor. Static and pure, so the rule set is testable without a colony. Lower score is better. + static ValidityResult validateChild(const ChildCandidate& child, + const AntSolutionRecord* parentRecord, unsigned int siblingFloorScore, unsigned int threshold); + + // Validates and, if accepted, appends the record and its network to the store. + ValidityResult commit(const AntCommitInput& in, const AntSolutionRecord* parentRec, + unsigned int score, const Ann& childAnn, unsigned int childAnnHash); + +private: + // Computes the bar a new child must beat, beyond just beating its parent: the best (lowest) + // score among the siblings that count as competition, or WORST_SCORE when there is none. + // + // PRIVATE ON PURPOSE - tick processor only. It is the only reader of the two head maps, and + // QPI::HashMap has no reader/writer protocol: set() makes a slot's key visible before its value, + // so a reader asking for the key being inserted can get a garbage index. Everything else public + // here is safe off-thread because records/annPool are append-only behind the _solutionCount + // barrier, but these maps are mutated in place. If a request path ever needs this value (see + // RespondAntMineableParents), serve it from a snapshot the tick processor computed, do not + // recompute it on the request thread. + unsigned int siblingFloor(const SolutionRef& parentRef, const m256i& childPubkey, + unsigned int childAnchorTick /* ABSOLUTE */, + unsigned int walkLimit = ANT_MAX_NODES_PER_EPOCH) const; + + AntSolutionRecord* _records; + PackedAnn* _annPool; + AntTickSlot* _tickIndex; + AnchorRing* _anchors; + + // Solutions already committed this epoch, so a resend is rejected instead of re-added. + QPI::HashSet* _dedup; + // Both give siblingFloor() a parent's children without scanning the store: the value is the + // newest child's record index, and nextSiblingIdx chains back to the older ones. + // parent's address -> newest child + QPI::HashMap* _childHeadByParent; + // miner's pubkey -> newest depth-1 node (a child OF that miner's root, not a root itself). + // Keyed by miner because ROOT_REF is shared by everyone. + QPI::HashMap* _childHeadByMiner; + + unsigned int _solutionCount; + unsigned int _errorThreshold; + m256i _rootSeed; + AntColonyDiagnostics _stats; +}; + +// No concrete scorer is named here on purpose - see ant_colony_bpp9000.h for the binding and for +// what a second algorithm would still cost beyond it. + +// --------------------------------------------------------------------------------------------- + +template +inline bool AntColony::init() +{ + setMem(this, sizeof(*this), 0); + + if (!allocPoolWithErrorLog(L"AntColony::_records", + ANT_RECORDS_BYTES, (void**)&_records, __LINE__)) + { + return false; + } + if (!allocPoolWithErrorLog(L"AntColony::_annPool", + ANT_ANN_POOL_BYTES, (void**)&_annPool, __LINE__)) + { + return false; + } + if (!allocPoolWithErrorLog(L"AntColony::_tickIndex", + (unsigned long long)MAX_NUMBER_OF_TICKS_PER_EPOCH * sizeof(AntTickSlot), + (void**)&_tickIndex, __LINE__)) + { + return false; + } + if (!allocPoolWithErrorLog(L"AntColony::_anchors", + sizeof(AnchorRing), (void**)&_anchors, __LINE__)) + { + return false; + } + if (!allocPoolWithErrorLog(L"AntColony::_childHeadByParent", + sizeof(QPI::HashMap), + (void**)&_childHeadByParent, __LINE__)) + { + return false; + } + if (!allocPoolWithErrorLog(L"AntColony::_childHeadByMiner", + sizeof(QPI::HashMap), + (void**)&_childHeadByMiner, __LINE__)) + { + return false; + } + if (!allocPoolWithErrorLog(L"AntColony::_dedup", + sizeof(QPI::HashSet), + (void**)&_dedup, __LINE__)) + { + return false; + } + + reset(); + return true; +} + +template +inline void AntColony::deinit() +{ + if (_dedup) + { + freePool(_dedup); + } + if (_childHeadByMiner) + { + freePool(_childHeadByMiner); + } + if (_childHeadByParent) + { + freePool(_childHeadByParent); + } + if (_anchors) + { + freePool(_anchors); + } + if (_tickIndex) + { + freePool(_tickIndex); + } + if (_annPool) + { + freePool(_annPool); + } + if (_records) + { + freePool(_records); + } + + _dedup = nullptr; + _childHeadByMiner = nullptr; + _childHeadByParent = nullptr; + _anchors = nullptr; + _tickIndex = nullptr; + _annPool = nullptr; + _records = nullptr; +} + +template +inline void AntColony::reset() +{ + ASSERT(_records != nullptr); + ASSERT(_annPool != nullptr); + ASSERT(_tickIndex != nullptr); + ASSERT(_anchors != nullptr); + ASSERT(_childHeadByParent != nullptr); + ASSERT(_childHeadByMiner != nullptr); + ASSERT(_dedup != nullptr); + + setMem(_records, ANT_RECORDS_BYTES, 0); + setMem(_tickIndex, + (unsigned long long)MAX_NUMBER_OF_TICKS_PER_EPOCH * sizeof(AntTickSlot), 0); + _childHeadByParent->reset(); + _childHeadByMiner->reset(); + _dedup->reset(); + + // ANT_ANCHOR_TICK_NONE is used rather than zero + for (unsigned int i = 0; i < ANT_ANCHOR_RING_SIZE; i++) + { + _anchors->ticks[i] = ANT_ANCHOR_TICK_NONE; + _anchors->digests[i] = m256i::zero(); + } + + _solutionCount = 0; + _rootSeed = m256i::zero(); + + // Forgets setErrorThreshold rejects everything but a perfect score, rather than silently reusing the previous epoch's bound. + _errorThreshold = 0; + + _stats.reset(); +} + +template +inline void AntColony::recordAnchorDigest(unsigned int tick, const m256i& digest) +{ + const unsigned int slot = tick & (ANT_ANCHOR_RING_SIZE - 1); + // Invalidate first + ATOMIC_STORE32(_anchors->ticks[slot], (long)ANT_ANCHOR_TICK_NONE); + _anchors->digests[slot] = digest; + // The tick marker is written last, a reader that sees the tick must already see its digest. + ATOMIC_STORE32(_anchors->ticks[slot], (long)tick); +} + +template +inline bool AntColony::getAnchorDigest(unsigned int tick, m256i& digest) const +{ + if (tick == ANT_ANCHOR_TICK_NONE) + { + return false; + } + const unsigned int slot = tick & (ANT_ANCHOR_RING_SIZE - 1); + if (_anchors->ticks[slot] != tick) + { + return false; // never recorded, or aged out and overwritten by a newer tick + } + digest = _anchors->digests[slot]; + return (_anchors->ticks[slot] == tick); +} + +template +inline long long AntColony::findIndexBySolutionRef(const SolutionRef& ref) const +{ + if (ref.isRoot() || ref.tickOffset >= MAX_NUMBER_OF_TICKS_PER_EPOCH) + { + return ANT_INVALID_INDEX; + } + // Start from tick' begin index in the record and loop total of record in the tick + const AntTickSlot& slot = _tickIndex[ref.tickOffset]; + for (unsigned int i = 0; i < slot.count; i++) + { + const unsigned int idx = slot.startIdx + i; + if (idx >= _solutionCount) + { + break; + } + if (_records[idx].selfRef == ref) + { + return (long long)idx; + } + } + return ANT_INVALID_INDEX; +} + +template +inline unsigned int AntColony::siblingFloor(const SolutionRef& parentRef, const m256i& childPubkey, + unsigned int childAnchorTick, unsigned int walkLimit) const +{ + // A competing sibling anchors more than N ticks earlier, so guard the subtraction. This only + // fires during the network's first N ticks, when there are no siblings to compete with anyway. + if (childAnchorTick <= ANT_FRESHNESS_WINDOW_TICKS) + { + return WORST_SCORE; + } + const unsigned int boundary = childAnchorTick - ANT_FRESHNESS_WINDOW_TICKS; + + // Depth-1 nodes chain per identity, so one miner's never raise another's floor. + // Deeper nodes chain per parent, which is single-identity by the wrong-tree check. + unsigned int idx = NO_SIBLING; + if (parentRef.isRoot()) + { + if (!_childHeadByMiner->get(childPubkey, idx)) + { + return WORST_SCORE; + } + } + else if (!_childHeadByParent->get(parentRef, idx)) + { + return WORST_SCORE; + } + + // The bar is the BEST score among competing siblings, because lower is better. + // The chain strictly decreases (commit head-inserts) so it terminates on its own; walkLimit is a + // backstop, and the default does not truncate. Truncating from the head would be wrong rather + // than merely approximate: entries are newest-first and only the older ones compete, so a + // head-side cut removes exactly the siblings that set the floor. + unsigned int floor = WORST_SCORE; + unsigned int hops = 0; + while (idx != NO_SIBLING && hops < walkLimit) + { + if (idx >= _solutionCount) + { + break; + } + const AntSolutionRecord& s = _records[idx]; + if (s.anchorTick < boundary && s.score < floor) + { + floor = s.score; + } + idx = s.nextSiblingIdx; + hops++; + } + return floor; +} + +template +inline ValidityResult AntColony::tryGetParent(const SolutionRef& parentRef, + const AntSolutionRecord** outParentRec) const +{ + *outParentRec = nullptr; + if (parentRef.isRoot()) + { + return ValidityResult::Valid; // root is not a record; a null parent is the valid answer + } + + const long long parentIdx = findIndexBySolutionRef(parentRef); + if (parentIdx == ANT_INVALID_INDEX) + { + return ValidityResult::RejectParentNotRegistered; + } + const AntSolutionRecord* rec = recordAt(parentIdx); + if (rec == nullptr) + { + return ValidityResult::RejectParentNotRegistered; + } + *outParentRec = rec; + return ValidityResult::Valid; +} + +template +inline ValidityResult AntColony::validateChild(const ChildCandidate& child, + const AntSolutionRecord* parentRecord, unsigned int siblingFloorScore, unsigned int threshold) +{ + // Freshness, the anchor cannot be in the future, and publication cannot lag it by more than N. + if (child.anchorTick > child.publishTick + || (child.publishTick - child.anchorTick) > ANT_FRESHNESS_WINDOW_TICKS) + { + return ValidityResult::RejectStale; + } + + // A null parent record means ROOT, it has no score of its own, so seed WORST_SCORE and any child + // improves on it. A non-root parent must belong to the same identity + unsigned int parentScore = WORST_SCORE; + if (parentRecord != nullptr) + { + if (!(parentRecord->pubkey == child.pubkey)) + { + return ValidityResult::RejectWrongTree; + } + parentScore = parentRecord->score; + } + + if (child.score > threshold) + { + return ValidityResult::RejectBelowThreshold; + } + if (child.score >= parentScore) + { + return ValidityResult::RejectLeParent; + } + if (child.score >= siblingFloorScore) + { + return ValidityResult::RejectBelowSiblingFloor; + } + return ValidityResult::Valid; +} + +template +inline ValidityResult AntColony::commit(const AntCommitInput& in, const AntSolutionRecord* parentRec, + unsigned int score, const Ann& childAnn, unsigned int childAnnHash) +{ + const unsigned int floor = siblingFloor(in.parentRef, in.pubkey, in.anchorTick); + const ChildCandidate child{ in.pubkey, score, in.anchorTick, in.publishTick }; + + const ValidityResult result = validateChild(child, parentRec, floor, _errorThreshold); + if (result != ValidityResult::Valid) + { + _stats.count(result); + return result; + } + + const AntDedupKey dedupKey{ in.pubkey, in.nonce, in.parentRef }; + if (_dedup->contains(dedupKey)) + { + _stats.count(ValidityResult::RejectReplay); + return ValidityResult::RejectReplay; + } + if (_solutionCount >= ANT_MAX_NODES_PER_EPOCH) + { + _stats.count(ValidityResult::RejectRecordsCapFull); + return ValidityResult::RejectRecordsCapFull; + } + if (in.selfRef.tickOffset >= MAX_NUMBER_OF_TICKS_PER_EPOCH) + { + _stats.count(ValidityResult::RejectTickOutOfRange); + return ValidityResult::RejectTickOutOfRange; + } + // never commit a solution without recording its replay key. Cannot fire under the + // cap (population <= ANT_MAX_NODES_PER_EPOCH = 50% of ANT_DEDUP_SIZE), kept as a defensive check + if (_dedup->add(dedupKey) == QPI::NULL_INDEX) + { + _stats.count(ValidityResult::RejectDedupFull); + return ValidityResult::RejectDedupFull; + } + + const unsigned int newIdx = _solutionCount; + + // Claim the sibling-chain head before writing the record + unsigned int prevHead = NO_SIBLING; + if (in.parentRef.isRoot()) + { + _childHeadByMiner->get(in.pubkey, prevHead); + if (_childHeadByMiner->set(in.pubkey, newIdx) == QPI::NULL_INDEX) + { + // Fail closed. Degrading instead, accepting the node but leaving the identity without a + // chain head, would silently drop its sibling floor + _dedup->remove(dedupKey); + _stats.count(ValidityResult::RejectMinerIndexFull); + return ValidityResult::RejectMinerIndexFull; + } + } + else + { + _childHeadByParent->get(in.parentRef, prevHead); + _childHeadByParent->set(in.parentRef, newIdx); // cannot fail, see the static_assert on its size + } + + // The record and its network share an index, which keeps the used portion of the allocation a + // contiguous prefix. + _annPool[newIdx].pack(childAnn.lut); + + AntSolutionRecord& newRec = _records[newIdx]; + newRec.pubkey = in.pubkey; + newRec.nonce = in.nonce; + newRec.parentRef = in.parentRef; + newRec.selfRef = in.selfRef; + newRec.score = score; + newRec.anchorTick = in.anchorTick; + newRec.depth = (parentRec != nullptr) ? (parentRec->depth + 1) : 1; + newRec.childAnnHash = childAnnHash; + newRec.annStateSlot = newIdx; + newRec.nextSiblingIdx = prevHead; + + AntTickSlot& tslot = _tickIndex[in.selfRef.tickOffset]; + if (tslot.count == 0) + { + tslot.startIdx = newIdx; + } + + // PUBLICATION ORDER, load-bearing. Readers on other threads gate on tslot.count, so everything + // they may then read must already be visible: record fields, then _solutionCount, then + // tslot.count last. _solutionCount must rise before tslot.count or findIndexBySolutionRef can + // resolve an index that recordAt() rejects. + // ATOMIC_STORE32 is here for the ordering barrier, not for atomicity of the value: these are + // plain unsigned ints written only by the tick processor + ATOMIC_STORE32(_solutionCount, (long)(newIdx + 1)); + ATOMIC_STORE32(tslot.count, (long)(tslot.count + 1)); + + _stats.acceptedSolutions++; + _stats.treeSizeCurrent = _solutionCount; + if (newRec.depth > _stats.treeDepthMax) + { + _stats.treeDepthMax = newRec.depth; + } + return ValidityResult::Valid; +} diff --git a/src/mining/ant_colony_bpp9000.h b/src/mining/ant_colony_bpp9000.h new file mode 100644 index 00000000..1e64f5ff --- /dev/null +++ b/src/mining/ant_colony_bpp9000.h @@ -0,0 +1,8 @@ +#pragma once + +#include "ant_colony.h" +#include "../score.h" + +// Binds the colony to bpp9000. This is the only place a concrete scorer is named, which is why +// ant_colony.h itself can stay free of score.h and everything it drags in. +using AntColonyBpp9000T = AntColony; diff --git a/src/mining/mining.h b/src/mining/mining.h index bee203f9..dd7799f0 100644 --- a/src/mining/mining.h +++ b/src/mining/mining.h @@ -10,6 +10,11 @@ #include +// Miners tracked in the ranking table that feeds computor selection. A hard cap rather than mere +// sizing: once full, a newcomer is admitted only if it outranks the current worst entry. Lives here +// rather than in qubic.cpp so mining headers can size per-miner structures against it. +#define MAX_NUMBER_OF_MINERS 8192 + static unsigned int getTickInDogeBroadcastCycle() { #ifdef NO_UEFI diff --git a/src/mining/trit_pack.h b/src/mining/trit_pack.h new file mode 100644 index 00000000..bd3d86ef --- /dev/null +++ b/src/mining/trit_pack.h @@ -0,0 +1,51 @@ +#pragma once + +// Ternary storage: values {0,1,2} at 2 bits each. +// +// Qubic's mining networks store their genome as trits +// One byte per trit wastes six bits of eight; two bits per trit cuts the stored genome to a quarter. +// Callers hash and transmit the unpacked bytes + +namespace score_engine +{ + +template +struct PackedTrits +{ + static_assert(GROUPS > 0, "need at least one group"); + static_assert(TRITS_PER_GROUP > 0, "a group needs at least one trit"); + static_assert(TRITS_PER_GROUP * 2 <= 64, "a group must fit at 2 bits per trit in one uint64"); + + static constexpr unsigned long long groupCount = GROUPS; + static constexpr unsigned long long tritsPerGroup = TRITS_PER_GROUP; + static constexpr unsigned long long tritCount = GROUPS * TRITS_PER_GROUP; + + unsigned long long word[GROUPS]; + + void pack(const unsigned char* src) + { + for (unsigned long long g = 0; g < GROUPS; g++) + { + unsigned long long packed = 0; + for (unsigned long long i = 0; i < TRITS_PER_GROUP; i++) + { + packed |= ((unsigned long long)(src[g * TRITS_PER_GROUP + i] & 3u)) << (i * 2); + } + word[g] = packed; + } + } + + void unpack(unsigned char* dst) const + { + for (unsigned long long g = 0; g < GROUPS; g++) + { + const unsigned long long packed = word[g]; + for (unsigned long long i = 0; i < TRITS_PER_GROUP; i++) + { + dst[g * TRITS_PER_GROUP + i] = (unsigned char)((packed >> (i * 2)) & 3ull); + } + } + } +}; + +} diff --git a/src/public_settings.h b/src/public_settings.h index b5696078..2e6691a0 100644 --- a/src/public_settings.h +++ b/src/public_settings.h @@ -126,6 +126,15 @@ static constexpr unsigned long long BPP9000_NUMBER_OF_MUTATIONS = 100; static constexpr unsigned long long BPP9000_NUMBER_OF_WINDOWS = BPP9000_SEQUENCE_LENGTH - BPP9000_WINDOW_WIDTH; static constexpr unsigned int BPP9000_SOLUTION_THRESHOLD_DEFAULT = 3838; +// Ant colony: a solution anchors to a recent tick (its RNG seeds from that tick's digest) and must be +// published within ANT_FRESHNESS_WINDOW_TICKS of it. The same window is the sibling no-compete band: +// two siblings whose anchor ticks differ by <= N coexist; a child only has to beat siblings whose +// anchor tick is more than N earlier. +static constexpr unsigned int ANT_FRESHNESS_WINDOW_TICKS = 676; + +// Ant colony: tree nodes recorded per epoch; one per accepted solution. +static constexpr unsigned int ANT_MAX_NODES_PER_EPOCH = 1u << 23; + // Multipler of score static constexpr unsigned int NEURAXON_SOLUTION_MULTIPLER = 1; static constexpr unsigned int BPP9000_SOLUTION_MULTIPLER = 1; diff --git a/src/qubic.cpp b/src/qubic.cpp index 4b901d27..887fa349 100644 --- a/src/qubic.cpp +++ b/src/qubic.cpp @@ -95,7 +95,6 @@ #define CONTRACT_STATES_DEPTH 10 // Is derived from MAX_NUMBER_OF_CONTRACTS (=N) #define TICK_REQUESTING_PERIOD 500ULL #define MAX_NUMBER_EPOCH 1000ULL -#define MAX_NUMBER_OF_MINERS 8192 #define NUMBER_OF_MINER_SOLUTION_FLAGS 0x100000000 #define MAX_MESSAGE_PAYLOAD_SIZE MAX_TRANSACTION_SIZE #define MAX_UNIVERSE_SIZE 1073741824 diff --git a/test/ant_colony.cpp b/test/ant_colony.cpp new file mode 100644 index 00000000..4f825cc9 --- /dev/null +++ b/test/ant_colony.cpp @@ -0,0 +1,260 @@ +#define NO_UEFI + +#include "gtest/gtest.h" + +#define ENABLE_PROFILING 0 + +// The bound colony, not the bare template: these tests check bpp9000's binding as well as the rules. +#include "../src/mining/ant_colony_bpp9000.h" + +#include + +// The colony's rules and its stored network form. validateChild() is static and touches no member state, +// so the whole rule set is exercised here without allocating a colony, loading a task, or running +// the engine. +// +// Score is an ERROR COUNT: lower is better. Every expectation below depends on that direction, which +// is the thing most likely to be silently inverted by a future edit - a stale `>` reads fine and +// quietly accepts the wrong children. + +static constexpr unsigned int TEST_THRESHOLD = 3838; // BPP9000_SOLUTION_THRESHOLD_DEFAULT + +static m256i makeKey(unsigned long long n) +{ + m256i k = m256i::zero(); + k.m256i_u64[0] = n + 1; + return k; +} + +// A parent sitting at the given score, owned by the given identity. +static AntSolutionRecord makeParent(const m256i& owner, unsigned int score, unsigned int depth = 1) +{ + AntSolutionRecord r; + setMem(&r, sizeof(r), 0); + r.pubkey = owner; + r.score = score; + r.depth = depth; + r.parentRef = ROOT_REF; + r.nextSiblingIdx = NO_SIBLING; + return r; +} + +// A candidate from `owner` at `score`, anchored and published in the same tick unless the test is +// about freshness. +static ChildCandidate makeChild(const m256i& owner, unsigned int score, + unsigned int anchorTick = 1000, unsigned int publishTick = 1000) +{ + ChildCandidate c; + c.pubkey = owner; + c.score = score; + c.anchorTick = anchorTick; + c.publishTick = publishTick; + return c; +} + +// Every test runs at TEST_THRESHOLD, so wrapping it keeps the assertions on one line. +static ValidityResult admit(const ChildCandidate& child, const AntSolutionRecord* parent, + unsigned int siblingFloorScore) +{ + return AntColonyBpp9000T::validateChild(child, parent, siblingFloorScore, TEST_THRESHOLD); +} + +// --------------------------------------------------------------------------------------------- +// Stored network form +// --------------------------------------------------------------------------------------------- + +// The packing itself is generic and tested exhaustively in trit_pack.cpp. What is ant-specific is +// the binding: that PackedAnn is dimensioned from the scorer's real ANN and covers all of it. The +// hazard is bpp9000's two strides - ANN is packed at lutSize (27) while the engine's internal +// PaddedLut is 32 - so a PackedAnn built on the wrong one would drop or duplicate entries, change +// childAnnHash, and surface as a resourceTestingDigest split rather than a crash. +TEST(TestAntColonyPackedAnn, CoversAWholeAnnAtTheUnpaddedStride) +{ + AntColonyBpp9000T::Ann src; + for (unsigned long long i = 0; i < sizeof(src); i++) + { + src.lut[i] = (unsigned char)(i % 3); // mutate() only ever writes 0, 1 or 2 + } + + AntColonyBpp9000T::PackedAnn packed; + packed.pack(src.lut); + + AntColonyBpp9000T::Ann back; + setMem(&back, sizeof(back), 0xFF); + packed.unpack(back.lut); + + for (unsigned long long i = 0; i < sizeof(src); i++) + { + ASSERT_EQ(back.lut[i], src.lut[i]) << "entry " << i; + } +} + +// --------------------------------------------------------------------------------------------- +// Threshold +// --------------------------------------------------------------------------------------------- + +// The threshold is checked before the parent comparison, so nodes worse than it are never stored - +// which is why the early descent never consumes the store. +TEST(TestAntColonyValidate, ThresholdIsAnUpperBoundOnError) +{ + const m256i me = makeKey(1); + + // A parent that would otherwise admit anything, so only the threshold can reject. + const AntSolutionRecord looseParent = makeParent(me, WORST_SCORE); + EXPECT_EQ(admit(makeChild(me, 3839), &looseParent, WORST_SCORE), + ValidityResult::RejectBelowThreshold); + + // Exactly at the bound is accepted: the rule is score > threshold, not >=. + EXPECT_EQ(admit(makeChild(me, TEST_THRESHOLD), &looseParent, WORST_SCORE), ValidityResult::Valid); +} + +// --------------------------------------------------------------------------------------------- +// Parent +// --------------------------------------------------------------------------------------------- + +TEST(TestAntColonyValidate, MustStrictlyBeatParent) +{ + const m256i me = makeKey(2); + const AntSolutionRecord parent = makeParent(me, 3800); + + EXPECT_EQ(admit(makeChild(me, 3799), &parent, WORST_SCORE), ValidityResult::Valid); + EXPECT_EQ(admit(makeChild(me, 3800), &parent, WORST_SCORE), ValidityResult::RejectLeParent); + EXPECT_EQ(admit(makeChild(me, 3801), &parent, WORST_SCORE), ValidityResult::RejectLeParent); +} + +// A root has no score of its own, so any threshold-passing child improves on it. This is what lets a +// lineage start at all. +TEST(TestAntColonyValidate, RootParentAdmitsAnyPassingScore) +{ + const m256i me = makeKey(3); + + EXPECT_EQ(admit(makeChild(me, TEST_THRESHOLD), nullptr, WORST_SCORE), ValidityResult::Valid); + EXPECT_EQ(admit(makeChild(me, 0), nullptr, WORST_SCORE), ValidityResult::Valid); + EXPECT_EQ(admit(makeChild(me, TEST_THRESHOLD + 1), nullptr, WORST_SCORE), + ValidityResult::RejectBelowThreshold); +} + +// Trees are isolated per identity: a miner cannot branch off someone else's node. +TEST(TestAntColonyValidate, CannotBranchFromAnotherIdentity) +{ + const m256i me = makeKey(4); + const m256i someoneElse = makeKey(5); + const AntSolutionRecord theirNode = makeParent(someoneElse, 3800); + + EXPECT_EQ(admit(makeChild(me, 3700), &theirNode, WORST_SCORE), ValidityResult::RejectWrongTree); + + const AntSolutionRecord myNode = makeParent(me, 3800); + EXPECT_EQ(admit(makeChild(me, 3700), &myNode, WORST_SCORE), ValidityResult::Valid); +} + +// --------------------------------------------------------------------------------------------- +// Sibling floor +// --------------------------------------------------------------------------------------------- + +TEST(TestAntColonyValidate, MustStrictlyBeatSiblingFloor) +{ + const m256i me = makeKey(6); + const AntSolutionRecord parent = makeParent(me, WORST_SCORE); + + EXPECT_EQ(admit(makeChild(me, 3799), &parent, 3800), ValidityResult::Valid); + EXPECT_EQ(admit(makeChild(me, 3800), &parent, 3800), ValidityResult::RejectBelowSiblingFloor); + EXPECT_EQ(admit(makeChild(me, 3801), &parent, 3800), ValidityResult::RejectBelowSiblingFloor); + + // No competing sibling yet: the floor is WORST_SCORE and anything passing gets through. + EXPECT_EQ(admit(makeChild(me, TEST_THRESHOLD), &parent, WORST_SCORE), ValidityResult::Valid); +} + +// --------------------------------------------------------------------------------------------- +// Freshness +// --------------------------------------------------------------------------------------------- + +TEST(TestAntColonyValidate, FreshnessWindowBoundaries) +{ + const m256i me = makeKey(7); + const AntSolutionRecord parent = makeParent(me, WORST_SCORE); + const unsigned int anchor = 100000; + + // Published in the same tick it anchored to: the tightest legal case. + EXPECT_EQ(admit(makeChild(me, 3700, anchor, anchor), &parent, WORST_SCORE), + ValidityResult::Valid); + + // Exactly at the window edge is still legal; one past it is not. + EXPECT_EQ(admit(makeChild(me, 3700, anchor, anchor + ANT_FRESHNESS_WINDOW_TICKS), + &parent, WORST_SCORE), ValidityResult::Valid); + EXPECT_EQ(admit(makeChild(me, 3700, anchor, anchor + ANT_FRESHNESS_WINDOW_TICKS + 1), + &parent, WORST_SCORE), ValidityResult::RejectStale); + + // An anchor in the future is rejected rather than wrapping the unsigned subtraction. + EXPECT_EQ(admit(makeChild(me, 3700, anchor + 1, anchor), &parent, WORST_SCORE), + ValidityResult::RejectStale); +} + +// --------------------------------------------------------------------------------------------- +// Order of checks +// --------------------------------------------------------------------------------------------- + +// The order is consensus-visible: it decides which reject a solution is charged with, which drives +// the diagnostics an operator reads. Freshness first, then tree isolation, then threshold, then +// parent, then sibling floor. +TEST(TestAntColonyValidate, ReportsTheFirstFailingRule) +{ + const m256i me = makeKey(8); + const m256i other = makeKey(9); + const unsigned int anchor = 100000; + const unsigned int stalePublish = anchor + ANT_FRESHNESS_WINDOW_TICKS + 1; + + const AntSolutionRecord theirs = makeParent(other, 3000); + + // Stale AND wrong tree AND above threshold AND worse than parent -> reports Stale. + EXPECT_EQ(admit(makeChild(me, 9999, anchor, stalePublish), &theirs, 100), + ValidityResult::RejectStale); + + // Fresh, but wrong tree AND above threshold -> reports WrongTree. + EXPECT_EQ(admit(makeChild(me, 9999, anchor, anchor), &theirs, 100), + ValidityResult::RejectWrongTree); + + // Own tree, above threshold AND worse than parent -> reports the threshold. + const AntSolutionRecord mine = makeParent(me, 3000); + EXPECT_EQ(admit(makeChild(me, 9999, anchor, anchor), &mine, 100), + ValidityResult::RejectBelowThreshold); + + // Passes the threshold, but worse than parent AND below the floor -> reports the parent. + EXPECT_EQ(admit(makeChild(me, 3500, anchor, anchor), &mine, 100), + ValidityResult::RejectLeParent); +} + +// --------------------------------------------------------------------------------------------- +// Direction sweep +// --------------------------------------------------------------------------------------------- + +// The whole rule set restated as one property: for a fixed parent, floor and threshold, acceptance +// must be monotone in the score - every score at or below the tightest bound is accepted, every +// score above it is rejected. An inverted comparison anywhere breaks this even if the individual +// boundary tests above were adjusted to match it. +TEST(TestAntColonyValidate, AcceptanceIsMonotoneInScore) +{ + const m256i me = makeKey(10); + const unsigned int parentScore = 3800; + const unsigned int floor = 3750; + const AntSolutionRecord parent = makeParent(me, parentScore); + + // Tightest of: <= threshold, < parent, < floor. + const unsigned int bestRejected = (parentScore < floor) ? parentScore : floor; + + bool sawAccept = false; + for (unsigned int score = 3700; score <= 3900; score++) + { + const ValidityResult r = admit(makeChild(me, score), &parent, floor); + const bool accepted = (r == ValidityResult::Valid); + if (score < bestRejected && score <= TEST_THRESHOLD) + { + ASSERT_TRUE(accepted) << "score " << score << " should be accepted, got " << (int)r; + sawAccept = true; + } + else + { + ASSERT_FALSE(accepted) << "score " << score << " should be rejected"; + } + } + EXPECT_TRUE(sawAccept) << "the sweep must cover the accepting region"; +} diff --git a/test/test.vcxproj b/test/test.vcxproj index fe0736f6..2a54d0c9 100644 --- a/test/test.vcxproj +++ b/test/test.vcxproj @@ -176,9 +176,11 @@ + + diff --git a/test/trit_pack.cpp b/test/trit_pack.cpp new file mode 100644 index 00000000..e8aef529 --- /dev/null +++ b/test/trit_pack.cpp @@ -0,0 +1,137 @@ +#define NO_UEFI + +#include "gtest/gtest.h" + +#include "../src/mining/trit_pack.h" + +// Trit packing is a STORAGE format: the ant colony's ANN pool is packed in memory and written to +// disk that way, so a layout change silently breaks snapshot loads rather than failing to compile. +// The layout is therefore pinned by golden values here, not just round-tripped. +// +// trit_pack.h includes nothing, so this file does too - if that ever stops being true the test +// stops building and the header has quietly grown a dependency. + +using score_engine::PackedTrits; + +// Two groups of five trits is small enough that every value the structure can hold fits in a loop, +// so this is exhaustive rather than a sample: 3^10 assignments, each packed and unpacked. +TEST(TestTritPack, RoundTripsEveryPossibleValue) +{ + using P = PackedTrits<2, 5>; + static constexpr unsigned int COMBINATIONS = 59049; // 3^10 + + for (unsigned int v = 0; v < COMBINATIONS; v++) + { + unsigned char src[P::tritCount]; + unsigned int rest = v; + for (unsigned long long i = 0; i < P::tritCount; i++) + { + src[i] = (unsigned char)(rest % 3); + rest /= 3; + } + + P packed; + packed.pack(src); + + unsigned char back[P::tritCount]; + for (unsigned long long i = 0; i < P::tritCount; i++) + { + back[i] = 0xFF; // so a trit the unpack never writes fails loudly + } + packed.unpack(back); + + for (unsigned long long i = 0; i < P::tritCount; i++) + { + ASSERT_EQ(back[i], src[i]) << "value " << v << ", trit " << i; + } + } +} + +// The documented layout - trit i of group g at bits [2i, 2i+2), lowest index in the lowest bits. +// Round-trip tests pass under any self-consistent layout, so only fixed words catch a reordering +// that would leave existing snapshot files unreadable. +TEST(TestTritPack, LayoutIsTwoBitsPerTritLowestIndexFirst) +{ + PackedTrits<2, 4> packed; + const unsigned char src[8] = { 1, 2, 0, 1, 2, 2, 1, 0 }; + packed.pack(src); + + EXPECT_EQ(packed.word[0], 1ull + (2ull << 2) + (0ull << 4) + (1ull << 6)); // 73 + EXPECT_EQ(packed.word[1], 2ull + (2ull << 2) + (1ull << 4) + (0ull << 6)); // 26 +} + +// A group is one scorer row and gets its own word, so editing a row must not touch any other. This +// is what lets the colony reason about a neuron's LUT independently. +TEST(TestTritPack, GroupsAreIndependent) +{ + using P = PackedTrits<4, 6>; + unsigned char src[P::tritCount]; + for (unsigned long long i = 0; i < P::tritCount; i++) + { + src[i] = 0; + } + + P base; + base.pack(src); + + for (unsigned long long g = 0; g < P::groupCount; g++) + { + src[g * P::tritsPerGroup] = 2; + P edited; + edited.pack(src); + src[g * P::tritsPerGroup] = 0; + + for (unsigned long long m = 0; m < P::groupCount; m++) + { + if (m == g) + { + ASSERT_NE(edited.word[m], base.word[m]) << "group " << m << " should have changed"; + } + else + { + ASSERT_EQ(edited.word[m], base.word[m]) << "group " << m << " must not change"; + } + } + } +} + +// 32 trits is the widest group a uint64 holds, so the top trit sits at bits 62-63. A shift written +// on a 32-bit type would be undefined there and would typically lose the high half silently. +TEST(TestTritPack, WidestLegalGroupRoundTrips) +{ + using P = PackedTrits<1, 32>; + unsigned char src[P::tritCount]; + for (unsigned long long i = 0; i < P::tritCount; i++) + { + src[i] = 2; + } + + P packed; + packed.pack(src); + EXPECT_EQ(packed.word[0], 0xAAAAAAAAAAAAAAAAull); // every trit 0b10 + + unsigned char back[P::tritCount]; + packed.unpack(back); + for (unsigned long long i = 0; i < P::tritCount; i++) + { + ASSERT_EQ(back[i], 2) << "trit " << i; + } +} + +// pack() masks instead of validating. A byte the scorer should never have written is truncated to +// its low two bits and stays inside its own trit - it does not shift the ones after it, which is +// the property that keeps one bad byte from corrupting a whole row. +TEST(TestTritPack, OutOfRangeByteCannotDisturbItsNeighbours) +{ + PackedTrits<1, 4> packed; + const unsigned char src[4] = { 4, 1, 7, 2 }; // 4 -> 0, 7 -> 3 + packed.pack(src); + + unsigned char back[4]; + packed.unpack(back); + + EXPECT_EQ(back[0], 0); + EXPECT_EQ(back[1], 1); + EXPECT_EQ(back[2], 3); + EXPECT_EQ(back[3], 2); +} From 5ba764629fc2722a5d722044bc2eeca7bfd11c38 Mon Sep 17 00:00:00 2001 From: cyber-pc <165458555+cyber-pc@users.noreply.github.com> Date: Fri, 7 Aug 2026 11:23:56 +0700 Subject: [PATCH 08/46] Add the ant colony lifecycle and per-tick anchor digests --- src/mining/ant_colony.h | 5 ----- src/qubic.cpp | 50 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 5 deletions(-) diff --git a/src/mining/ant_colony.h b/src/mining/ant_colony.h index 300bdb60..325b1127 100644 --- a/src/mining/ant_colony.h +++ b/src/mining/ant_colony.h @@ -327,11 +327,6 @@ class AntColony AntColonyDiagnostics _stats; }; -// No concrete scorer is named here on purpose - see ant_colony_bpp9000.h for the binding and for -// what a second algorithm would still cost beyond it. - -// --------------------------------------------------------------------------------------------- - template inline bool AntColony::init() { diff --git a/src/qubic.cpp b/src/qubic.cpp index 887fa349..aa23882c 100644 --- a/src/qubic.cpp +++ b/src/qubic.cpp @@ -75,6 +75,7 @@ #include "files/files.h" #include "mining/mining.h" #include "mining/custom_qubic_mining_storage.h" +#include "mining/ant_colony_bpp9000.h" #include "oracle_core/oracle_engine.h" #include "oracle_core/net_msg_impl.h" @@ -263,6 +264,35 @@ static int getSolutionThreshold(score_engine::AlgoType selectedAlgo) } static bool applyBpp9000Task(); +static AntColonyBpp9000T gAntColony; + +// The anchor digest a solution's mutation walk seeds from, K12(tick || transactionDigest) +static void computeAntAnchorDigest(unsigned int tick, const m256i& transactionDigest, m256i& out) +{ + unsigned char preimage[sizeof(unsigned int) + sizeof(m256i)]; + copyMem(preimage, &tick, sizeof(tick)); + copyMem(preimage + sizeof(tick), &transactionDigest, sizeof(transactionDigest)); + KangarooTwelve(preimage, sizeof(preimage), &out, sizeof(out)); +} + +// Reseed the colony for a new epoch. The root seed is score->currentRandomSeed, the epoch-start +// spectrum digest +static void antColonyBeginEpoch() +{ + gAntColony.beginEpoch(score->currentRandomSeed); + gAntColony.setErrorThreshold((unsigned int)getSolutionThreshold(score_engine::AlgoType::Bpp9000)); + + // Every identity's root derives from this one value, so a node that seeded differently builds a + // different forest and diverges. Logged as an identity so operators can compare it across nodes + // by eye at epoch start, which is cheaper than finding out from a digest split later. + CHAR16 digestChars[60 + 1]; + getIdentity(gAntColony.rootSeed().m256i_u8, digestChars, true); + CHAR16 msg[128]; + setText(msg, L"[ant-colony] Root seed = "); + appendText(msg, digestChars); + logToConsole(msg); +} + // DOGE merged-mining shares static volatile char gDogeMiningSharesCountLock = 0; static unsigned int gDogeMiningSharesCount[NUMBER_OF_COMPUTORS] = { 0 }; @@ -1816,6 +1846,7 @@ static void checkAndSwitchMiningPhase(short tickEpoch, TimeDate tickDate, bool r if (resetPhase) { setNewMiningSeed(); + antColonyBeginEpoch(); } // Roll DOGE per-phase stats at broadcast-cycle boundaries (display only). @@ -3227,6 +3258,7 @@ static void processTick(unsigned long long processorNumber) ts.tickData.acquireLock(); copyMem(&nextTickData, &ts.tickData[tickIndex], sizeof(TickData)); ts.tickData.releaseLock(); + unsigned long long solutionProcessStartTick = __rdtsc(); // for tracking the time processing solutions if (nextTickData.epoch == system.epoch) { @@ -3374,6 +3406,15 @@ static void processTick(unsigned long long processorNumber) } } PROFILE_SCOPE_END(); + + // Ant colony anchor for this non-empty tick, K12(tick || transactionDigest) + { + m256i anchorTxDigest; + KangarooTwelve(&nextTickData, sizeof(TickData), &anchorTxDigest, 32); + m256i anchorDigest; + computeAntAnchorDigest(system.tick, anchorTxDigest, anchorDigest); + gAntColony.recordAnchorDigest(system.tick, anchorDigest); + } } // Resend own oracle queries for share validation if they were scheduled for but not included in this tick. @@ -6369,6 +6410,11 @@ static bool initialize() } setMem(score_qpi, sizeof(*score_qpi), 0); + if (!gAntColony.init()) + { + return false; + } + setMem(&solutionThreshold[0][0], sizeof(int) * MAX_NUMBER_EPOCH * score_engine::AlgoType::MaxAlgoCount, 0); if (!allocPoolWithErrorLog(L"minserSolutionFlag", NUMBER_OF_MINER_SOLUTION_FLAGS / 8, (void**)&minerSolutionFlags, __LINE__)) { @@ -6564,6 +6610,8 @@ static bool initialize() { score->initMiningData(initialRandomSeedFromPersistingState); loadMiningSeedFromFile = false;; + // This branch does not go through checkAndSwitchMiningPhase, so the colony is seeded here + antColonyBeginEpoch(); } else { @@ -6741,6 +6789,8 @@ static void deinitialize() pendingTxsPool.deinit(); + gAntColony.deinit(); + if (score) { freePool(score); From 693813cf5344e182e57aaa0523305937877ebb8a Mon Sep 17 00:00:00 2001 From: cyber-pc <165458555+cyber-pc@users.noreply.github.com> Date: Fri, 7 Aug 2026 11:45:05 +0700 Subject: [PATCH 09/46] Main score function for the ant colony. --- src/mining/ant_colony.h | 21 +++++++++++++++------ src/score.h | 41 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 6 deletions(-) diff --git a/src/mining/ant_colony.h b/src/mining/ant_colony.h index 325b1127..33858672 100644 --- a/src/mining/ant_colony.h +++ b/src/mining/ant_colony.h @@ -87,6 +87,7 @@ enum ValidityResult RejectReplay, // (pubkey, parentRef, nonce) already committed this epoch RejectDedupFull, RejectMinerIndexFull, // more than MAX_NUMBER_OF_MINERS identities hold a tree this epoch + RejectNonCanonicalNonce, // scorer refused the nonce; no score was produced }; struct AntColonyDiagnostics @@ -102,6 +103,7 @@ struct AntColonyDiagnostics unsigned long long rejectReplay; unsigned long long rejectDedupFull; unsigned long long rejectMinerIndexFull; + unsigned long long rejectNonCanonicalNonce; unsigned long long acceptedSolutions; unsigned long long treeDepthMax; @@ -127,6 +129,7 @@ struct AntColonyDiagnostics case ValidityResult::RejectReplay: rejectReplay++; break; case ValidityResult::RejectDedupFull: rejectDedupFull++; break; case ValidityResult::RejectMinerIndexFull: rejectMinerIndexFull++; break; + case ValidityResult::RejectNonCanonicalNonce: rejectNonCanonicalNonce++; break; default: break; } } @@ -243,6 +246,12 @@ class AntColony return _stats; } + void recordReject(ValidityResult r) + { + ASSERT(r != ValidityResult::Valid); + _stats.count(r); + } + // Anchor digests. Both take an ABSOLUTE system tick, never an epoch-relative tickOffset. // Called from tick processor only void recordAnchorDigest(unsigned int tick, const m256i& digest); @@ -625,31 +634,31 @@ inline ValidityResult AntColony::commit(const AntCommitInput& in, const const ValidityResult result = validateChild(child, parentRec, floor, _errorThreshold); if (result != ValidityResult::Valid) { - _stats.count(result); + recordReject(result); return result; } const AntDedupKey dedupKey{ in.pubkey, in.nonce, in.parentRef }; if (_dedup->contains(dedupKey)) { - _stats.count(ValidityResult::RejectReplay); + recordReject(ValidityResult::RejectReplay); return ValidityResult::RejectReplay; } if (_solutionCount >= ANT_MAX_NODES_PER_EPOCH) { - _stats.count(ValidityResult::RejectRecordsCapFull); + recordReject(ValidityResult::RejectRecordsCapFull); return ValidityResult::RejectRecordsCapFull; } if (in.selfRef.tickOffset >= MAX_NUMBER_OF_TICKS_PER_EPOCH) { - _stats.count(ValidityResult::RejectTickOutOfRange); + recordReject(ValidityResult::RejectTickOutOfRange); return ValidityResult::RejectTickOutOfRange; } // never commit a solution without recording its replay key. Cannot fire under the // cap (population <= ANT_MAX_NODES_PER_EPOCH = 50% of ANT_DEDUP_SIZE), kept as a defensive check if (_dedup->add(dedupKey) == QPI::NULL_INDEX) { - _stats.count(ValidityResult::RejectDedupFull); + recordReject(ValidityResult::RejectDedupFull); return ValidityResult::RejectDedupFull; } @@ -665,7 +674,7 @@ inline ValidityResult AntColony::commit(const AntCommitInput& in, const // Fail closed. Degrading instead, accepting the node but leaving the identity without a // chain head, would silently drop its sibling floor _dedup->remove(dedupKey); - _stats.count(ValidityResult::RejectMinerIndexFull); + recordReject(ValidityResult::RejectMinerIndexFull); return ValidityResult::RejectMinerIndexFull; } } diff --git a/src/score.h b/src/score.h index c2ec227c..31f0772c 100644 --- a/src/score.h +++ b/src/score.h @@ -56,6 +56,9 @@ struct ScoreFunction score_engine::ScoreEngineT _computeBuffer[solutionBufferCount]; volatile char solutionEngineLock[solutionBufferCount]; + // Scratch for the ant root derivation, one per engine slot and covered by that slot's own lock + score_engine::ScoreBpp9000T::ANN _antRootScratch[solutionBufferCount]; + public: volatile char random2PoolLock; unsigned char state[score_engine::STATE_SIZE]; @@ -197,6 +200,44 @@ struct ScoreFunction LockGuard guard(solutionEngineLock[processor_Number]); return _computeBuffer[processor_Number].getLastOutput(); } + + // Ant colony main score function + // score a child by inheriting its parent's network and walking it with the child's own seeds. + // parentAnn == nullptr means the parent is the submitter's root, which is derived here from the pubkey + // Returns INVALID_SCORE_VALUE for a non-canonical nonce, in which case outChildAnn is not written + // bestANN would still hold the previous call's network, and committing that would put one node's + // stale bytes into childAnnHash. + unsigned int computeAntChildScore( + const unsigned long long processor_Number, + const score_engine::ScoreBpp9000T::ANN* parentAnn, + const m256i& publicKey, + const m256i& nonce, + const m256i& anchorDigest, + score_engine::ScoreBpp9000T::ANN& outChildAnn) + { + const int solutionBufIdx = (int)(processor_Number % solutionBufferCount); + LockGuard guard(solutionEngineLock[solutionBufIdx]); + score_engine::ScoreBpp9000T& engine = _computeBuffer[solutionBufIdx]._bpp9000Score; + + // Derived into this slot's scratch rather than the engine's own buffer: deriveRootANN() uses + // currentANN as working space + const score_engine::ScoreBpp9000T::ANN* parent = parentAnn; + // Depth 1, the start node of every public key + if (parent == nullptr) + { + engine.deriveRootANN(publicKey.m256i_u8, poolVec, _antRootScratch[solutionBufIdx]); + parent = &_antRootScratch[solutionBufIdx]; + } + + const unsigned int childScore = engine.computeScoreFromParent( + *parent, publicKey.m256i_u8, nonce.m256i_u8, anchorDigest.m256i_u8, poolVec); + if (childScore == score_engine::INVALID_SCORE_VALUE) + { + return childScore; + } + engine.getBestANN(outChildAnn); + return childScore; + } // main score function unsigned int operator()(const unsigned long long processor_Number, const m256i& publicKey, const m256i& miningSeed, const m256i& nonce) { From 72453896ded99c123121ada9d8abf72069fc5500 Mon Sep 17 00:00:00 2001 From: cyber-pc <165458555+cyber-pc@users.noreply.github.com> Date: Fri, 7 Aug 2026 15:11:34 +0700 Subject: [PATCH 10/46] Handle the solution transaction of ant colony. --- src/qubic.cpp | 100 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 100 insertions(+) diff --git a/src/qubic.cpp b/src/qubic.cpp index aa23882c..57a5d8c2 100644 --- a/src/qubic.cpp +++ b/src/qubic.cpp @@ -265,6 +265,8 @@ static int getSolutionThreshold(score_engine::AlgoType selectedAlgo) static bool applyBpp9000Task(); static AntColonyBpp9000T gAntColony; +static AntColonyBpp9000T::Ann gAntParentAnnScratch[MAX_NUMBER_OF_PROCESSORS]; +static AntColonyBpp9000T::Ann gAntChildAnnScratch[MAX_NUMBER_OF_PROCESSORS]; // The anchor digest a solution's mutation walk seeds from, K12(tick || transactionDigest) static void computeAntAnchorDigest(unsigned int tick, const m256i& transactionDigest, m256i& out) @@ -2780,6 +2782,91 @@ static void processTickTransactionSolution(const MiningSolutionTransaction* tran } } +// One ant solution: resolve its parent, score the child against it, and commit. Every rejection +// forfeits the deposit by simply not refunding it +static void processTickTransactionAntColonySolution( + const AntColonyMiningSolutionTransaction* transaction, + unsigned int transactionIndex, + const unsigned long long processorNumber) +{ + AntColonyBpp9000T::Ann& parentAnnScratch = gAntParentAnnScratch[processorNumber]; + AntColonyBpp9000T::Ann& childAnnScratch = gAntChildAnnScratch[processorNumber]; + + const SolutionRef parentRef = { transaction->parentTickOffset, transaction->parentSolutionIndexInTick }; + + const AntSolutionRecord* parentRec = nullptr; + ValidityResult result = gAntColony.tryGetParent(parentRef, &parentRec); + if (result != ValidityResult::Valid) + { + gAntColony.recordReject(result); + return; + } + + // The anchor digest seeds the child's mutation walk, so an anchor the ring no longer holds cannot + // be scored + m256i anchorDigest; + if (!gAntColony.getAnchorDigest(transaction->anchorTick, anchorDigest)) + { + gAntColony.recordReject(ValidityResult::RejectStale); + return; + } + + // A null parent record means root, the scorer derives the submitter's own root, since roots are + // never stored and so cannot be handed in. + const AntColonyBpp9000T::Ann* parentAnn = nullptr; + if (parentRec != nullptr) + { + if (!gAntColony.annOfNonRoot(*parentRec, parentAnnScratch)) + { + gAntColony.recordReject(ValidityResult::RejectParentNotRegistered); + return; + } + parentAnn = &parentAnnScratch; + } + + const unsigned int childScore = score->computeAntChildScore( + processorNumber, parentAnn, transaction->sourcePublicKey, transaction->nonce, + anchorDigest, childAnnScratch); + if (!score->isValidScore(childScore, score_engine::AlgoType::Bpp9000)) + { + gAntColony.recordReject(ValidityResult::RejectNonCanonicalNonce); + return; + } + + // Keep previous behavior, we fold both good and bad score into resource testing digest + unsigned int childAnnHash; + KangarooTwelve(&childAnnScratch, sizeof(childAnnScratch), &childAnnHash, sizeof(childAnnHash)); + resourceTestingDigest ^= childScore; + resourceTestingDigest ^= childAnnHash; + KangarooTwelve(&resourceTestingDigest, sizeof(resourceTestingDigest), &resourceTestingDigest, sizeof(resourceTestingDigest)); + + const AntCommitInput in = { + transaction->sourcePublicKey, + transaction->nonce, + parentRef, + { system.tick - system.initialTick, transactionIndex }, // selfRef, epoch-RELATIVE tick + transaction->anchorTick, // ABSOLUTE + system.tick }; // ABSOLUTE + result = gAntColony.commit(in, parentRec, childScore, childAnnScratch, childAnnHash); + if (result != ValidityResult::Valid) + { + return; // commit() counted this one itself + } + + // Refund AND ranking + if (transaction->claimedScore == childScore) + { + increaseEnergy(transaction->sourcePublicKey, transaction->amount); + + const QuTransfer quTransfer = { m256i::zero(), transaction->sourcePublicKey, transaction->amount }; + logger.logQuTransfer(quTransfer); + + // A miner is ranked by its single best score of the epoch + const unsigned int newScore = childScore * gScoreMultiplier[score_engine::AlgoType::Bpp9000]; + updateMinerRankingAndFutureComputors(transaction->sourcePublicKey, newScore, system.tick); + } +} + static void processTickTransaction(const Transaction* transaction, unsigned int transactionIndex, unsigned long long processorNumber) { PROFILE_SCOPE(); @@ -2899,6 +2986,19 @@ static void processTickTransaction(const Transaction* transaction, unsigned int } break; + case AntColonyMiningSolutionTransaction::transactionType(): + { + // Exact inputSize, not >=: the payload is fixed and a longer one is not a + // forward-compatible variant, it is a different transaction. + if (transaction->amount >= AntColonyMiningSolutionTransaction::minAmount() + && transaction->inputSize == AntColonyMiningSolutionTransaction::minInputSize()) + { + processTickTransactionAntColonySolution( + (AntColonyMiningSolutionTransaction*)transaction, transactionIndex, processorNumber); + } + } + break; + case OracleReplyCommitTransactionPrefix::transactionType(): { oracleEngine.processOracleReplyCommitTransaction((OracleReplyCommitTransactionPrefix*)transaction); From fdb8fb9fa9391770882cd471b2cb5f8eb9a5e598 Mon Sep 17 00:00:00 2001 From: cyber-pc <165458555+cyber-pc@users.noreply.github.com> Date: Fri, 7 Aug 2026 16:59:37 +0700 Subject: [PATCH 11/46] Score ant solutions on the task queue and add a seen filter --- src/qubic.cpp | 184 ++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 171 insertions(+), 13 deletions(-) diff --git a/src/qubic.cpp b/src/qubic.cpp index 57a5d8c2..62ff96d3 100644 --- a/src/qubic.cpp +++ b/src/qubic.cpp @@ -97,6 +97,7 @@ #define TICK_REQUESTING_PERIOD 500ULL #define MAX_NUMBER_EPOCH 1000ULL #define NUMBER_OF_MINER_SOLUTION_FLAGS 0x100000000 +#define NUMBER_OF_ANT_SOLUTION_FLAGS 0x100000000 #define MAX_MESSAGE_PAYLOAD_SIZE MAX_TRANSACTION_SIZE #define MAX_UNIVERSE_SIZE 1073741824 #define MESSAGE_DISSEMINATION_THRESHOLD 1000000000 @@ -227,6 +228,7 @@ static void scoreLegacySolutionTask(unsigned long long processorNumber, void* pa static volatile char solutionsLock = 0; static unsigned long long* minerSolutionFlags = NULL; +static unsigned long long* gAntSolutionFlags = NULL; static volatile m256i minerPublicKeys[MAX_NUMBER_OF_MINERS + 1]; static volatile unsigned int minerScores[MAX_NUMBER_OF_MINERS + 1]; // Tick in which each miner reached its currently recorded best score, used as ranking tie-breaker @@ -268,6 +270,87 @@ static AntColonyBpp9000T gAntColony; static AntColonyBpp9000T::Ann gAntParentAnnScratch[MAX_NUMBER_OF_PROCESSORS]; static AntColonyBpp9000T::Ann gAntChildAnnScratch[MAX_NUMBER_OF_PROCESSORS]; +// Pre-scored ant solutions for the current tick, indexed by TRANSACTION index. Each transaction is +// enqueued at most once, so no two workers ever write the same slot and no lock is needed +static bool gAntScoredReady[NUMBER_OF_TRANSACTIONS_PER_TICK]; +static unsigned int gAntScoredValue[NUMBER_OF_TRANSACTIONS_PER_TICK]; +static AntColonyBpp9000T::Ann gAntScoredAnn[NUMBER_OF_TRANSACTIONS_PER_TICK]; + +// Enqueued per ant solution transaction. 80 bytes, inside ScoreFunction::TASK_PAYLOAD_MAX. +struct AntScoreTaskPayload +{ + m256i pubkey; + m256i nonce; + SolutionRef parentRef; + unsigned int anchorTick; + unsigned int txIdx; +}; +static_assert(sizeof(AntScoreTaskPayload) <= 128, "ant score payload must fit TASK_PAYLOAD_MAX"); + +// Score one ant solution ahead of the transaction loop, on whichever processor drains the queue. +static void scoreAntSolutionTask(unsigned long long processorNumber, void* payload) +{ + const AntScoreTaskPayload* task = (const AntScoreTaskPayload*)payload; + + const AntSolutionRecord* parentRec = nullptr; + if (gAntColony.tryGetParent(task->parentRef, &parentRec) != ValidityResult::Valid) + { + return; + } + + m256i anchorDigest; + if (!gAntColony.getAnchorDigest(task->anchorTick, anchorDigest)) + { + return; + } + + const AntColonyBpp9000T::Ann* parentAnn = nullptr; + if (parentRec != nullptr) + { + if (!gAntColony.annOfNonRoot(*parentRec, gAntParentAnnScratch[processorNumber])) + { + return; + } + parentAnn = &gAntParentAnnScratch[processorNumber]; + } + + // Straight into this transaction's own result slot, so nothing is copied afterwards. + gAntScoredValue[task->txIdx] = score->computeAntChildScore( + processorNumber, parentAnn, task->pubkey, task->nonce, + anchorDigest, gAntScoredAnn[task->txIdx]); + + // Last, so the transaction loop never sees a slot whose score or network is half written. + gAntScoredReady[task->txIdx] = true; +} + +// The two independent bits an ant solution occupies in gAntSolutionFlags. Hashed over the same +// triple AntDedupKey uses, so "seen" and "already in the tree" agree on what counts as one solution. +// Bytes are laid out explicitly rather than hashing a struct, so no padding can make the digest +// differ between compilers. +static void computeAntSolutionFlagIndices(const m256i& pubkey, const m256i& nonce, + const SolutionRef& parentRef, unsigned int* outFlagIndices) +{ + unsigned char preimage[sizeof(m256i) + sizeof(m256i) + sizeof(SolutionRef)]; + copyMem(preimage, &pubkey, sizeof(pubkey)); + copyMem(preimage + sizeof(pubkey), &nonce, sizeof(nonce)); + copyMem(preimage + sizeof(pubkey) + sizeof(nonce), &parentRef, sizeof(parentRef)); + KangarooTwelve(preimage, sizeof(preimage), outFlagIndices, 2 * sizeof(unsigned int)); +} + +// Seen means BOTH bits are set, matching the legacy filter: one bit alone is a collision with some +// other solution, and requiring two drops the false-positive rate from ~N/2^32 to ~N^2/2^64. +static bool isAntSolutionSeen(const unsigned int* flagIndices) +{ + return (gAntSolutionFlags[flagIndices[0] >> 6] & (1ULL << (flagIndices[0] & 63))) + && (gAntSolutionFlags[flagIndices[1] >> 6] & (1ULL << (flagIndices[1] & 63))); +} + +static void markAntSolutionSeen(const unsigned int* flagIndices) +{ + gAntSolutionFlags[flagIndices[0] >> 6] |= (1ULL << (flagIndices[0] & 63)); + gAntSolutionFlags[flagIndices[1] >> 6] |= (1ULL << (flagIndices[1] & 63)); +} + // The anchor digest a solution's mutation walk seeds from, K12(tick || transactionDigest) static void computeAntAnchorDigest(unsigned int tick, const m256i& transactionDigest, m256i& out) { @@ -2794,6 +2877,17 @@ static void processTickTransactionAntColonySolution( const SolutionRef parentRef = { transaction->parentTickOffset, transaction->parentSolutionIndexInTick }; + // Already looked at, accepted or not. Marked BEFORE the walk below, so one solution costs at + // most one walk for the whole epoch - _dedup alone would only cover the accepted ones. + unsigned int antFlagIndices[2]; + computeAntSolutionFlagIndices(transaction->sourcePublicKey, transaction->nonce, parentRef, antFlagIndices); + if (isAntSolutionSeen(antFlagIndices)) + { + gAntColony.recordReject(ValidityResult::RejectReplay); + return; + } + markAntSolutionSeen(antFlagIndices); + const AntSolutionRecord* parentRec = nullptr; ValidityResult result = gAntColony.tryGetParent(parentRef, &parentRec); if (result != ValidityResult::Valid) @@ -2811,22 +2905,34 @@ static void processTickTransactionAntColonySolution( return; } - // A null parent record means root, the scorer derives the submitter's own root, since roots are - // never stored and so cannot be handed in. - const AntColonyBpp9000T::Ann* parentAnn = nullptr; - if (parentRec != nullptr) + unsigned int childScore; + const AntColonyBpp9000T::Ann* childAnn; + if (gAntScoredReady[transactionIndex]) { - if (!gAntColony.annOfNonRoot(*parentRec, parentAnnScratch)) + childScore = gAntScoredValue[transactionIndex]; + childAnn = &gAntScoredAnn[transactionIndex]; + } + else + { + // A null parent record means root, the scorer derives the submitter's own root, since roots + // are never stored and so cannot be handed in. + const AntColonyBpp9000T::Ann* parentAnn = nullptr; + if (parentRec != nullptr) { - gAntColony.recordReject(ValidityResult::RejectParentNotRegistered); - return; + if (!gAntColony.annOfNonRoot(*parentRec, parentAnnScratch)) + { + gAntColony.recordReject(ValidityResult::RejectParentNotRegistered); + return; + } + parentAnn = &parentAnnScratch; } - parentAnn = &parentAnnScratch; + + childScore = score->computeAntChildScore( + processorNumber, parentAnn, transaction->sourcePublicKey, transaction->nonce, + anchorDigest, childAnnScratch); + childAnn = &childAnnScratch; } - const unsigned int childScore = score->computeAntChildScore( - processorNumber, parentAnn, transaction->sourcePublicKey, transaction->nonce, - anchorDigest, childAnnScratch); if (!score->isValidScore(childScore, score_engine::AlgoType::Bpp9000)) { gAntColony.recordReject(ValidityResult::RejectNonCanonicalNonce); @@ -2835,7 +2941,7 @@ static void processTickTransactionAntColonySolution( // Keep previous behavior, we fold both good and bad score into resource testing digest unsigned int childAnnHash; - KangarooTwelve(&childAnnScratch, sizeof(childAnnScratch), &childAnnHash, sizeof(childAnnHash)); + KangarooTwelve(childAnn, sizeof(*childAnn), &childAnnHash, sizeof(childAnnHash)); resourceTestingDigest ^= childScore; resourceTestingDigest ^= childAnnHash; KangarooTwelve(&resourceTestingDigest, sizeof(resourceTestingDigest), &resourceTestingDigest, sizeof(resourceTestingDigest)); @@ -2847,7 +2953,7 @@ static void processTickTransactionAntColonySolution( { system.tick - system.initialTick, transactionIndex }, // selfRef, epoch-RELATIVE tick transaction->anchorTick, // ABSOLUTE system.tick }; // ABSOLUTE - result = gAntColony.commit(in, parentRec, childScore, childAnnScratch, childAnnHash); + result = gAntColony.commit(in, parentRec, childScore, *childAnn, childAnnHash); if (result != ValidityResult::Valid) { return; // commit() counted this one itself @@ -3369,6 +3475,8 @@ static void processTick(unsigned long long processorNumber) PROFILE_NAMED_SCOPE_BEGIN("processTick(): pre-scan solutions"); // reset solution task queue score->resetTaskQueue(); + // Only the gate needs clearing; the score and the network are written before it is set. + setMem(gAntScoredReady, sizeof(gAntScoredReady), 0); // pre-scan any solution tx and add them to solution task queue for (unsigned int transactionIndex = 0; transactionIndex < NUMBER_OF_TRANSACTIONS_PER_TICK; transactionIndex++) { @@ -3402,6 +3510,29 @@ static void processTick(unsigned long long processorNumber) } } } + // Ant solutions ride the same async queue + else if (isZero(transaction->destinationPublicKey) + && transaction->amount >= AntColonyMiningSolutionTransaction::minAmount() + && transaction->inputType == AntColonyMiningSolutionTransaction::transactionType() + && transaction->inputSize == AntColonyMiningSolutionTransaction::minInputSize()) + { + const AntColonyMiningSolutionTransaction* antTx = + (const AntColonyMiningSolutionTransaction*)transaction; + AntScoreTaskPayload task; + task.pubkey = antTx->sourcePublicKey; + task.nonce = antTx->nonce; + task.parentRef.tickOffset = antTx->parentTickOffset; + task.parentRef.solutionIndexInTick = antTx->parentSolutionIndexInTick; + task.anchorTick = antTx->anchorTick; + task.txIdx = transactionIndex; + // Skip anything this node has already looked at, accepted or not + unsigned int antFlagIndices[2]; + computeAntSolutionFlagIndices(task.pubkey, task.nonce, task.parentRef, antFlagIndices); + if (!isAntSolutionSeen(antFlagIndices)) + { + score->addTask(scoreAntSolutionTask, &task, sizeof(task)); + } + } } } } @@ -4218,6 +4349,7 @@ static void beginEpoch() score->initMemory(); score->resetTaskQueue(); setMem(minerSolutionFlags, NUMBER_OF_MINER_SOLUTION_FLAGS / 8, 0); + setMem(gAntSolutionFlags, NUMBER_OF_ANT_SOLUTION_FLAGS / 8, 0); setMem((void*)minerPublicKeys, sizeof(minerPublicKeys), 0); setMem((void*)minerScores, sizeof(minerScores), 0xFF); setMem((void*)minerBestScoreTicks, sizeof(minerBestScoreTicks), 0); @@ -4703,6 +4835,15 @@ static bool saveAllNodeStates() return false; } + CHAR16 ANT_SOL_FLAG_FILE_NAME[] = L"snapshotAntSolutionFlag"; + logToConsole(L"Saving ant solution flags"); + savedSize = save(ANT_SOL_FLAG_FILE_NAME, NUMBER_OF_ANT_SOLUTION_FLAGS / 8, (unsigned char*)gAntSolutionFlags, directory); + if (savedSize != NUMBER_OF_ANT_SOLUTION_FLAGS / 8) + { + logToConsole(L"Failed to save ant solution flag"); + return false; + } + setText(message, L"Saving tick storage "); logToConsole(message); if (ts.trySaveToFile(system.epoch, system.tick, directory) != 0) @@ -4962,6 +5103,15 @@ static bool loadAllNodeStates() return false; } + CHAR16 ANT_SOL_FLAG_FILE_NAME[] = L"snapshotAntSolutionFlag"; + logToConsole(L"Loading ant solution flags"); + loadedSize = load(ANT_SOL_FLAG_FILE_NAME, NUMBER_OF_ANT_SOLUTION_FLAGS / 8, (unsigned char*)gAntSolutionFlags, directory); + if (loadedSize != NUMBER_OF_ANT_SOLUTION_FLAGS / 8) + { + logToConsole(L"Failed to load ant solution flag"); + return false; + } + if (!oracleEngine.loadSnapshot(system.epoch, directory)) { return false; @@ -6520,6 +6670,10 @@ static bool initialize() { return false; } + if (!allocPoolWithErrorLog(L"antSolutionFlag", NUMBER_OF_ANT_SOLUTION_FLAGS / 8, (void**)&gAntSolutionFlags, __LINE__)) + { + return false; + } if (!customQubicMiningStorage.init()) { @@ -6899,6 +7053,10 @@ static void deinitialize() { freePool(minerSolutionFlags); } + if (gAntSolutionFlags) + { + freePool(gAntSolutionFlags); + } if (dejavu0) { From 80db1c5542e51487f24baa5c7b26056a99c4e083 Mon Sep 17 00:00:00 2001 From: cyber-pc <165458555+cyber-pc@users.noreply.github.com> Date: Fri, 7 Aug 2026 17:14:58 +0700 Subject: [PATCH 12/46] Improve the ant colony test --- test/ant_colony.cpp | 219 +++++++++++++++++++++++++++++++++++--------- 1 file changed, 175 insertions(+), 44 deletions(-) diff --git a/test/ant_colony.cpp b/test/ant_colony.cpp index 4f825cc9..daefe0aa 100644 --- a/test/ant_colony.cpp +++ b/test/ant_colony.cpp @@ -9,14 +9,6 @@ #include -// The colony's rules and its stored network form. validateChild() is static and touches no member state, -// so the whole rule set is exercised here without allocating a colony, loading a task, or running -// the engine. -// -// Score is an ERROR COUNT: lower is better. Every expectation below depends on that direction, which -// is the thing most likely to be silently inverted by a future edit - a stale `>` reads fine and -// quietly accepts the wrong children. - static constexpr unsigned int TEST_THRESHOLD = 3838; // BPP9000_SOLUTION_THRESHOLD_DEFAULT static m256i makeKey(unsigned long long n) @@ -59,15 +51,7 @@ static ValidityResult admit(const ChildCandidate& child, const AntSolutionRecord return AntColonyBpp9000T::validateChild(child, parent, siblingFloorScore, TEST_THRESHOLD); } -// --------------------------------------------------------------------------------------------- -// Stored network form -// --------------------------------------------------------------------------------------------- - -// The packing itself is generic and tested exhaustively in trit_pack.cpp. What is ant-specific is -// the binding: that PackedAnn is dimensioned from the scorer's real ANN and covers all of it. The -// hazard is bpp9000's two strides - ANN is packed at lutSize (27) while the engine's internal -// PaddedLut is 32 - so a PackedAnn built on the wrong one would drop or duplicate entries, change -// childAnnHash, and surface as a resourceTestingDigest split rather than a crash. +// The packing itself is generic and tested exhaustively TEST(TestAntColonyPackedAnn, CoversAWholeAnnAtTheUnpaddedStride) { AntColonyBpp9000T::Ann src; @@ -89,12 +73,7 @@ TEST(TestAntColonyPackedAnn, CoversAWholeAnnAtTheUnpaddedStride) } } -// --------------------------------------------------------------------------------------------- -// Threshold -// --------------------------------------------------------------------------------------------- - -// The threshold is checked before the parent comparison, so nodes worse than it are never stored - -// which is why the early descent never consumes the store. +// The threshold is checked before the parent comparison, so nodes worse than it are never stored TEST(TestAntColonyValidate, ThresholdIsAnUpperBoundOnError) { const m256i me = makeKey(1); @@ -108,10 +87,6 @@ TEST(TestAntColonyValidate, ThresholdIsAnUpperBoundOnError) EXPECT_EQ(admit(makeChild(me, TEST_THRESHOLD), &looseParent, WORST_SCORE), ValidityResult::Valid); } -// --------------------------------------------------------------------------------------------- -// Parent -// --------------------------------------------------------------------------------------------- - TEST(TestAntColonyValidate, MustStrictlyBeatParent) { const m256i me = makeKey(2); @@ -147,10 +122,7 @@ TEST(TestAntColonyValidate, CannotBranchFromAnotherIdentity) EXPECT_EQ(admit(makeChild(me, 3700), &myNode, WORST_SCORE), ValidityResult::Valid); } -// --------------------------------------------------------------------------------------------- // Sibling floor -// --------------------------------------------------------------------------------------------- - TEST(TestAntColonyValidate, MustStrictlyBeatSiblingFloor) { const m256i me = makeKey(6); @@ -164,10 +136,7 @@ TEST(TestAntColonyValidate, MustStrictlyBeatSiblingFloor) EXPECT_EQ(admit(makeChild(me, TEST_THRESHOLD), &parent, WORST_SCORE), ValidityResult::Valid); } -// --------------------------------------------------------------------------------------------- // Freshness -// --------------------------------------------------------------------------------------------- - TEST(TestAntColonyValidate, FreshnessWindowBoundaries) { const m256i me = makeKey(7); @@ -189,12 +158,7 @@ TEST(TestAntColonyValidate, FreshnessWindowBoundaries) ValidityResult::RejectStale); } -// --------------------------------------------------------------------------------------------- -// Order of checks -// --------------------------------------------------------------------------------------------- - -// The order is consensus-visible: it decides which reject a solution is charged with, which drives -// the diagnostics an operator reads. Freshness first, then tree isolation, then threshold, then +// Order of checks: Freshness first, then tree isolation, then threshold, then // parent, then sibling floor. TEST(TestAntColonyValidate, ReportsTheFirstFailingRule) { @@ -223,11 +187,7 @@ TEST(TestAntColonyValidate, ReportsTheFirstFailingRule) ValidityResult::RejectLeParent); } -// --------------------------------------------------------------------------------------------- -// Direction sweep -// --------------------------------------------------------------------------------------------- - -// The whole rule set restated as one property: for a fixed parent, floor and threshold, acceptance +// For a fixed parent, floor and threshold, acceptance // must be monotone in the score - every score at or below the tightest bound is accepted, every // score above it is rejected. An inverted comparison anywhere breaks this even if the individual // boundary tests above were adjusted to match it. @@ -258,3 +218,174 @@ TEST(TestAntColonyValidate, AcceptanceIsMonotoneInScore) } EXPECT_TRUE(sawAccept) << "the sweep must cover the accepting region"; } + +// init() allocates ~6.2 GB, so the colony is built once for the file and re-seeded between tests. +// Lazy rather than SetUpTestSuite: that does not exist before gtest 1.10, and test.vcxproj builds +// against 1.8.1, where it would compile clean and never run. +static AntColonyBpp9000T* freshColony() +{ + static AntColonyBpp9000T colony; + static bool allocated = false; + static bool allocationFailed = false; + + if (!allocated && !allocationFailed) + { + allocationFailed = !colony.init(); + allocated = !allocationFailed; + } + if (allocationFailed) + { + return nullptr; + } + + colony.beginEpoch(makeKey(999)); + colony.setErrorThreshold(TEST_THRESHOLD); + return &colony; +} + +// Commits one child of the root, returns its index or ANT_INVALID_INDEX. nonceSeed keeps calls +// distinct, since (pubkey, nonce, parentRef) is the replay key. +static long long commitRootChild(AntColonyBpp9000T* colony, const m256i& owner, unsigned int score, + unsigned int txIdx, unsigned long long nonceSeed, unsigned int tick = 100000) +{ + AntCommitInput in; + in.pubkey = owner; + in.nonce = makeKey(nonceSeed); + in.parentRef = ROOT_REF; + in.selfRef.tickOffset = 7000; + in.selfRef.solutionIndexInTick = txIdx; + in.anchorTick = tick; + in.publishTick = tick; + + AntColonyBpp9000T::Ann ann; + setMem(&ann, sizeof(ann), 0); + ann.lut[0] = (unsigned char)(score % 3); + + const long long landsAt = (long long)colony->solutionCount(); + if (colony->commit(in, nullptr, score, ann, score) != ValidityResult::Valid) + { + return ANT_INVALID_INDEX; + } + return landsAt; +} + +// commit() head-inserts, so children run newest to oldest. siblingFloor() relies on it: only the +// older ones compete, so they sit at the tail. +TEST(TestAntColonyStore, SiblingsChainNewestFirst) +{ + AntColonyBpp9000T* colony = freshColony(); + ASSERT_NE(colony, nullptr) << "colony init failed; needs ~6.2 GB"; + + const m256i me = makeKey(1); + // Same anchor tick, so all three are inside the freshness window and coexist. + const long long a = commitRootChild(colony, me, 3800, 0, 500); + const long long b = commitRootChild(colony, me, 3810, 1, 501); + const long long c = commitRootChild(colony, me, 3820, 2, 502); + ASSERT_NE(a, ANT_INVALID_INDEX); + ASSERT_NE(b, ANT_INVALID_INDEX); + ASSERT_NE(c, ANT_INVALID_INDEX); + + EXPECT_EQ(colony->recordAt(c)->nextSiblingIdx, (unsigned int)b); + EXPECT_EQ(colony->recordAt(b)->nextSiblingIdx, (unsigned int)a); + EXPECT_EQ(colony->recordAt(a)->nextSiblingIdx, NO_SIBLING); +} + +// parentRef is a logical address, so it must map back to the record index. +TEST(TestAntColonyStore, SolutionRefResolvesToItsRecord) +{ + AntColonyBpp9000T* colony = freshColony(); + ASSERT_NE(colony, nullptr) << "colony init failed; needs ~6.2 GB"; + + const m256i me = makeKey(2); + const long long idx = commitRootChild(colony, me, 3800, 42, 600); + ASSERT_NE(idx, ANT_INVALID_INDEX); + + const SolutionRef ref = { 7000, 42 }; + EXPECT_EQ(colony->findIndexBySolutionRef(ref), idx); + + // An uncommitted ref must not resolve to a neighbour. + const SolutionRef missing = { 7000, 43 }; + EXPECT_EQ(colony->findIndexBySolutionRef(missing), ANT_INVALID_INDEX); +} + +// Same (pubkey, nonce, parentRef) is a replay whatever its score. +TEST(TestAntColonyStore, SameSolutionCannotCommitTwice) +{ + AntColonyBpp9000T* colony = freshColony(); + ASSERT_NE(colony, nullptr) << "colony init failed; needs ~6.2 GB"; + + const m256i me = makeKey(3); + ASSERT_NE(commitRootChild(colony, me, 3800, 0, 700), ANT_INVALID_INDEX); + + EXPECT_EQ(commitRootChild(colony, me, 3700, 1, 700), ANT_INVALID_INDEX); + EXPECT_EQ(colony->stats().rejectReplay, 1u); + EXPECT_EQ(colony->solutionCount(), 1u); +} + +// ROOT is never stored, so resolving it is Valid with a null record, not a lookup failure. +TEST(TestAntColonyStore, RootRefResolvesToValidWithNoRecord) +{ + AntColonyBpp9000T* colony = freshColony(); + ASSERT_NE(colony, nullptr) << "colony init failed; needs ~6.2 GB"; + + const AntSolutionRecord* parent = (const AntSolutionRecord*)1; // must be overwritten + EXPECT_EQ(colony->tryGetParent(ROOT_REF, &parent), ValidityResult::Valid); + EXPECT_EQ(parent, nullptr); + + const SolutionRef missing = { 7000, 0 }; + EXPECT_EQ(colony->tryGetParent(missing, &parent), ValidityResult::RejectParentNotRegistered); +} + +// Anchor ring +TEST(TestAntColonyStore, AnchorDigestRoundTrips) +{ + AntColonyBpp9000T* colony = freshColony(); + ASSERT_NE(colony, nullptr) << "colony init failed; needs ~6.2 GB"; + + const m256i digest = makeKey(4242); + colony->recordAnchorDigest(100000, digest); + + m256i out = m256i::zero(); + EXPECT_TRUE(colony->getAnchorDigest(100000, out)); + EXPECT_TRUE(out == digest); + + // An unrecorded tick is a miss, not whatever sits in that slot. + EXPECT_FALSE(colony->getAnchorDigest(100001, out)); +} + +// A tick ANT_ANCHOR_RING_SIZE later lands in the same slot. The evicted one must miss - returning +// the new digest would score against a network the miner never used. +TEST(TestAntColonyStore, AgedOutAnchorIsAMissNotTheWrongDigest) +{ + AntColonyBpp9000T* colony = freshColony(); + ASSERT_NE(colony, nullptr) << "colony init failed; needs ~6.2 GB"; + + const unsigned int oldTick = 100000; + const unsigned int newTick = oldTick + ANT_ANCHOR_RING_SIZE; + const m256i oldDigest = makeKey(11); + const m256i newDigest = makeKey(22); + + colony->recordAnchorDigest(oldTick, oldDigest); + colony->recordAnchorDigest(newTick, newDigest); + + m256i out = m256i::zero(); + EXPECT_FALSE(colony->getAnchorDigest(oldTick, out)); + EXPECT_TRUE(colony->getAnchorDigest(newTick, out)); + EXPECT_TRUE(out == newDigest); +} + +// beginEpoch() wipes the ring. It fills with ANT_ANCHOR_TICK_NONE, not zero, so tick 0 does not +// look recorded. +TEST(TestAntColonyStore, EpochResetClearsTheRing) +{ + AntColonyBpp9000T* colony = freshColony(); + ASSERT_NE(colony, nullptr) << "colony init failed; needs ~6.2 GB"; + + colony->recordAnchorDigest(100000, makeKey(7)); + m256i out = m256i::zero(); + ASSERT_TRUE(colony->getAnchorDigest(100000, out)); + + colony->beginEpoch(makeKey(999)); + EXPECT_FALSE(colony->getAnchorDigest(100000, out)); + EXPECT_FALSE(colony->getAnchorDigest(0, out)); +} From 223a3782a1c8aa00fc8c267ade69adf8f5e73967 Mon Sep 17 00:00:00 2001 From: cyber-pc <165458555+cyber-pc@users.noreply.github.com> Date: Fri, 7 Aug 2026 22:34:40 +0700 Subject: [PATCH 13/46] Support save load snapshot for ant colony. --- src/Qubic.vcxproj | 1 + src/Qubic.vcxproj.filters | 3 + src/mining/ant_colony.h | 13 ++ src/mining/ant_colony_snapshot.h | 361 +++++++++++++++++++++++++++++++ src/qubic.cpp | 27 ++- test/ant_colony.cpp | 220 ++++++++++++++++++- 6 files changed, 620 insertions(+), 5 deletions(-) create mode 100644 src/mining/ant_colony_snapshot.h diff --git a/src/Qubic.vcxproj b/src/Qubic.vcxproj index 524cf2d8..fa8b32b2 100644 --- a/src/Qubic.vcxproj +++ b/src/Qubic.vcxproj @@ -68,6 +68,7 @@ + diff --git a/src/Qubic.vcxproj.filters b/src/Qubic.vcxproj.filters index 88632333..fbb43940 100644 --- a/src/Qubic.vcxproj.filters +++ b/src/Qubic.vcxproj.filters @@ -194,6 +194,9 @@ mining + + mining + mining diff --git a/src/mining/ant_colony.h b/src/mining/ant_colony.h index 33858672..e7278907 100644 --- a/src/mining/ant_colony.h +++ b/src/mining/ant_colony.h @@ -252,6 +252,15 @@ class AntColony _stats.count(r); } + // Only records, the ANN pool and the anchor ring are written; + // the tick index, both head maps and the dedup set are DERIVED and are + // rebuilt from the records on load + bool saveSnapshot(unsigned short epoch, CHAR16* directory, unsigned int initialTick) const; + // rootSeed and errorThreshold are the NODE's values, not the file's. The snapshot must agree + // with them or it is refused + bool loadSnapshot(unsigned short epoch, CHAR16* directory, + const m256i& rootSeed, unsigned int errorThreshold, unsigned int initialTick); + // Anchor digests. Both take an ABSOLUTE system tick, never an epoch-relative tickOffset. // Called from tick processor only void recordAnchorDigest(unsigned int tick, const m256i& digest); @@ -315,6 +324,10 @@ class AntColony unsigned int childAnchorTick /* ABSOLUTE */, unsigned int walkLimit = ANT_MAX_NODES_PER_EPOCH) const; + // loadSnapshot() helper: rebuild the tick index, head maps and dedup set from the loaded + // records, treating them as untrusted input. Defined in ant_colony_snapshot.h. + bool rebuildDerivedState(unsigned int initialTick); + AntSolutionRecord* _records; PackedAnn* _annPool; AntTickSlot* _tickIndex; diff --git a/src/mining/ant_colony_snapshot.h b/src/mining/ant_colony_snapshot.h new file mode 100644 index 00000000..359b5b3b --- /dev/null +++ b/src/mining/ant_colony_snapshot.h @@ -0,0 +1,361 @@ +#pragma once + +#include "mining/ant_colony_bpp9000.h" +#include "platform/file_io.h" + +// Only what cannot be derived is written. The tick index, both head maps and the dedup set are +// rebuilt from the records +static unsigned short ANT_SNAPSHOT_META_FILENAME[] = L"snapshotAntColonyMeta.???"; +static unsigned short ANT_SNAPSHOT_ANCHORS_FILENAME[] = L"snapshotAntColonyAnchors.???"; +static unsigned short ANT_SNAPSHOT_RECORDS_FILENAME[] = L"snapshotAntColonyRecords.???"; +static unsigned short ANT_SNAPSHOT_POOL_FILENAME[] = L"snapshotAntColonyPool.???"; + +struct AntColonySnapshotMeta +{ + unsigned int magic; + unsigned int version; + unsigned int epoch; + unsigned int solutionCount; + // Layout guards. A snapshot written by a build with a different record or ANN size must be + // refused rather than reinterpreted + unsigned int recordSizeBytes; + unsigned int annPoolEntryBytes; + unsigned int errorThreshold; + // The base every selfRef.tickOffset in the records file is relative to. Records address each + // other by offset, so a snapshot restored against a different base is silently mis-addressed + unsigned int initialTick; + m256i rootSeed; + + static constexpr unsigned int MAGIC = 0x414E5443; // "ANTC" + static constexpr unsigned int VERSION = 1; +}; +static_assert(sizeof(AntColonySnapshotMeta) == 32 + 32, "AntColonySnapshotMeta unexpected padding"); + +static void antSnapshotFailure(const CHAR16* what, unsigned long long a, unsigned long long b) +{ + CHAR16 message[256]; + setText(message, L"[ant-colony] snapshot: "); + appendText(message, what); + appendText(message, L" "); + appendNumber(message, a, FALSE); + appendText(message, L" / "); + appendNumber(message, b, FALSE); + logToConsole(message); +} + +// Records and pool are sized by this, never by the raw count. At least one slot is always written, +// so no snapshot file is ever zero length +static unsigned long long antSnapshotSlotCount(unsigned int solutionCount) +{ + return (solutionCount > 0) ? (unsigned long long)solutionCount : 1ULL; +} + +static void antSnapshotNameForEpoch(unsigned short epoch) +{ + addEpochToFileName(ANT_SNAPSHOT_META_FILENAME, sizeof(ANT_SNAPSHOT_META_FILENAME) / sizeof(ANT_SNAPSHOT_META_FILENAME[0]), epoch); + addEpochToFileName(ANT_SNAPSHOT_ANCHORS_FILENAME, sizeof(ANT_SNAPSHOT_ANCHORS_FILENAME) / sizeof(ANT_SNAPSHOT_ANCHORS_FILENAME[0]), epoch); + addEpochToFileName(ANT_SNAPSHOT_RECORDS_FILENAME, sizeof(ANT_SNAPSHOT_RECORDS_FILENAME) / sizeof(ANT_SNAPSHOT_RECORDS_FILENAME[0]), epoch); + addEpochToFileName(ANT_SNAPSHOT_POOL_FILENAME, sizeof(ANT_SNAPSHOT_POOL_FILENAME) / sizeof(ANT_SNAPSHOT_POOL_FILENAME[0]), epoch); +} + +template +inline bool AntColony::saveSnapshot(unsigned short epoch, CHAR16* directory, + unsigned int initialTick) const +{ + ASSERT(_records != nullptr); + ASSERT(_annPool != nullptr); + ASSERT(_anchors != nullptr); + + antSnapshotNameForEpoch(epoch); + + AntColonySnapshotMeta meta; + setMem(&meta, sizeof(meta), 0); + meta.magic = AntColonySnapshotMeta::MAGIC; + meta.version = AntColonySnapshotMeta::VERSION; + meta.epoch = epoch; + meta.solutionCount = _solutionCount; + meta.recordSizeBytes = (unsigned int)sizeof(AntSolutionRecord); + meta.annPoolEntryBytes = (unsigned int)sizeof(PackedAnn); + meta.errorThreshold = _errorThreshold; + meta.initialTick = initialTick; + meta.rootSeed = _rootSeed; + + if (save(ANT_SNAPSHOT_META_FILENAME, sizeof(meta), (unsigned char*)&meta, directory) + != (long long)sizeof(meta)) + { + logToConsole(L"[ant-colony] failed to save snapshot meta"); + return false; + } + if (save(ANT_SNAPSHOT_ANCHORS_FILENAME, sizeof(AnchorRing), (unsigned char*)_anchors, directory) + != (long long)sizeof(AnchorRing)) + { + logToConsole(L"[ant-colony] failed to save snapshot anchors"); + return false; + } + + // Written even when the colony is empty, so the operator's snapshot is always the same number of files + const unsigned long long slots = antSnapshotSlotCount(_solutionCount); + const unsigned long long recordBytes = slots * sizeof(AntSolutionRecord); + const unsigned long long poolBytes = slots * sizeof(PackedAnn); + if (saveLargeFile(ANT_SNAPSHOT_RECORDS_FILENAME, recordBytes, (unsigned char*)_records, directory, false) + != (long long)recordBytes) + { + logToConsole(L"[ant-colony] failed to save snapshot records"); + return false; + } + if (saveLargeFile(ANT_SNAPSHOT_POOL_FILENAME, poolBytes, (unsigned char*)_annPool, directory, false) + != (long long)poolBytes) + { + logToConsole(L"[ant-colony] failed to save snapshot pool"); + return false; + } + return true; +} + +template +inline bool AntColony::loadSnapshot(unsigned short epoch, CHAR16* directory, + const m256i& rootSeed, unsigned int errorThreshold, unsigned int initialTick) +{ + ASSERT(_records != nullptr); + ASSERT(_annPool != nullptr); + ASSERT(_anchors != nullptr); + + antSnapshotNameForEpoch(epoch); + + AntColonySnapshotMeta meta; + if (load(ANT_SNAPSHOT_META_FILENAME, sizeof(meta), (unsigned char*)&meta, directory) + != (long long)sizeof(meta)) + { + logToConsole(L"[ant-colony] failed to load snapshot meta"); + return false; + } + if (meta.magic != AntColonySnapshotMeta::MAGIC || meta.version != AntColonySnapshotMeta::VERSION) + { + antSnapshotFailure(L"bad magic/version", meta.magic, meta.version); + return false; + } + if (meta.epoch != epoch) + { + antSnapshotFailure(L"epoch mismatch, file/expected", meta.epoch, epoch); + return false; + } + // A differently sized record or ANN would parse cleanly and produce a tree that is silently wrong. + if (meta.recordSizeBytes != sizeof(AntSolutionRecord) || meta.annPoolEntryBytes != sizeof(PackedAnn)) + { + antSnapshotFailure(L"layout mismatch, record/ann", meta.recordSizeBytes, meta.annPoolEntryBytes); + return false; + } + if (meta.solutionCount > ANT_MAX_NODES_PER_EPOCH) + { + antSnapshotFailure(L"solutionCount exceeds the cap, count/cap", meta.solutionCount, ANT_MAX_NODES_PER_EPOCH); + return false; + } + // Cross-check against the node state restored alongside this file. A mismatch means the two + // snapshots are not from the same moment - loading the tree anyway would derive every root from + // a seed the rest of the network is not using. + if (!(meta.rootSeed == rootSeed)) + { + antSnapshotFailure(L"root seed does not match the restored node state, record/0", 0, 0); + return false; + } + if (meta.errorThreshold != errorThreshold) + { + antSnapshotFailure(L"threshold does not match the node, file/node", meta.errorThreshold, errorThreshold); + return false; + } + // Every selfRef.tickOffset is relative to this. Restoring against a different base would not + // fail anywhere - it would resolve parent references to the wrong records + if (meta.initialTick != initialTick) + { + antSnapshotFailure(L"initial tick does not match the node, file/node", meta.initialTick, initialTick); + return false; + } + + // Everything above only read the meta, so a refusal there leaves the colony untouched. From here + // on the state is being overwritten + reset(); + + if (load(ANT_SNAPSHOT_ANCHORS_FILENAME, sizeof(AnchorRing), (unsigned char*)_anchors, directory) + != (long long)sizeof(AnchorRing)) + { + logToConsole(L"[ant-colony] failed to load snapshot anchors"); + reset(); + return false; + } + + // Read unconditionally and at the same sizing the save used, so an incomplete copy is refused + // here rather than booting a node with an empty tree. + const unsigned long long slots = antSnapshotSlotCount(meta.solutionCount); + const unsigned long long recordBytes = slots * sizeof(AntSolutionRecord); + const unsigned long long poolBytes = slots * sizeof(PackedAnn); + if (loadLargeFile(ANT_SNAPSHOT_RECORDS_FILENAME, recordBytes, (unsigned char*)_records, directory) + != (long long)recordBytes) + { + logToConsole(L"[ant-colony] failed to load snapshot records"); + reset(); + return false; + } + if (loadLargeFile(ANT_SNAPSHOT_POOL_FILENAME, poolBytes, (unsigned char*)_annPool, directory) + != (long long)poolBytes) + { + logToConsole(L"[ant-colony] failed to load snapshot pool"); + reset(); + return false; + } + + // The caller's values, which the checks above proved the file agrees with. + _rootSeed = rootSeed; + _errorThreshold = errorThreshold; + _solutionCount = meta.solutionCount; + + // Rebuild the intermediate data + if (!rebuildDerivedState(initialTick)) + { + reset(); + return false; + } + return true; +} + +template +inline bool AntColony::rebuildDerivedState(unsigned int initialTick) +{ + // Hoisted out of the loop: unpacking each stored network to verify its hash needs somewhere to + // put it, and this path runs once at boot. + Ann annBuffer; + + for (unsigned int i = 0; i < _solutionCount; i++) + { + const AntSolutionRecord& rec = _records[i]; + + if (rec.selfRef.tickOffset >= MAX_NUMBER_OF_TICKS_PER_EPOCH) + { + antSnapshotFailure(L"tickOffset out of range, record/tickOffset", i, rec.selfRef.tickOffset); + return false; + } + // commit() writes the record and its network at the same index, and findIndexBySolutionRef + // and annOfNonRoot both rely on it + // annStateSlot point to the ANN that must have similar index to the record + if (rec.annStateSlot != i) + { + antSnapshotFailure(L"annStateSlot is not the record index, record/slot", i, rec.annStateSlot); + return false; + } + + // The freshness rule validateChild() applied at admission, re-checked here. This is the only + // guard on anchorTick, which is consensus-relevant: siblingFloor() compares a stored record's + // anchorTick against a later child's, so a corrupt one changes which siblings compete and + // therefore which solutions the node accepts. publishTick was not stored because it does not + // need to be - commit() sets tickOffset to (publishTick - initialTick), so it inverts exactly + const unsigned int publishTick = initialTick + rec.selfRef.tickOffset; + if (rec.anchorTick > publishTick + || publishTick - rec.anchorTick > ANT_FRESHNESS_WINDOW_TICKS) + { + antSnapshotFailure(L"anchor tick outside the freshness window, record/anchorTick", i, rec.anchorTick); + return false; + } + + // Rebuild the tick index + AntTickSlot& tslot = _tickIndex[rec.selfRef.tickOffset]; + if (tslot.count == 0) + { + tslot.startIdx = i; + } + else if (tslot.startIdx + tslot.count != i) + { + antSnapshotFailure(L"record breaks tick contiguity, record/tickOffset", i, rec.selfRef.tickOffset); + return false; + } + tslot.count++; + + // The parent must be ROOT or an EARLIER record. The tick index built so far covers only + // records before this one, so a forward or self reference fails to resolve - which is what + // rejects a cycle. + const AntSolutionRecord* parentRec = nullptr; + if (!rec.parentRef.isRoot()) + { + const long long parentIdx = findIndexBySolutionRef(rec.parentRef); + if (parentIdx == ANT_INVALID_INDEX || (unsigned long long)parentIdx >= i) + { + antSnapshotFailure(L"parent not an earlier record, record/parentTickOffset", i, rec.parentRef.tickOffset); + return false; + } + parentRec = &_records[parentIdx]; + if (!(parentRec->pubkey == rec.pubkey)) + { + antSnapshotFailure(L"parent belongs to another identity, record", i, 0); + return false; + } + } + const unsigned int expectedDepth = (parentRec != nullptr) ? (parentRec->depth + 1) : 1; + if (rec.depth != expectedDepth) + { + antSnapshotFailure(L"depth does not match the parent, record/depth", i, rec.depth); + return false; + } + + // The two score rules validateChild() enforced when this record was admitted. A corrupt + // score would otherwise set a wrong sibling floor and a wrong bar for its own children. + if (rec.score > _errorThreshold) + { + antSnapshotFailure(L"score above the epoch threshold, record/score", i, rec.score); + return false; + } + if (parentRec != nullptr && rec.score >= parentRec->score) + { + antSnapshotFailure(L"score does not beat the parent, record/score", i, rec.score); + return false; + } + + // Re-derive the hash from the stored network + _annPool[i].unpack(annBuffer.lut); + unsigned int annHash; + KangarooTwelve(&annBuffer, sizeof(annBuffer), &annHash, sizeof(annHash)); + if (annHash != rec.childAnnHash) + { + antSnapshotFailure(L"stored network does not match childAnnHash, record", i, 0); + return false; + } + + const AntDedupKey key{ rec.pubkey, rec.nonce, rec.parentRef }; + if (_dedup->contains(key)) + { + antSnapshotFailure(L"duplicate solution, record", i, 0); + return false; + } + if (_dedup->add(key) == QPI::NULL_INDEX) + { + antSnapshotFailure(L"dedup set full, record", i, 0); + return false; + } + + // Rebuild the _childHeadByMiner and _childHeadByParent + unsigned int prevHead = NO_SIBLING; + if (rec.parentRef.isRoot()) + { + _childHeadByMiner->get(rec.pubkey, prevHead); + _records[i].nextSiblingIdx = prevHead; + if (_childHeadByMiner->set(rec.pubkey, i) == QPI::NULL_INDEX) + { + antSnapshotFailure(L"miner index full, record", i, 0); + return false; + } + } + else + { + _childHeadByParent->get(rec.parentRef, prevHead); + _records[i].nextSiblingIdx = prevHead; + _childHeadByParent->set(rec.parentRef, i); + } + + // Restore the stats also + _stats.acceptedSolutions++; + if (rec.depth > _stats.treeDepthMax) + { + _stats.treeDepthMax = rec.depth; + } + } + + _stats.treeSizeCurrent = _solutionCount; + return true; +} diff --git a/src/qubic.cpp b/src/qubic.cpp index 62ff96d3..e7d686b5 100644 --- a/src/qubic.cpp +++ b/src/qubic.cpp @@ -80,6 +80,7 @@ #include "oracle_core/oracle_engine.h" #include "oracle_core/net_msg_impl.h" #include "oracle_core/snapshot_files.h" +#include "mining/ant_colony_snapshot.h" #include "oracle_core/oracle_interfaces_def.h" #include "qpi/impl/qpi_oracle_impl.h" @@ -4855,6 +4856,11 @@ static bool saveAllNodeStates() #if !defined(NDEBUG) oracleEngine.checkStateConsistencyWithAssert(); #endif + if (!gAntColony.saveSnapshot(system.epoch, directory, system.initialTick)) + { + return false; + } + if (!oracleEngine.saveSnapshot(system.epoch, directory)) { return false; @@ -5112,6 +5118,16 @@ static bool loadAllNodeStates() return false; } + // initialRandomSeedFromPersistingState, not score->currentRandomSeed, the scorer is not reseeded + // until initialize() finishes, so this is the only restored copy available here. + if (!gAntColony.loadSnapshot(system.epoch, directory, + initialRandomSeedFromPersistingState, + (unsigned int)getSolutionThreshold(score_engine::AlgoType::Bpp9000), + system.initialTick)) + { + return false; + } + if (!oracleEngine.loadSnapshot(system.epoch, directory)) { return false; @@ -6864,16 +6880,19 @@ static bool initialize() { score->initMiningData(initialRandomSeedFromPersistingState); loadMiningSeedFromFile = false;; - // This branch does not go through checkAndSwitchMiningPhase, so the colony is seeded here - antColonyBeginEpoch(); + // Skipped entirely when a snapshot was restored + if (!loadAllNodeStateFromFile) + { + antColonyBeginEpoch(); + } } else { - short tickEpoch = -1; + short tickEpoch = -1; TimeDate tickDate; setMem((void*)&tickDate, sizeof(TimeDate), 0); checkAndSwitchMiningPhase(tickEpoch, tickDate, true); - } + } score->loadScoreCache(system.epoch); // Load + hash-verify the bpp9000 task once at init diff --git a/test/ant_colony.cpp b/test/ant_colony.cpp index daefe0aa..a3843146 100644 --- a/test/ant_colony.cpp +++ b/test/ant_colony.cpp @@ -6,6 +6,7 @@ // The bound colony, not the bare template: these tests check bpp9000's binding as well as the rules. #include "../src/mining/ant_colony_bpp9000.h" +#include "../src/mining/ant_colony_snapshot.h" #include @@ -261,8 +262,12 @@ static long long commitRootChild(AntColonyBpp9000T* colony, const m256i& owner, setMem(&ann, sizeof(ann), 0); ann.lut[0] = (unsigned char)(score % 3); + // The real hash, not a stand-in: the snapshot rebuild re-derives it from the stored network. + unsigned int annHash; + KangarooTwelve(&ann, sizeof(ann), &annHash, sizeof(annHash)); + const long long landsAt = (long long)colony->solutionCount(); - if (colony->commit(in, nullptr, score, ann, score) != ValidityResult::Valid) + if (colony->commit(in, nullptr, score, ann, annHash) != ValidityResult::Valid) { return ANT_INVALID_INDEX; } @@ -389,3 +394,216 @@ TEST(TestAntColonyStore, EpochResetClearsTheRing) EXPECT_FALSE(colony->getAnchorDigest(100000, out)); EXPECT_FALSE(colony->getAnchorDigest(0, out)); } + + +// Snapshot test cases + +static constexpr unsigned short TEST_EPOCH = 200; +static const m256i TEST_ROOT_SEED = makeKey(999); // what freshColony() seeds with + +// commitRootChild() writes tickOffset 7000 and publishes at tick 100000, so this is the base those +// two agree on. The load re-derives publishTick as initialTick + tickOffset and re-checks freshness, +// so a mismatched base makes every record look stale. +static constexpr unsigned int TEST_INITIAL_TICK = 100000 - 7000; + +// Save, wipe, load. beginEpoch() clears everything the load has to bring back, so anything that +// survives came out of the files. +static bool saveWipeLoad(AntColonyBpp9000T* colony) +{ + if (!colony->saveSnapshot(TEST_EPOCH, NULL, TEST_INITIAL_TICK)) + { + return false; + } + colony->beginEpoch(TEST_ROOT_SEED); + colony->setErrorThreshold(TEST_THRESHOLD); + return colony->loadSnapshot(TEST_EPOCH, NULL, TEST_ROOT_SEED, TEST_THRESHOLD, TEST_INITIAL_TICK); +} + +// Records and the tick index come back, and the sibling chain is rebuilt to the same shape commit() +// built. Only the records are on disk - nextSiblingIdx is replayed, so matching the pre-save chain +// is what proves the replay reproduces the head-insert. +TEST(TestAntColonySnapshot, RoundTripRestoresTheTree) +{ + AntColonyBpp9000T* colony = freshColony(); + ASSERT_NE(colony, nullptr) << "colony init failed; needs ~6.2 GB"; + + const m256i me = makeKey(1); + ASSERT_NE(commitRootChild(colony, me, 3800, 0, 500), ANT_INVALID_INDEX); + ASSERT_NE(commitRootChild(colony, me, 3810, 1, 501), ANT_INVALID_INDEX); + ASSERT_NE(commitRootChild(colony, me, 3820, 2, 502), ANT_INVALID_INDEX); + + ASSERT_TRUE(saveWipeLoad(colony)); + + ASSERT_EQ(colony->solutionCount(), 3u); + EXPECT_EQ(colony->recordAt(2)->nextSiblingIdx, 1u); + EXPECT_EQ(colony->recordAt(1)->nextSiblingIdx, 0u); + EXPECT_EQ(colony->recordAt(0)->nextSiblingIdx, NO_SIBLING); + EXPECT_EQ(colony->recordAt(1)->score, 3810u); + EXPECT_TRUE(colony->recordAt(1)->pubkey == me); + + // The tick index is derived too, so resolving a logical ref proves it was rebuilt. + const SolutionRef ref = { 7000, 1 }; + EXPECT_EQ(colony->findIndexBySolutionRef(ref), 1LL); +} + +// The stored network must come back byte for byte, otherwise children score against a parent the +// rest of the network does not have. +TEST(TestAntColonySnapshot, RoundTripRestoresTheStoredNetwork) +{ + AntColonyBpp9000T* colony = freshColony(); + ASSERT_NE(colony, nullptr) << "colony init failed; needs ~6.2 GB"; + + const m256i me = makeKey(2); + ASSERT_NE(commitRootChild(colony, me, 3800, 0, 900), ANT_INVALID_INDEX); + + AntColonyBpp9000T::Ann before; + ASSERT_TRUE(colony->annOfNonRoot(*colony->recordAt(0), before)); + + ASSERT_TRUE(saveWipeLoad(colony)); + + AntColonyBpp9000T::Ann after; + ASSERT_TRUE(colony->annOfNonRoot(*colony->recordAt(0), after)); + for (unsigned long long i = 0; i < sizeof(before); i++) + { + ASSERT_EQ(after.lut[i], before.lut[i]) << "entry " << i; + } +} + +// The dedup set is not written to disk. If the rebuild misses it, a restarted node re-accepts +// solutions it already has. +TEST(TestAntColonySnapshot, DedupIsRebuiltSoReplaysStillFail) +{ + AntColonyBpp9000T* colony = freshColony(); + ASSERT_NE(colony, nullptr) << "colony init failed; needs ~6.2 GB"; + + const m256i me = makeKey(3); + ASSERT_NE(commitRootChild(colony, me, 3800, 0, 600), ANT_INVALID_INDEX); + ASSERT_TRUE(saveWipeLoad(colony)); + + // Same (pubkey, nonce, parentRef) as before the restart. + EXPECT_EQ(commitRootChild(colony, me, 3700, 1, 600), ANT_INVALID_INDEX); + EXPECT_EQ(colony->solutionCount(), 1u); +} + +// A cold ring would reject solutions anchored before the restart that peers accept. +TEST(TestAntColonySnapshot, AnchorRingSurvivesTheRoundTrip) +{ + AntColonyBpp9000T* colony = freshColony(); + ASSERT_NE(colony, nullptr) << "colony init failed; needs ~6.2 GB"; + + const m256i digest = makeKey(4242); + colony->recordAnchorDigest(100000, digest); + ASSERT_TRUE(saveWipeLoad(colony)); + + m256i out = m256i::zero(); + EXPECT_TRUE(colony->getAnchorDigest(100000, out)); + EXPECT_TRUE(out == digest); + EXPECT_FALSE(colony->getAnchorDigest(100001, out)); +} + +// The seed and threshold are supplied by the node, not read from the file. A disagreement means the +// colony files and the node state are from different moments, so the tree is refused. +TEST(TestAntColonySnapshot, FileMustAgreeWithTheNodeState) +{ + AntColonyBpp9000T* colony = freshColony(); + ASSERT_NE(colony, nullptr) << "colony init failed; needs ~6.2 GB"; + + ASSERT_NE(commitRootChild(colony, makeKey(4), 3800, 0, 700), ANT_INVALID_INDEX); + ASSERT_TRUE(colony->saveSnapshot(TEST_EPOCH, NULL, TEST_INITIAL_TICK)); + + EXPECT_FALSE(colony->loadSnapshot(TEST_EPOCH, NULL, makeKey(12345), TEST_THRESHOLD, TEST_INITIAL_TICK)); + EXPECT_FALSE(colony->loadSnapshot(TEST_EPOCH, NULL, TEST_ROOT_SEED, TEST_THRESHOLD + 1, TEST_INITIAL_TICK)); + + // A different base would resolve every parentRef to the wrong record. + EXPECT_FALSE(colony->loadSnapshot(TEST_EPOCH, NULL, TEST_ROOT_SEED, TEST_THRESHOLD, TEST_INITIAL_TICK + 1)); + + // The epoch is part of the file name, so a different one finds no snapshot at all. + EXPECT_FALSE(colony->loadSnapshot((unsigned short)(TEST_EPOCH + 1), NULL, TEST_ROOT_SEED, TEST_THRESHOLD, TEST_INITIAL_TICK)); + + // And the matching one still loads, so the refusals above were the checks and not a bad file. + EXPECT_TRUE(colony->loadSnapshot(TEST_EPOCH, NULL, TEST_ROOT_SEED, TEST_THRESHOLD, TEST_INITIAL_TICK)); +} + +// Those refusals all happen while only the meta has been read, so the tree the node is already +// running on must be left alone. +TEST(TestAntColonySnapshot, RefusedLoadLeavesTheRunningTreeIntact) +{ + AntColonyBpp9000T* colony = freshColony(); + ASSERT_NE(colony, nullptr) << "colony init failed; needs ~6.2 GB"; + + const m256i me = makeKey(5); + ASSERT_NE(commitRootChild(colony, me, 3800, 0, 800), ANT_INVALID_INDEX); + ASSERT_TRUE(colony->saveSnapshot(TEST_EPOCH, NULL, TEST_INITIAL_TICK)); + ASSERT_NE(commitRootChild(colony, me, 3790, 1, 801), ANT_INVALID_INDEX); + ASSERT_EQ(colony->solutionCount(), 2u); + + EXPECT_FALSE(colony->loadSnapshot(TEST_EPOCH, NULL, makeKey(12345), TEST_THRESHOLD, TEST_INITIAL_TICK)); + EXPECT_EQ(colony->solutionCount(), 2u); +} + +// An empty colony still writes all four files, so an operator's backup is always the same set and a +// short one means a lost file rather than an empty epoch. +TEST(TestAntColonySnapshot, EmptyColonyWritesTheFullFileSet) +{ + AntColonyBpp9000T* colony = freshColony(); + ASSERT_NE(colony, nullptr) << "colony init failed; needs ~6.2 GB"; + + ASSERT_EQ(colony->solutionCount(), 0u); + + // Clear whatever an earlier test left at this epoch, so the files below can only come from the + // save under test. + antSnapshotNameForEpoch(TEST_EPOCH); + _wremove(ANT_SNAPSHOT_RECORDS_FILENAME); + _wremove(ANT_SNAPSHOT_POOL_FILENAME); + + ASSERT_TRUE(colony->saveSnapshot(TEST_EPOCH, NULL, TEST_INITIAL_TICK)); + + // Both exist and hold a full slot, so neither is zero length. + AntSolutionRecord recordSlot; + AntColonyBpp9000T::PackedAnn poolSlot; + EXPECT_EQ(load(ANT_SNAPSHOT_RECORDS_FILENAME, sizeof(recordSlot), (unsigned char*)&recordSlot), + (long long)sizeof(recordSlot)); + EXPECT_EQ(load(ANT_SNAPSHOT_POOL_FILENAME, sizeof(poolSlot), (unsigned char*)&poolSlot), + (long long)sizeof(poolSlot)); + + EXPECT_TRUE(colony->loadSnapshot(TEST_EPOCH, NULL, TEST_ROOT_SEED, TEST_THRESHOLD, TEST_INITIAL_TICK)); + EXPECT_EQ(colony->solutionCount(), 0u); +} + +// childAnnHash is the only thing tying a record to its stored network, so it is the only check on +// the pool file. Overwrite the pool behind the colony's back and the load must refuse. +TEST(TestAntColonySnapshot, CorruptedPoolIsRefused) +{ + AntColonyBpp9000T* colony = freshColony(); + ASSERT_NE(colony, nullptr) << "colony init failed; needs ~6.2 GB"; + + // score % 3 == 2, so an all-zero network is not the one this record hashes to. + ASSERT_NE(commitRootChild(colony, makeKey(6), 3800, 0, 1000), ANT_INVALID_INDEX); + ASSERT_TRUE(colony->saveSnapshot(TEST_EPOCH, NULL, TEST_INITIAL_TICK)); + + AntColonyBpp9000T::PackedAnn junk; + setMem(&junk, sizeof(junk), 0); + ASSERT_EQ(save(ANT_SNAPSHOT_POOL_FILENAME, sizeof(junk), (unsigned char*)&junk), + (long long)sizeof(junk)); + + EXPECT_FALSE(colony->loadSnapshot(TEST_EPOCH, NULL, TEST_ROOT_SEED, TEST_THRESHOLD, TEST_INITIAL_TICK)); + + // The pool is read after the meta checks pass, so a refusal here does clear the colony. + EXPECT_EQ(colony->solutionCount(), 0u); +} + +// anchorTick is the sibling-floor clock and has no other guard, so the load re-derives publishTick +// from the record's own address and re-checks the freshness rule. A record anchored 100000 ticks +// after the tick it claims to sit in cannot have passed that rule when it was admitted. +TEST(TestAntColonySnapshot, RecordOutsideItsFreshnessWindowIsRefused) +{ + AntColonyBpp9000T* colony = freshColony(); + ASSERT_NE(colony, nullptr) << "colony init failed; needs ~6.2 GB"; + + // Published at tick 200000 but still written at tickOffset 7000, so the load re-derives 100000. + ASSERT_NE(commitRootChild(colony, makeKey(7), 3800, 0, 1100, 200000), ANT_INVALID_INDEX); + ASSERT_TRUE(colony->saveSnapshot(TEST_EPOCH, NULL, TEST_INITIAL_TICK)); + + EXPECT_FALSE(colony->loadSnapshot(TEST_EPOCH, NULL, TEST_ROOT_SEED, TEST_THRESHOLD, TEST_INITIAL_TICK)); + EXPECT_EQ(colony->solutionCount(), 0u); +} From 6f07fc0d75255f43f37cc252ca15fca82a8d1e09 Mon Sep 17 00:00:00 2001 From: cyber-pc <165458555+cyber-pc@users.noreply.github.com> Date: Sat, 8 Aug 2026 07:18:17 +0700 Subject: [PATCH 14/46] Add the score cache for ant colony. --- src/mining/ant_colony.h | 186 ++++++++++++++++++++++++++++++++++++++++ src/public_settings.h | 6 ++ src/qubic.cpp | 64 ++++++++++++-- test/ant_colony.cpp | 168 ++++++++++++++++++++++++++++++++++++ 4 files changed, 417 insertions(+), 7 deletions(-) diff --git a/src/mining/ant_colony.h b/src/mining/ant_colony.h index e7278907..7d8263a7 100644 --- a/src/mining/ant_colony.h +++ b/src/mining/ant_colony.h @@ -5,6 +5,8 @@ #include "platform/m256.h" #include "platform/memory.h" #include "platform/memory_util.h" +#include "kangaroo_twelve.h" +#include "platform/file_io.h" #include "contract_core/pre_qpi_def.h" #include "qpi/qpi.h" #include "qpi/impl/qpi_hash_map_impl.h" @@ -209,6 +211,38 @@ class AntColony static constexpr unsigned long long ANT_ANN_POOL_BYTES = (unsigned long long)ANT_MAX_NODES_PER_EPOCH * sizeof(PackedAnn); + // The score is actually a function of below + struct ReplayKey + { + m256i pubkey; + m256i nonce; + m256i parentAnnHash; // K12 of the parent's ANN bytes; zero for a child of the root + m256i anchorDigest; // the digest the walk consumed, not the tick it came from + + bool operator==(const ReplayKey& other) const + { + return (pubkey == other.pubkey) && (nonce == other.nonce) + && (parentAnnHash == other.parentAnnHash) && (anchorDigest == other.anchorDigest); + } + }; + static_assert(sizeof(ReplayKey) == 4 * sizeof(m256i), "ReplayKey must be padding-free"); + + struct ReplayEntry + { + ReplayKey key; + PackedAnn ann; + unsigned int score; + unsigned int occupied; + }; + + // Padding-free, so the on-disk entry matches the in-memory one byte for byte. + static_assert(sizeof(ReplayEntry) == + sizeof(ReplayKey) + sizeof(PackedAnn) + 2 * sizeof(unsigned int), + "ReplayEntry unexpected padding"); + + static constexpr unsigned long long ANT_REPLAY_CACHE_BYTES = + (unsigned long long)ANT_REPLAY_CACHE_SIZE * sizeof(ReplayEntry); + bool init(); void deinit(); @@ -218,6 +252,7 @@ class AntColony void beginEpoch(const m256i& rootSeed) { reset(); + clearReplayCache(); _rootSeed = rootSeed; } @@ -261,6 +296,16 @@ class AntColony bool loadSnapshot(unsigned short epoch, CHAR16* directory, const m256i& rootSeed, unsigned int errorThreshold, unsigned int initialTick); + void putReplayScore(const ReplayKey& key, unsigned int score, const Ann& ann); + bool tryGetReplayScore(const ReplayKey& key, unsigned int& outScore, Ann& outAnn); + void clearReplayCache(); + unsigned int replayCacheOccupancy() const + { + return _replayCacheOccupancy; + } + bool saveReplayCache(unsigned short epoch, CHAR16* directory); + bool loadReplayCache(unsigned short epoch, CHAR16* directory); + // Anchor digests. Both take an ABSOLUTE system tick, never an epoch-relative tickOffset. // Called from tick processor only void recordAnchorDigest(unsigned int tick, const m256i& digest); @@ -328,6 +373,13 @@ class AntColony // records, treating them as untrusted input. Defined in ant_colony_snapshot.h. bool rebuildDerivedState(unsigned int initialTick); + static unsigned int replaySlotOf(const ReplayKey& key) + { + unsigned long long digest; + KangarooTwelve(&key, sizeof(key), &digest, sizeof(digest)); + return (unsigned int)(digest & (ANT_REPLAY_CACHE_SIZE - 1)); + } + AntSolutionRecord* _records; PackedAnn* _annPool; AntTickSlot* _tickIndex; @@ -343,6 +395,10 @@ class AntColony // Keyed by miner because ROOT_REF is shared by everyone. QPI::HashMap* _childHeadByMiner; + ReplayEntry* _replayCache; + volatile char _replayCacheLock; + unsigned int _replayCacheOccupancy; + unsigned int _solutionCount; unsigned int _errorThreshold; m256i _rootSeed; @@ -393,14 +449,24 @@ inline bool AntColony::init() { return false; } + if (!allocPoolWithErrorLog(L"AntColony::_replayCache", + ANT_REPLAY_CACHE_BYTES, (void**)&_replayCache, __LINE__)) + { + return false; + } reset(); + clearReplayCache(); return true; } template inline void AntColony::deinit() { + if (_replayCache) + { + freePool(_replayCache); + } if (_dedup) { freePool(_dedup); @@ -430,6 +496,7 @@ inline void AntColony::deinit() freePool(_records); } + _replayCache = nullptr; _dedup = nullptr; _childHeadByMiner = nullptr; _childHeadByParent = nullptr; @@ -473,6 +540,125 @@ inline void AntColony::reset() _stats.reset(); } +template +inline void AntColony::clearReplayCache() +{ + if (_replayCache == nullptr) + { + return; + } + LockGuard guard(_replayCacheLock); + for (unsigned int i = 0; i < ANT_REPLAY_CACHE_SIZE; i++) + { + _replayCache[i].occupied = 0; + } + _replayCacheOccupancy = 0; +} + +template +inline void AntColony::putReplayScore(const ReplayKey& key, unsigned int score, const Ann& ann) +{ + if (_replayCache == nullptr) + { + return; + } + ReplayEntry staged; + staged.key = key; + staged.ann.pack(ann.lut); + staged.score = score; + staged.occupied = 1; + + ReplayEntry& slot = _replayCache[replaySlotOf(key)]; + LockGuard guard(_replayCacheLock); + if (!slot.occupied) + { + _replayCacheOccupancy++; + } + copyMem(&slot, &staged, sizeof(ReplayEntry)); +} + +template +inline bool AntColony::tryGetReplayScore(const ReplayKey& key, unsigned int& outScore, Ann& outAnn) +{ + if (_replayCache == nullptr) + { + return false; + } + ReplayEntry& slot = _replayCache[replaySlotOf(key)]; + LockGuard guard(_replayCacheLock); + if (!slot.occupied || !(slot.key == key)) + { + return false; + } + outScore = slot.score; + slot.ann.unpack(outAnn.lut); + return true; +} + +// Cache the already computed score for the ant colony +static unsigned short ANT_COLONY_REPLAY_CACHE_FILENAME[] = L"antColonyReplayCache.???"; + +template +inline bool AntColony::saveReplayCache(unsigned short epoch, CHAR16* directory) +{ + if (_replayCache == nullptr) + { + return false; + } + addEpochToFileName(ANT_COLONY_REPLAY_CACHE_FILENAME, + sizeof(ANT_COLONY_REPLAY_CACHE_FILENAME) / sizeof(ANT_COLONY_REPLAY_CACHE_FILENAME[0]), epoch); + + // Held across the file IO so the table cannot change under the write. That blocks the solution + // processors for the duration + LockGuard guard(_replayCacheLock); + if (saveLargeFile(ANT_COLONY_REPLAY_CACHE_FILENAME, ANT_REPLAY_CACHE_BYTES, + (unsigned char*)_replayCache, directory, false) != (long long)ANT_REPLAY_CACHE_BYTES) + { + logToConsole(L"[ant-colony] failed to save replay cache"); + return false; + } + return true; +} + +template +inline bool AntColony::loadReplayCache(unsigned short epoch, CHAR16* directory) +{ + if (_replayCache == nullptr) + { + return false; + } + addEpochToFileName(ANT_COLONY_REPLAY_CACHE_FILENAME, + sizeof(ANT_COLONY_REPLAY_CACHE_FILENAME) / sizeof(ANT_COLONY_REPLAY_CACHE_FILENAME[0]), epoch); + + LockGuard guard(_replayCacheLock); + if (loadLargeFile(ANT_COLONY_REPLAY_CACHE_FILENAME, ANT_REPLAY_CACHE_BYTES, + (unsigned char*)_replayCache, directory) != (long long)ANT_REPLAY_CACHE_BYTES) + { + // Absent at the start of an epoch, and a wrong size means another build wrote it. Either way + // the table is zeroed and every solution gets computed honestly. + logToConsole(L"[ant-colony] no usable replay cache, solutions will be recomputed"); + setMem(_replayCache, ANT_REPLAY_CACHE_BYTES, 0); + _replayCacheOccupancy = 0; + return false; + } + + unsigned int occupied = 0; + for (unsigned int i = 0; i < ANT_REPLAY_CACHE_SIZE; i++) + { + if (_replayCache[i].occupied) + { + occupied++; + } + } + _replayCacheOccupancy = occupied; + + CHAR16 message[192]; + setText(message, L"[ant-colony] replay cache loaded, entries "); + appendNumber(message, occupied, FALSE); + logToConsole(message); + return true; +} + template inline void AntColony::recordAnchorDigest(unsigned int tick, const m256i& digest) { diff --git a/src/public_settings.h b/src/public_settings.h index 2e6691a0..8e2b088d 100644 --- a/src/public_settings.h +++ b/src/public_settings.h @@ -135,6 +135,12 @@ static constexpr unsigned int ANT_FRESHNESS_WINDOW_TICKS = 676; // Ant colony: tree nodes recorded per epoch; one per accepted solution. static constexpr unsigned int ANT_MAX_NODES_PER_EPOCH = 1u << 23; +// Ant colony: replay-cache entries, scores this node already computed so a restart does not +// recompute them. Node-local, not consensus; a miss only costs time. +static constexpr unsigned int ANT_REPLAY_CACHE_SIZE = 1u << 20; +static_assert((ANT_REPLAY_CACHE_SIZE & (ANT_REPLAY_CACHE_SIZE - 1)) == 0, + "ANT_REPLAY_CACHE_SIZE must be a power of two, the slot index masks with it"); + // Multipler of score static constexpr unsigned int NEURAXON_SOLUTION_MULTIPLER = 1; static constexpr unsigned int BPP9000_SOLUTION_MULTIPLER = 1; diff --git a/src/qubic.cpp b/src/qubic.cpp index e7d686b5..dbdafa13 100644 --- a/src/qubic.cpp +++ b/src/qubic.cpp @@ -288,6 +288,24 @@ struct AntScoreTaskPayload }; static_assert(sizeof(AntScoreTaskPayload) <= 128, "ant score payload must fit TASK_PAYLOAD_MAX"); +// The cache is keyed by what the score is a function of, not by where the inputs were found. A +// parent named by position and an anchor named by tick number both depend on this node already +// holding the same tree, which is exactly what a node replaying after a restart is still building. +static AntColonyBpp9000T::ReplayKey makeAntReplayKey(const m256i& pubkey, const m256i& nonce, + const AntColonyBpp9000T::Ann* parentAnn, const m256i& anchorDigest) +{ + AntColonyBpp9000T::ReplayKey key; + key.pubkey = pubkey; + key.nonce = nonce; + key.parentAnnHash = m256i::zero(); // a child of the root has no parent network to name + if (parentAnn != nullptr) + { + KangarooTwelve(parentAnn, sizeof(*parentAnn), &key.parentAnnHash, sizeof(key.parentAnnHash)); + } + key.anchorDigest = anchorDigest; + return key; +} + // Score one ant solution ahead of the transaction loop, on whichever processor drains the queue. static void scoreAntSolutionTask(unsigned long long processorNumber, void* payload) { @@ -315,10 +333,18 @@ static void scoreAntSolutionTask(unsigned long long processorNumber, void* paylo parentAnn = &gAntParentAnnScratch[processorNumber]; } - // Straight into this transaction's own result slot, so nothing is copied afterwards. - gAntScoredValue[task->txIdx] = score->computeAntChildScore( - processorNumber, parentAnn, task->pubkey, task->nonce, - anchorDigest, gAntScoredAnn[task->txIdx]); + const AntColonyBpp9000T::ReplayKey replayKey = + makeAntReplayKey(task->pubkey, task->nonce, parentAnn, anchorDigest); + + // Check in the cache first if this sol was computed + if (!gAntColony.tryGetReplayScore(replayKey, gAntScoredValue[task->txIdx], gAntScoredAnn[task->txIdx])) + { + // Straight into this transaction's own result slot, so nothing is copied afterwards. + gAntScoredValue[task->txIdx] = score->computeAntChildScore( + processorNumber, parentAnn, task->pubkey, task->nonce, + anchorDigest, gAntScoredAnn[task->txIdx]); + gAntColony.putReplayScore(replayKey, gAntScoredValue[task->txIdx], gAntScoredAnn[task->txIdx]); + } // Last, so the transaction loop never sees a slot whose score or network is half written. gAntScoredReady[task->txIdx] = true; @@ -2928,9 +2954,17 @@ static void processTickTransactionAntColonySolution( parentAnn = &parentAnnScratch; } - childScore = score->computeAntChildScore( - processorNumber, parentAnn, transaction->sourcePublicKey, transaction->nonce, - anchorDigest, childAnnScratch); + // Same cache the async path uses. Reached when the pre-scan did not enqueue this one or the + // queue did not drain in time, which is exactly the catch-up case the cache exists for. + const AntColonyBpp9000T::ReplayKey replayKey = + makeAntReplayKey(transaction->sourcePublicKey, transaction->nonce, parentAnn, anchorDigest); + if (!gAntColony.tryGetReplayScore(replayKey, childScore, childAnnScratch)) + { + childScore = score->computeAntChildScore( + processorNumber, parentAnn, transaction->sourcePublicKey, transaction->nonce, + anchorDigest, childAnnScratch); + gAntColony.putReplayScore(replayKey, childScore, childAnnScratch); + } childAnn = &childAnnScratch; } @@ -6895,6 +6929,11 @@ static bool initialize() } score->loadScoreCache(system.epoch); + // After the branch above, never before: both paths can call antColonyBeginEpoch(), which clears + // the cache. A memo, not state - absence or any load failure just means the solutions get + // computed honestly. + gAntColony.loadReplayCache(system.epoch, NULL); + // Load + hash-verify the bpp9000 task once at init if (!loadBpp9000Task()) { @@ -8118,6 +8157,7 @@ EFI_STATUS efi_main(EFI_HANDLE imageHandle, EFI_SYSTEM_TABLE* systemTable) #endif unsigned long long clockTick = 0, systemDataSavingTick = 0, loggingTick = 0, peerRefreshingTick = 0, tickRequestingTick = 0; + unsigned long long antReplayCacheSavingTick = 0; unsigned int tickRequestingIndicator = 0, futureTickRequestingIndicator = 0; autoResendTickVotes.lastTick = system.initialTick; autoResendTickVotes.lastCheck = __rdtsc(); @@ -8305,6 +8345,16 @@ EFI_STATUS efi_main(EFI_HANDLE imageHandle, EFI_SYSTEM_TABLE* systemTable) score->saveScoreCache(system.epoch); } #endif + // Deliberately outside the guard above: the cache earns its keep by being newer than + // the snapshot, so tying it to the snapshot's schedule would make every entry it holds + // one the restored tree already covers. AUX only - saving parks the solution + // processors for the length of the write. + if ((!isMainMode()) + && curTimeTick - antReplayCacheSavingTick >= SYSTEM_DATA_SAVING_PERIOD * frequency / 1000) + { + antReplayCacheSavingTick = curTimeTick; + gAntColony.saveReplayCache(system.epoch, NULL); + } tryResendTickVotes(); if (curTimeTick - peerRefreshingTick >= PEER_REFRESHING_PERIOD * frequency / 1000) diff --git a/test/ant_colony.cpp b/test/ant_colony.cpp index a3843146..03ca550b 100644 --- a/test/ant_colony.cpp +++ b/test/ant_colony.cpp @@ -607,3 +607,171 @@ TEST(TestAntColonySnapshot, RecordOutsideItsFreshnessWindowIsRefused) EXPECT_FALSE(colony->loadSnapshot(TEST_EPOCH, NULL, TEST_ROOT_SEED, TEST_THRESHOLD, TEST_INITIAL_TICK)); EXPECT_EQ(colony->solutionCount(), 0u); } + +// --------------------------------------------------------------------------------------------- +// Replay cache + +// A key whose four components are all distinct, so a slot function that ignores one still separates +// these. +static AntColonyBpp9000T::ReplayKey makeReplayKey(unsigned long long n) +{ + AntColonyBpp9000T::ReplayKey k; + k.pubkey = makeKey(n); + k.nonce = makeKey(n + 1000); + k.parentAnnHash = makeKey(n + 2000); + k.anchorDigest = makeKey(n + 3000); + return k; +} + +// lut[0] carries n so two networks are distinguishable; the rest stays a legal trit. +static AntColonyBpp9000T::Ann makeAnn(unsigned char n) +{ + AntColonyBpp9000T::Ann a; + setMem(&a, sizeof(a), 0); + a.lut[0] = (unsigned char)(n % 3); + a.lut[1] = (unsigned char)((n / 3) % 3); + return a; +} + +static bool annEquals(const AntColonyBpp9000T::Ann& a, const AntColonyBpp9000T::Ann& b) +{ + for (unsigned long long i = 0; i < sizeof(a); i++) + { + if (a.lut[i] != b.lut[i]) + { + return false; + } + } + return true; +} + +// The score and the network both come back. The network matters as much as the score: commit() +// stores it and childAnnHash folds it into resourceTestingDigest. +TEST(TestAntColonyReplayCache, StoresAndReturnsScoreAndNetwork) +{ + AntColonyBpp9000T* colony = freshColony(); + ASSERT_NE(colony, nullptr) << "colony init failed; needs ~6.9 GB"; + + const AntColonyBpp9000T::ReplayKey key = makeReplayKey(1); + const AntColonyBpp9000T::Ann ann = makeAnn(7); + colony->putReplayScore(key, 3800, ann); + + unsigned int score = 0; + AntColonyBpp9000T::Ann out; + setMem(&out, sizeof(out), 0xFF); + ASSERT_TRUE(colony->tryGetReplayScore(key, score, out)); + EXPECT_EQ(score, 3800u); + EXPECT_TRUE(annEquals(out, ann)); +} + +// Every component is part of the key, so changing any one of them must miss. Missing one would +// return a score computed from different inputs. +TEST(TestAntColonyReplayCache, EveryKeyComponentIsPartOfTheLookup) +{ + AntColonyBpp9000T* colony = freshColony(); + ASSERT_NE(colony, nullptr) << "colony init failed; needs ~6.9 GB"; + + const AntColonyBpp9000T::ReplayKey key = makeReplayKey(2); + colony->putReplayScore(key, 3800, makeAnn(1)); + + unsigned int score = 0; + AntColonyBpp9000T::Ann out; + for (int component = 0; component < 4; component++) + { + AntColonyBpp9000T::ReplayKey altered = key; + switch (component) + { + case 0: altered.pubkey = makeKey(90001); break; + case 1: altered.nonce = makeKey(90002); break; + case 2: altered.parentAnnHash = makeKey(90003); break; + case 3: altered.anchorDigest = makeKey(90004); break; + } + EXPECT_FALSE(colony->tryGetReplayScore(altered, score, out)) << "component " << component; + } + EXPECT_TRUE(colony->tryGetReplayScore(key, score, out)); +} + +// A new epoch changes every root and anchor digest, so no entry could hit anyway; keeping them would +// just hold slots. +TEST(TestAntColonyReplayCache, BeginEpochClearsIt) +{ + AntColonyBpp9000T* colony = freshColony(); + ASSERT_NE(colony, nullptr) << "colony init failed; needs ~6.9 GB"; + + const AntColonyBpp9000T::ReplayKey key = makeReplayKey(3); + colony->putReplayScore(key, 3800, makeAnn(2)); + ASSERT_EQ(colony->replayCacheOccupancy(), 1u); + + colony->beginEpoch(TEST_ROOT_SEED); + + unsigned int score = 0; + AntColonyBpp9000T::Ann out; + EXPECT_FALSE(colony->tryGetReplayScore(key, score, out)); + EXPECT_EQ(colony->replayCacheOccupancy(), 0u); +} + +// loadSnapshot() calls reset(), and the catch-up that follows a restore is the one moment the cache +// is worth most. Losing it there would defeat the feature. +TEST(TestAntColonyReplayCache, SurvivesResetAndSnapshotLoad) +{ + AntColonyBpp9000T* colony = freshColony(); + ASSERT_NE(colony, nullptr) << "colony init failed; needs ~6.9 GB"; + + const AntColonyBpp9000T::ReplayKey key = makeReplayKey(4); + colony->putReplayScore(key, 3800, makeAnn(3)); + ASSERT_TRUE(colony->saveSnapshot(TEST_EPOCH, NULL, TEST_INITIAL_TICK)); + ASSERT_TRUE(colony->loadSnapshot(TEST_EPOCH, NULL, TEST_ROOT_SEED, TEST_THRESHOLD, TEST_INITIAL_TICK)); + + unsigned int score = 0; + AntColonyBpp9000T::Ann out; + EXPECT_TRUE(colony->tryGetReplayScore(key, score, out)); + EXPECT_EQ(score, 3800u); +} + +// The file is the table verbatim, so this checks that entries survive the write and stay findable +// under their own keys. Enough of them that collisions and evictions are in play. One save writes +// the whole ANT_REPLAY_CACHE_BYTES table, so this is the only test here that touches a file. +TEST(TestAntColonyReplayCache, RoundTripsThroughAFile) +{ + AntColonyBpp9000T* colony = freshColony(); + ASSERT_NE(colony, nullptr) << "colony init failed; needs ~6.9 GB"; + + constexpr unsigned int COUNT = 500; + for (unsigned int i = 0; i < COUNT; i++) + { + colony->putReplayScore(makeReplayKey(10000 + i), 3000 + i, makeAnn((unsigned char)i)); + } + ASSERT_EQ(colony->replayCacheOccupancy(), COUNT); + ASSERT_TRUE(colony->saveReplayCache(TEST_EPOCH, NULL)); + + colony->clearReplayCache(); + ASSERT_EQ(colony->replayCacheOccupancy(), 0u); + + ASSERT_TRUE(colony->loadReplayCache(TEST_EPOCH, NULL)); + EXPECT_EQ(colony->replayCacheOccupancy(), COUNT); + + unsigned int score = 0; + AntColonyBpp9000T::Ann out; + for (unsigned int i = 0; i < COUNT; i++) + { + ASSERT_TRUE(colony->tryGetReplayScore(makeReplayKey(10000 + i), score, out)) << "entry " << i; + ASSERT_EQ(score, 3000 + i) << "entry " << i; + ASSERT_TRUE(annEquals(out, makeAnn((unsigned char)i))) << "entry " << i; + } +} + +// No cache is the normal state at the start of an epoch, so it must report a miss and leave an empty +// table rather than fail the boot. +TEST(TestAntColonyReplayCache, AbsentFileIsNotAnError) +{ + AntColonyBpp9000T* colony = freshColony(); + ASSERT_NE(colony, nullptr) << "colony init failed; needs ~6.9 GB"; + + colony->putReplayScore(makeReplayKey(7), 3800, makeAnn(6)); + EXPECT_FALSE(colony->loadReplayCache((unsigned short)(TEST_EPOCH + 77), NULL)); + EXPECT_EQ(colony->replayCacheOccupancy(), 0u); + + unsigned int score = 0; + AntColonyBpp9000T::Ann out; + EXPECT_FALSE(colony->tryGetReplayScore(makeReplayKey(7), score, out)); +} From 8fab23ce632bc3e7b2f75797c91c65390772cd72 Mon Sep 17 00:00:00 2001 From: cyber-pc <165458555+cyber-pc@users.noreply.github.com> Date: Sat, 8 Aug 2026 12:10:56 +0700 Subject: [PATCH 15/46] Handle the case ant colony tree is full. --- src/mining/ant_colony.h | 14 +++++++++----- src/qubic.cpp | 11 +++++++---- 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/src/mining/ant_colony.h b/src/mining/ant_colony.h index 7d8263a7..c7d56474 100644 --- a/src/mining/ant_colony.h +++ b/src/mining/ant_colony.h @@ -78,13 +78,15 @@ struct AntTickSlot enum ValidityResult { Valid, + // Passed every rule but the store is full, so it was not recorded. Still a valid solution: its + // score is already folded into resourceTestingDigest, and the caller must refund and rank it. + ValidNotStored, RejectParentNotRegistered, RejectStale, // anchor in the future, or published more than N ticks after it RejectWrongTree, // parent belongs to a different identity RejectBelowThreshold, // score above the per-epoch error bound RejectLeParent, // did not strictly beat the parent RejectBelowSiblingFloor, // did not strictly beat the best sibling anchored more than N earlier - RejectRecordsCapFull, RejectTickOutOfRange, RejectReplay, // (pubkey, parentRef, nonce) already committed this epoch RejectDedupFull, @@ -100,7 +102,6 @@ struct AntColonyDiagnostics unsigned long long rejectThreshold; unsigned long long rejectLeParent; unsigned long long rejectSiblingFloor; - unsigned long long rejectRecordsCapFull; unsigned long long rejectTickOutOfRange; unsigned long long rejectReplay; unsigned long long rejectDedupFull; @@ -108,6 +109,7 @@ struct AntColonyDiagnostics unsigned long long rejectNonCanonicalNonce; unsigned long long acceptedSolutions; + unsigned long long acceptedNotStored; unsigned long long treeDepthMax; unsigned long long treeSizeCurrent; @@ -126,7 +128,6 @@ struct AntColonyDiagnostics case ValidityResult::RejectBelowThreshold: rejectThreshold++; break; case ValidityResult::RejectLeParent: rejectLeParent++; break; case ValidityResult::RejectBelowSiblingFloor: rejectSiblingFloor++; break; - case ValidityResult::RejectRecordsCapFull: rejectRecordsCapFull++; break; case ValidityResult::RejectTickOutOfRange: rejectTickOutOfRange++; break; case ValidityResult::RejectReplay: rejectReplay++; break; case ValidityResult::RejectDedupFull: rejectDedupFull++; break; @@ -843,10 +844,13 @@ inline ValidityResult AntColony::commit(const AntCommitInput& in, const recordReject(ValidityResult::RejectReplay); return ValidityResult::RejectReplay; } + // Store full. Every rule above already passed, so the solution is honest work and its score is + // in the digest whatever happens here - rejecting it would burn the deposit for a valid answer. + // Honour it and stop storing: the tree freezes, the leaderboard does not. if (_solutionCount >= ANT_MAX_NODES_PER_EPOCH) { - recordReject(ValidityResult::RejectRecordsCapFull); - return ValidityResult::RejectRecordsCapFull; + _stats.acceptedNotStored++; + return ValidityResult::ValidNotStored; } if (in.selfRef.tickOffset >= MAX_NUMBER_OF_TICKS_PER_EPOCH) { diff --git a/src/qubic.cpp b/src/qubic.cpp index dbdafa13..6f3e6c19 100644 --- a/src/qubic.cpp +++ b/src/qubic.cpp @@ -2629,8 +2629,7 @@ static bool ranksBelow(unsigned int scoreA, unsigned int tickA, unsigned int sco return tickA > tickB; } -// Tick-processor only: the competitor sort between the two minerScoreArrayLock sections runs -// unlocked on purpose. +// Tick-processor only static void updateMinerRankingAndFutureComputors( const m256i& sourcePublicKey, unsigned int newScore, @@ -2989,14 +2988,18 @@ static void processTickTransactionAntColonySolution( transaction->anchorTick, // ABSOLUTE system.tick }; // ABSOLUTE result = gAntColony.commit(in, parentRec, childScore, *childAnn, childAnnHash); - if (result != ValidityResult::Valid) + // ValidNotStored is the store being full: the solution passed every rule and only missed a slot, + // so it earns its refund and its ranking exactly like a stored one + if (result != ValidityResult::Valid && result != ValidityResult::ValidNotStored) { return; // commit() counted this one itself } - // Refund AND ranking + // Refund AND ranking. A valid solution is refunded whether or not it improved this miner's best, + // and whether or not the store had room for it, ranking is best-score-only if (transaction->claimedScore == childScore) { + // Refund if this score == its claimed score and the ann tree check increaseEnergy(transaction->sourcePublicKey, transaction->amount); const QuTransfer quTransfer = { m256i::zero(), transaction->sourcePublicKey, transaction->amount }; From 8d49a2b7ef657878d71a4d8da16c8b58b65bc129 Mon Sep 17 00:00:00 2001 From: cyber-pc <165458555+cyber-pc@users.noreply.github.com> Date: Sat, 8 Aug 2026 17:57:01 +0700 Subject: [PATCH 16/46] Collect best ANN at the end of epoch --- src/mining/ant_colony.h | 187 ++++++++++++++++++++++++++ src/mining/ant_colony_snapshot.h | 33 +++++ src/qubic.cpp | 2 + test/ant_colony.cpp | 224 +++++++++++++++++++++++++++++++ 4 files changed, 446 insertions(+) diff --git a/src/mining/ant_colony.h b/src/mining/ant_colony.h index c7d56474..9161db57 100644 --- a/src/mining/ant_colony.h +++ b/src/mining/ant_colony.h @@ -65,6 +65,16 @@ static constexpr unsigned int WORST_SCORE = 0xFFFFFFFFu; static constexpr long long ANT_INVALID_INDEX = -1; static_assert(sizeof(AntSolutionRecord) == 104, "AntSolutionRecord unexpected padding"); +// ANN that will be saved for the epoch +template +struct AntExportSlotT +{ + m256i pubkey; + unsigned int score; + unsigned int depth; + PackedAnnT ann; +}; + // tickOffset -> the run of records committed in that tick, so findIndexBySolutionRef() resolves a // SolutionRef without scanning the store. The run is unbroken because the store is append-only and // a tick's solutions all commit while that tick is processed @@ -171,6 +181,10 @@ static_assert(ANT_CHILD_HEAD_BY_PARENT_SIZE >= 2ULL * ANT_MAX_NODES_PER_EPOCH, // submit, so this one CAN fill - commit() fails closed with RejectMinerIndexFull. static constexpr unsigned long long ANT_CHILD_HEAD_BY_MINER_SIZE = 2ULL * MAX_NUMBER_OF_MINERS; +// How many ANN the epoch's harvest keeps. The target is the LUT with the best error, so this is +// simply the lowest N scores of the epoch - not one per identity, and not tied to the ranking +static constexpr unsigned int ANT_EXPORT_MAX_SOLUTIONS = NUMBER_OF_COMPUTORS; + // Anchor digests for recent ticks, indexed by tick & (size - 1). Smallest power of two holding // 2*(N+1) entries so a lookup inside the freshness window can never be aliased by a newer tick. static constexpr unsigned int antAnchorRingSize(unsigned int window) @@ -207,6 +221,19 @@ class AntColony // Catches sizing from a scorer's padded genome (bpp9000: lutSize 27 vs PaddedLut 32). static_assert(PackedAnn::tritCount == sizeof(Ann), "PackedAnn must cover exactly one ANN"); + using ExportSlot = AntExportSlotT; + + // The epoch's best ANN, maintained as solutions arrive + struct ExportSet + { + ExportSlot slots[ANT_EXPORT_MAX_SOLUTIONS]; + // Indices into slots, ascending by score. Kept separate so an insert shifts 4-byte indices + // rather than 552-byte slots. + unsigned int order[ANT_EXPORT_MAX_SOLUTIONS]; + unsigned int count; + unsigned int padding; + }; + static constexpr unsigned long long ANT_RECORDS_BYTES = (unsigned long long)ANT_MAX_NODES_PER_EPOCH * sizeof(AntSolutionRecord); static constexpr unsigned long long ANT_ANN_POOL_BYTES = @@ -307,6 +334,10 @@ class AntColony bool saveReplayCache(unsigned short epoch, CHAR16* directory); bool loadReplayCache(unsigned short epoch, CHAR16* directory); + // Writes the ANT_EXPORT_MAX_SOLUTIONS lowest-scoring networks of the epoch to a file for offline + // extraction. MUST be called between endEpoch() and the reset that starts the next epoch + bool exportBestSolutions(unsigned short epoch, CHAR16* directory); + // Anchor digests. Both take an ABSOLUTE system tick, never an epoch-relative tickOffset. // Called from tick processor only void recordAnchorDigest(unsigned int tick, const m256i& digest); @@ -370,6 +401,47 @@ class AntColony unsigned int childAnchorTick /* ABSOLUTE */, unsigned int walkLimit = ANT_MAX_NODES_PER_EPOCH) const; + // Offers a solution to the epoch's best-N set. Called for EVERY solution that passes the rules + void noteExportCandidate(const m256i& pubkey, unsigned int score, unsigned int depth, const Ann& ann) + { + ExportSet& set = *_exportSet; + unsigned int slot; + if (set.count < ANT_EXPORT_MAX_SOLUTIONS) + { + slot = set.count; + } + else if (score >= set.slots[set.order[ANT_EXPORT_MAX_SOLUTIONS - 1]].score) + { + // The common case once the set is full + return; + } + else + { + // Reuse the worst entry's storage; its index leaves the order below. + slot = set.order[ANT_EXPORT_MAX_SOLUTIONS - 1]; + } + + set.slots[slot].pubkey = pubkey; + set.slots[slot].score = score; + set.slots[slot].depth = depth; + set.slots[slot].ann.pack(ann.lut); + + // Insert into the order, shifting indices only. Equal scores keep the incumbent ahead, so + // among equals the earlier solution ranks first + const unsigned int end = (set.count < ANT_EXPORT_MAX_SOLUTIONS) ? set.count : (ANT_EXPORT_MAX_SOLUTIONS - 1); + unsigned int i = end; + while (i > 0 && set.slots[set.order[i - 1]].score > score) + { + set.order[i] = set.order[i - 1]; + i--; + } + set.order[i] = slot; + if (set.count < ANT_EXPORT_MAX_SOLUTIONS) + { + set.count++; + } + } + // loadSnapshot() helper: rebuild the tick index, head maps and dedup set from the loaded // records, treating them as untrusted input. Defined in ant_colony_snapshot.h. bool rebuildDerivedState(unsigned int initialTick); @@ -388,6 +460,7 @@ class AntColony // Solutions already committed this epoch, so a resend is rejected instead of re-added. QPI::HashSet* _dedup; + ExportSet* _exportSet; // Both give siblingFloor() a parent's children without scanning the store: the value is the // newest child's record index, and nextSiblingIdx chains back to the older ones. // parent's address -> newest child @@ -444,6 +517,11 @@ inline bool AntColony::init() { return false; } + if (!allocPoolWithErrorLog(L"AntColony::_exportSet", + sizeof(ExportSet), (void**)&_exportSet, __LINE__)) + { + return false; + } if (!allocPoolWithErrorLog(L"AntColony::_dedup", sizeof(QPI::HashSet), (void**)&_dedup, __LINE__)) @@ -468,6 +546,10 @@ inline void AntColony::deinit() { freePool(_replayCache); } + if (_exportSet) + { + freePool(_exportSet); + } if (_dedup) { freePool(_dedup); @@ -498,6 +580,7 @@ inline void AntColony::deinit() } _replayCache = nullptr; + _exportSet = nullptr; _dedup = nullptr; _childHeadByMiner = nullptr; _childHeadByParent = nullptr; @@ -517,6 +600,7 @@ inline void AntColony::reset() ASSERT(_childHeadByParent != nullptr); ASSERT(_childHeadByMiner != nullptr); ASSERT(_dedup != nullptr); + ASSERT(_exportSet != nullptr); setMem(_records, ANT_RECORDS_BYTES, 0); setMem(_tickIndex, @@ -524,6 +608,7 @@ inline void AntColony::reset() _childHeadByParent->reset(); _childHeadByMiner->reset(); _dedup->reset(); + setMem(_exportSet, sizeof(ExportSet), 0); // ANT_ANCHOR_TICK_NONE is used rather than zero for (unsigned int i = 0; i < ANT_ANCHOR_RING_SIZE; i++) @@ -660,6 +745,104 @@ inline bool AntColony::loadReplayCache(unsigned short epoch, CHAR16* dir return true; } +// The epoch's harvest file +static unsigned short ANT_COLONY_SOLUTIONS_EOE_FILENAME[] = L"antColonySolutions.eoe"; + + +// Written once at the front of the file +struct AntColonyExportHeader +{ + unsigned int epoch; + unsigned int entryCount; + unsigned int entrySizeBytes; // of AntColonyExportEntry, not of a store record + unsigned int annSizeBytes; + unsigned int solutionCount; // the whole epoch, of which entryCount are exported + unsigned int errorThreshold; + unsigned char topologyHash[32]; // == BPP9000_TOPOLOGY_HASH of the build that wrote this + unsigned char dataHash[32]; // == BPP9000_DATA_HASH + m256i rootSeed; +}; +static_assert(sizeof(AntColonyExportHeader) == 24 + 64 + 32, "AntColonyExportHeader unexpected padding"); + +// What the harvest needs to reproduce the best error +struct AntColonyExportEntry +{ + // Names the identity that found this network, log only + m256i pubkey; + unsigned int score; // error count, lower is better - how good this network is + unsigned int depth; // generations of strict improvement behind it, so the chain length is visible +}; +static_assert(sizeof(AntColonyExportEntry) == 32 + 8, "AntColonyExportEntry unexpected padding"); + +template +inline bool AntColony::exportBestSolutions(unsigned short epoch, CHAR16* directory) +{ + ASSERT(_exportSet != nullptr); + + struct Entry + { + AntColonyExportEntry meta; + Ann ann; + }; + const ExportSet& set = *_exportSet; + + AntColonyExportHeader header; + setMem(&header, sizeof(header), 0); + header.epoch = epoch; + header.entryCount = set.count; + header.entrySizeBytes = (unsigned int)sizeof(AntColonyExportEntry); + header.annSizeBytes = (unsigned int)sizeof(Ann); + header.solutionCount = _solutionCount; + header.errorThreshold = _errorThreshold; + copyMem(header.topologyHash, BPP9000_TOPOLOGY_HASH, sizeof(header.topologyHash)); + copyMem(header.dataHash, BPP9000_DATA_HASH, sizeof(header.dataHash)); + header.rootSeed = _rootSeed; + + if (set.count == 0) + { + return save(ANT_COLONY_SOLUTIONS_EOE_FILENAME, sizeof(header), (unsigned char*)&header, directory) + == (long long)sizeof(header); + } + + const unsigned long long totalBytes = sizeof(header) + (unsigned long long)set.count * sizeof(Entry); + unsigned char* buffer = nullptr; + if (!allocPoolWithErrorLog(L"AntColony::export", totalBytes, (void**)&buffer, __LINE__)) + { + logToConsole(L"[ant-colony] no memory for the solution export, harvest for this epoch is lost"); + return false; + } + + copyMem(buffer, &header, sizeof(header)); + Entry* out = (Entry*)(buffer + sizeof(header)); + for (unsigned int i = 0; i < set.count; i++) + { + const ExportSlot& slot = set.slots[set.order[i]]; + out[i].meta.pubkey = slot.pubkey; + out[i].meta.score = slot.score; + out[i].meta.depth = slot.depth; + slot.ann.unpack(out[i].ann.lut); + } + + const long long saved = save(ANT_COLONY_SOLUTIONS_EOE_FILENAME, totalBytes, buffer, directory); + freePool(buffer); + + if (saved != (long long)totalBytes) + { + logToConsole(L"[ant-colony] failed to write the solution export"); + return false; + } + + CHAR16 message[192]; + setText(message, L"[ant-colony] exported best networks, entries "); + appendNumber(message, set.count, FALSE); + appendText(message, L", best error "); + appendNumber(message, set.slots[set.order[0]].score, FALSE); + appendText(message, L", worst kept "); + appendNumber(message, set.slots[set.order[set.count - 1]].score, FALSE); + logToConsole(message); + return true; +} + template inline void AntColony::recordAnchorDigest(unsigned int tick, const m256i& digest) { @@ -849,6 +1032,8 @@ inline ValidityResult AntColony::commit(const AntCommitInput& in, const // Honour it and stop storing: the tree freezes, the leaderboard does not. if (_solutionCount >= ANT_MAX_NODES_PER_EPOCH) { + // The record is dropped, the network is not, still note this sols for end of epoch exppot + noteExportCandidate(in.pubkey, score, (parentRec != nullptr) ? (parentRec->depth + 1) : 1, childAnn); _stats.acceptedNotStored++; return ValidityResult::ValidNotStored; } @@ -918,6 +1103,8 @@ inline ValidityResult AntColony::commit(const AntCommitInput& in, const ATOMIC_STORE32(_solutionCount, (long)(newIdx + 1)); ATOMIC_STORE32(tslot.count, (long)(tslot.count + 1)); + noteExportCandidate(in.pubkey, score, newRec.depth, childAnn); + _stats.acceptedSolutions++; _stats.treeSizeCurrent = _solutionCount; if (newRec.depth > _stats.treeDepthMax) diff --git a/src/mining/ant_colony_snapshot.h b/src/mining/ant_colony_snapshot.h index 359b5b3b..12fbd1dd 100644 --- a/src/mining/ant_colony_snapshot.h +++ b/src/mining/ant_colony_snapshot.h @@ -9,6 +9,7 @@ static unsigned short ANT_SNAPSHOT_META_FILENAME[] = L"snapshotAntColonyMeta.??? static unsigned short ANT_SNAPSHOT_ANCHORS_FILENAME[] = L"snapshotAntColonyAnchors.???"; static unsigned short ANT_SNAPSHOT_RECORDS_FILENAME[] = L"snapshotAntColonyRecords.???"; static unsigned short ANT_SNAPSHOT_POOL_FILENAME[] = L"snapshotAntColonyPool.???"; +static unsigned short ANT_SNAPSHOT_EXPORT_FILENAME[] = L"snapshotAntColonyExport.???"; struct AntColonySnapshotMeta { @@ -56,6 +57,7 @@ static void antSnapshotNameForEpoch(unsigned short epoch) addEpochToFileName(ANT_SNAPSHOT_ANCHORS_FILENAME, sizeof(ANT_SNAPSHOT_ANCHORS_FILENAME) / sizeof(ANT_SNAPSHOT_ANCHORS_FILENAME[0]), epoch); addEpochToFileName(ANT_SNAPSHOT_RECORDS_FILENAME, sizeof(ANT_SNAPSHOT_RECORDS_FILENAME) / sizeof(ANT_SNAPSHOT_RECORDS_FILENAME[0]), epoch); addEpochToFileName(ANT_SNAPSHOT_POOL_FILENAME, sizeof(ANT_SNAPSHOT_POOL_FILENAME) / sizeof(ANT_SNAPSHOT_POOL_FILENAME[0]), epoch); + addEpochToFileName(ANT_SNAPSHOT_EXPORT_FILENAME, sizeof(ANT_SNAPSHOT_EXPORT_FILENAME) / sizeof(ANT_SNAPSHOT_EXPORT_FILENAME[0]), epoch); } template @@ -109,6 +111,13 @@ inline bool AntColony::saveSnapshot(unsigned short epoch, CHAR16* direct logToConsole(L"[ant-colony] failed to save snapshot pool"); return false; } + // Whole struct, fixed size - there is no used prefix to take, the order array indexes all of it. + if (save(ANT_SNAPSHOT_EXPORT_FILENAME, sizeof(ExportSet), (unsigned char*)_exportSet, directory) + != (long long)sizeof(ExportSet)) + { + logToConsole(L"[ant-colony] failed to save snapshot export set"); + return false; + } return true; } @@ -203,6 +212,30 @@ inline bool AntColony::loadSnapshot(unsigned short epoch, CHAR16* direct return false; } + if (load(ANT_SNAPSHOT_EXPORT_FILENAME, sizeof(ExportSet), (unsigned char*)_exportSet, directory) + != (long long)sizeof(ExportSet)) + { + logToConsole(L"[ant-colony] failed to load snapshot export set"); + reset(); + return false; + } + if (_exportSet->count > ANT_EXPORT_MAX_SOLUTIONS) + { + antSnapshotFailure(L"export count exceeds the cap, count/cap", _exportSet->count, ANT_EXPORT_MAX_SOLUTIONS); + reset(); + return false; + } + for (unsigned int i = 0; i < _exportSet->count; i++) + { + // order[] indexes slots[]; a bad entry would make the export read a slot that was never written. + if (_exportSet->order[i] >= ANT_EXPORT_MAX_SOLUTIONS) + { + antSnapshotFailure(L"export order out of range, position/slot", i, _exportSet->order[i]); + reset(); + return false; + } + } + // The caller's values, which the checks above proved the file agrees with. _rootSeed = rootSeed; _errorThreshold = errorThreshold; diff --git a/src/qubic.cpp b/src/qubic.cpp index 6f3e6c19..c60438df 100644 --- a/src/qubic.cpp +++ b/src/qubic.cpp @@ -6196,6 +6196,8 @@ static void tickProcessor(void*) asyncSave(REVENUE_DATA_END_OF_EPOCH_FILE_NAME, sizeof(gEpochRevenueData), (unsigned char*)&gEpochRevenueData); // Multi-dim revenue (shadow) - for offline comparison against the additive asyncSave(MULTIDIM_REVENUE_END_OF_EPOCH_FILE_NAME, sizeof(gMultiDimRevenue), (unsigned char*)&gMultiDimRevenue); + // The epoch's best networks, for offline extraction + gAntColony.exportBestSolutions(system.epoch, NULL); // Reorder futureComputors so requalifying computors keep their index // This is needed for correct execution fee reporting across epoch boundaries diff --git a/test/ant_colony.cpp b/test/ant_colony.cpp index 03ca550b..5bdd1d8e 100644 --- a/test/ant_colony.cpp +++ b/test/ant_colony.cpp @@ -274,6 +274,39 @@ static long long commitRootChild(AntColonyBpp9000T* colony, const m256i& owner, return landsAt; } +// A child of an existing node, so a test can build a lineage rather than a flat set of root children. +static long long commitChild(AntColonyBpp9000T* colony, const m256i& owner, const SolutionRef& parentRef, + unsigned int score, unsigned int txIdx, unsigned long long nonceSeed, unsigned int tick = 100000) +{ + const AntSolutionRecord* parentRec = nullptr; + if (colony->tryGetParent(parentRef, &parentRec) != ValidityResult::Valid) + { + return ANT_INVALID_INDEX; + } + + AntCommitInput in; + in.pubkey = owner; + in.nonce = makeKey(nonceSeed); + in.parentRef = parentRef; + in.selfRef.tickOffset = 7000; + in.selfRef.solutionIndexInTick = txIdx; + in.anchorTick = tick; + in.publishTick = tick; + + AntColonyBpp9000T::Ann ann; + setMem(&ann, sizeof(ann), 0); + ann.lut[0] = (unsigned char)(score % 3); + unsigned int annHash; + KangarooTwelve(&ann, sizeof(ann), &annHash, sizeof(annHash)); + + const long long landsAt = (long long)colony->solutionCount(); + if (colony->commit(in, parentRec, score, ann, annHash) != ValidityResult::Valid) + { + return ANT_INVALID_INDEX; + } + return landsAt; +} + // commit() head-inserts, so children run newest to oldest. siblingFloor() relies on it: only the // older ones compete, so they sit at the tail. TEST(TestAntColonyStore, SiblingsChainNewestFirst) @@ -775,3 +808,194 @@ TEST(TestAntColonyReplayCache, AbsentFileIsNotAnError) AntColonyBpp9000T::Ann out; EXPECT_FALSE(colony->tryGetReplayScore(makeReplayKey(7), score, out)); } + +// --------------------------------------------------------------------------------------------- +// Export best ANN at the end of epoch + +// The file layout: one header, then entryCount of these. +struct ExportFileEntry +{ + AntColonyExportEntry meta; + AntColonyBpp9000T::Ann ann; +}; + +// Reads antColonySolutions.eoe back. Header first, since only it says how long the body is. +static bool readExport(AntColonyExportHeader& header, std::vector& entries) +{ + if (load(ANT_COLONY_SOLUTIONS_EOE_FILENAME, sizeof(header), (unsigned char*)&header) + != (long long)sizeof(header)) + { + return false; + } + entries.clear(); + if (header.entryCount == 0) + { + return true; + } + + const unsigned long long total = sizeof(header) + + (unsigned long long)header.entryCount * sizeof(ExportFileEntry); + std::vector raw(total); + if (load(ANT_COLONY_SOLUTIONS_EOE_FILENAME, total, raw.data()) != (long long)total) + { + return false; + } + entries.resize(header.entryCount); + copyMem(entries.data(), raw.data() + sizeof(header), total - sizeof(header)); + return true; +} + +// More solutions than the file holds, so the cap, the eviction and the ordering are all exercised. +TEST(TestAntColonyExport, KeepsTheLowestScoresInOrder) +{ + AntColonyBpp9000T* colony = freshColony(); + ASSERT_NE(colony, nullptr) << "colony init failed; needs ~6.6 GB"; + + const m256i me = makeKey(1); + constexpr unsigned int COMMITTED = ANT_EXPORT_MAX_SOLUTIONS + 24; + for (unsigned int i = 0; i < COMMITTED; i++) + { + ASSERT_NE(commitRootChild(colony, me, 3000 + i, i, 5000 + i), ANT_INVALID_INDEX) << "commit " << i; + } + ASSERT_TRUE(colony->exportBestSolutions(TEST_EPOCH, NULL)); + + AntColonyExportHeader header; + std::vector entries; + ASSERT_TRUE(readExport(header, entries)); + + EXPECT_EQ(header.entryCount, ANT_EXPORT_MAX_SOLUTIONS); + EXPECT_EQ(header.solutionCount, COMMITTED); + EXPECT_EQ(header.entrySizeBytes, (unsigned int)sizeof(AntColonyExportEntry)); + EXPECT_EQ(header.annSizeBytes, (unsigned int)sizeof(AntColonyBpp9000T::Ann)); + ASSERT_EQ(entries.size(), (size_t)ANT_EXPORT_MAX_SOLUTIONS); + + // The 676 lowest of 3000..3699, so exactly 3000..3675, ascending. + EXPECT_EQ(entries[0].meta.score, 3000u) << "entry 0 must be the best network of the epoch"; + EXPECT_EQ(entries[ANT_EXPORT_MAX_SOLUTIONS - 1].meta.score, 3000u + ANT_EXPORT_MAX_SOLUTIONS - 1); + for (unsigned int i = 1; i < entries.size(); i++) + { + ASSERT_LE(entries[i - 1].meta.score, entries[i].meta.score) << "not ascending at " << i; + } +} + +// Below the cap the file holds everything, still ordered - and the scores are committed descending +// here, so every insert lands at the front and the shift path is the one being used. +TEST(TestAntColonyExport, OrdersFewerThanTheCap) +{ + AntColonyBpp9000T* colony = freshColony(); + ASSERT_NE(colony, nullptr) << "colony init failed; needs ~6.6 GB"; + + const m256i me = makeKey(2); + constexpr unsigned int COUNT = 40; + for (unsigned int i = 0; i < COUNT; i++) + { + ASSERT_NE(commitRootChild(colony, me, 3800 - i, i, 6000 + i), ANT_INVALID_INDEX); + } + ASSERT_TRUE(colony->exportBestSolutions(TEST_EPOCH, NULL)); + + AntColonyExportHeader header; + std::vector entries; + ASSERT_TRUE(readExport(header, entries)); + + ASSERT_EQ(header.entryCount, COUNT); + EXPECT_EQ(entries[0].meta.score, 3800u - (COUNT - 1)); + for (unsigned int i = 1; i < entries.size(); i++) + { + ASSERT_LE(entries[i - 1].meta.score, entries[i].meta.score) << "not ascending at " << i; + } +} + +// Equal scores keep the incumbent, so the earlier solution ranks first. Without a total order two +// nodes with the same solutions could write different files. +TEST(TestAntColonyExport, TiesKeepTheEarlierSolution) +{ + AntColonyBpp9000T* colony = freshColony(); + ASSERT_NE(colony, nullptr) << "colony init failed; needs ~6.6 GB"; + + const m256i first = makeKey(3); + const m256i second = makeKey(4); + ASSERT_NE(commitRootChild(colony, first, 3500, 0, 6100), ANT_INVALID_INDEX); + ASSERT_NE(commitRootChild(colony, second, 3500, 1, 6101), ANT_INVALID_INDEX); + ASSERT_TRUE(colony->exportBestSolutions(TEST_EPOCH, NULL)); + + AntColonyExportHeader header; + std::vector entries; + ASSERT_TRUE(readExport(header, entries)); + + ASSERT_EQ(header.entryCount, 2u); + EXPECT_TRUE(entries[0].meta.pubkey == first); + EXPECT_TRUE(entries[1].meta.pubkey == second); +} + +// The stored network has to survive the round trip, a wrong ANN here is a wrong harvest, and +// nothing downstream would notice. +TEST(TestAntColonyExport, CarriesTheNetworkAndItsDepth) +{ + AntColonyBpp9000T* colony = freshColony(); + ASSERT_NE(colony, nullptr) << "colony init failed; needs ~6.6 GB"; + + const m256i me = makeKey(5); + ASSERT_NE(commitRootChild(colony, me, 3800, 0, 6200), ANT_INVALID_INDEX); + const SolutionRef aRef = { 7000, 0 }; + ASSERT_NE(commitChild(colony, me, aRef, 3700, 1, 6201), ANT_INVALID_INDEX); + ASSERT_TRUE(colony->exportBestSolutions(TEST_EPOCH, NULL)); + + AntColonyExportHeader header; + std::vector entries; + ASSERT_TRUE(readExport(header, entries)); + ASSERT_EQ(header.entryCount, 2u); + + // Best first: the depth-2 child at 3700, then its depth-1 parent at 3800. + EXPECT_EQ(entries[0].meta.score, 3700u); + EXPECT_EQ(entries[0].meta.depth, 2u); + EXPECT_EQ(entries[1].meta.score, 3800u); + EXPECT_EQ(entries[1].meta.depth, 1u); + + AntColonyBpp9000T::Ann expected; + ASSERT_TRUE(colony->annOfNonRoot(*colony->recordAt(1), expected)); + for (unsigned long long i = 0; i < sizeof(expected); i++) + { + ASSERT_EQ(entries[0].ann.lut[i], expected.lut[i]) << "genome byte " << i; + } +} + +// The set holds networks the records cannot reproduce once the store is full, so it is saved rather +// than rebuilt. If that file went missing the export would come back empty after a restart. +TEST(TestAntColonyExport, SurvivesASnapshot) +{ + AntColonyBpp9000T* colony = freshColony(); + ASSERT_NE(colony, nullptr) << "colony init failed; needs ~6.6 GB"; + + const m256i me = makeKey(6); + for (unsigned int i = 0; i < 10; i++) + { + ASSERT_NE(commitRootChild(colony, me, 3700 + i, i, 6300 + i), ANT_INVALID_INDEX); + } + ASSERT_TRUE(saveWipeLoad(colony)); + ASSERT_TRUE(colony->exportBestSolutions(TEST_EPOCH, NULL)); + + AntColonyExportHeader header; + std::vector entries; + ASSERT_TRUE(readExport(header, entries)); + + ASSERT_EQ(header.entryCount, 10u); + EXPECT_EQ(entries[0].meta.score, 3700u); + EXPECT_EQ(entries[9].meta.score, 3709u); +} + +// A new epoch starts with nothing to export, and the file must say so rather than carry last epoch's. +TEST(TestAntColonyExport, BeginEpochClearsIt) +{ + AntColonyBpp9000T* colony = freshColony(); + ASSERT_NE(colony, nullptr) << "colony init failed; needs ~6.6 GB"; + + ASSERT_NE(commitRootChild(colony, makeKey(7), 3500, 0, 6400), ANT_INVALID_INDEX); + colony->beginEpoch(TEST_ROOT_SEED); + ASSERT_TRUE(colony->exportBestSolutions(TEST_EPOCH, NULL)); + + AntColonyExportHeader header; + std::vector entries; + ASSERT_TRUE(readExport(header, entries)); + EXPECT_EQ(header.entryCount, 0u); + EXPECT_EQ(header.solutionCount, 0u); +} From f1524ff296ae81f7a0d4d8c66b538a8ca780425c Mon Sep 17 00:00:00 2001 From: cyber-pc <165458555+cyber-pc@users.noreply.github.com> Date: Sat, 8 Aug 2026 23:18:22 +0700 Subject: [PATCH 17/46] Implement request responde for mineableParent and ant's stats in epoch. --- src/mining/ant_colony.h | 132 +++++++++++++++------- src/network_messages/ant_colony_message.h | 50 ++++++-- src/qubic.cpp | 106 +++++++++++++++++ 3 files changed, 238 insertions(+), 50 deletions(-) diff --git a/src/mining/ant_colony.h b/src/mining/ant_colony.h index 9161db57..b8273d2f 100644 --- a/src/mining/ant_colony.h +++ b/src/mining/ant_colony.h @@ -304,6 +304,14 @@ class AntColony return _solutionCount; } + // Slots a miner can still claim. Reaching zero does not stop acceptance, a valid solution is + // still scored, folded, refunded and ranked, but no NEW branch point can be created, which is + // what a miner needs to know before planning a lineage. + unsigned int freeAnnSlotsCount() const + { + return (_solutionCount < ANT_MAX_NODES_PER_EPOCH) ? (ANT_MAX_NODES_PER_EPOCH - _solutionCount) : 0; + } + const AntColonyDiagnostics& stats() const { return _stats; @@ -338,6 +346,24 @@ class AntColony // extraction. MUST be called between endEpoch() and the reset that starts the next epoch bool exportBestSolutions(unsigned short epoch, CHAR16* directory); + // Get the sibling floor for querry purpose. Note that it return the best at current time + unsigned int siblingFloorForQuery(const SolutionRef& parentRef, const m256i& childPubkey, + unsigned int childAnchorTick) + { + unsigned int head = NO_SIBLING; + // Take the latest child first with lock to touch the head map + { + LockGuard guard(_headMapLock); + if (!chainHead(parentRef, childPubkey, head)) + { + return WORST_SCORE; + } + } + + // Escape the lock, then read from head, in which the record is imutable + return siblingFloorFromHead(head, childAnchorTick); + } + // Anchor digests. Both take an ABSOLUTE system tick, never an epoch-relative tickOffset. // Called from tick processor only void recordAnchorDigest(unsigned int tick, const m256i& digest); @@ -398,8 +424,26 @@ class AntColony // RespondAntMineableParents), serve it from a snapshot the tick processor computed, do not // recompute it on the request thread. unsigned int siblingFloor(const SolutionRef& parentRef, const m256i& childPubkey, - unsigned int childAnchorTick /* ABSOLUTE */, - unsigned int walkLimit = ANT_MAX_NODES_PER_EPOCH) const; + unsigned int childAnchorTick /* ABSOLUTE */) const; + + // The map read, and the ONLY part of a floor computation that touches a head map. Split out + // because the walk that follows it does not, which is what lets the query hold the lock for a + // constant time instead of a whole chain. + // Depth-1 nodes chain per identity, so one miner's never raise another's floor; deeper nodes + // chain per parent, which is single-identity by the wrong-tree check. + bool chainHead(const SolutionRef& parentRef, const m256i& childPubkey, unsigned int& out) const + { + if (parentRef.isRoot()) + { + return _childHeadByMiner->get(childPubkey, out); + } + return _childHeadByParent->get(parentRef, out); + } + + // The walk half, from a chain head already in hand. Takes no lock: _records is append-only and a + // record's nextSiblingIdx is written once at commit and never touched again, so a chain is stable + // to follow even while the tick processor is appending elsewhere. + unsigned int siblingFloorFromHead(unsigned int head, unsigned int childAnchorTick) const; // Offers a solution to the epoch's best-N set. Called for EVERY solution that passes the rules void noteExportCandidate(const m256i& pubkey, unsigned int score, unsigned int depth, const Ann& ann) @@ -461,6 +505,11 @@ class AntColony // Solutions already committed this epoch, so a resend is rejected instead of re-added. QPI::HashSet* _dedup; ExportSet* _exportSet; + + // The two head maps have no reader/writer protocol of their own: QPI::HashMap::set() makes a + // slot's key visible before its value, so a reader asking for the key being inserted can get a + // garbage index + volatile char _headMapLock; // Both give siblingFloor() a parent's children without scanning the store: the value is the // newest child's record index, and nextSiblingIdx chains back to the older ones. // parent's address -> newest child @@ -896,40 +945,42 @@ inline long long AntColony::findIndexBySolutionRef(const SolutionRef& re template inline unsigned int AntColony::siblingFloor(const SolutionRef& parentRef, const m256i& childPubkey, - unsigned int childAnchorTick, unsigned int walkLimit) const + unsigned int childAnchorTick) const { - // A competing sibling anchors more than N ticks earlier, so guard the subtraction. This only - // fires during the network's first N ticks, when there are no siblings to compete with anyway. - if (childAnchorTick <= ANT_FRESHNESS_WINDOW_TICKS) + // Tick processor only, so the head map read needs no lock here - nothing else writes it. + unsigned int head = NO_SIBLING; + if (!chainHead(parentRef, childPubkey, head)) { return WORST_SCORE; } - const unsigned int boundary = childAnchorTick - ANT_FRESHNESS_WINDOW_TICKS; + return siblingFloorFromHead(head, childAnchorTick); +} - // Depth-1 nodes chain per identity, so one miner's never raise another's floor. - // Deeper nodes chain per parent, which is single-identity by the wrong-tree check. - unsigned int idx = NO_SIBLING; - if (parentRef.isRoot()) - { - if (!_childHeadByMiner->get(childPubkey, idx)) - { - return WORST_SCORE; - } - } - else if (!_childHeadByParent->get(parentRef, idx)) +template +inline unsigned int AntColony::siblingFloorFromHead(unsigned int head, + unsigned int childAnchorTick) const +{ + // A competing sibling anchors more than N ticks earlier, so guard the subtraction. This only + // fires during the network's first N ticks, when there are no siblings to compete with anyway. + if (childAnchorTick <= ANT_FRESHNESS_WINDOW_TICKS) { return WORST_SCORE; } + const unsigned int boundary = childAnchorTick - ANT_FRESHNESS_WINDOW_TICKS; - // The bar is the BEST score among competing siblings, because lower is better. - // The chain strictly decreases (commit head-inserts) so it terminates on its own; walkLimit is a - // backstop, and the default does not truncate. Truncating from the head would be wrong rather - // than merely approximate: entries are newest-first and only the older ones compete, so a - // head-side cut removes exactly the siblings that set the floor. + // The bar is the BEST score among competing siblings, because lower is better. The chain is + // finite and terminates on its own, and there is deliberately no hop limit: entries are + // newest-first and only the older ones compete, so cutting from the head would remove exactly + // the siblings that set the floor - wrong, not merely approximate. unsigned int floor = WORST_SCORE; - unsigned int hops = 0; - while (idx != NO_SIBLING && hops < walkLimit) - { + unsigned int idx = head; + while (idx != NO_SIBLING) + { + // NOT a redundant bounds check - it is the publication barrier this walk relies on. commit() + // points the head map at a new index BEFORE it writes that record, and only bumps + // _solutionCount once the record is complete. An off-thread walk that reaches the new index + // early therefore stops here instead of reading half a record. Removing this reintroduces a + // torn read that would only ever show up under load. if (idx >= _solutionCount) { break; @@ -940,7 +991,6 @@ inline unsigned int AntColony::siblingFloor(const SolutionRef& parentRef floor = s.score; } idx = s.nextSiblingIdx; - hops++; } return floor; } @@ -1054,22 +1104,28 @@ inline ValidityResult AntColony::commit(const AntCommitInput& in, const // Claim the sibling-chain head before writing the record unsigned int prevHead = NO_SIBLING; - if (in.parentRef.isRoot()) + bool headClaimed = true; { - _childHeadByMiner->get(in.pubkey, prevHead); - if (_childHeadByMiner->set(in.pubkey, newIdx) == QPI::NULL_INDEX) + LockGuard guard(_headMapLock); + if (in.parentRef.isRoot()) + { + _childHeadByMiner->get(in.pubkey, prevHead); + headClaimed = (_childHeadByMiner->set(in.pubkey, newIdx) != QPI::NULL_INDEX); + } + else { - // Fail closed. Degrading instead, accepting the node but leaving the identity without a - // chain head, would silently drop its sibling floor - _dedup->remove(dedupKey); - recordReject(ValidityResult::RejectMinerIndexFull); - return ValidityResult::RejectMinerIndexFull; + _childHeadByParent->get(in.parentRef, prevHead); + _childHeadByParent->set(in.parentRef, newIdx); // cannot fail, see the static_assert on its size } } - else + if (!headClaimed) { - _childHeadByParent->get(in.parentRef, prevHead); - _childHeadByParent->set(in.parentRef, newIdx); // cannot fail, see the static_assert on its size + // Fail closed. Degrading instead, accepting the node but leaving the identity without a + // chain head, would silently drop its sibling floor. Released first: this touches _dedup, + // which must not be reached under the head-map lock. + _dedup->remove(dedupKey); + recordReject(ValidityResult::RejectMinerIndexFull); + return ValidityResult::RejectMinerIndexFull; } // The record and its network share an index, which keeps the used portion of the allocation a diff --git a/src/network_messages/ant_colony_message.h b/src/network_messages/ant_colony_message.h index d61b9b91..79308531 100644 --- a/src/network_messages/ant_colony_message.h +++ b/src/network_messages/ant_colony_message.h @@ -2,18 +2,28 @@ #include "common_def.h" -// Asks for the current frontier of parents it can branch a child from +// Asks for the parents one identity can branch a child from. Scoped by pubkey because a child must +// name a parent in its OWN tree - validate() rejects anything else with RejectWrongTree - +// Operator-signed: the request payload is followed by SIGNATURE_SIZE bytes signed by +// operatorPublicKey. Signature only, no monotonic nonce - the shape the retired +// REQUEST_CUSTOM_MINING_DATA used, not SpecialCommand's. The nonce sequences operator ACTIONS so each +// executes once; replaying a read costs a duplicate answer, while consuming the nonce would make a +// polling miner collide with every other operator command. +// // Paginated via fromIndex / nextIndex. struct RequestAntMineableParents { + // Whose tree to report. Usually the caller's own. + m256i pubkey; // Record index to resume scanning from (0 on the first call). unsigned int fromIndex; + unsigned int padding; static constexpr unsigned char type() { return REQUEST_ANT_MINEABLE_PARENTS; } }; -static_assert(sizeof(RequestAntMineableParents) == 4, "RequestAntMineableParents unexpected size"); +static_assert(sizeof(RequestAntMineableParents) == 40, "RequestAntMineableParents unexpected size"); // Max mineable-parent entries returned per response. Miners page through the // rest via the nextIndex cursor. @@ -22,20 +32,25 @@ constexpr unsigned int ANT_MINEABLE_PARENTS_PER_RESPONSE = 64; // Max records scanned per request constexpr unsigned int ANT_MINEABLE_PARENTS_SCAN_BUDGET = 1024; -// One mineable parent. parentTickOffset/parentSolutionIndexInTick is the ref a child sets as its own -// parentRef. The score is an error count, so smaller is better: the child must score strictly below -// parentScore and strictly below siblingFloor (the best sibling more than N ticks earlier, computed -// for a child anchoring at the current tick). +// One stored node of the requested identity's tree. selfTickOffset/selfSolutionIndexInTick is the +// ref a child sets as its own parentRef to extend this node; parentTickOffset/parentSolutionIndexInTick +// is this node's OWN parent - (0, 0xFFFFFFFF) means the root - so paging every node of a pubkey +// reconstructs the whole tree, edges included, without fetching any network bytes. +// The score is an error count, so smaller is better: a child must score strictly below score and +// strictly below siblingFloor (the best sibling more than N ticks earlier, computed for a child +// anchoring at the current tick). struct AntMineableParent { + unsigned int selfTickOffset; + unsigned int selfSolutionIndexInTick; unsigned int parentTickOffset; unsigned int parentSolutionIndexInTick; - unsigned int parentScore; + unsigned int score; unsigned int siblingFloor; - unsigned int anchorTick; // the parent's own anchor tick number + unsigned int anchorTick; // this node's own anchor tick number unsigned int depth; }; -static_assert(sizeof(AntMineableParent) == 24, "AntMineableParent unexpected size"); +static_assert(sizeof(AntMineableParent) == 32, "AntMineableParent unexpected size"); // Metadata header only; followed by count * AntMineableParent (count * itemSize // bytes). itemSize lets the receiver validate the payload without hardcoding the @@ -55,6 +70,17 @@ struct RespondAntMineableParentsHeader }; static_assert(sizeof(RespondAntMineableParentsHeader) == 12, "RespondAntMineableParentsHeader unexpected size"); +// The largest a mineable-parents response can be, the header followed by a full page of entries +struct AntMineableParentsResponse +{ + RespondAntMineableParentsHeader header; + AntMineableParent items[ANT_MINEABLE_PARENTS_PER_RESPONSE]; +}; +static_assert(sizeof(AntMineableParentsResponse) + == sizeof(RespondAntMineableParentsHeader) + + ANT_MINEABLE_PARENTS_PER_RESPONSE * sizeof(AntMineableParent), + "AntMineableParentsResponse must have no padding between the header and the items"); + // RespondAntAnnStateHeader.status values. constexpr unsigned char ANT_ANN_STATUS_OK = 0; // ANN bytes follow the header constexpr unsigned char ANT_ANN_STATUS_NOT_FOUND = 1; // parentRef has no record @@ -102,14 +128,14 @@ struct RequestAntEpochContext }; // Per-epoch ant-colony parameters a miner needs to start building solutions: -// the score threshold, the freshness window, the per-identity root seed (spectrum digest), -// and pool occupancy. The anchor digest is not included; a miner derives it from the standard +// the score threshold, the freshness window, the epoch's root seed, and pool occupancy. +// The anchor digest is not included; a miner derives it from the standard // protocol as K12(anchorTick || transactionDigest), with transactionDigest taken from the // anchor tick's quorum votes (REQUEST_QUORUM_TICK). #pragma pack(push, 1) struct RespondAntEpochContext { - // per-identity root seed (epoch-start spectrum digest); each root = K12(pubkey || this) + // The epoch-start spectrum digest m256i spectrumDigest; // score threshold for this epoch unsigned int threshold; diff --git a/src/qubic.cpp b/src/qubic.cpp index c60438df..4d56461c 100644 --- a/src/qubic.cpp +++ b/src/qubic.cpp @@ -1511,6 +1511,100 @@ static void processRequestContractFunction(Peer* peer, const unsigned long long } } +// One response buffer per processor +static AntMineableParentsResponse gAntMineableParentsResponseBuffer[MAX_NUMBER_OF_PROCESSORS]; + +// Request ant colony in epoch contex +static void processRequestAntEpochContext(Peer* peer, RequestResponseHeader* header) +{ + RespondAntEpochContext respond; + setMem(&respond, sizeof(respond), 0); + + respond.spectrumDigest = gAntColony.rootSeed(); + respond.threshold = gAntColony.errorThreshold(); + respond.freshnessWindow = ANT_FRESHNESS_WINDOW_TICKS; + respond.solutionCount = gAntColony.solutionCount(); + respond.freeAnnSlotsCount = gAntColony.freeAnnSlotsCount(); + respond.epoch = system.epoch; + + enqueueResponse(peer, sizeof(respond), RespondAntEpochContext::type(), header->dejavu(), &respond); +} + +// The parents ONE identity can branch from, each with the bar a child of it must beat. Scoped by +// pubkey: a child must name a parent in its own tree, so an unscoped answer would be mostly nodes the +// caller can never use. +// +// Paged, because the store holds millions and a response is one datagram - the cursor is a record +// index, and ANT_MINEABLE_PARENTS_SCAN_BUDGET caps how far one request may scan, so a caller cannot +// walk the whole store in a single call. Sweeping a full store therefore costs many requests; the +// alternative, walking the identity's tree through the head maps, needs a resumable cursor that does +// not fit a record index, and the scan is cache-friendly where a chain walk is not. +// +// Operator-signed +static void processRequestAntMineableParents(unsigned long long processorNumber, Peer* peer, RequestResponseHeader* header) +{ + if (header->size() != sizeof(RequestResponseHeader) + sizeof(RequestAntMineableParents) + SIGNATURE_SIZE) + { + return; + } + const RequestAntMineableParents* request = header->getPayload(); + + // Signature check + unsigned char digest[32]; + KangarooTwelve(request, header->size() - sizeof(RequestResponseHeader) - SIGNATURE_SIZE, digest, sizeof(digest)); + if (!verify(operatorPublicKey.m256i_u8, digest, ((const unsigned char*)header + (header->size() - SIGNATURE_SIZE)))) + { + return; + } + + AntMineableParentsResponse& response = gAntMineableParentsResponseBuffer[processorNumber]; + setMem(&response, sizeof(response), 0); + response.header.itemSize = (unsigned int)sizeof(AntMineableParent); + + // Sampled once: it only grows, and a child mined against this frontier anchors at or after the + // tick this answer describes. + const unsigned int total = gAntColony.solutionCount(); + const unsigned int anchorTick = system.tick; + + unsigned int idx = request->fromIndex; + unsigned int scanned = 0; + while (idx < total + && response.header.count < ANT_MINEABLE_PARENTS_PER_RESPONSE + && scanned < ANT_MINEABLE_PARENTS_SCAN_BUDGET) + { + const AntSolutionRecord* rec = gAntColony.recordAt(idx); + if (rec == nullptr) + { + break; + } + scanned++; + if (!(rec->pubkey == request->pubkey)) + { + idx++; + continue; + } + + AntMineableParent& item = response.items[response.header.count]; + item.selfTickOffset = rec->selfRef.tickOffset; + item.selfSolutionIndexInTick = rec->selfRef.solutionIndexInTick; + item.parentTickOffset = rec->parentRef.tickOffset; + item.parentSolutionIndexInTick = rec->parentRef.solutionIndexInTick; + item.score = rec->score; + item.siblingFloor = gAntColony.siblingFloorForQuery(rec->selfRef, rec->pubkey, anchorTick); + item.anchorTick = rec->anchorTick; + item.depth = rec->depth; + response.header.count++; + idx++; + } + + // Zero means the caller reached the end of what this node holds, per the protocol. + response.header.nextIndex = (idx < total) ? idx : 0; + + enqueueResponse(peer, + (unsigned int)sizeof(response.header) + response.header.count * (unsigned int)sizeof(AntMineableParent), + RespondAntMineableParentsHeader::type(), header->dejavu(), &response); +} + static void processRequestSystemInfo(Peer* peer, RequestResponseHeader* header) { RespondSystemInfo respondedSystemInfo; @@ -2284,6 +2378,18 @@ static void requestProcessor(void* ProcedureArgument) } break; + case RequestAntEpochContext::type(): + { + processRequestAntEpochContext(peer, header); + } + break; + + case RequestAntMineableParents::type(): + { + processRequestAntMineableParents(processorNumber, peer, header); + } + break; + #if ADDON_TX_STATUS_REQUEST /* qli: process RequestTxStatus message */ case RequestTxStatus::type(): From 5224c4abe3897f752e4ad3e6d7fab0f966c6b437 Mon Sep 17 00:00:00 2001 From: cyber-pc <165458555+cyber-pc@users.noreply.github.com> Date: Sun, 9 Aug 2026 00:02:21 +0700 Subject: [PATCH 18/46] Print stats of ant colony --- src/mining/ant_colony.h | 38 +++++++++++++++++++++++ src/network_messages/ant_colony_message.h | 9 +++--- src/qubic.cpp | 6 ++++ 3 files changed, 48 insertions(+), 5 deletions(-) diff --git a/src/mining/ant_colony.h b/src/mining/ant_colony.h index b8273d2f..8b833c8d 100644 --- a/src/mining/ant_colony.h +++ b/src/mining/ant_colony.h @@ -128,6 +128,44 @@ struct AntColonyDiagnostics setMem(this, sizeof(*this), 0); } + void appendLog(CHAR16* message) const + { + appendText(message, L"tree "); + appendNumber(message, treeSizeCurrent, TRUE); + appendText(message, L"/"); + appendNumber(message, ANT_MAX_NODES_PER_EPOCH, TRUE); + appendText(message, L" depth "); + appendNumber(message, treeDepthMax, FALSE); + appendText(message, L" | accepted "); + appendNumber(message, acceptedSolutions, TRUE); + appendText(message, L" (not stored "); + appendNumber(message, acceptedNotStored, TRUE); + appendText(message, L")"); + + appendText(message, L" | rejected: parent "); + appendNumber(message, rejectParentNotRegistered, TRUE); + appendText(message, L", stale "); + appendNumber(message, rejectStale, TRUE); + appendText(message, L", wrongTree "); + appendNumber(message, rejectWrongTree, TRUE); + appendText(message, L", threshold "); + appendNumber(message, rejectThreshold, TRUE); + appendText(message, L", leParent "); + appendNumber(message, rejectLeParent, TRUE); + appendText(message, L", floor "); + appendNumber(message, rejectSiblingFloor, TRUE); + appendText(message, L", tickRange "); + appendNumber(message, rejectTickOutOfRange, TRUE); + appendText(message, L", replay "); + appendNumber(message, rejectReplay, TRUE); + appendText(message, L", dedupFull "); + appendNumber(message, rejectDedupFull, TRUE); + appendText(message, L", minerIndexFull "); + appendNumber(message, rejectMinerIndexFull, TRUE); + appendText(message, L", nonCanonicalNonce "); + appendNumber(message, rejectNonCanonicalNonce, TRUE); + } + void count(ValidityResult r) { switch (r) diff --git a/src/network_messages/ant_colony_message.h b/src/network_messages/ant_colony_message.h index 79308531..726df1a2 100644 --- a/src/network_messages/ant_colony_message.h +++ b/src/network_messages/ant_colony_message.h @@ -5,10 +5,9 @@ // Asks for the parents one identity can branch a child from. Scoped by pubkey because a child must // name a parent in its OWN tree - validate() rejects anything else with RejectWrongTree - // Operator-signed: the request payload is followed by SIGNATURE_SIZE bytes signed by -// operatorPublicKey. Signature only, no monotonic nonce - the shape the retired -// REQUEST_CUSTOM_MINING_DATA used, not SpecialCommand's. The nonce sequences operator ACTIONS so each -// executes once; replaying a read costs a duplicate answer, while consuming the nonce would make a -// polling miner collide with every other operator command. +// operatorPublicKey. Signature only, with no monotonic nonce. A nonce exists to make an operator +// ACTION execute exactly once; replaying a read just costs a duplicate answer, while consuming the +// nonce would put a polling miner in contention with every other operator command. // // Paginated via fromIndex / nextIndex. struct RequestAntMineableParents @@ -90,7 +89,7 @@ constexpr unsigned char ANT_ANN_STATUS_IS_ROOT = 2; // ROOT_REF; no ANN payloa // operatorPublicKey. struct RequestAntAnnState { - // Monotonic per-operator nonce, the same rule processSpecialCommand uses + // Monotonic per-operator nonce: must exceed the last one the node accepted. unsigned long long everIncreasingNonce; unsigned int parentRefTickOffset; unsigned int parentRefSolutionIndexInTick; diff --git a/src/qubic.cpp b/src/qubic.cpp index 4d56461c..bff9c38e 100644 --- a/src/qubic.cpp +++ b/src/qubic.cpp @@ -7924,6 +7924,12 @@ static void processKeyPresses() setText(message, L"DogeMining: "); gDogeMiningStats.appendLog(message); logToConsole(message); + + setText(message, L"AntColony: "); + gAntColony.stats().appendLog(message); + appendText(message, L" | replay cache "); + appendNumber(message, gAntColony.replayCacheOccupancy(), TRUE); + logToConsole(message); } break; From 4d41c3ff15da4456fc33a827c6583d51de5cfef5 Mon Sep 17 00:00:00 2001 From: cyber-pc <165458555+cyber-pc@users.noreply.github.com> Date: Sun, 9 Aug 2026 12:42:30 +0700 Subject: [PATCH 19/46] Add ant colony retries queue. --- src/Qubic.vcxproj | 1 + src/Qubic.vcxproj.filters | 3 + src/mining/ant_colony.h | 8 +- src/mining/ant_colony_snapshot.h | 2 +- src/mining/ant_pending_solutions.h | 404 ++++++++++++++++++++++ src/network_messages/ant_colony_message.h | 4 +- src/public_settings.h | 10 +- src/qubic.cpp | 2 +- test/ant_colony.cpp | 6 +- test/ant_pending_solutions.cpp | 223 ++++++++++++ test/test.vcxproj | 1 + 11 files changed, 649 insertions(+), 15 deletions(-) create mode 100644 src/mining/ant_pending_solutions.h create mode 100644 test/ant_pending_solutions.cpp diff --git a/src/Qubic.vcxproj b/src/Qubic.vcxproj index fa8b32b2..6e29ab30 100644 --- a/src/Qubic.vcxproj +++ b/src/Qubic.vcxproj @@ -69,6 +69,7 @@ + diff --git a/src/Qubic.vcxproj.filters b/src/Qubic.vcxproj.filters index fbb43940..4ec38666 100644 --- a/src/Qubic.vcxproj.filters +++ b/src/Qubic.vcxproj.filters @@ -191,6 +191,9 @@ mining + + mining + mining diff --git a/src/mining/ant_colony.h b/src/mining/ant_colony.h index 8b833c8d..f1183061 100644 --- a/src/mining/ant_colony.h +++ b/src/mining/ant_colony.h @@ -234,7 +234,7 @@ static constexpr unsigned int antAnchorRingSize(unsigned int window) } return size; } -static constexpr unsigned int ANT_ANCHOR_RING_SIZE = antAnchorRingSize(ANT_FRESHNESS_WINDOW_TICKS); +static constexpr unsigned int ANT_ANCHOR_RING_SIZE = antAnchorRingSize(ANT_PUBLISH_WINDOW_TICKS); static constexpr unsigned int ANT_ANCHOR_TICK_NONE = 0xFFFFFFFFU; struct AnchorRing @@ -1000,11 +1000,11 @@ inline unsigned int AntColony::siblingFloorFromHead(unsigned int head, { // A competing sibling anchors more than N ticks earlier, so guard the subtraction. This only // fires during the network's first N ticks, when there are no siblings to compete with anyway. - if (childAnchorTick <= ANT_FRESHNESS_WINDOW_TICKS) + if (childAnchorTick <= ANT_SIBLING_NOCOMPETE_TICKS) { return WORST_SCORE; } - const unsigned int boundary = childAnchorTick - ANT_FRESHNESS_WINDOW_TICKS; + const unsigned int boundary = childAnchorTick - ANT_SIBLING_NOCOMPETE_TICKS; // The bar is the BEST score among competing siblings, because lower is better. The chain is // finite and terminates on its own, and there is deliberately no hop limit: entries are @@ -1063,7 +1063,7 @@ inline ValidityResult AntColony::validateChild(const ChildCandidate& chi { // Freshness, the anchor cannot be in the future, and publication cannot lag it by more than N. if (child.anchorTick > child.publishTick - || (child.publishTick - child.anchorTick) > ANT_FRESHNESS_WINDOW_TICKS) + || (child.publishTick - child.anchorTick) > ANT_PUBLISH_WINDOW_TICKS) { return ValidityResult::RejectStale; } diff --git a/src/mining/ant_colony_snapshot.h b/src/mining/ant_colony_snapshot.h index 12fbd1dd..e1c7f34a 100644 --- a/src/mining/ant_colony_snapshot.h +++ b/src/mining/ant_colony_snapshot.h @@ -282,7 +282,7 @@ inline bool AntColony::rebuildDerivedState(unsigned int initialTick) // need to be - commit() sets tickOffset to (publishTick - initialTick), so it inverts exactly const unsigned int publishTick = initialTick + rec.selfRef.tickOffset; if (rec.anchorTick > publishTick - || publishTick - rec.anchorTick > ANT_FRESHNESS_WINDOW_TICKS) + || publishTick - rec.anchorTick > ANT_PUBLISH_WINDOW_TICKS) { antSnapshotFailure(L"anchor tick outside the freshness window, record/anchorTick", i, rec.anchorTick); return false; diff --git a/src/mining/ant_pending_solutions.h b/src/mining/ant_pending_solutions.h new file mode 100644 index 00000000..d749d21c --- /dev/null +++ b/src/mining/ant_pending_solutions.h @@ -0,0 +1,404 @@ +#pragma once + +#include "platform/m256.h" +#include "platform/concurrency.h" +#include "platform/memory.h" +#include "ant_colony.h" + +// Solutions waiting to be published as transactions signed by the node's own computors +// A queue is needed because a broadcast can be lost with nothing reporting it. An entry stores the +// tick its transaction was targeted at, if it is not on-chain by then, publish again. +struct AntPendingSolution +{ + m256i computorPublicKey; + m256i nonce; + SolutionRef parentRef; + unsigned int anchorTick; // ABSOLUTE. Bounds how long this entry is worth publishing. + unsigned int padding; +}; +static_assert(sizeof(AntPendingSolution) == 32 + 32 + 8 + 8, "AntPendingSolution unexpected padding"); + +class AntPendingSolutions +{ +public: + static constexpr unsigned int CAPACITY = 65536; + static_assert((CAPACITY & (CAPACITY - 1)) == 0, "CAPACITY must be a power of two"); + + // publicationTick[] states. A positive value is the tick the transaction was targeted at, which + // is also the deadline to see it on-chain + static constexpr int NOT_SCHEDULED = 0; + static constexpr int RECORDED = -1; // observed on-chain; never publish again + static constexpr int OBSOLETE = -2; // can never land; stop occupying the retry slot + + struct Stats + { + unsigned long long received; + unsigned long long droppedNonCanonical; + unsigned long long droppedParentUnknown; + unsigned long long droppedDuplicate; + unsigned long long droppedFull; + unsigned long long published; + unsigned long long recorded; + unsigned long long obsoleteParentGone; + unsigned long long obsoleteExpired; + unsigned long long obsoleteGateRejected; + }; + + bool init() + { + setMem(this, sizeof(*this), 0); + if (!allocPoolWithErrorLog(L"AntPendingSolutions::_entries", + CAPACITY * sizeof(AntPendingSolution), (void**)&_entries, __LINE__)) + { + return false; + } + if (!allocPoolWithErrorLog(L"AntPendingSolutions::_publicationTick", + CAPACITY * sizeof(int), (void**)&_publicationTick, __LINE__)) + { + return false; + } + if (!allocPoolWithErrorLog(L"AntPendingSolutions::_index", + INDEX_CAPACITY * sizeof(unsigned int), (void**)&_index, __LINE__)) + { + return false; + } + reset(); + return true; + } + + void deinit() + { + if (_index) + { + freePool(_index); + } + if (_publicationTick) + { + freePool(_publicationTick); + } + if (_entries) + { + freePool(_entries); + } + _index = nullptr; + _publicationTick = nullptr; + _entries = nullptr; + } + + void reset() + { + ASSERT(_entries != nullptr); + LockGuard guard(_lock); + setMem(_entries, CAPACITY * sizeof(AntPendingSolution), 0); + setMem(_publicationTick, CAPACITY * sizeof(int), 0); + // setMem, not a fill loop: MSVC turns one into a memset call, which does not exist in the + // freestanding UEFI build. EMPTY is all-ones, so a 0xFF byte fill is exact. + setMem(_index, INDEX_CAPACITY * sizeof(unsigned int), 0xFF); + _count = 0; + _nextFree = 0; + _touchedSlots = 0; + setMem(&_stats, sizeof(_stats), 0); + } + + // Ingress only counts what it drops, the caller decides what is worth dropping, because the + // reasons live where the colony can be consulted. + void noteReceived() + { + LockGuard guard(_lock); + _stats.received++; + } + void noteDroppedNonCanonical() + { + LockGuard guard(_lock); + _stats.droppedNonCanonical++; + } + void noteDroppedParentUnknown() + { + LockGuard guard(_lock); + _stats.droppedParentUnknown++; + } + + void getStats(Stats& outStats, unsigned int& outCount) const + { + LockGuard guard(_lock); + outStats = _stats; + outCount = _count; + } + + // Queue a solution for publication, called from request processors. + bool add(const m256i& computorPublicKey, const SolutionRef& parentRef, + unsigned int anchorTick, const m256i& nonce) + { + LockGuard guard(_lock); + const unsigned int slot = indexSlotFor(computorPublicKey, parentRef, nonce); + unsigned int entryIdx = NO_ENTRY; + if (_index[slot] != EMPTY) + { + // An OBSOLETE entry is not a duplicate + // Every other state is a real duplicate: NOT_SCHEDULED and scheduled are still live, and + // RECORDED already landed, so a resend would be rejected as a replay anyway. + if (_publicationTick[_index[slot]] != OBSOLETE) + { + _stats.droppedDuplicate++; + return false; + } + entryIdx = _index[slot]; + } + else + { + entryIdx = findFreeEntry(); + if (entryIdx == NO_ENTRY) + { + _stats.droppedFull++; + return false; + } + } + + AntPendingSolution& e = _entries[entryIdx]; + e.computorPublicKey = computorPublicKey; + e.nonce = nonce; + e.parentRef = parentRef; + e.anchorTick = anchorTick; + e.padding = 0; + _publicationTick[entryIdx] = NOT_SCHEDULED; + if (_index[slot] == EMPTY) + { + _index[slot] = entryIdx; + _count++; + } + return true; + } + + // Pick the next solution this computor should publish, or NO_ENTRY. + // Anything already past its publish window is retired here rather than published + unsigned int selectForPublish(const m256i& computorPublicKey, unsigned int currentTick, + AntPendingSolution& outEntry) + { + LockGuard guard(_lock); + + unsigned int found = scanForPublish(computorPublicKey, currentTick, true); + if (found == NO_ENTRY) + { + found = scanForPublish(computorPublicKey, currentTick, false); + } + if (found != NO_ENTRY) + { + outEntry = _entries[found]; + } + return found; + } + + void markScheduled(unsigned int index, int publicationTick) + { + LockGuard guard(_lock); + if (index >= CAPACITY || _publicationTick[index] < 0) + { + return; + } + _publicationTick[index] = publicationTick; + _stats.published++; + } + + void markObsoleteParentGone(unsigned int index) + { + retire(index, _stats.obsoleteParentGone); + } + void markObsoleteExpired(unsigned int index) + { + retire(index, _stats.obsoleteExpired); + } + void markObsoleteGateRejected(unsigned int index) + { + retire(index, _stats.obsoleteGateRejected); + } + + void markRecorded(const m256i& computorPublicKey, const SolutionRef& parentRef, const m256i& nonce) + { + LockGuard guard(_lock); + + const unsigned int slot = indexSlotFor(computorPublicKey, parentRef, nonce); + if (_index[slot] != EMPTY) + { + _publicationTick[_index[slot]] = RECORDED; + _stats.recorded++; + return; + } + + const unsigned int entryIdx = findFreeEntry(); + if (entryIdx == NO_ENTRY) + { + // Nothing to reclaim + return; + } + + AntPendingSolution& e = _entries[entryIdx]; + e.computorPublicKey = computorPublicKey; + e.nonce = nonce; + e.parentRef = parentRef; + e.anchorTick = 0; + e.padding = 0; + _publicationTick[entryIdx] = RECORDED; + _index[slot] = entryIdx; + _count++; + _stats.recorded++; + } + +private: + static constexpr unsigned int INDEX_CAPACITY = 2 * CAPACITY; + static constexpr unsigned int EMPTY = 0xFFFFFFFFU; + static constexpr unsigned int NO_ENTRY = 0xFFFFFFFFU; + + // A slot is free if it has never been used or if its entry is finished. Reuse is IN PLACE: + // nothing is ever moved, so an index the tick processor is holding across the publish gate + // stays valid. + unsigned int findFreeEntry() + { + for (unsigned int i = 0; i < CAPACITY; i++) + { + const unsigned int idx = (_nextFree + i) & (CAPACITY - 1); + if (isZero(_entries[idx].computorPublicKey)) + { + claim(idx); + return idx; + } + if (_publicationTick[idx] == RECORDED || _publicationTick[idx] == OBSOLETE) + { + indexRemove(_entries[idx]); + _count--; + claim(idx); + return idx; + } + } + return NO_ENTRY; + } + + void claim(unsigned int idx) + { + _nextFree = (idx + 1) & (CAPACITY - 1); + if (idx + 1 > _touchedSlots) + { + _touchedSlots = idx + 1; + } + } + + unsigned int scanForPublish(const m256i& computorPublicKey, unsigned int currentTick, bool retries) + { + for (unsigned int i = 0; i < _touchedSlots; i++) + { + const int state = _publicationTick[i]; + if (retries) + { + if (state <= NOT_SCHEDULED || state > (int)currentTick) + { + continue; + } + } + else if (state != NOT_SCHEDULED) + { + continue; + } + if (isZero(_entries[i].computorPublicKey) || !(_entries[i].computorPublicKey == computorPublicKey)) + { + continue; + } + if (currentTick - _entries[i].anchorTick > ANT_PUBLISH_WINDOW_TICKS) + { + retireLocked(i, _stats.obsoleteExpired); + continue; + } + return i; + } + return NO_ENTRY; + } + + void retire(unsigned int index, unsigned long long& counter) + { + LockGuard guard(_lock); + retireLocked(index, counter); + } + + void retireLocked(unsigned int index, unsigned long long& counter) + { + if (index >= CAPACITY || _publicationTick[index] < 0) + { + return; + } + _publicationTick[index] = OBSOLETE; + counter++; + } + + static AntDedupKey keyOf(const m256i& computorPublicKey, const SolutionRef& parentRef, + const m256i& nonce) + { + AntDedupKey key; + key.pubkey = computorPublicKey; + key.nonce = nonce; + key.parentRef = parentRef; + return key; + } + + // Reads only the low words would put every nonce differing above them in one slot, which is the + // clustering the index exists to avoid. + static unsigned int hashOf(const m256i& computorPublicKey, const SolutionRef& parentRef, + const m256i& nonce) + { + const AntDedupKey key = keyOf(computorPublicKey, parentRef, nonce); + unsigned long long digest; + KangarooTwelve(&key, sizeof(key), &digest, sizeof(digest)); + return (unsigned int)(digest & (INDEX_CAPACITY - 1)); + } + + // Returns the slot holding this key, or the first free slot if it is absent. Linear probing over + // a table at most half full, so the walk always terminates. + unsigned int indexSlotFor(const m256i& computorPublicKey, const SolutionRef& parentRef, + const m256i& nonce) const + { + unsigned int slot = hashOf(computorPublicKey, parentRef, nonce); + for (unsigned int probe = 0; probe < INDEX_CAPACITY; probe++) + { + const unsigned int e = _index[slot]; + if (e == EMPTY) + { + return slot; + } + if (keyOf(_entries[e].computorPublicKey, _entries[e].parentRef, _entries[e].nonce) + == keyOf(computorPublicKey, parentRef, nonce)) + { + return slot; + } + slot = (slot + 1) & (INDEX_CAPACITY - 1); + } + return 0; + } + + void indexRemove(const AntPendingSolution& entry) + { + const unsigned int slot = indexSlotFor(entry.computorPublicKey, entry.parentRef, entry.nonce); + if (_index[slot] == EMPTY) + { + return; + } + _index[slot] = EMPTY; + + // Re-place the run that followed it, or a probe would stop early at the hole just made. + unsigned int next = (slot + 1) & (INDEX_CAPACITY - 1); + while (_index[next] != EMPTY) + { + const unsigned int moved = _index[next]; + _index[next] = EMPTY; + const unsigned int target = indexSlotFor(_entries[moved].computorPublicKey, + _entries[moved].parentRef, _entries[moved].nonce); + _index[target] = moved; + next = (next + 1) & (INDEX_CAPACITY - 1); + } + } + + AntPendingSolution* _entries; + int* _publicationTick; + unsigned int* _index; + unsigned int _count; + unsigned int _nextFree; + unsigned int _touchedSlots; + Stats _stats; + mutable volatile char _lock; +}; diff --git a/src/network_messages/ant_colony_message.h b/src/network_messages/ant_colony_message.h index 726df1a2..e1ff2fd9 100644 --- a/src/network_messages/ant_colony_message.h +++ b/src/network_messages/ant_colony_message.h @@ -138,7 +138,9 @@ struct RespondAntEpochContext m256i spectrumDigest; // score threshold for this epoch unsigned int threshold; - // ANT_FRESHNESS_WINDOW_TICKS (N): publish within N of the anchor tick; siblings within N coexist + // ANT_PUBLISH_WINDOW_TICKS: publish within this many ticks of the anchor. The sibling + // no-compete band is a separate constant and is not reported here - mineable-parents already + // returns the computed floor per parent, so a miner never needs to derive it. unsigned int freshnessWindow; // accepted solutions so far this epoch unsigned int solutionCount; diff --git a/src/public_settings.h b/src/public_settings.h index 8e2b088d..bb55fc20 100644 --- a/src/public_settings.h +++ b/src/public_settings.h @@ -126,11 +126,11 @@ static constexpr unsigned long long BPP9000_NUMBER_OF_MUTATIONS = 100; static constexpr unsigned long long BPP9000_NUMBER_OF_WINDOWS = BPP9000_SEQUENCE_LENGTH - BPP9000_WINDOW_WIDTH; static constexpr unsigned int BPP9000_SOLUTION_THRESHOLD_DEFAULT = 3838; -// Ant colony: a solution anchors to a recent tick (its RNG seeds from that tick's digest) and must be -// published within ANT_FRESHNESS_WINDOW_TICKS of it. The same window is the sibling no-compete band: -// two siblings whose anchor ticks differ by <= N coexist; a child only has to beat siblings whose -// anchor tick is more than N earlier. -static constexpr unsigned int ANT_FRESHNESS_WINDOW_TICKS = 676; +// Ant colony: a solution must be published within this many ticks of the anchor its walk seeded from. +static constexpr unsigned int ANT_PUBLISH_WINDOW_TICKS = 16000; + +// Sibling no-compete band: a child only has to beat siblings anchored more than N ticks earlier. +static constexpr unsigned int ANT_SIBLING_NOCOMPETE_TICKS = 676; // Ant colony: tree nodes recorded per epoch; one per accepted solution. static constexpr unsigned int ANT_MAX_NODES_PER_EPOCH = 1u << 23; diff --git a/src/qubic.cpp b/src/qubic.cpp index bff9c38e..d2928945 100644 --- a/src/qubic.cpp +++ b/src/qubic.cpp @@ -1522,7 +1522,7 @@ static void processRequestAntEpochContext(Peer* peer, RequestResponseHeader* hea respond.spectrumDigest = gAntColony.rootSeed(); respond.threshold = gAntColony.errorThreshold(); - respond.freshnessWindow = ANT_FRESHNESS_WINDOW_TICKS; + respond.freshnessWindow = ANT_PUBLISH_WINDOW_TICKS; respond.solutionCount = gAntColony.solutionCount(); respond.freeAnnSlotsCount = gAntColony.freeAnnSlotsCount(); respond.epoch = system.epoch; diff --git a/test/ant_colony.cpp b/test/ant_colony.cpp index 5bdd1d8e..874538bc 100644 --- a/test/ant_colony.cpp +++ b/test/ant_colony.cpp @@ -149,9 +149,9 @@ TEST(TestAntColonyValidate, FreshnessWindowBoundaries) ValidityResult::Valid); // Exactly at the window edge is still legal; one past it is not. - EXPECT_EQ(admit(makeChild(me, 3700, anchor, anchor + ANT_FRESHNESS_WINDOW_TICKS), + EXPECT_EQ(admit(makeChild(me, 3700, anchor, anchor + ANT_PUBLISH_WINDOW_TICKS), &parent, WORST_SCORE), ValidityResult::Valid); - EXPECT_EQ(admit(makeChild(me, 3700, anchor, anchor + ANT_FRESHNESS_WINDOW_TICKS + 1), + EXPECT_EQ(admit(makeChild(me, 3700, anchor, anchor + ANT_PUBLISH_WINDOW_TICKS + 1), &parent, WORST_SCORE), ValidityResult::RejectStale); // An anchor in the future is rejected rather than wrapping the unsigned subtraction. @@ -166,7 +166,7 @@ TEST(TestAntColonyValidate, ReportsTheFirstFailingRule) const m256i me = makeKey(8); const m256i other = makeKey(9); const unsigned int anchor = 100000; - const unsigned int stalePublish = anchor + ANT_FRESHNESS_WINDOW_TICKS + 1; + const unsigned int stalePublish = anchor + ANT_PUBLISH_WINDOW_TICKS + 1; const AntSolutionRecord theirs = makeParent(other, 3000); diff --git a/test/ant_pending_solutions.cpp b/test/ant_pending_solutions.cpp new file mode 100644 index 00000000..06f9d1a2 --- /dev/null +++ b/test/ant_pending_solutions.cpp @@ -0,0 +1,223 @@ +#define NO_UEFI + +#include "gtest/gtest.h" + +#include "../src/mining/ant_pending_solutions.h" + +static constexpr unsigned int NO_ENTRY = 0xFFFFFFFFu; + +static m256i key(unsigned long long n) +{ + m256i k = m256i::zero(); + k.m256i_u64[0] = n + 1; // never zero: a zero pubkey marks an unused slot + return k; +} + +static SolutionRef ref(unsigned int tickOffset, unsigned int idx) +{ + SolutionRef r; + r.tickOffset = tickOffset; + r.solutionIndexInTick = idx; + return r; +} + +// 5.75 MB, so one buffer for the file, reset between tests. +static AntPendingSolutions* freshPool() +{ + static AntPendingSolutions pool; + static bool allocated = false; + static bool failed = false; + if (!allocated && !failed) + { + failed = !pool.init(); + allocated = !failed; + } + if (failed) + { + return nullptr; + } + pool.reset(); + return &pool; +} + +// The key is (computor, parentRef, nonce). Same triple twice is one solution, whatever else differs. +TEST(TestAntPending, DedupsOnTheConsensusKey) +{ + AntPendingSolutions* pool = freshPool(); + ASSERT_NE(pool, nullptr); + + EXPECT_TRUE(pool->add(key(1), ref(100, 0), 5000, key(900))); + EXPECT_FALSE(pool->add(key(1), ref(100, 0), 5000, key(900))); + + // A different anchor is the SAME solution - anchorTick is deliberately not in the key, so a + // re-anchored resend cannot be published twice. + EXPECT_FALSE(pool->add(key(1), ref(100, 0), 9999, key(900))); + + // Any other field differing makes it a different solution. + EXPECT_TRUE(pool->add(key(2), ref(100, 0), 5000, key(900))); + EXPECT_TRUE(pool->add(key(1), ref(100, 1), 5000, key(900))); + EXPECT_TRUE(pool->add(key(1), ref(100, 0), 5000, key(901))); +} + +// A fresh entry is selectable, and only by the computor it belongs to. +TEST(TestAntPending, SelectsOnlyForItsOwnComputor) +{ + AntPendingSolutions* pool = freshPool(); + ASSERT_NE(pool, nullptr); + ASSERT_TRUE(pool->add(key(1), ref(100, 0), 5000, key(900))); + + AntPendingSolution out; + EXPECT_EQ(pool->selectForPublish(key(2), 5000, out), NO_ENTRY); + + const unsigned int idx = pool->selectForPublish(key(1), 5000, out); + ASSERT_NE(idx, NO_ENTRY); + EXPECT_TRUE(out.nonce == key(900)); + EXPECT_EQ(out.anchorTick, 5000u); +} + +// Scheduling records a deadline. Before it passes the entry must not come back, or the node would +// republish a transaction that is still in flight. +TEST(TestAntPending, ScheduledEntryIsNotReselectedBeforeItsDeadline) +{ + AntPendingSolutions* pool = freshPool(); + ASSERT_NE(pool, nullptr); + ASSERT_TRUE(pool->add(key(1), ref(100, 0), 5000, key(900))); + + AntPendingSolution out; + const unsigned int idx = pool->selectForPublish(key(1), 5000, out); + ASSERT_NE(idx, NO_ENTRY); + pool->markScheduled(idx, 5003); + + EXPECT_EQ(pool->selectForPublish(key(1), 5001, out), NO_ENTRY); + EXPECT_EQ(pool->selectForPublish(key(1), 5002, out), NO_ENTRY); + + // Deadline reached with no acknowledgement: republish. + EXPECT_EQ(pool->selectForPublish(key(1), 5003, out), idx); +} + +// The whole reason the state is a tick and not a flag. +TEST(TestAntPending, RetriesOutrankFreshEntries) +{ + AntPendingSolutions* pool = freshPool(); + ASSERT_NE(pool, nullptr); + + ASSERT_TRUE(pool->add(key(1), ref(100, 0), 5000, key(900))); + AntPendingSolution out; + const unsigned int stale = pool->selectForPublish(key(1), 5000, out); + ASSERT_NE(stale, NO_ENTRY); + pool->markScheduled(stale, 5003); + + // Newer solutions keep arriving while the first one's transaction is lost. + ASSERT_TRUE(pool->add(key(1), ref(100, 1), 5001, key(901))); + ASSERT_TRUE(pool->add(key(1), ref(100, 2), 5002, key(902))); + + // Past the deadline the retry must win, or a steady stream of new work starves it forever. + EXPECT_EQ(pool->selectForPublish(key(1), 5003, out), stale); +} + +// RECORDED comes from observing the chain, and must both stop republication and suppress a resend. +TEST(TestAntPending, RecordedStopsRepublishingAndSuppressesResend) +{ + AntPendingSolutions* pool = freshPool(); + ASSERT_NE(pool, nullptr); + ASSERT_TRUE(pool->add(key(1), ref(100, 0), 5000, key(900))); + + AntPendingSolution out; + const unsigned int idx = pool->selectForPublish(key(1), 5000, out); + ASSERT_NE(idx, NO_ENTRY); + pool->markScheduled(idx, 5003); + pool->markRecorded(key(1), ref(100, 0), key(900)); + + EXPECT_EQ(pool->selectForPublish(key(1), 9000, out), NO_ENTRY); + EXPECT_FALSE(pool->add(key(1), ref(100, 0), 5000, key(900))); +} + +// A transaction the node never queued still has to be remembered, or a miner resending a solution +// that is already on-chain makes the pool publish it a second time and pay a second deposit. +TEST(TestAntPending, RecordingAnUnqueuedSolutionSuppressesALaterSubmission) +{ + AntPendingSolutions* pool = freshPool(); + ASSERT_NE(pool, nullptr); + + pool->markRecorded(key(1), ref(100, 0), key(900)); + EXPECT_FALSE(pool->add(key(1), ref(100, 0), 5000, key(900))); +} + +// Past the publish window the commit path rejects it as stale, so publishing spends the deposit for +// nothing. Selection has to drop it rather than hand it over. +TEST(TestAntPending, ExpiredEntriesAreRetiredNotPublished) +{ + AntPendingSolutions* pool = freshPool(); + ASSERT_NE(pool, nullptr); + ASSERT_TRUE(pool->add(key(1), ref(100, 0), 5000, key(900))); + + AntPendingSolution out; + EXPECT_EQ(pool->selectForPublish(key(1), 5000 + ANT_PUBLISH_WINDOW_TICKS, out), 0u); + EXPECT_EQ(pool->selectForPublish(key(1), 5000 + ANT_PUBLISH_WINDOW_TICKS + 1, out), NO_ENTRY); + + AntPendingSolutions::Stats stats; + unsigned int count = 0; + pool->getStats(stats, count); + EXPECT_EQ(stats.obsoleteExpired, 1u); +} + +// An entry retired for expiry was never published, so the seen filter was never marked and the key +// is still usable. A resubmission with a fresh anchor must replace it rather than be refused as a +// duplicate - otherwise the solution is stranded and the miner is never told why. +TEST(TestAntPending, ExpiredEntryCanBeResubmittedWithAFreshAnchor) +{ + AntPendingSolutions* pool = freshPool(); + ASSERT_NE(pool, nullptr); + ASSERT_TRUE(pool->add(key(1), ref(100, 0), 5000, key(900))); + + // Selection retires it instead of publishing: publishing a stale one would mark the seen filter + // and kill this key permanently. + AntPendingSolution out; + ASSERT_EQ(pool->selectForPublish(key(1), 5000 + ANT_PUBLISH_WINDOW_TICKS + 1, out), NO_ENTRY); + + // Same triple, newer anchor. This is the replacement, not a duplicate. + EXPECT_TRUE(pool->add(key(1), ref(100, 0), 40000, key(900))); + + const unsigned int idx = pool->selectForPublish(key(1), 40000, out); + ASSERT_NE(idx, NO_ENTRY); + EXPECT_EQ(out.anchorTick, 40000u); +} + +// A live entry is a real duplicate, whether it has been scheduled or not. +TEST(TestAntPending, LiveEntryStillRejectsAResubmission) +{ + AntPendingSolutions* pool = freshPool(); + ASSERT_NE(pool, nullptr); + ASSERT_TRUE(pool->add(key(1), ref(100, 0), 5000, key(900))); + + EXPECT_FALSE(pool->add(key(1), ref(100, 0), 6000, key(900))); + + AntPendingSolution out; + const unsigned int idx = pool->selectForPublish(key(1), 5000, out); + ASSERT_NE(idx, NO_ENTRY); + pool->markScheduled(idx, 5003); + EXPECT_FALSE(pool->add(key(1), ref(100, 0), 6000, key(900))); +} + +// Finished slots are reused in place, so a pool that has published for a whole epoch does not fill. +TEST(TestAntPending, FinishedSlotsAreReclaimed) +{ + AntPendingSolutions* pool = freshPool(); + ASSERT_NE(pool, nullptr); + + for (unsigned int i = 0; i < 4; i++) + { + ASSERT_TRUE(pool->add(key(1), ref(100, i), 5000, key(900 + i))); + pool->markRecorded(key(1), ref(100, i), key(900 + i)); + } + + AntPendingSolutions::Stats stats; + unsigned int count = 0; + pool->getStats(stats, count); + EXPECT_EQ(stats.recorded, 4u); + + // Nothing left to publish, and new work still fits. + AntPendingSolution out; + EXPECT_EQ(pool->selectForPublish(key(1), 5000, out), NO_ENTRY); + EXPECT_TRUE(pool->add(key(1), ref(200, 0), 5000, key(1000))); +} diff --git a/test/test.vcxproj b/test/test.vcxproj index 2a54d0c9..b47d1523 100644 --- a/test/test.vcxproj +++ b/test/test.vcxproj @@ -177,6 +177,7 @@ + From b8ce2418457c3601d448c1772888b8c29a63b85b Mon Sep 17 00:00:00 2001 From: cyber-pc <165458555+cyber-pc@users.noreply.github.com> Date: Sun, 9 Aug 2026 18:20:16 +0700 Subject: [PATCH 20/46] Queue and publish pool ant solutions. --- src/mining/ant_pending_solutions.h | 64 ++++-- src/network_messages/ant_colony_message.h | 12 ++ src/qubic.cpp | 245 ++++++++++++++++++++++ test/ant_pending_solutions.cpp | 104 ++++----- 4 files changed, 361 insertions(+), 64 deletions(-) diff --git a/src/mining/ant_pending_solutions.h b/src/mining/ant_pending_solutions.h index d749d21c..80d2f61b 100644 --- a/src/mining/ant_pending_solutions.h +++ b/src/mining/ant_pending_solutions.h @@ -14,7 +14,7 @@ struct AntPendingSolution m256i nonce; SolutionRef parentRef; unsigned int anchorTick; // ABSOLUTE. Bounds how long this entry is worth publishing. - unsigned int padding; + unsigned int score; // computed at receipt; the publisher uses it without re-scoring }; static_assert(sizeof(AntPendingSolution) == 32 + 32 + 8 + 8, "AntPendingSolution unexpected padding"); @@ -23,6 +23,7 @@ class AntPendingSolutions public: static constexpr unsigned int CAPACITY = 65536; static_assert((CAPACITY & (CAPACITY - 1)) == 0, "CAPACITY must be a power of two"); + static constexpr unsigned int NO_ENTRY = 0xFFFFFFFFU; // publicationTick[] states. A positive value is the tick the transaction was targeted at, which // is also the deadline to see it on-chain @@ -34,7 +35,10 @@ class AntPendingSolutions { unsigned long long received; unsigned long long droppedNonCanonical; - unsigned long long droppedParentUnknown; + unsigned long long droppedBadAnchor; // anchor in the future, or aged out of the ring + unsigned long long droppedParentUnknown; // parentRef names a node this node does not hold + unsigned long long droppedUnscorable; // the scorer returned no usable value + unsigned long long droppedUnacceptable; // scored, but the colony would reject it now unsigned long long droppedDuplicate; unsigned long long droppedFull; unsigned long long published; @@ -42,6 +46,7 @@ class AntPendingSolutions unsigned long long obsoleteParentGone; unsigned long long obsoleteExpired; unsigned long long obsoleteGateRejected; + unsigned long long claimMismatch; }; bool init() @@ -91,8 +96,6 @@ class AntPendingSolutions LockGuard guard(_lock); setMem(_entries, CAPACITY * sizeof(AntPendingSolution), 0); setMem(_publicationTick, CAPACITY * sizeof(int), 0); - // setMem, not a fill loop: MSVC turns one into a memset call, which does not exist in the - // freestanding UEFI build. EMPTY is all-ones, so a 0xFF byte fill is exact. setMem(_index, INDEX_CAPACITY * sizeof(unsigned int), 0xFF); _count = 0; _nextFree = 0; @@ -112,11 +115,31 @@ class AntPendingSolutions LockGuard guard(_lock); _stats.droppedNonCanonical++; } + void noteDroppedBadAnchor() + { + LockGuard guard(_lock); + _stats.droppedBadAnchor++; + } void noteDroppedParentUnknown() { LockGuard guard(_lock); _stats.droppedParentUnknown++; } + void noteDroppedDuplicate() + { + LockGuard guard(_lock); + _stats.droppedDuplicate++; + } + void noteDroppedUnscorable() + { + LockGuard guard(_lock); + _stats.droppedUnscorable++; + } + void noteDroppedUnacceptable() + { + LockGuard guard(_lock); + _stats.droppedUnacceptable++; + } void getStats(Stats& outStats, unsigned int& outCount) const { @@ -127,12 +150,12 @@ class AntPendingSolutions // Queue a solution for publication, called from request processors. bool add(const m256i& computorPublicKey, const SolutionRef& parentRef, - unsigned int anchorTick, const m256i& nonce) + unsigned int anchorTick, unsigned int score, const m256i& nonce) { LockGuard guard(_lock); const unsigned int slot = indexSlotFor(computorPublicKey, parentRef, nonce); unsigned int entryIdx = NO_ENTRY; - if (_index[slot] != EMPTY) + if (_index[slot] != INDEX_EMPTY) { // An OBSOLETE entry is not a duplicate // Every other state is a real duplicate: NOT_SCHEDULED and scheduled are still live, and @@ -159,9 +182,9 @@ class AntPendingSolutions e.nonce = nonce; e.parentRef = parentRef; e.anchorTick = anchorTick; - e.padding = 0; + e.score = score; _publicationTick[entryIdx] = NOT_SCHEDULED; - if (_index[slot] == EMPTY) + if (_index[slot] == INDEX_EMPTY) { _index[slot] = entryIdx; _count++; @@ -212,12 +235,18 @@ class AntPendingSolutions retire(index, _stats.obsoleteGateRejected); } + void noteClaimMismatch() + { + LockGuard guard(_lock); + _stats.claimMismatch++; + } + void markRecorded(const m256i& computorPublicKey, const SolutionRef& parentRef, const m256i& nonce) { LockGuard guard(_lock); const unsigned int slot = indexSlotFor(computorPublicKey, parentRef, nonce); - if (_index[slot] != EMPTY) + if (_index[slot] != INDEX_EMPTY) { _publicationTick[_index[slot]] = RECORDED; _stats.recorded++; @@ -236,7 +265,7 @@ class AntPendingSolutions e.nonce = nonce; e.parentRef = parentRef; e.anchorTick = 0; - e.padding = 0; + e.score = 0; _publicationTick[entryIdx] = RECORDED; _index[slot] = entryIdx; _count++; @@ -245,8 +274,9 @@ class AntPendingSolutions private: static constexpr unsigned int INDEX_CAPACITY = 2 * CAPACITY; - static constexpr unsigned int EMPTY = 0xFFFFFFFFU; - static constexpr unsigned int NO_ENTRY = 0xFFFFFFFFU; + // Not INDEX_EMPTY: network_messages/assets.h defines that as a macro, and this header is included + // after it in qubic.cpp. + static constexpr unsigned int INDEX_EMPTY = 0xFFFFFFFFU; // A slot is free if it has never been used or if its entry is finished. Reuse is IN PLACE: // nothing is ever moved, so an index the tick processor is holding across the publish gate @@ -357,7 +387,7 @@ class AntPendingSolutions for (unsigned int probe = 0; probe < INDEX_CAPACITY; probe++) { const unsigned int e = _index[slot]; - if (e == EMPTY) + if (e == INDEX_EMPTY) { return slot; } @@ -374,18 +404,18 @@ class AntPendingSolutions void indexRemove(const AntPendingSolution& entry) { const unsigned int slot = indexSlotFor(entry.computorPublicKey, entry.parentRef, entry.nonce); - if (_index[slot] == EMPTY) + if (_index[slot] == INDEX_EMPTY) { return; } - _index[slot] = EMPTY; + _index[slot] = INDEX_EMPTY; // Re-place the run that followed it, or a probe would stop early at the hole just made. unsigned int next = (slot + 1) & (INDEX_CAPACITY - 1); - while (_index[next] != EMPTY) + while (_index[next] != INDEX_EMPTY) { const unsigned int moved = _index[next]; - _index[next] = EMPTY; + _index[next] = INDEX_EMPTY; const unsigned int target = indexSlotFor(_entries[moved].computorPublicKey, _entries[moved].parentRef, _entries[moved].nonce); _index[target] = moved; diff --git a/src/network_messages/ant_colony_message.h b/src/network_messages/ant_colony_message.h index e1ff2fd9..3461b9eb 100644 --- a/src/network_messages/ant_colony_message.h +++ b/src/network_messages/ant_colony_message.h @@ -24,6 +24,18 @@ struct RequestAntMineableParents }; static_assert(sizeof(RequestAntMineableParents) == 40, "RequestAntMineableParents unexpected size"); +// A pool miner hands its computor a solution over BroadcastMessage(MESSAGE_TYPE_ANT_SOLUTION); this +// is the payload that follows the header. +struct AntSolutionBroadcastPayload +{ + unsigned int parentTickOffset; + unsigned int parentSolutionIndexInTick; + unsigned int anchorTick; // ABSOLUTE + unsigned int claimedScore; + m256i nonce; +}; +static_assert(sizeof(AntSolutionBroadcastPayload) == 48, "AntSolutionBroadcastPayload unexpected size"); + // Max mineable-parent entries returned per response. Miners page through the // rest via the nextIndex cursor. constexpr unsigned int ANT_MINEABLE_PARENTS_PER_RESPONSE = 64; diff --git a/src/qubic.cpp b/src/qubic.cpp index d2928945..41275f68 100644 --- a/src/qubic.cpp +++ b/src/qubic.cpp @@ -81,6 +81,7 @@ #include "oracle_core/net_msg_impl.h" #include "oracle_core/snapshot_files.h" #include "mining/ant_colony_snapshot.h" +#include "mining/ant_pending_solutions.h" #include "oracle_core/oracle_interfaces_def.h" #include "qpi/impl/qpi_oracle_impl.h" @@ -268,6 +269,7 @@ static int getSolutionThreshold(score_engine::AlgoType selectedAlgo) static bool applyBpp9000Task(); static AntColonyBpp9000T gAntColony; +static AntPendingSolutions gAntPendingSolutions; static AntColonyBpp9000T::Ann gAntParentAnnScratch[MAX_NUMBER_OF_PROCESSORS]; static AntColonyBpp9000T::Ann gAntChildAnnScratch[MAX_NUMBER_OF_PROCESSORS]; @@ -387,10 +389,108 @@ static void computeAntAnchorDigest(unsigned int tick, const m256i& transactionDi KangarooTwelve(preimage, sizeof(preimage), &out, sizeof(out)); } +// A pool miner's solution, arriving over BroadcastMessage +static void queueAntSolution(unsigned long long processorNumber, const m256i& computorPublicKey, + const AntSolutionBroadcastPayload& payload) +{ + gAntPendingSolutions.noteReceived(); + + // A non-canonical nonce is rejected by the scorer without producing a score, so the transaction + // would forfeit the deposit with nothing to show. The computor pays that, not the miner. + if (!score_engine::isCanonicalAntNonce(payload.nonce.m256i_u8, + score_engine::ScoreBpp9000T::numberOfMutations)) + { + gAntPendingSolutions.noteDroppedNonCanonical(); + return; + } + + // The same bits the commit path checks before RejectReplay, so a hit here is a solution this + // node has already processed on-chain - publishing it again would forfeit the deposit. The + // filter is restored from the snapshot, so unlike this buffer the check survives a restart. + const SolutionRef parentRef = { payload.parentTickOffset, payload.parentSolutionIndexInTick }; + unsigned int seenFlagIndices[2]; + computeAntSolutionFlagIndices(computorPublicKey, payload.nonce, parentRef, seenFlagIndices); + if (isAntSolutionSeen(seenFlagIndices)) + { + gAntPendingSolutions.noteDroppedDuplicate(); + return; + } + + // An anchor in the future is malformed, and one the ring no longer holds cannot be scored. + m256i anchorDigest; + if (payload.anchorTick > system.tick + || system.tick - payload.anchorTick > ANT_PUBLISH_WINDOW_TICKS + || !gAntColony.getAnchorDigest(payload.anchorTick, anchorDigest)) + { + gAntPendingSolutions.noteDroppedBadAnchor(); + return; + } + + const AntSolutionRecord* parentRec = nullptr; + if (gAntColony.tryGetParent(parentRef, &parentRec) != ValidityResult::Valid) + { + gAntPendingSolutions.noteDroppedParentUnknown(); + return; + } + + const score_engine::ScoreBpp9000T::ANN* parentAnn = nullptr; + if (parentRec != nullptr) + { + if (!gAntColony.annOfNonRoot(*parentRec, gAntParentAnnScratch[processorNumber])) + { + gAntPendingSolutions.noteDroppedParentUnknown(); + return; + } + parentAnn = &gAntParentAnnScratch[processorNumber]; + } + + // Building the key is far cheaper than a miss, so the cache is consulted first. + const AntColonyBpp9000T::ReplayKey replayKey = + makeAntReplayKey(computorPublicKey, payload.nonce, parentAnn, anchorDigest); + unsigned int childScore = 0; + if (!gAntColony.tryGetReplayScore(replayKey, childScore, gAntChildAnnScratch[processorNumber])) + { + childScore = score->computeAntChildScore(processorNumber, parentAnn, computorPublicKey, + payload.nonce, anchorDigest, gAntChildAnnScratch[processorNumber]); + // Cached whatever the outcome: a timed-out network scores invalid, and the walk is + // deterministic, so the cached rejection stays right - without the entry the same doomed + // solution costs a full walk again on every path that sees it. + gAntColony.putReplayScore(replayKey, childScore, gAntChildAnnScratch[processorNumber]); + } + if (!score->isValidScore(childScore, score_engine::AlgoType::Bpp9000)) + { + gAntPendingSolutions.noteDroppedUnscorable(); + return; + } + + // The sender's own number, checked where it can still prevent work rather than merely be counted. + if (payload.claimedScore != childScore) + { + gAntPendingSolutions.noteClaimMismatch(); + return; + } + + // The floor moves as siblings age into competition, so passing now is not a promise it will pass + // at publication - the publisher re-checks. Failing now is final enough to refuse the slot. + const unsigned int floor = gAntColony.siblingFloorForQuery(parentRef, computorPublicKey, + payload.anchorTick); + const ChildCandidate candidate{ computorPublicKey, childScore, payload.anchorTick, system.tick }; + if (AntColonyBpp9000T::validateChild(candidate, parentRec, floor, + gAntColony.errorThreshold()) != ValidityResult::Valid) + { + gAntPendingSolutions.noteDroppedUnacceptable(); + return; + } + + gAntPendingSolutions.add(computorPublicKey, parentRef, payload.anchorTick, childScore, + payload.nonce); +} + // Reseed the colony for a new epoch. The root seed is score->currentRandomSeed, the epoch-start // spectrum digest static void antColonyBeginEpoch() { + gAntPendingSolutions.reset(); gAntColony.beginEpoch(score->currentRandomSeed); gAntColony.setErrorThreshold((unsigned int)getSolutionThreshold(score_engine::AlgoType::Bpp9000)); @@ -821,6 +921,19 @@ static void processBroadcastMessage(const unsigned long long processorNumber, Re } } break; + + case MESSAGE_TYPE_ANT_SOLUTION: + { + // Exact size, not a minimum: the payload is fixed, so a longer + // one is a different message rather than a forward-compatible + // variant of this one. + if (messagePayloadSize == sizeof(AntSolutionBroadcastPayload)) + { + queueAntSolution(processorNumber, request->destinationPublicKey, + *(AntSolutionBroadcastPayload*)((unsigned char*)request + sizeof(BroadcastMessage))); + } + } + break; } } } @@ -3093,6 +3206,16 @@ static void processTickTransactionAntColonySolution( { system.tick - system.initialTick, transactionIndex }, // selfRef, epoch-RELATIVE tick transaction->anchorTick, // ABSOLUTE system.tick }; // ABSOLUTE + // Seeing this transaction execute, that mean those solution is recognized on-chain, mark them as recorded + for (unsigned int ownIdx = 0; ownIdx < computorSeedsCount; ownIdx++) + { + if (transaction->sourcePublicKey == computorPublicKeys[ownIdx]) + { + gAntPendingSolutions.markRecorded(transaction->sourcePublicKey, parentRef, transaction->nonce); + break; + } + } + result = gAntColony.commit(in, parentRec, childScore, *childAnn, childAnnHash); // ValidNotStored is the store being full: the solution passed every rule and only missed a slot, // so it earns its refund and its ranking exactly like a stored one @@ -3531,6 +3654,85 @@ static bool makeAndBroadcastExecutionFeeTransaction(int i, BroadcastFutureTickDa } OPTIMIZE_OFF() + +// Publish at most one queued ant solution for computor i, retrying anything whose transaction never +// landed. Mirrors the legacy solution publisher: the tick the transaction is targeted at is also the +// deadline to see it on-chain, so missing it is what triggers the retry. +static void publishAntSolutionFor(unsigned long long processorNumber, unsigned int computorIndex) +{ + AntPendingSolution entry; + const unsigned int idx = gAntPendingSolutions.selectForPublish( + computorPublicKeys[computorIndex], system.tick, entry); + if (idx == AntPendingSolutions::NO_ENTRY) + { + return; + } + + const AntSolutionRecord* parentRec = nullptr; + if (gAntColony.tryGetParent(entry.parentRef, &parentRec) != ValidityResult::Valid) + { + gAntPendingSolutions.markObsoleteParentGone(idx); + return; + } + + m256i anchorDigest; + if (!gAntColony.getAnchorDigest(entry.anchorTick, anchorDigest)) + { + // The ring no longer holds it, so this node could not score the transaction it is about to + // publish - and neither could anyone else. + gAntPendingSolutions.markObsoleteExpired(idx); + return; + } + + // Last point before the node signs with its own computor key and funds the deposit from its own + // balance. The commit path forfeits the deposit on a non-canonical nonce, and a check this cheap + // belongs on both sides of the queue. + if (!score_engine::isCanonicalAntNonce(entry.nonce.m256i_u8, + score_engine::ScoreBpp9000T::numberOfMutations)) + { + gAntPendingSolutions.markObsoleteGateRejected(idx); + return; + } + + // The score was computed at receipt, on a request processor, and the entry has + // carried it since. + // Judged at the tick the transaction will EXECUTE in, not the current one, so this gate sees + // what commit will see - a solution at the window boundary now would commit stale. + const unsigned int publishTick = system.tick + MIN_MINING_SOLUTIONS_PUBLICATION_OFFSET; + const unsigned int floor = gAntColony.siblingFloorForQuery(entry.parentRef, + entry.computorPublicKey, entry.anchorTick); + const ChildCandidate candidate{ entry.computorPublicKey, entry.score, entry.anchorTick, publishTick }; + if (AntColonyBpp9000T::validateChild(candidate, parentRec, floor, + gAntColony.errorThreshold()) != ValidityResult::Valid) + { + gAntPendingSolutions.markObsoleteGateRejected(idx); + return; + } + + AntColonyMiningSolutionTransaction payload; + setMem(&payload, sizeof(payload), 0); + payload.sourcePublicKey = computorPublicKeys[computorIndex]; + payload.destinationPublicKey = m256i::zero(); + payload.amount = AntColonyMiningSolutionTransaction::minAmount(); + payload.tick = publishTick; + payload.inputType = AntColonyMiningSolutionTransaction::transactionType(); + payload.inputSize = AntColonyMiningSolutionTransaction::minInputSize(); + payload.parentTickOffset = entry.parentRef.tickOffset; + payload.parentSolutionIndexInTick = entry.parentRef.solutionIndexInTick; + payload.anchorTick = entry.anchorTick; + payload.claimedScore = entry.score; + payload.nonce = entry.nonce; + + unsigned char digest[32]; + KangarooTwelve(&payload, sizeof(Transaction) + AntColonyMiningSolutionTransaction::minInputSize(), + digest, sizeof(digest)); + sign(computorSubseeds[computorIndex].m256i_u8, computorPublicKeys[computorIndex].m256i_u8, + digest, payload.signature); + + enqueueResponse(NULL, sizeof(payload), BROADCAST_TRANSACTION, 0, &payload); + gAntPendingSolutions.markScheduled(idx, (int)payload.tick); +} + static void processTick(unsigned long long processorNumber) { PROFILE_SCOPE(); @@ -4401,6 +4603,9 @@ static void processTick(unsigned long long processorNumber) enqueueResponse(NULL, sizeof(payload), BROADCAST_TRANSACTION, 0, &payload); } + + // Re-publish the solutions that are not on chain + publishAntSolutionFor(processorNumber, i); } } @@ -6821,6 +7026,10 @@ static bool initialize() } setMem(score_qpi, sizeof(*score_qpi), 0); + if (!gAntPendingSolutions.init()) + { + return false; + } if (!gAntColony.init()) { return false; @@ -7212,6 +7421,7 @@ static void deinitialize() pendingTxsPool.deinit(); + gAntPendingSolutions.deinit(); gAntColony.deinit(); if (score) @@ -7930,6 +8140,41 @@ static void processKeyPresses() appendText(message, L" | replay cache "); appendNumber(message, gAntColony.replayCacheOccupancy(), TRUE); logToConsole(message); + + AntPendingSolutions::Stats pending; + unsigned int pendingCount = 0; + gAntPendingSolutions.getStats(pending, pendingCount); + setText(message, L"AntPool: queued "); + appendNumber(message, pendingCount, TRUE); + appendText(message, L" | received "); + appendNumber(message, pending.received, TRUE); + appendText(message, L" | published "); + appendNumber(message, pending.published, TRUE); + appendText(message, L" | recorded "); + appendNumber(message, pending.recorded, TRUE); + appendText(message, L" | dropped: nonCanonical "); + appendNumber(message, pending.droppedNonCanonical, TRUE); + appendText(message, L", badAnchor "); + appendNumber(message, pending.droppedBadAnchor, TRUE); + appendText(message, L", parentUnknown "); + appendNumber(message, pending.droppedParentUnknown, TRUE); + appendText(message, L", unscorable "); + appendNumber(message, pending.droppedUnscorable, TRUE); + appendText(message, L", unacceptable "); + appendNumber(message, pending.droppedUnacceptable, TRUE); + appendText(message, L", duplicate "); + appendNumber(message, pending.droppedDuplicate, TRUE); + appendText(message, L", full "); + appendNumber(message, pending.droppedFull, TRUE); + appendText(message, L" | obsolete: parentGone "); + appendNumber(message, pending.obsoleteParentGone, TRUE); + appendText(message, L", expired "); + appendNumber(message, pending.obsoleteExpired, TRUE); + appendText(message, L", gateRejected "); + appendNumber(message, pending.obsoleteGateRejected, TRUE); + appendText(message, L" | claim mismatch "); + appendNumber(message, pending.claimMismatch, TRUE); + logToConsole(message); } break; diff --git a/test/ant_pending_solutions.cpp b/test/ant_pending_solutions.cpp index 06f9d1a2..e60ce3c9 100644 --- a/test/ant_pending_solutions.cpp +++ b/test/ant_pending_solutions.cpp @@ -4,8 +4,6 @@ #include "../src/mining/ant_pending_solutions.h" -static constexpr unsigned int NO_ENTRY = 0xFFFFFFFFu; - static m256i key(unsigned long long n) { m256i k = m256i::zero(); @@ -41,119 +39,119 @@ static AntPendingSolutions* freshPool() } // The key is (computor, parentRef, nonce). Same triple twice is one solution, whatever else differs. -TEST(TestAntPending, DedupsOnTheConsensusKey) +TEST(TestAntColonyPending, DedupsOnTheConsensusKey) { AntPendingSolutions* pool = freshPool(); ASSERT_NE(pool, nullptr); - EXPECT_TRUE(pool->add(key(1), ref(100, 0), 5000, key(900))); - EXPECT_FALSE(pool->add(key(1), ref(100, 0), 5000, key(900))); + EXPECT_TRUE(pool->add(key(1), ref(100, 0), 5000, 0, key(900))); + EXPECT_FALSE(pool->add(key(1), ref(100, 0), 5000, 0, key(900))); // A different anchor is the SAME solution - anchorTick is deliberately not in the key, so a // re-anchored resend cannot be published twice. - EXPECT_FALSE(pool->add(key(1), ref(100, 0), 9999, key(900))); + EXPECT_FALSE(pool->add(key(1), ref(100, 0), 9999, 0, key(900))); // Any other field differing makes it a different solution. - EXPECT_TRUE(pool->add(key(2), ref(100, 0), 5000, key(900))); - EXPECT_TRUE(pool->add(key(1), ref(100, 1), 5000, key(900))); - EXPECT_TRUE(pool->add(key(1), ref(100, 0), 5000, key(901))); + EXPECT_TRUE(pool->add(key(2), ref(100, 0), 5000, 0, key(900))); + EXPECT_TRUE(pool->add(key(1), ref(100, 1), 5000, 0, key(900))); + EXPECT_TRUE(pool->add(key(1), ref(100, 0), 5000, 0, key(901))); } // A fresh entry is selectable, and only by the computor it belongs to. -TEST(TestAntPending, SelectsOnlyForItsOwnComputor) +TEST(TestAntColonyPending, SelectsOnlyForItsOwnComputor) { AntPendingSolutions* pool = freshPool(); ASSERT_NE(pool, nullptr); - ASSERT_TRUE(pool->add(key(1), ref(100, 0), 5000, key(900))); + ASSERT_TRUE(pool->add(key(1), ref(100, 0), 5000, 0, key(900))); AntPendingSolution out; - EXPECT_EQ(pool->selectForPublish(key(2), 5000, out), NO_ENTRY); + EXPECT_EQ(pool->selectForPublish(key(2), 5000, out), AntPendingSolutions::NO_ENTRY); const unsigned int idx = pool->selectForPublish(key(1), 5000, out); - ASSERT_NE(idx, NO_ENTRY); + ASSERT_NE(idx, AntPendingSolutions::NO_ENTRY); EXPECT_TRUE(out.nonce == key(900)); EXPECT_EQ(out.anchorTick, 5000u); } // Scheduling records a deadline. Before it passes the entry must not come back, or the node would // republish a transaction that is still in flight. -TEST(TestAntPending, ScheduledEntryIsNotReselectedBeforeItsDeadline) +TEST(TestAntColonyPending, ScheduledEntryIsNotReselectedBeforeItsDeadline) { AntPendingSolutions* pool = freshPool(); ASSERT_NE(pool, nullptr); - ASSERT_TRUE(pool->add(key(1), ref(100, 0), 5000, key(900))); + ASSERT_TRUE(pool->add(key(1), ref(100, 0), 5000, 0, key(900))); AntPendingSolution out; const unsigned int idx = pool->selectForPublish(key(1), 5000, out); - ASSERT_NE(idx, NO_ENTRY); + ASSERT_NE(idx, AntPendingSolutions::NO_ENTRY); pool->markScheduled(idx, 5003); - EXPECT_EQ(pool->selectForPublish(key(1), 5001, out), NO_ENTRY); - EXPECT_EQ(pool->selectForPublish(key(1), 5002, out), NO_ENTRY); + EXPECT_EQ(pool->selectForPublish(key(1), 5001, out), AntPendingSolutions::NO_ENTRY); + EXPECT_EQ(pool->selectForPublish(key(1), 5002, out), AntPendingSolutions::NO_ENTRY); // Deadline reached with no acknowledgement: republish. EXPECT_EQ(pool->selectForPublish(key(1), 5003, out), idx); } // The whole reason the state is a tick and not a flag. -TEST(TestAntPending, RetriesOutrankFreshEntries) +TEST(TestAntColonyPending, RetriesOutrankFreshEntries) { AntPendingSolutions* pool = freshPool(); ASSERT_NE(pool, nullptr); - ASSERT_TRUE(pool->add(key(1), ref(100, 0), 5000, key(900))); + ASSERT_TRUE(pool->add(key(1), ref(100, 0), 5000, 0, key(900))); AntPendingSolution out; const unsigned int stale = pool->selectForPublish(key(1), 5000, out); - ASSERT_NE(stale, NO_ENTRY); + ASSERT_NE(stale, AntPendingSolutions::NO_ENTRY); pool->markScheduled(stale, 5003); // Newer solutions keep arriving while the first one's transaction is lost. - ASSERT_TRUE(pool->add(key(1), ref(100, 1), 5001, key(901))); - ASSERT_TRUE(pool->add(key(1), ref(100, 2), 5002, key(902))); + ASSERT_TRUE(pool->add(key(1), ref(100, 1), 5001, 0, key(901))); + ASSERT_TRUE(pool->add(key(1), ref(100, 2), 5002, 0, key(902))); // Past the deadline the retry must win, or a steady stream of new work starves it forever. EXPECT_EQ(pool->selectForPublish(key(1), 5003, out), stale); } // RECORDED comes from observing the chain, and must both stop republication and suppress a resend. -TEST(TestAntPending, RecordedStopsRepublishingAndSuppressesResend) +TEST(TestAntColonyPending, RecordedStopsRepublishingAndSuppressesResend) { AntPendingSolutions* pool = freshPool(); ASSERT_NE(pool, nullptr); - ASSERT_TRUE(pool->add(key(1), ref(100, 0), 5000, key(900))); + ASSERT_TRUE(pool->add(key(1), ref(100, 0), 5000, 0, key(900))); AntPendingSolution out; const unsigned int idx = pool->selectForPublish(key(1), 5000, out); - ASSERT_NE(idx, NO_ENTRY); + ASSERT_NE(idx, AntPendingSolutions::NO_ENTRY); pool->markScheduled(idx, 5003); pool->markRecorded(key(1), ref(100, 0), key(900)); - EXPECT_EQ(pool->selectForPublish(key(1), 9000, out), NO_ENTRY); - EXPECT_FALSE(pool->add(key(1), ref(100, 0), 5000, key(900))); + EXPECT_EQ(pool->selectForPublish(key(1), 9000, out), AntPendingSolutions::NO_ENTRY); + EXPECT_FALSE(pool->add(key(1), ref(100, 0), 5000, 0, key(900))); } // A transaction the node never queued still has to be remembered, or a miner resending a solution // that is already on-chain makes the pool publish it a second time and pay a second deposit. -TEST(TestAntPending, RecordingAnUnqueuedSolutionSuppressesALaterSubmission) +TEST(TestAntColonyPending, RecordingAnUnqueuedSolutionSuppressesALaterSubmission) { AntPendingSolutions* pool = freshPool(); ASSERT_NE(pool, nullptr); pool->markRecorded(key(1), ref(100, 0), key(900)); - EXPECT_FALSE(pool->add(key(1), ref(100, 0), 5000, key(900))); + EXPECT_FALSE(pool->add(key(1), ref(100, 0), 5000, 0, key(900))); } // Past the publish window the commit path rejects it as stale, so publishing spends the deposit for // nothing. Selection has to drop it rather than hand it over. -TEST(TestAntPending, ExpiredEntriesAreRetiredNotPublished) +TEST(TestAntColonyPending, ExpiredEntriesAreRetiredNotPublished) { AntPendingSolutions* pool = freshPool(); ASSERT_NE(pool, nullptr); - ASSERT_TRUE(pool->add(key(1), ref(100, 0), 5000, key(900))); + ASSERT_TRUE(pool->add(key(1), ref(100, 0), 5000, 0, key(900))); AntPendingSolution out; EXPECT_EQ(pool->selectForPublish(key(1), 5000 + ANT_PUBLISH_WINDOW_TICKS, out), 0u); - EXPECT_EQ(pool->selectForPublish(key(1), 5000 + ANT_PUBLISH_WINDOW_TICKS + 1, out), NO_ENTRY); + EXPECT_EQ(pool->selectForPublish(key(1), 5000 + ANT_PUBLISH_WINDOW_TICKS + 1, out), AntPendingSolutions::NO_ENTRY); AntPendingSolutions::Stats stats; unsigned int count = 0; @@ -164,50 +162,62 @@ TEST(TestAntPending, ExpiredEntriesAreRetiredNotPublished) // An entry retired for expiry was never published, so the seen filter was never marked and the key // is still usable. A resubmission with a fresh anchor must replace it rather than be refused as a // duplicate - otherwise the solution is stranded and the miner is never told why. -TEST(TestAntPending, ExpiredEntryCanBeResubmittedWithAFreshAnchor) +TEST(TestAntColonyPending, ExpiredEntryCanBeResubmittedWithAFreshAnchor) { AntPendingSolutions* pool = freshPool(); ASSERT_NE(pool, nullptr); - ASSERT_TRUE(pool->add(key(1), ref(100, 0), 5000, key(900))); + ASSERT_TRUE(pool->add(key(1), ref(100, 0), 5000, 0, key(900))); // Selection retires it instead of publishing: publishing a stale one would mark the seen filter // and kill this key permanently. AntPendingSolution out; - ASSERT_EQ(pool->selectForPublish(key(1), 5000 + ANT_PUBLISH_WINDOW_TICKS + 1, out), NO_ENTRY); + ASSERT_EQ(pool->selectForPublish(key(1), 5000 + ANT_PUBLISH_WINDOW_TICKS + 1, out), AntPendingSolutions::NO_ENTRY); // Same triple, newer anchor. This is the replacement, not a duplicate. - EXPECT_TRUE(pool->add(key(1), ref(100, 0), 40000, key(900))); + EXPECT_TRUE(pool->add(key(1), ref(100, 0), 40000, 0, key(900))); const unsigned int idx = pool->selectForPublish(key(1), 40000, out); - ASSERT_NE(idx, NO_ENTRY); + ASSERT_NE(idx, AntPendingSolutions::NO_ENTRY); EXPECT_EQ(out.anchorTick, 40000u); } +// The score is computed once, at receipt, and the publisher reads it back from the entry. +TEST(TestAntColonyPending, CarriesTheScore) +{ + AntPendingSolutions* pool = freshPool(); + ASSERT_NE(pool, nullptr); + ASSERT_TRUE(pool->add(key(1), ref(100, 0), 5000, 3771, key(900))); + + AntPendingSolution out; + ASSERT_NE(pool->selectForPublish(key(1), 5000, out), AntPendingSolutions::NO_ENTRY); + EXPECT_EQ(out.score, 3771u); +} + // A live entry is a real duplicate, whether it has been scheduled or not. -TEST(TestAntPending, LiveEntryStillRejectsAResubmission) +TEST(TestAntColonyPending, LiveEntryStillRejectsAResubmission) { AntPendingSolutions* pool = freshPool(); ASSERT_NE(pool, nullptr); - ASSERT_TRUE(pool->add(key(1), ref(100, 0), 5000, key(900))); + ASSERT_TRUE(pool->add(key(1), ref(100, 0), 5000, 0, key(900))); - EXPECT_FALSE(pool->add(key(1), ref(100, 0), 6000, key(900))); + EXPECT_FALSE(pool->add(key(1), ref(100, 0), 6000, 0, key(900))); AntPendingSolution out; const unsigned int idx = pool->selectForPublish(key(1), 5000, out); - ASSERT_NE(idx, NO_ENTRY); + ASSERT_NE(idx, AntPendingSolutions::NO_ENTRY); pool->markScheduled(idx, 5003); - EXPECT_FALSE(pool->add(key(1), ref(100, 0), 6000, key(900))); + EXPECT_FALSE(pool->add(key(1), ref(100, 0), 6000, 0, key(900))); } // Finished slots are reused in place, so a pool that has published for a whole epoch does not fill. -TEST(TestAntPending, FinishedSlotsAreReclaimed) +TEST(TestAntColonyPending, FinishedSlotsAreReclaimed) { AntPendingSolutions* pool = freshPool(); ASSERT_NE(pool, nullptr); for (unsigned int i = 0; i < 4; i++) { - ASSERT_TRUE(pool->add(key(1), ref(100, i), 5000, key(900 + i))); + ASSERT_TRUE(pool->add(key(1), ref(100, i), 5000, 0, key(900 + i))); pool->markRecorded(key(1), ref(100, i), key(900 + i)); } @@ -218,6 +228,6 @@ TEST(TestAntPending, FinishedSlotsAreReclaimed) // Nothing left to publish, and new work still fits. AntPendingSolution out; - EXPECT_EQ(pool->selectForPublish(key(1), 5000, out), NO_ENTRY); - EXPECT_TRUE(pool->add(key(1), ref(200, 0), 5000, key(1000))); + EXPECT_EQ(pool->selectForPublish(key(1), 5000, out), AntPendingSolutions::NO_ENTRY); + EXPECT_TRUE(pool->add(key(1), ref(200, 0), 5000, 0, key(1000))); } From 5a6934937eadcf1c3d8e75766f29c1d0e4c7ddec Mon Sep 17 00:00:00 2001 From: cyber-pc <165458555+cyber-pc@users.noreply.github.com> Date: Sun, 9 Aug 2026 18:47:58 +0700 Subject: [PATCH 21/46] Enable logging for ant colony. --- src/qubic.cpp | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/src/qubic.cpp b/src/qubic.cpp index 41275f68..cf4338fe 100644 --- a/src/qubic.cpp +++ b/src/qubic.cpp @@ -3112,6 +3112,25 @@ static void processTickTransactionSolution(const MiningSolutionTransaction* tran // One ant solution: resolve its parent, score the child against it, and commit. Every rejection // forfeits the deposit by simply not refunding it +// One line per ant solution transaction, whatever became of it. The body follows the logger's own +// convention: compiled out with LOG_CUSTOM_MESSAGES, so a build without qlogging pays nothing here. +static void logAntSolutionOutcome(const AntColonyMiningSolutionTransaction* transaction, + unsigned int score, ValidityResult result) +{ +#if LOG_CUSTOM_MESSAGES + AntSolutionLogMessage logMsg; + logMsg._type = CUSTOM_MESSAGE_ANT_SOLUTION; + logMsg.sourcePublicKey = transaction->sourcePublicKey; + logMsg.nonce = transaction->nonce; + logMsg.parentTickOffset = transaction->parentTickOffset; + logMsg.parentSolutionIndexInTick = transaction->parentSolutionIndexInTick; + logMsg.anchorTick = transaction->anchorTick; + logMsg.score = score; + logMsg.result = (unsigned int)result; + logger.logCustomMessage(logMsg); +#endif +} + static void processTickTransactionAntColonySolution( const AntColonyMiningSolutionTransaction* transaction, unsigned int transactionIndex, @@ -3129,6 +3148,7 @@ static void processTickTransactionAntColonySolution( if (isAntSolutionSeen(antFlagIndices)) { gAntColony.recordReject(ValidityResult::RejectReplay); + logAntSolutionOutcome(transaction, 0, ValidityResult::RejectReplay); return; } markAntSolutionSeen(antFlagIndices); @@ -3138,6 +3158,7 @@ static void processTickTransactionAntColonySolution( if (result != ValidityResult::Valid) { gAntColony.recordReject(result); + logAntSolutionOutcome(transaction, 0, result); return; } @@ -3147,6 +3168,7 @@ static void processTickTransactionAntColonySolution( if (!gAntColony.getAnchorDigest(transaction->anchorTick, anchorDigest)) { gAntColony.recordReject(ValidityResult::RejectStale); + logAntSolutionOutcome(transaction, 0, ValidityResult::RejectStale); return; } @@ -3167,6 +3189,7 @@ static void processTickTransactionAntColonySolution( if (!gAntColony.annOfNonRoot(*parentRec, parentAnnScratch)) { gAntColony.recordReject(ValidityResult::RejectParentNotRegistered); + logAntSolutionOutcome(transaction, 0, ValidityResult::RejectParentNotRegistered); return; } parentAnn = &parentAnnScratch; @@ -3189,6 +3212,7 @@ static void processTickTransactionAntColonySolution( if (!score->isValidScore(childScore, score_engine::AlgoType::Bpp9000)) { gAntColony.recordReject(ValidityResult::RejectNonCanonicalNonce); + logAntSolutionOutcome(transaction, 0, ValidityResult::RejectNonCanonicalNonce); return; } @@ -3217,6 +3241,7 @@ static void processTickTransactionAntColonySolution( } result = gAntColony.commit(in, parentRec, childScore, *childAnn, childAnnHash); + logAntSolutionOutcome(transaction, childScore, result); // ValidNotStored is the store being full: the solution passed every rule and only missed a slot, // so it earns its refund and its ranking exactly like a stored one if (result != ValidityResult::Valid && result != ValidityResult::ValidNotStored) @@ -9070,3 +9095,5 @@ EFI_STATUS efi_main(EFI_HANDLE imageHandle, EFI_SYSTEM_TABLE* systemTable) return EFI_SUCCESS; } + + From 875f8a4694726f427b6702334fcc4ddf221dcf3b Mon Sep 17 00:00:00 2001 From: cyber-pc <165458555+cyber-pc@users.noreply.github.com> Date: Sun, 9 Aug 2026 19:09:12 +0700 Subject: [PATCH 22/46] Pre-score ant solution transactions at arrival. --- src/qubic.cpp | 47 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/src/qubic.cpp b/src/qubic.cpp index cf4338fe..e1ffd04d 100644 --- a/src/qubic.cpp +++ b/src/qubic.cpp @@ -1284,6 +1284,53 @@ static void processBroadcastTransaction(Peer* peer, RequestResponseHeader* heade } } + // Same latency hiding for ant solution transactions, we do simple check first then the last + // is the score engine that where the heavy load stay + if (preprocessSolutionFlags[processorNumber] + && AntColonyMiningSolutionTransaction::isSolutionTransaction(request)) + { + const AntColonyMiningSolutionTransaction* antTx = (const AntColonyMiningSolutionTransaction*)request; + const SolutionRef preParentRef = { antTx->parentTickOffset, antTx->parentSolutionIndexInTick }; + unsigned int preFlagIndices[2]; + computeAntSolutionFlagIndices(antTx->sourcePublicKey, antTx->nonce, preParentRef, preFlagIndices); + const int spectrumIdx = spectrumIndex(antTx->sourcePublicKey); + if (spectrumIdx >= 0 + && energy(spectrumIdx) >= AntColonyMiningSolutionTransaction::minAmount() + && !isAntSolutionSeen(preFlagIndices) + && score_engine::isCanonicalAntNonce(antTx->nonce.m256i_u8, + score_engine::ScoreBpp9000T::numberOfMutations)) + { + const AntSolutionRecord* preParentRec = nullptr; + m256i preAnchorDigest; + if (gAntColony.tryGetParent(preParentRef, &preParentRec) == ValidityResult::Valid + && gAntColony.getAnchorDigest(antTx->anchorTick, preAnchorDigest)) + { + const AntColonyBpp9000T::Ann* preParentAnn = nullptr; + bool preParentOk = true; + if (preParentRec != nullptr) + { + preParentOk = gAntColony.annOfNonRoot(*preParentRec, gAntParentAnnScratch[processorNumber]); + preParentAnn = &gAntParentAnnScratch[processorNumber]; + } + if (preParentOk) + { + const AntColonyBpp9000T::ReplayKey preKey = makeAntReplayKey( + antTx->sourcePublicKey, antTx->nonce, preParentAnn, preAnchorDigest); + unsigned int preScore = 0; + if (!gAntColony.tryGetReplayScore(preKey, preScore, gAntChildAnnScratch[processorNumber])) + { + preScore = score->computeAntChildScore(processorNumber, preParentAnn, + antTx->sourcePublicKey, antTx->nonce, preAnchorDigest, + gAntChildAnnScratch[processorNumber]); + // cache this score so later can skip the heavy score computation, + // invalid ones included + gAntColony.putReplayScore(preKey, preScore, gAntChildAnnScratch[processorNumber]); + } + } + } + } + } + // shortcut: oracle reply reveal transactions are analyzed immediately after receiving them (before execution of the tx), // in order to minimize the number of reveal transaction (one per oracle query is enough, so no reveal tx is generated // after one has been seen) From e5c1d335d652d75641b225954ac25c232ef17da5 Mon Sep 17 00:00:00 2001 From: cyber-pc <165458555+cyber-pc@users.noreply.github.com> Date: Sun, 9 Aug 2026 19:15:06 +0700 Subject: [PATCH 23/46] Enable ant cache saving. --- src/qubic.cpp | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/qubic.cpp b/src/qubic.cpp index e1ffd04d..49f3959a 100644 --- a/src/qubic.cpp +++ b/src/qubic.cpp @@ -5171,6 +5171,7 @@ static bool saveAllNodeStates() } score->saveScoreCache(system.epoch, directory); + gAntColony.saveReplayCache(system.epoch, directory); copyMem(&nodeStateBuffer.etalonTick, &etalonTick, sizeof(etalonTick)); copyMem(nodeStateBuffer.minerPublicKeys, (void*)minerPublicKeys, sizeof(minerPublicKeys)); @@ -9115,6 +9116,7 @@ EFI_STATUS efi_main(EFI_HANDLE imageHandle, EFI_SYSTEM_TABLE* systemTable) saveSystem(); score->saveScoreCache(system.epoch); + gAntColony.saveReplayCache(system.epoch, NULL); #ifdef ENABLE_PROFILING gProfilingDataCollector.writeToFile(); #endif From 5013689494bfc3193b0ceff0aad7bf13790621f9 Mon Sep 17 00:00:00 2001 From: cyber-pc <165458555+cyber-pc@users.noreply.github.com> Date: Sun, 9 Aug 2026 19:23:23 +0700 Subject: [PATCH 24/46] Rename the ant ANN state message pair to parent ANN. --- src/network_messages/ant_colony_message.h | 24 ++++++++++++--------- src/network_messages/network_message_type.h | 4 ++-- 2 files changed, 16 insertions(+), 12 deletions(-) diff --git a/src/network_messages/ant_colony_message.h b/src/network_messages/ant_colony_message.h index 3461b9eb..d1752560 100644 --- a/src/network_messages/ant_colony_message.h +++ b/src/network_messages/ant_colony_message.h @@ -92,14 +92,18 @@ static_assert(sizeof(AntMineableParentsResponse) + ANT_MINEABLE_PARENTS_PER_RESPONSE * sizeof(AntMineableParent), "AntMineableParentsResponse must have no padding between the header and the items"); -// RespondAntAnnStateHeader.status values. -constexpr unsigned char ANT_ANN_STATUS_OK = 0; // ANN bytes follow the header -constexpr unsigned char ANT_ANN_STATUS_NOT_FOUND = 1; // parentRef has no record -constexpr unsigned char ANT_ANN_STATUS_IS_ROOT = 2; // ROOT_REF; no ANN payload - miner derives its own per-identity root +// RespondAntParentAnnHeader.status values. +constexpr unsigned char ANT_PARENT_ANN_STATUS_OK = 0; // ANN bytes follow the header +constexpr unsigned char ANT_PARENT_ANN_STATUS_NOT_FOUND = 1; // parentRef has no record +constexpr unsigned char ANT_PARENT_ANN_STATUS_IS_ROOT = 2; // ROOT_REF; no ANN payload - miner derives its own per-identity root +// ONE tree node's stored network, named by parentRef - the 1728 bytes a miner mutates to extend +// that node. The tree itself is listed by mineable-parents; this fetches the material for a single +// chosen parent. +// // Operator-signed. The request payload is followed by SIGNATURE_SIZE bytes signed by // operatorPublicKey. -struct RequestAntAnnState +struct RequestAntParentAnn { // Monotonic per-operator nonce: must exceed the last one the node accepted. unsigned long long everIncreasingNonce; @@ -107,15 +111,15 @@ struct RequestAntAnnState unsigned int parentRefSolutionIndexInTick; static constexpr unsigned char type() { - return REQUEST_ANT_ANN_STATE; + return REQUEST_ANT_PARENT_ANN; } }; -static_assert(sizeof(RequestAntAnnState) == 16, "RequestAntAnnState unexpected size"); +static_assert(sizeof(RequestAntParentAnn) == 16, "RequestAntParentAnn unexpected size"); // Metadata header only; when status is Ok or IsRoot, annSizeBytes bytes of packed // ANN follow the header (annSizeBytes is 0 otherwise). Kept ANN-agnostic here to // avoid a heavy include; the receiver uses annSizeBytes to read the trailing blob. -struct RespondAntAnnStateHeader +struct RespondAntParentAnnHeader { unsigned int parentRefTickOffset; unsigned int parentRefSolutionIndexInTick; @@ -125,10 +129,10 @@ struct RespondAntAnnStateHeader unsigned char padding[3]; static constexpr unsigned char type() { - return RESPOND_ANT_ANN_STATE; + return RESPOND_ANT_PARENT_ANN; } }; -static_assert(sizeof(RespondAntAnnStateHeader) == 16, "RespondAntAnnStateHeader unexpected size"); +static_assert(sizeof(RespondAntParentAnnHeader) == 16, "RespondAntParentAnnHeader unexpected size"); struct RequestAntEpochContext { diff --git a/src/network_messages/network_message_type.h b/src/network_messages/network_message_type.h index ca0a8d89..94d16214 100644 --- a/src/network_messages/network_message_type.h +++ b/src/network_messages/network_message_type.h @@ -53,8 +53,8 @@ enum NetworkMessageType : unsigned char RESPOND_REVENUE_DATA = 71, REQUEST_ANT_MINEABLE_PARENTS = 72, RESPOND_ANT_MINEABLE_PARENTS = 73, - REQUEST_ANT_ANN_STATE = 74, - RESPOND_ANT_ANN_STATE = 75, + REQUEST_ANT_PARENT_ANN = 74, + RESPOND_ANT_PARENT_ANN = 75, REQUEST_ANT_EPOCH_CONTEXT = 76, RESPOND_ANT_EPOCH_CONTEXT = 77, ORACLE_MACHINE_QUERY = 190, // only on communication channel Core node <-> OM node From d7db85e4713791a999fd80784645e6b9deaf77e8 Mon Sep 17 00:00:00 2001 From: cyber-pc <165458555+cyber-pc@users.noreply.github.com> Date: Sun, 9 Aug 2026 19:34:08 +0700 Subject: [PATCH 25/46] Allow fetch the ANN state of a node. --- src/network_messages/ant_colony_message.h | 21 +++---- src/qubic.cpp | 75 +++++++++++++++++++++++ 2 files changed, 85 insertions(+), 11 deletions(-) diff --git a/src/network_messages/ant_colony_message.h b/src/network_messages/ant_colony_message.h index d1752560..13c742e9 100644 --- a/src/network_messages/ant_colony_message.h +++ b/src/network_messages/ant_colony_message.h @@ -97,16 +97,13 @@ constexpr unsigned char ANT_PARENT_ANN_STATUS_OK = 0; // ANN bytes follow constexpr unsigned char ANT_PARENT_ANN_STATUS_NOT_FOUND = 1; // parentRef has no record constexpr unsigned char ANT_PARENT_ANN_STATUS_IS_ROOT = 2; // ROOT_REF; no ANN payload - miner derives its own per-identity root -// ONE tree node's stored network, named by parentRef - the 1728 bytes a miner mutates to extend +// ONE tree node's stored network, named by parentRef - the ANN state a miner mutates to extend // that node. The tree itself is listed by mineable-parents; this fetches the material for a single // chosen parent. -// -// Operator-signed. The request payload is followed by SIGNATURE_SIZE bytes signed by -// operatorPublicKey. +// Operator-signed: the request payload is followed by SIGNATURE_SIZE bytes signed by +// operatorPublicKey struct RequestAntParentAnn { - // Monotonic per-operator nonce: must exceed the last one the node accepted. - unsigned long long everIncreasingNonce; unsigned int parentRefTickOffset; unsigned int parentRefSolutionIndexInTick; static constexpr unsigned char type() @@ -114,16 +111,18 @@ struct RequestAntParentAnn return REQUEST_ANT_PARENT_ANN; } }; -static_assert(sizeof(RequestAntParentAnn) == 16, "RequestAntParentAnn unexpected size"); +static_assert(sizeof(RequestAntParentAnn) == 8, "RequestAntParentAnn unexpected size"); -// Metadata header only; when status is Ok or IsRoot, annSizeBytes bytes of packed -// ANN follow the header (annSizeBytes is 0 otherwise). Kept ANN-agnostic here to -// avoid a heavy include; the receiver uses annSizeBytes to read the trailing blob. +// Metadata header, when status is Ok, annSizeBytes bytes of CANONICAL ANN follow it - one trit per +// byte, the form the scorer consumes, so the receiver does no unpacking. annSizeBytes is 0 for every +// other status. Kept ANN-agnostic here to avoid a heavy include; the receiver reads the trailing +// blob by annSizeBytes. struct RespondAntParentAnnHeader { unsigned int parentRefTickOffset; unsigned int parentRefSolutionIndexInTick; - // Bytes of packed ANN that follow this header (0 unless status is Ok/IsRoot). + // Bytes of canonical ANN that follow this header: ANN LUT size when status is Ok, 0 for every other + // status. unsigned int annSizeBytes; unsigned char status; unsigned char padding[3]; diff --git a/src/qubic.cpp b/src/qubic.cpp index 49f3959a..d81f9e2d 100644 --- a/src/qubic.cpp +++ b/src/qubic.cpp @@ -1674,6 +1674,16 @@ static void processRequestContractFunction(Peer* peer, const unsigned long long // One response buffer per processor static AntMineableParentsResponse gAntMineableParentsResponseBuffer[MAX_NUMBER_OF_PROCESSORS]; +struct AntParentAnnResponse +{ + RespondAntParentAnnHeader header; + AntColonyBpp9000T::Ann ann; +}; +static_assert(sizeof(AntParentAnnResponse) + == sizeof(RespondAntParentAnnHeader) + sizeof(AntColonyBpp9000T::Ann), + "AntParentAnnResponse must have no padding between the header and the network"); +static AntParentAnnResponse gAntParentAnnResponseBuffer[MAX_NUMBER_OF_PROCESSORS]; + // Request ant colony in epoch contex static void processRequestAntEpochContext(Peer* peer, RequestResponseHeader* header) { @@ -1703,6 +1713,10 @@ static void processRequestAntEpochContext(Peer* peer, RequestResponseHeader* hea // Operator-signed static void processRequestAntMineableParents(unsigned long long processorNumber, Peer* peer, RequestResponseHeader* header) { + if (processorNumber >= MAX_NUMBER_OF_PROCESSORS) + { + return; + } if (header->size() != sizeof(RequestResponseHeader) + sizeof(RequestAntMineableParents) + SIGNATURE_SIZE) { return; @@ -1765,6 +1779,61 @@ static void processRequestAntMineableParents(unsigned long long processorNumber, RespondAntMineableParentsHeader::type(), header->dejavu(), &response); } +// One stored node's network, for the pool that is about to mine a child of it +static void processRequestAntParentAnn(unsigned long long processorNumber, Peer* peer, RequestResponseHeader* header) +{ + if (processorNumber >= MAX_NUMBER_OF_PROCESSORS) + { + return; + } + if (header->size() != sizeof(RequestResponseHeader) + sizeof(RequestAntParentAnn) + SIGNATURE_SIZE) + { + return; + } + const RequestAntParentAnn* request = header->getPayload(); + + // Signature check + unsigned char digest[32]; + KangarooTwelve(request, header->size() - sizeof(RequestResponseHeader) - SIGNATURE_SIZE, digest, sizeof(digest)); + if (!verify(operatorPublicKey.m256i_u8, digest, ((const unsigned char*)header + (header->size() - SIGNATURE_SIZE)))) + { + return; + } + + AntParentAnnResponse& response = gAntParentAnnResponseBuffer[processorNumber]; + setMem(&response, sizeof(response), 0); + response.header.parentRefTickOffset = request->parentRefTickOffset; + response.header.parentRefSolutionIndexInTick = request->parentRefSolutionIndexInTick; + + const SolutionRef ref = { request->parentRefTickOffset, request->parentRefSolutionIndexInTick }; + if (ref.isRoot()) + { + // Roots are never stored; the miner derives its own from the epoch context's seed. + response.header.status = ANT_PARENT_ANN_STATUS_IS_ROOT; + enqueueResponse(peer, sizeof(response.header), RespondAntParentAnnHeader::type(), header->dejavu(), &response); + return; + } + + const AntSolutionRecord* rec = nullptr; + const ValidityResult parentResult = gAntColony.tryGetParent(ref, &rec); + bool annLoaded = false; + if (parentResult == ValidityResult::Valid && rec != nullptr) + { + annLoaded = gAntColony.annOfNonRoot(*rec, response.ann); + } + if (!annLoaded) + { + response.header.status = ANT_PARENT_ANN_STATUS_NOT_FOUND; + enqueueResponse(peer, sizeof(response.header), RespondAntParentAnnHeader::type(), header->dejavu(), &response); + return; + } + + response.header.status = ANT_PARENT_ANN_STATUS_OK; + response.header.annSizeBytes = (unsigned int)sizeof(response.ann); + enqueueResponse(peer, (unsigned int)(sizeof(response.header) + sizeof(response.ann)), + RespondAntParentAnnHeader::type(), header->dejavu(), &response); +} + static void processRequestSystemInfo(Peer* peer, RequestResponseHeader* header) { RespondSystemInfo respondedSystemInfo; @@ -2550,6 +2619,12 @@ static void requestProcessor(void* ProcedureArgument) } break; + case RequestAntParentAnn::type(): + { + processRequestAntParentAnn(processorNumber, peer, header); + } + break; + #if ADDON_TX_STATUS_REQUEST /* qli: process RequestTxStatus message */ case RequestTxStatus::type(): From 833ce2a0f0752ed8b6976857d0245f65ba6954a0 Mon Sep 17 00:00:00 2001 From: cyber-pc <165458555+cyber-pc@users.noreply.github.com> Date: Sun, 9 Aug 2026 19:59:07 +0700 Subject: [PATCH 26/46] Add debug log message for ant colony. --- src/qubic.cpp | 145 +++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 144 insertions(+), 1 deletion(-) diff --git a/src/qubic.cpp b/src/qubic.cpp index d81f9e2d..ffe218df 100644 --- a/src/qubic.cpp +++ b/src/qubic.cpp @@ -273,6 +273,107 @@ static AntPendingSolutions gAntPendingSolutions; static AntColonyBpp9000T::Ann gAntParentAnnScratch[MAX_NUMBER_OF_PROCESSORS]; static AntColonyBpp9000T::Ann gAntChildAnnScratch[MAX_NUMBER_OF_PROCESSORS]; +#ifndef NDEBUG +static constexpr unsigned int ANT_DEBUG_PRINTS_PER_EPOCH = 512; +static unsigned int gAntDebugPrintBudget = ANT_DEBUG_PRINTS_PER_EPOCH; +static bool antDebugCanPrint() +{ + if (gAntDebugPrintBudget == 0) + { + return false; + } + gAntDebugPrintBudget--; + if (gAntDebugPrintBudget == 0) + { + logToConsole(L"[ant-colony] debug print budget exhausted, silent until next epoch"); + } + return true; +} +#endif + +static void antDebugLine(const CHAR16* text) +{ +#ifndef NDEBUG + if (antDebugCanPrint()) + { + logToConsole(text); + } +#endif +} + +static void antDebugPoolDrop(const CHAR16* reason, const AntSolutionBroadcastPayload& payload) +{ +#ifndef NDEBUG + if (!antDebugCanPrint()) + { + return; + } + CHAR16 msg[256]; + setText(msg, L"[ant-colony] pool drop "); + appendText(msg, reason); + appendText(msg, L": parent="); + appendNumber(msg, payload.parentTickOffset, FALSE); + appendText(msg, L"/"); + appendNumber(msg, payload.parentSolutionIndexInTick, FALSE); + appendText(msg, L" anchor="); + appendNumber(msg, payload.anchorTick, FALSE); + appendText(msg, L" nonce0="); + appendNumber(msg, payload.nonce.m256i_u64[0], FALSE); + logToConsole(msg); +#endif +} + +static void antDebugPending(const CHAR16* outcome, const AntPendingSolution& entry, unsigned int targetTick) +{ +#ifndef NDEBUG + if (!antDebugCanPrint()) + { + return; + } + CHAR16 msg[256]; + setText(msg, L"[ant-colony] "); + appendText(msg, outcome); + appendText(msg, L": parent="); + appendNumber(msg, entry.parentRef.tickOffset, FALSE); + appendText(msg, L"/"); + appendNumber(msg, entry.parentRef.solutionIndexInTick, FALSE); + appendText(msg, L" anchor="); + appendNumber(msg, entry.anchorTick, FALSE); + appendText(msg, L" score="); + appendNumber(msg, entry.score, FALSE); + appendText(msg, L" target="); + appendNumber(msg, targetTick, FALSE); + logToConsole(msg); +#endif +} + +static void antDebugAccepted(const AntColonyMiningSolutionTransaction* transaction, unsigned int score, + unsigned int depth, unsigned int transactionIndex, ValidityResult result) +{ +#ifndef NDEBUG + if (result != ValidityResult::Valid && result != ValidityResult::ValidNotStored) + { + return; + } + if (!antDebugCanPrint()) + { + return; + } + CHAR16 msg[256]; + setText(msg, L"[ant-colony] accepted: tick="); + appendNumber(msg, system.tick, FALSE); + appendText(msg, L" idx="); + appendNumber(msg, transactionIndex, FALSE); + appendText(msg, L" score="); + appendNumber(msg, score, FALSE); + appendText(msg, L" depth="); + appendNumber(msg, depth, FALSE); + appendText(msg, L" stored="); + appendNumber(msg, (result == ValidityResult::Valid) ? 1 : 0, FALSE); + logToConsole(msg); +#endif +} + // Pre-scored ant solutions for the current tick, indexed by TRANSACTION index. Each transaction is // enqueued at most once, so no two workers ever write the same slot and no lock is needed static bool gAntScoredReady[NUMBER_OF_TRANSACTIONS_PER_TICK]; @@ -400,6 +501,7 @@ static void queueAntSolution(unsigned long long processorNumber, const m256i& co if (!score_engine::isCanonicalAntNonce(payload.nonce.m256i_u8, score_engine::ScoreBpp9000T::numberOfMutations)) { + antDebugPoolDrop(L"nonCanonical", payload); gAntPendingSolutions.noteDroppedNonCanonical(); return; } @@ -422,6 +524,7 @@ static void queueAntSolution(unsigned long long processorNumber, const m256i& co || system.tick - payload.anchorTick > ANT_PUBLISH_WINDOW_TICKS || !gAntColony.getAnchorDigest(payload.anchorTick, anchorDigest)) { + antDebugPoolDrop(L"badAnchor", payload); gAntPendingSolutions.noteDroppedBadAnchor(); return; } @@ -429,6 +532,7 @@ static void queueAntSolution(unsigned long long processorNumber, const m256i& co const AntSolutionRecord* parentRec = nullptr; if (gAntColony.tryGetParent(parentRef, &parentRec) != ValidityResult::Valid) { + antDebugPoolDrop(L"parentUnknown", payload); gAntPendingSolutions.noteDroppedParentUnknown(); return; } @@ -438,7 +542,8 @@ static void queueAntSolution(unsigned long long processorNumber, const m256i& co { if (!gAntColony.annOfNonRoot(*parentRec, gAntParentAnnScratch[processorNumber])) { - gAntPendingSolutions.noteDroppedParentUnknown(); + antDebugPoolDrop(L"parentUnknown", payload); + gAntPendingSolutions.noteDroppedParentUnknown(); return; } parentAnn = &gAntParentAnnScratch[processorNumber]; @@ -459,6 +564,7 @@ static void queueAntSolution(unsigned long long processorNumber, const m256i& co } if (!score->isValidScore(childScore, score_engine::AlgoType::Bpp9000)) { + antDebugPoolDrop(L"unscorable", payload); gAntPendingSolutions.noteDroppedUnscorable(); return; } @@ -466,6 +572,7 @@ static void queueAntSolution(unsigned long long processorNumber, const m256i& co // The sender's own number, checked where it can still prevent work rather than merely be counted. if (payload.claimedScore != childScore) { + antDebugPoolDrop(L"claimMismatch", payload); gAntPendingSolutions.noteClaimMismatch(); return; } @@ -478,6 +585,7 @@ static void queueAntSolution(unsigned long long processorNumber, const m256i& co if (AntColonyBpp9000T::validateChild(candidate, parentRec, floor, gAntColony.errorThreshold()) != ValidityResult::Valid) { + antDebugPoolDrop(L"unacceptable", payload); gAntPendingSolutions.noteDroppedUnacceptable(); return; } @@ -490,6 +598,9 @@ static void queueAntSolution(unsigned long long processorNumber, const m256i& co // spectrum digest static void antColonyBeginEpoch() { +#ifndef NDEBUG + gAntDebugPrintBudget = ANT_DEBUG_PRINTS_PER_EPOCH; +#endif gAntPendingSolutions.reset(); gAntColony.beginEpoch(score->currentRandomSeed); gAntColony.setErrorThreshold((unsigned int)getSolutionThreshold(score_engine::AlgoType::Bpp9000)); @@ -1728,6 +1839,7 @@ static void processRequestAntMineableParents(unsigned long long processorNumber, KangarooTwelve(request, header->size() - sizeof(RequestResponseHeader) - SIGNATURE_SIZE, digest, sizeof(digest)); if (!verify(operatorPublicKey.m256i_u8, digest, ((const unsigned char*)header + (header->size() - SIGNATURE_SIZE)))) { + antDebugLine(L"[ant-colony] query signature rejected"); return; } @@ -1797,6 +1909,7 @@ static void processRequestAntParentAnn(unsigned long long processorNumber, Peer* KangarooTwelve(request, header->size() - sizeof(RequestResponseHeader) - SIGNATURE_SIZE, digest, sizeof(digest)); if (!verify(operatorPublicKey.m256i_u8, digest, ((const unsigned char*)header + (header->size() - SIGNATURE_SIZE)))) { + antDebugLine(L"[ant-colony] query signature rejected"); return; } @@ -3364,6 +3477,7 @@ static void processTickTransactionAntColonySolution( result = gAntColony.commit(in, parentRec, childScore, *childAnn, childAnnHash); logAntSolutionOutcome(transaction, childScore, result); + antDebugAccepted(transaction, childScore, (parentRec != nullptr) ? (parentRec->depth + 1) : 1, transactionIndex, result); // ValidNotStored is the store being full: the solution passed every rule and only missed a slot, // so it earns its refund and its ranking exactly like a stored one if (result != ValidityResult::Valid && result != ValidityResult::ValidNotStored) @@ -3819,6 +3933,7 @@ static void publishAntSolutionFor(unsigned long long processorNumber, unsigned i if (gAntColony.tryGetParent(entry.parentRef, &parentRec) != ValidityResult::Valid) { gAntPendingSolutions.markObsoleteParentGone(idx); + antDebugPending(L"retire parentGone", entry, 0); return; } @@ -3828,6 +3943,7 @@ static void publishAntSolutionFor(unsigned long long processorNumber, unsigned i // The ring no longer holds it, so this node could not score the transaction it is about to // publish - and neither could anyone else. gAntPendingSolutions.markObsoleteExpired(idx); + antDebugPending(L"retire expired", entry, 0); return; } @@ -3838,6 +3954,7 @@ static void publishAntSolutionFor(unsigned long long processorNumber, unsigned i score_engine::ScoreBpp9000T::numberOfMutations)) { gAntPendingSolutions.markObsoleteGateRejected(idx); + antDebugPending(L"retire gateRejected", entry, 0); return; } @@ -3853,6 +3970,7 @@ static void publishAntSolutionFor(unsigned long long processorNumber, unsigned i gAntColony.errorThreshold()) != ValidityResult::Valid) { gAntPendingSolutions.markObsoleteGateRejected(idx); + antDebugPending(L"retire gateRejected", entry, 0); return; } @@ -3878,6 +3996,7 @@ static void publishAntSolutionFor(unsigned long long processorNumber, unsigned i enqueueResponse(NULL, sizeof(payload), BROADCAST_TRANSACTION, 0, &payload); gAntPendingSolutions.markScheduled(idx, (int)payload.tick); + antDebugPending(L"published", entry, payload.tick); } static void processTick(unsigned long long processorNumber) @@ -5623,6 +5742,14 @@ static bool loadAllNodeStates() { return false; } +#ifndef NDEBUG + { + CHAR16 dbg[128]; + setText(dbg, L"[ant-colony] snapshot loaded, solutions="); + appendNumber(dbg, gAntColony.solutionCount(), FALSE); + logToConsole(dbg); + } +#endif if (!oracleEngine.loadSnapshot(system.epoch, directory)) { @@ -6656,6 +6783,14 @@ static void tickProcessor(void*) // Multi-dim revenue (shadow) - for offline comparison against the additive asyncSave(MULTIDIM_REVENUE_END_OF_EPOCH_FILE_NAME, sizeof(gMultiDimRevenue), (unsigned char*)&gMultiDimRevenue); // The epoch's best networks, for offline extraction +#ifndef NDEBUG + { + CHAR16 dbg[768]; + setText(dbg, L"[ant-colony] epoch end: "); + gAntColony.stats().appendLog(dbg); + logToConsole(dbg); + } +#endif gAntColony.exportBestSolutions(system.epoch, NULL); // Reorder futureComputors so requalifying computors keep their index @@ -7401,6 +7536,14 @@ static bool initialize() // the cache. A memo, not state - absence or any load failure just means the solutions get // computed honestly. gAntColony.loadReplayCache(system.epoch, NULL); +#ifndef NDEBUG + { + CHAR16 dbg[128]; + setText(dbg, L"[ant-colony] replay cache loaded, occupancy="); + appendNumber(dbg, gAntColony.replayCacheOccupancy(), FALSE); + logToConsole(dbg); + } +#endif // Load + hash-verify the bpp9000 task once at init if (!loadBpp9000Task()) From 90f07a0e3e694ee62a6dc696836d2aad6dd7a517 Mon Sep 17 00:00:00 2001 From: cyber-pc <165458555+cyber-pc@users.noreply.github.com> Date: Sun, 9 Aug 2026 20:11:36 +0700 Subject: [PATCH 27/46] Bind the ant canonical-nonce rule to the scorer and dispatch by algorithm. --- src/mining/score_bpp9000.h | 22 ++++++++++------------ src/mining/score_engine.h | 14 ++++++++++++++ src/qubic.cpp | 9 +++------ test/score.cpp | 16 ++++++++-------- 4 files changed, 35 insertions(+), 26 deletions(-) diff --git a/src/mining/score_bpp9000.h b/src/mining/score_bpp9000.h index 9960f07c..40468c8c 100644 --- a/src/mining/score_bpp9000.h +++ b/src/mining/score_bpp9000.h @@ -21,17 +21,6 @@ static bool isCanonicalBpp9000Nonce(const unsigned char* nonce) && (nonce[2] == 0); } -// Same rule for the ant colony, except that K is a real degree of freedom there: the walk restores -// K = nonce[2] as its explore-step count, so K is range-checked instead of pinned to 0. Values above -// numberOfMutations are rejected -static bool isCanonicalAntNonce(const unsigned char* nonce, unsigned long long numberOfMutations) -{ - return (getAlgoType(nonce) == AlgoType::Bpp9000) - && (nonce[1] >= 1) - && (nonce[1] <= MAX_LUT_ENTRIES_PER_STEP) - && (nonce[2] <= numberOfMutations); -} - template struct ScoreBpp9000 { @@ -56,6 +45,15 @@ struct ScoreBpp9000 static_assert(lutSize <= lutStride, "LUT rows must fit the padded stride"); + // K is a real degree of freedom here: the walk restores K = nonce[2] as its explore-step count + static bool isCanonicalAntNonce(const unsigned char* nonce) + { + return (getAlgoType(nonce) == AlgoType::Bpp9000) + && (nonce[1] >= 1) + && (nonce[1] <= MAX_LUT_ENTRIES_PER_STEP) + && (nonce[2] <= numberOfMutations); + } + // random2 draw sizes padded up to a multiple of 64 bytes; leading bytes bit-exact with reference. static constexpr unsigned long long lutInitBytes = maxNumberOfNeurons * lutSize; static constexpr unsigned long long lutInitPaddedBytes = ((lutInitBytes + 63) / 64) * 64; @@ -1191,7 +1189,7 @@ struct ScoreBpp9000 const unsigned char* pRandom2Pool) { // The canonical rule - if (!isCanonicalAntNonce(nonce, numberOfMutations)) + if (!isCanonicalAntNonce(nonce)) { return INVALID_SCORE_VALUE; } diff --git a/src/mining/score_engine.h b/src/mining/score_engine.h index d6752a80..a95cb048 100644 --- a/src/mining/score_engine.h +++ b/src/mining/score_engine.h @@ -61,6 +61,20 @@ struct ScoreEngine } } + // Each engine owns its canonical ant-nonce rule; this switch is the algorithm seam, so ingress + // code stays algorithm-agnostic. Neuraxon is reserved and not ant-minable, so no nonce in its + // slot is canonical. + static bool isCanonicalAntNonce(const unsigned char* nonce) + { + switch (getAlgoType(nonce)) + { + case AlgoType::Bpp9000: + return ScoreBpp9000::isCanonicalAntNonce(nonce); + default: + return false; + } + } + // returns last computed output neurons of the active bpp9000 slot m256i getLastOutput() { diff --git a/src/qubic.cpp b/src/qubic.cpp index ffe218df..f5b0c9ed 100644 --- a/src/qubic.cpp +++ b/src/qubic.cpp @@ -498,8 +498,7 @@ static void queueAntSolution(unsigned long long processorNumber, const m256i& co // A non-canonical nonce is rejected by the scorer without producing a score, so the transaction // would forfeit the deposit with nothing to show. The computor pays that, not the miner. - if (!score_engine::isCanonicalAntNonce(payload.nonce.m256i_u8, - score_engine::ScoreBpp9000T::numberOfMutations)) + if (!score_engine::ScoreEngineT::isCanonicalAntNonce(payload.nonce.m256i_u8)) { antDebugPoolDrop(L"nonCanonical", payload); gAntPendingSolutions.noteDroppedNonCanonical(); @@ -1408,8 +1407,7 @@ static void processBroadcastTransaction(Peer* peer, RequestResponseHeader* heade if (spectrumIdx >= 0 && energy(spectrumIdx) >= AntColonyMiningSolutionTransaction::minAmount() && !isAntSolutionSeen(preFlagIndices) - && score_engine::isCanonicalAntNonce(antTx->nonce.m256i_u8, - score_engine::ScoreBpp9000T::numberOfMutations)) + && score_engine::ScoreEngineT::isCanonicalAntNonce(antTx->nonce.m256i_u8)) { const AntSolutionRecord* preParentRec = nullptr; m256i preAnchorDigest; @@ -3950,8 +3948,7 @@ static void publishAntSolutionFor(unsigned long long processorNumber, unsigned i // Last point before the node signs with its own computor key and funds the deposit from its own // balance. The commit path forfeits the deposit on a non-canonical nonce, and a check this cheap // belongs on both sides of the queue. - if (!score_engine::isCanonicalAntNonce(entry.nonce.m256i_u8, - score_engine::ScoreBpp9000T::numberOfMutations)) + if (!score_engine::ScoreEngineT::isCanonicalAntNonce(entry.nonce.m256i_u8)) { gAntPendingSolutions.markObsoleteGateRejected(idx); antDebugPending(L"retire gateRejected", entry, 0); diff --git a/test/score.cpp b/test/score.cpp index 2e1ff2e4..0c300063 100644 --- a/test/score.cpp +++ b/test/score.cpp @@ -834,17 +834,17 @@ TEST(TestQubicScoreAntColony, NonCanonicalNonceIsRejected) // L and K boundaries of the canonical rule, checked as a pure predicate so no walk is needed. TEST(TestQubicScoreAntColony, NonceCanonicalRuleBoundaries) { - constexpr unsigned long long mutations = AntCfg::numberOfMutations; + using AntScorer = score_engine::ScoreBpp9000; constexpr unsigned char maxL = (unsigned char)score_engine::MAX_LUT_ENTRIES_PER_STEP; - constexpr unsigned char maxK = (unsigned char)mutations; + constexpr unsigned char maxK = (unsigned char)AntScorer::numberOfMutations; - EXPECT_TRUE(score_engine::isCanonicalAntNonce(makeAntNonce(1, 0, 70).m256i_u8, mutations)); - EXPECT_TRUE(score_engine::isCanonicalAntNonce(makeAntNonce(maxL, 0, 71).m256i_u8, mutations)); - EXPECT_TRUE(score_engine::isCanonicalAntNonce(makeAntNonce(3, maxK, 72).m256i_u8, mutations)); + EXPECT_TRUE(AntScorer::isCanonicalAntNonce(makeAntNonce(1, 0, 70).m256i_u8)); + EXPECT_TRUE(AntScorer::isCanonicalAntNonce(makeAntNonce(maxL, 0, 71).m256i_u8)); + EXPECT_TRUE(AntScorer::isCanonicalAntNonce(makeAntNonce(3, maxK, 72).m256i_u8)); - EXPECT_FALSE(score_engine::isCanonicalAntNonce(makeAntNonce(0, 0, 73).m256i_u8, mutations)); - EXPECT_FALSE(score_engine::isCanonicalAntNonce(makeAntNonce((unsigned char)(maxL + 1), 0, 74).m256i_u8, mutations)); - EXPECT_FALSE(score_engine::isCanonicalAntNonce(makeAntNonce(3, (unsigned char)(maxK + 1), 75).m256i_u8, mutations)); + EXPECT_FALSE(AntScorer::isCanonicalAntNonce(makeAntNonce(0, 0, 73).m256i_u8)); + EXPECT_FALSE(AntScorer::isCanonicalAntNonce(makeAntNonce((unsigned char)(maxL + 1), 0, 74).m256i_u8)); + EXPECT_FALSE(AntScorer::isCanonicalAntNonce(makeAntNonce(3, (unsigned char)(maxK + 1), 75).m256i_u8)); } From 2b029f87f4888f1672e835e55b63c9f71c325884 Mon Sep 17 00:00:00 2001 From: cyber-pc <165458555+cyber-pc@users.noreply.github.com> Date: Sun, 9 Aug 2026 20:33:44 +0700 Subject: [PATCH 28/46] Gather the ant colony file names in public_settings. --- src/mining/ant_colony.h | 2 -- src/mining/ant_colony_snapshot.h | 5 ----- src/public_settings.h | 9 +++++++++ src/qubic.cpp | 2 -- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/mining/ant_colony.h b/src/mining/ant_colony.h index f1183061..c926d6e3 100644 --- a/src/mining/ant_colony.h +++ b/src/mining/ant_colony.h @@ -769,7 +769,6 @@ inline bool AntColony::tryGetReplayScore(const ReplayKey& key, unsigned } // Cache the already computed score for the ant colony -static unsigned short ANT_COLONY_REPLAY_CACHE_FILENAME[] = L"antColonyReplayCache.???"; template inline bool AntColony::saveReplayCache(unsigned short epoch, CHAR16* directory) @@ -833,7 +832,6 @@ inline bool AntColony::loadReplayCache(unsigned short epoch, CHAR16* dir } // The epoch's harvest file -static unsigned short ANT_COLONY_SOLUTIONS_EOE_FILENAME[] = L"antColonySolutions.eoe"; // Written once at the front of the file diff --git a/src/mining/ant_colony_snapshot.h b/src/mining/ant_colony_snapshot.h index e1c7f34a..b2426eb5 100644 --- a/src/mining/ant_colony_snapshot.h +++ b/src/mining/ant_colony_snapshot.h @@ -5,11 +5,6 @@ // Only what cannot be derived is written. The tick index, both head maps and the dedup set are // rebuilt from the records -static unsigned short ANT_SNAPSHOT_META_FILENAME[] = L"snapshotAntColonyMeta.???"; -static unsigned short ANT_SNAPSHOT_ANCHORS_FILENAME[] = L"snapshotAntColonyAnchors.???"; -static unsigned short ANT_SNAPSHOT_RECORDS_FILENAME[] = L"snapshotAntColonyRecords.???"; -static unsigned short ANT_SNAPSHOT_POOL_FILENAME[] = L"snapshotAntColonyPool.???"; -static unsigned short ANT_SNAPSHOT_EXPORT_FILENAME[] = L"snapshotAntColonyExport.???"; struct AntColonySnapshotMeta { diff --git a/src/public_settings.h b/src/public_settings.h index bb55fc20..52ec6c5f 100644 --- a/src/public_settings.h +++ b/src/public_settings.h @@ -93,6 +93,15 @@ static unsigned short REVENUE_DATA_END_OF_EPOCH_FILE_NAME[] = L"revenue_data.eoe static unsigned short REVENUE_DATA_SNAPSHOT_FILE_NAME[] = L"revenue_data.???"; static unsigned short MULTIDIM_REVENUE_SNAPSHOT_FILE_NAME[] = L"revenue_data_multi.???"; static unsigned short MULTIDIM_REVENUE_END_OF_EPOCH_FILE_NAME[] = L"revenue_data_multi.eoe"; +// Ant colony files +static unsigned short ANT_SNAPSHOT_META_FILENAME[] = L"snapshotAntColonyMeta.???"; +static unsigned short ANT_SNAPSHOT_ANCHORS_FILENAME[] = L"snapshotAntColonyAnchors.???"; +static unsigned short ANT_SNAPSHOT_RECORDS_FILENAME[] = L"snapshotAntColonyRecords.???"; +static unsigned short ANT_SNAPSHOT_POOL_FILENAME[] = L"snapshotAntColonyPool.???"; +static unsigned short ANT_SNAPSHOT_EXPORT_FILENAME[] = L"snapshotAntColonyExport.???"; +static unsigned short ANT_COLONY_REPLAY_CACHE_FILENAME[] = L"antColonyReplayCache.???"; +static unsigned short ANT_COLONY_SOLUTIONS_EOE_FILENAME[] = L"antColonySolutions.eoe"; +static unsigned short ANT_SOL_FLAG_FILE_NAME[] = L"snapshotAntSolutionFlag"; // Neuraxon (even-nonce slot) - reserved for a future algorithm, not yet implemented. static constexpr unsigned long long NEURAXON_NUMBER_OF_INPUT_NEURONS = 1; diff --git a/src/qubic.cpp b/src/qubic.cpp index f5b0c9ed..1c86f479 100644 --- a/src/qubic.cpp +++ b/src/qubic.cpp @@ -5448,7 +5448,6 @@ static bool saveAllNodeStates() return false; } - CHAR16 ANT_SOL_FLAG_FILE_NAME[] = L"snapshotAntSolutionFlag"; logToConsole(L"Saving ant solution flags"); savedSize = save(ANT_SOL_FLAG_FILE_NAME, NUMBER_OF_ANT_SOLUTION_FLAGS / 8, (unsigned char*)gAntSolutionFlags, directory); if (savedSize != NUMBER_OF_ANT_SOLUTION_FLAGS / 8) @@ -5721,7 +5720,6 @@ static bool loadAllNodeStates() return false; } - CHAR16 ANT_SOL_FLAG_FILE_NAME[] = L"snapshotAntSolutionFlag"; logToConsole(L"Loading ant solution flags"); loadedSize = load(ANT_SOL_FLAG_FILE_NAME, NUMBER_OF_ANT_SOLUTION_FLAGS / 8, (unsigned char*)gAntSolutionFlags, directory); if (loadedSize != NUMBER_OF_ANT_SOLUTION_FLAGS / 8) From 0fca52f0b65d70ee064e6a44f69221ae3c67441d Mon Sep 17 00:00:00 2001 From: cyber-pc <165458555+cyber-pc@users.noreply.github.com> Date: Sun, 9 Aug 2026 21:17:52 +0700 Subject: [PATCH 29/46] Move ant colony related files to the same folder. --- src/Qubic.vcxproj | 8 ++++---- src/Qubic.vcxproj.filters | 19 +++++++++++-------- src/mining/{ => ant_colony}/ant_colony.h | 4 ++-- .../{ => ant_colony}/ant_colony_bpp9000.h | 4 ++-- .../{ => ant_colony}/ant_colony_snapshot.h | 2 +- .../{ => ant_colony}/ant_pending_solutions.h | 2 +- src/qubic.cpp | 6 +++--- test/ant_colony.cpp | 4 ++-- test/ant_pending_solutions.cpp | 2 +- 9 files changed, 27 insertions(+), 24 deletions(-) rename src/mining/{ => ant_colony}/ant_colony.h (99%) rename src/mining/{ => ant_colony}/ant_colony_bpp9000.h (80%) rename src/mining/{ => ant_colony}/ant_colony_snapshot.h (99%) rename src/mining/{ => ant_colony}/ant_pending_solutions.h (99%) diff --git a/src/Qubic.vcxproj b/src/Qubic.vcxproj index 6e29ab30..d6ab201a 100644 --- a/src/Qubic.vcxproj +++ b/src/Qubic.vcxproj @@ -66,10 +66,10 @@ - - - - + + + + diff --git a/src/Qubic.vcxproj.filters b/src/Qubic.vcxproj.filters index 4ec38666..636bec86 100644 --- a/src/Qubic.vcxproj.filters +++ b/src/Qubic.vcxproj.filters @@ -188,17 +188,17 @@ mining - - mining + + mining\ant_colony - - mining + + mining\ant_colony - - mining + + mining\ant_colony - - mining + + mining\ant_colony mining @@ -474,6 +474,9 @@ {df525479-7504-470c-a25a-de4af8be0e5d} + + {7b1f3a52-9c4e-4d2a-b8e6-2a5c9d417f60} + {d334594b-f24d-440e-949a-c791aa13f867} diff --git a/src/mining/ant_colony.h b/src/mining/ant_colony/ant_colony.h similarity index 99% rename from src/mining/ant_colony.h rename to src/mining/ant_colony/ant_colony.h index c926d6e3..4359564c 100644 --- a/src/mining/ant_colony.h +++ b/src/mining/ant_colony/ant_colony.h @@ -11,8 +11,8 @@ #include "qpi/qpi.h" #include "qpi/impl/qpi_hash_map_impl.h" #include "public_settings.h" -#include "mining.h" -#include "trit_pack.h" +#include "mining/mining.h" +#include "mining/trit_pack.h" // (tickOffset, solutionIndexInTick), epoch-relative tick plus the solution transaction's index in tick struct SolutionRef diff --git a/src/mining/ant_colony_bpp9000.h b/src/mining/ant_colony/ant_colony_bpp9000.h similarity index 80% rename from src/mining/ant_colony_bpp9000.h rename to src/mining/ant_colony/ant_colony_bpp9000.h index 1e64f5ff..da88d0ab 100644 --- a/src/mining/ant_colony_bpp9000.h +++ b/src/mining/ant_colony/ant_colony_bpp9000.h @@ -1,7 +1,7 @@ #pragma once -#include "ant_colony.h" -#include "../score.h" +#include "mining/ant_colony/ant_colony.h" +#include "score.h" // Binds the colony to bpp9000. This is the only place a concrete scorer is named, which is why // ant_colony.h itself can stay free of score.h and everything it drags in. diff --git a/src/mining/ant_colony_snapshot.h b/src/mining/ant_colony/ant_colony_snapshot.h similarity index 99% rename from src/mining/ant_colony_snapshot.h rename to src/mining/ant_colony/ant_colony_snapshot.h index b2426eb5..254db12b 100644 --- a/src/mining/ant_colony_snapshot.h +++ b/src/mining/ant_colony/ant_colony_snapshot.h @@ -1,6 +1,6 @@ #pragma once -#include "mining/ant_colony_bpp9000.h" +#include "mining/ant_colony/ant_colony_bpp9000.h" #include "platform/file_io.h" // Only what cannot be derived is written. The tick index, both head maps and the dedup set are diff --git a/src/mining/ant_pending_solutions.h b/src/mining/ant_colony/ant_pending_solutions.h similarity index 99% rename from src/mining/ant_pending_solutions.h rename to src/mining/ant_colony/ant_pending_solutions.h index 80d2f61b..b3658cb1 100644 --- a/src/mining/ant_pending_solutions.h +++ b/src/mining/ant_colony/ant_pending_solutions.h @@ -3,7 +3,7 @@ #include "platform/m256.h" #include "platform/concurrency.h" #include "platform/memory.h" -#include "ant_colony.h" +#include "mining/ant_colony/ant_colony.h" // Solutions waiting to be published as transactions signed by the node's own computors // A queue is needed because a broadcast can be lost with nothing reporting it. An entry stores the diff --git a/src/qubic.cpp b/src/qubic.cpp index 1c86f479..6bc6b091 100644 --- a/src/qubic.cpp +++ b/src/qubic.cpp @@ -75,13 +75,13 @@ #include "files/files.h" #include "mining/mining.h" #include "mining/custom_qubic_mining_storage.h" -#include "mining/ant_colony_bpp9000.h" +#include "mining/ant_colony/ant_colony_bpp9000.h" #include "oracle_core/oracle_engine.h" #include "oracle_core/net_msg_impl.h" #include "oracle_core/snapshot_files.h" -#include "mining/ant_colony_snapshot.h" -#include "mining/ant_pending_solutions.h" +#include "mining/ant_colony/ant_colony_snapshot.h" +#include "mining/ant_colony/ant_pending_solutions.h" #include "oracle_core/oracle_interfaces_def.h" #include "qpi/impl/qpi_oracle_impl.h" diff --git a/test/ant_colony.cpp b/test/ant_colony.cpp index 874538bc..d49daf20 100644 --- a/test/ant_colony.cpp +++ b/test/ant_colony.cpp @@ -5,8 +5,8 @@ #define ENABLE_PROFILING 0 // The bound colony, not the bare template: these tests check bpp9000's binding as well as the rules. -#include "../src/mining/ant_colony_bpp9000.h" -#include "../src/mining/ant_colony_snapshot.h" +#include "../src/mining/ant_colony/ant_colony_bpp9000.h" +#include "../src/mining/ant_colony/ant_colony_snapshot.h" #include diff --git a/test/ant_pending_solutions.cpp b/test/ant_pending_solutions.cpp index e60ce3c9..02607a5d 100644 --- a/test/ant_pending_solutions.cpp +++ b/test/ant_pending_solutions.cpp @@ -2,7 +2,7 @@ #include "gtest/gtest.h" -#include "../src/mining/ant_pending_solutions.h" +#include "../src/mining/ant_colony/ant_pending_solutions.h" static m256i key(unsigned long long n) { From 785e4c532fff85fdc4466d1a9392237a17da9e16 Mon Sep 17 00:00:00 2001 From: cyber-pc <165458555+cyber-pc@users.noreply.github.com> Date: Sun, 9 Aug 2026 23:42:28 +0700 Subject: [PATCH 30/46] Reject ant solutions whose parent is in the current or a later tick. --- src/qubic.cpp | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/qubic.cpp b/src/qubic.cpp index 6bc6b091..4d4fb0a1 100644 --- a/src/qubic.cpp +++ b/src/qubic.cpp @@ -3386,6 +3386,15 @@ static void processTickTransactionAntColonySolution( } markAntSolutionSeen(antFlagIndices); + // Reject a parent ref into the current or a later tick: a real parent is always on-chain from an + // earlier tick. Root is exempt, it is derived rather than stored. + if (!parentRef.isRoot() && parentRef.tickOffset >= (unsigned int)(system.tick - system.initialTick)) + { + gAntColony.recordReject(ValidityResult::RejectParentNotRegistered); + logAntSolutionOutcome(transaction, 0, ValidityResult::RejectParentNotRegistered); + return; + } + const AntSolutionRecord* parentRec = nullptr; ValidityResult result = gAntColony.tryGetParent(parentRef, &parentRec); if (result != ValidityResult::Valid) From 639a0873b3c50d97e0143ef0fba2d7e40f87a35d Mon Sep 17 00:00:00 2001 From: cyber-pc <165458555+cyber-pc@users.noreply.github.com> Date: Sun, 9 Aug 2026 23:59:05 +0700 Subject: [PATCH 31/46] Make the ant anchor-ring seqlock reader use atomic tick loads --- src/mining/ant_colony/ant_colony.h | 6 ++++-- src/platform/concurrency.h | 1 + 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/mining/ant_colony/ant_colony.h b/src/mining/ant_colony/ant_colony.h index 4359564c..778afd18 100644 --- a/src/mining/ant_colony/ant_colony.h +++ b/src/mining/ant_colony/ant_colony.h @@ -947,12 +947,14 @@ inline bool AntColony::getAnchorDigest(unsigned int tick, m256i& digest) return false; } const unsigned int slot = tick & (ANT_ANCHOR_RING_SIZE - 1); - if (_anchors->ticks[slot] != tick) + // Seqlock read: the tick is loaded atomically before and after the digest copy, so a re-check + // that still sees the same tick means the digest was not overwritten mid-copy. + if ((unsigned int)ATOMIC_LOAD32(_anchors->ticks[slot]) != tick) { return false; // never recorded, or aged out and overwritten by a newer tick } digest = _anchors->digests[slot]; - return (_anchors->ticks[slot] == tick); + return ((unsigned int)ATOMIC_LOAD32(_anchors->ticks[slot]) == tick); } template diff --git a/src/platform/concurrency.h b/src/platform/concurrency.h index 9bdeb8ff..072213dd 100644 --- a/src/platform/concurrency.h +++ b/src/platform/concurrency.h @@ -93,6 +93,7 @@ struct LockGuard // long in windows is 32bits static_assert(sizeof(long) == 4, "Size of long for _InterlockedExchange is 4 bytes"); #define ATOMIC_STORE32(target, val) _InterlockedExchange((volatile long*)&target, val) +#define ATOMIC_LOAD32(target) _InterlockedCompareExchange((volatile long*)&target, 0, 0) #define ATOMIC_INC64(target) _InterlockedIncrement64(&target) #define ATOMIC_AND64(target, val) _InterlockedAnd64(&target, val) #define ATOMIC_STORE64(target, val) _InterlockedExchange64(&target, val) From 3e064da6f95f852bc3477662d11f795f3e75acb8 Mon Sep 17 00:00:00 2001 From: cyber-pc <165458555+cyber-pc@users.noreply.github.com> Date: Mon, 10 Aug 2026 00:19:48 +0700 Subject: [PATCH 32/46] Validate the ant export-set order as a permutation on snapshot load --- src/mining/ant_colony/ant_colony_snapshot.h | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/src/mining/ant_colony/ant_colony_snapshot.h b/src/mining/ant_colony/ant_colony_snapshot.h index 254db12b..d03ff8f8 100644 --- a/src/mining/ant_colony/ant_colony_snapshot.h +++ b/src/mining/ant_colony/ant_colony_snapshot.h @@ -222,13 +222,22 @@ inline bool AntColony::loadSnapshot(unsigned short epoch, CHAR16* direct } for (unsigned int i = 0; i < _exportSet->count; i++) { - // order[] indexes slots[]; a bad entry would make the export read a slot that was never written. - if (_exportSet->order[i] >= ANT_EXPORT_MAX_SOLUTIONS) + const unsigned int slot = _exportSet->order[i]; + if (slot >= _exportSet->count) { - antSnapshotFailure(L"export order out of range, position/slot", i, _exportSet->order[i]); + antSnapshotFailure(L"export order out of range, position/slot", i, slot); reset(); return false; } + for (unsigned int j = 0; j < i; j++) + { + if (_exportSet->order[j] == slot) + { + antSnapshotFailure(L"export order duplicate, position/slot", i, slot); + reset(); + return false; + } + } } // The caller's values, which the checks above proved the file agrees with. From 25d572ac35057e02533a8b4a7e0baf3cc8e2c856 Mon Sep 17 00:00:00 2001 From: cyber-pc <165458555+cyber-pc@users.noreply.github.com> Date: Mon, 10 Aug 2026 00:20:07 +0700 Subject: [PATCH 33/46] Pin the bpp9000 score multiplier to 1 with a static assert. --- src/qubic.cpp | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/qubic.cpp b/src/qubic.cpp index 4d4fb0a1..fa8e2ed0 100644 --- a/src/qubic.cpp +++ b/src/qubic.cpp @@ -254,6 +254,9 @@ static constexpr unsigned int gScoreMultiplier[score_engine::AlgoType::MaxAlgoCo NEURAXON_SOLUTION_MULTIPLER, // Neuraxon (reserved) BPP9000_SOLUTION_MULTIPLER // Bpp9000 }; +// Bpp9000's score is a raw error count consumed directly by the minimum-is-best ranking; scaling it +// serves no purpose and a large multiplier would overflow the ranking score. Pin it to 1. +static_assert(BPP9000_SOLUTION_MULTIPLER == 1, "Bpp9000 error score is ranked by minimum; its multiplier must be 1"); // Active solution threshold for an algorithm static int getSolutionThreshold(score_engine::AlgoType selectedAlgo) From 9476c8b557e3bd562e8b043fcc2b3177cffce222 Mon Sep 17 00:00:00 2001 From: cyber-pc <165458555+cyber-pc@users.noreply.github.com> Date: Mon, 10 Aug 2026 09:20:46 +0700 Subject: [PATCH 34/46] Combine the ant snapshot meta, anchors and export into one header file. --- src/mining/ant_colony/ant_colony_snapshot.h | 86 ++++++++++++--------- src/public_settings.h | 6 +- test/ant_colony.cpp | 9 ++- 3 files changed, 59 insertions(+), 42 deletions(-) diff --git a/src/mining/ant_colony/ant_colony_snapshot.h b/src/mining/ant_colony/ant_colony_snapshot.h index d03ff8f8..f9af7973 100644 --- a/src/mining/ant_colony/ant_colony_snapshot.h +++ b/src/mining/ant_colony/ant_colony_snapshot.h @@ -20,12 +20,14 @@ struct AntColonySnapshotMeta // The base every selfRef.tickOffset in the records file is relative to. Records address each // other by offset, so a snapshot restored against a different base is silently mis-addressed unsigned int initialTick; + unsigned int anchorRingBytes; + unsigned int exportSetBytes; m256i rootSeed; static constexpr unsigned int MAGIC = 0x414E5443; // "ANTC" static constexpr unsigned int VERSION = 1; }; -static_assert(sizeof(AntColonySnapshotMeta) == 32 + 32, "AntColonySnapshotMeta unexpected padding"); +static_assert(sizeof(AntColonySnapshotMeta) == 40 + 32, "AntColonySnapshotMeta unexpected padding"); static void antSnapshotFailure(const CHAR16* what, unsigned long long a, unsigned long long b) { @@ -48,11 +50,9 @@ static unsigned long long antSnapshotSlotCount(unsigned int solutionCount) static void antSnapshotNameForEpoch(unsigned short epoch) { - addEpochToFileName(ANT_SNAPSHOT_META_FILENAME, sizeof(ANT_SNAPSHOT_META_FILENAME) / sizeof(ANT_SNAPSHOT_META_FILENAME[0]), epoch); - addEpochToFileName(ANT_SNAPSHOT_ANCHORS_FILENAME, sizeof(ANT_SNAPSHOT_ANCHORS_FILENAME) / sizeof(ANT_SNAPSHOT_ANCHORS_FILENAME[0]), epoch); + addEpochToFileName(ANT_SNAPSHOT_HEADER_FILENAME, sizeof(ANT_SNAPSHOT_HEADER_FILENAME) / sizeof(ANT_SNAPSHOT_HEADER_FILENAME[0]), epoch); addEpochToFileName(ANT_SNAPSHOT_RECORDS_FILENAME, sizeof(ANT_SNAPSHOT_RECORDS_FILENAME) / sizeof(ANT_SNAPSHOT_RECORDS_FILENAME[0]), epoch); addEpochToFileName(ANT_SNAPSHOT_POOL_FILENAME, sizeof(ANT_SNAPSHOT_POOL_FILENAME) / sizeof(ANT_SNAPSHOT_POOL_FILENAME[0]), epoch); - addEpochToFileName(ANT_SNAPSHOT_EXPORT_FILENAME, sizeof(ANT_SNAPSHOT_EXPORT_FILENAME) / sizeof(ANT_SNAPSHOT_EXPORT_FILENAME[0]), epoch); } template @@ -75,18 +75,26 @@ inline bool AntColony::saveSnapshot(unsigned short epoch, CHAR16* direct meta.annPoolEntryBytes = (unsigned int)sizeof(PackedAnn); meta.errorThreshold = _errorThreshold; meta.initialTick = initialTick; + meta.anchorRingBytes = (unsigned int)sizeof(AnchorRing); + meta.exportSetBytes = (unsigned int)sizeof(ExportSet); meta.rootSeed = _rootSeed; - if (save(ANT_SNAPSHOT_META_FILENAME, sizeof(meta), (unsigned char*)&meta, directory) - != (long long)sizeof(meta)) + // The meta, anchor ring and export set share one file. The file API writes a single contiguous + // buffer, so the three are gathered into a scratch block: meta, then anchors, then export. + const unsigned long long headerBytes = sizeof(meta) + sizeof(AnchorRing) + sizeof(ExportSet); + unsigned char* headerBuffer = nullptr; + if (!allocPoolWithErrorLog(L"AntColony::snapshotHeader", headerBytes, (void**)&headerBuffer, __LINE__)) { - logToConsole(L"[ant-colony] failed to save snapshot meta"); return false; } - if (save(ANT_SNAPSHOT_ANCHORS_FILENAME, sizeof(AnchorRing), (unsigned char*)_anchors, directory) - != (long long)sizeof(AnchorRing)) + copyMem(headerBuffer, &meta, sizeof(meta)); + copyMem(headerBuffer + sizeof(meta), _anchors, sizeof(AnchorRing)); + copyMem(headerBuffer + sizeof(meta) + sizeof(AnchorRing), _exportSet, sizeof(ExportSet)); + const long long headerSaved = save(ANT_SNAPSHOT_HEADER_FILENAME, headerBytes, headerBuffer, directory); + freePool(headerBuffer); + if (headerSaved != (long long)headerBytes) { - logToConsole(L"[ant-colony] failed to save snapshot anchors"); + logToConsole(L"[ant-colony] failed to save snapshot header"); return false; } @@ -106,13 +114,6 @@ inline bool AntColony::saveSnapshot(unsigned short epoch, CHAR16* direct logToConsole(L"[ant-colony] failed to save snapshot pool"); return false; } - // Whole struct, fixed size - there is no used prefix to take, the order array indexes all of it. - if (save(ANT_SNAPSHOT_EXPORT_FILENAME, sizeof(ExportSet), (unsigned char*)_exportSet, directory) - != (long long)sizeof(ExportSet)) - { - logToConsole(L"[ant-colony] failed to save snapshot export set"); - return false; - } return true; } @@ -126,32 +127,54 @@ inline bool AntColony::loadSnapshot(unsigned short epoch, CHAR16* direct antSnapshotNameForEpoch(epoch); - AntColonySnapshotMeta meta; - if (load(ANT_SNAPSHOT_META_FILENAME, sizeof(meta), (unsigned char*)&meta, directory) - != (long long)sizeof(meta)) + // The meta, anchor ring and export set share one file. Read it whole, validate the meta before + // any colony state is touched, then copy the two sections into place. + const unsigned long long headerBytes = sizeof(AntColonySnapshotMeta) + sizeof(AnchorRing) + sizeof(ExportSet); + unsigned char* headerBuffer = nullptr; + if (!allocPoolWithErrorLog(L"AntColony::snapshotHeader", headerBytes, (void**)&headerBuffer, __LINE__)) { - logToConsole(L"[ant-colony] failed to load snapshot meta"); return false; } + if (load(ANT_SNAPSHOT_HEADER_FILENAME, headerBytes, headerBuffer, directory) != (long long)headerBytes) + { + logToConsole(L"[ant-colony] failed to load snapshot header"); + freePool(headerBuffer); + return false; + } + + AntColonySnapshotMeta meta; + copyMem(&meta, headerBuffer, sizeof(meta)); if (meta.magic != AntColonySnapshotMeta::MAGIC || meta.version != AntColonySnapshotMeta::VERSION) { antSnapshotFailure(L"bad magic/version", meta.magic, meta.version); + freePool(headerBuffer); return false; } if (meta.epoch != epoch) { antSnapshotFailure(L"epoch mismatch, file/expected", meta.epoch, epoch); + freePool(headerBuffer); return false; } // A differently sized record or ANN would parse cleanly and produce a tree that is silently wrong. if (meta.recordSizeBytes != sizeof(AntSolutionRecord) || meta.annPoolEntryBytes != sizeof(PackedAnn)) { antSnapshotFailure(L"layout mismatch, record/ann", meta.recordSizeBytes, meta.annPoolEntryBytes); + freePool(headerBuffer); + return false; + } + // The two sections after the meta must be exactly the size this build lays them out at, or the + // copies below would read them at the wrong offset. + if (meta.anchorRingBytes != sizeof(AnchorRing) || meta.exportSetBytes != sizeof(ExportSet)) + { + antSnapshotFailure(L"layout mismatch, anchors/export", meta.anchorRingBytes, meta.exportSetBytes); + freePool(headerBuffer); return false; } if (meta.solutionCount > ANT_MAX_NODES_PER_EPOCH) { antSnapshotFailure(L"solutionCount exceeds the cap, count/cap", meta.solutionCount, ANT_MAX_NODES_PER_EPOCH); + freePool(headerBuffer); return false; } // Cross-check against the node state restored alongside this file. A mismatch means the two @@ -160,11 +183,13 @@ inline bool AntColony::loadSnapshot(unsigned short epoch, CHAR16* direct if (!(meta.rootSeed == rootSeed)) { antSnapshotFailure(L"root seed does not match the restored node state, record/0", 0, 0); + freePool(headerBuffer); return false; } if (meta.errorThreshold != errorThreshold) { antSnapshotFailure(L"threshold does not match the node, file/node", meta.errorThreshold, errorThreshold); + freePool(headerBuffer); return false; } // Every selfRef.tickOffset is relative to this. Restoring against a different base would not @@ -172,6 +197,7 @@ inline bool AntColony::loadSnapshot(unsigned short epoch, CHAR16* direct if (meta.initialTick != initialTick) { antSnapshotFailure(L"initial tick does not match the node, file/node", meta.initialTick, initialTick); + freePool(headerBuffer); return false; } @@ -179,13 +205,9 @@ inline bool AntColony::loadSnapshot(unsigned short epoch, CHAR16* direct // on the state is being overwritten reset(); - if (load(ANT_SNAPSHOT_ANCHORS_FILENAME, sizeof(AnchorRing), (unsigned char*)_anchors, directory) - != (long long)sizeof(AnchorRing)) - { - logToConsole(L"[ant-colony] failed to load snapshot anchors"); - reset(); - return false; - } + copyMem(_anchors, headerBuffer + sizeof(meta), sizeof(AnchorRing)); + copyMem(_exportSet, headerBuffer + sizeof(meta) + sizeof(AnchorRing), sizeof(ExportSet)); + freePool(headerBuffer); // Read unconditionally and at the same sizing the save used, so an incomplete copy is refused // here rather than booting a node with an empty tree. @@ -206,14 +228,6 @@ inline bool AntColony::loadSnapshot(unsigned short epoch, CHAR16* direct reset(); return false; } - - if (load(ANT_SNAPSHOT_EXPORT_FILENAME, sizeof(ExportSet), (unsigned char*)_exportSet, directory) - != (long long)sizeof(ExportSet)) - { - logToConsole(L"[ant-colony] failed to load snapshot export set"); - reset(); - return false; - } if (_exportSet->count > ANT_EXPORT_MAX_SOLUTIONS) { antSnapshotFailure(L"export count exceeds the cap, count/cap", _exportSet->count, ANT_EXPORT_MAX_SOLUTIONS); diff --git a/src/public_settings.h b/src/public_settings.h index 52ec6c5f..c4c4b240 100644 --- a/src/public_settings.h +++ b/src/public_settings.h @@ -93,12 +93,10 @@ static unsigned short REVENUE_DATA_END_OF_EPOCH_FILE_NAME[] = L"revenue_data.eoe static unsigned short REVENUE_DATA_SNAPSHOT_FILE_NAME[] = L"revenue_data.???"; static unsigned short MULTIDIM_REVENUE_SNAPSHOT_FILE_NAME[] = L"revenue_data_multi.???"; static unsigned short MULTIDIM_REVENUE_END_OF_EPOCH_FILE_NAME[] = L"revenue_data_multi.eoe"; -// Ant colony files -static unsigned short ANT_SNAPSHOT_META_FILENAME[] = L"snapshotAntColonyMeta.???"; -static unsigned short ANT_SNAPSHOT_ANCHORS_FILENAME[] = L"snapshotAntColonyAnchors.???"; +// Ant colony files. The header file carries the meta, the anchor ring and the export set together +static unsigned short ANT_SNAPSHOT_HEADER_FILENAME[] = L"snapshotAntColonyHeader.???"; static unsigned short ANT_SNAPSHOT_RECORDS_FILENAME[] = L"snapshotAntColonyRecords.???"; static unsigned short ANT_SNAPSHOT_POOL_FILENAME[] = L"snapshotAntColonyPool.???"; -static unsigned short ANT_SNAPSHOT_EXPORT_FILENAME[] = L"snapshotAntColonyExport.???"; static unsigned short ANT_COLONY_REPLAY_CACHE_FILENAME[] = L"antColonyReplayCache.???"; static unsigned short ANT_COLONY_SOLUTIONS_EOE_FILENAME[] = L"antColonySolutions.eoe"; static unsigned short ANT_SOL_FLAG_FILE_NAME[] = L"snapshotAntSolutionFlag"; diff --git a/test/ant_colony.cpp b/test/ant_colony.cpp index d49daf20..707a50c7 100644 --- a/test/ant_colony.cpp +++ b/test/ant_colony.cpp @@ -574,7 +574,7 @@ TEST(TestAntColonySnapshot, RefusedLoadLeavesTheRunningTreeIntact) EXPECT_EQ(colony->solutionCount(), 2u); } -// An empty colony still writes all four files, so an operator's backup is always the same set and a +// An empty colony still writes all three files, so an operator's backup is always the same set and a // short one means a lost file rather than an empty epoch. TEST(TestAntColonySnapshot, EmptyColonyWritesTheFullFileSet) { @@ -586,14 +586,19 @@ TEST(TestAntColonySnapshot, EmptyColonyWritesTheFullFileSet) // Clear whatever an earlier test left at this epoch, so the files below can only come from the // save under test. antSnapshotNameForEpoch(TEST_EPOCH); + _wremove(ANT_SNAPSHOT_HEADER_FILENAME); _wremove(ANT_SNAPSHOT_RECORDS_FILENAME); _wremove(ANT_SNAPSHOT_POOL_FILENAME); ASSERT_TRUE(colony->saveSnapshot(TEST_EPOCH, NULL, TEST_INITIAL_TICK)); - // Both exist and hold a full slot, so neither is zero length. + // All three are written. Records and pool hold a full slot even when empty, so neither is zero + // length; the header always carries the meta, anchor ring and export set. + AntColonySnapshotMeta metaSlot; AntSolutionRecord recordSlot; AntColonyBpp9000T::PackedAnn poolSlot; + EXPECT_EQ(load(ANT_SNAPSHOT_HEADER_FILENAME, sizeof(metaSlot), (unsigned char*)&metaSlot), + (long long)sizeof(metaSlot)); EXPECT_EQ(load(ANT_SNAPSHOT_RECORDS_FILENAME, sizeof(recordSlot), (unsigned char*)&recordSlot), (long long)sizeof(recordSlot)); EXPECT_EQ(load(ANT_SNAPSHOT_POOL_FILENAME, sizeof(poolSlot), (unsigned char*)&poolSlot), From 818520312cb87006f7bcecb6ecfdc675df6a184b Mon Sep 17 00:00:00 2001 From: cyber-pc <165458555+cyber-pc@users.noreply.github.com> Date: Mon, 10 Aug 2026 09:50:09 +0700 Subject: [PATCH 35/46] Use a dedicated colony buffer for the snapshot header --- src/mining/ant_colony/ant_colony.h | 28 ++++++++++++------ src/mining/ant_colony/ant_colony_snapshot.h | 32 ++++++--------------- 2 files changed, 28 insertions(+), 32 deletions(-) diff --git a/src/mining/ant_colony/ant_colony.h b/src/mining/ant_colony/ant_colony.h index 778afd18..7754a2aa 100644 --- a/src/mining/ant_colony/ant_colony.h +++ b/src/mining/ant_colony/ant_colony.h @@ -223,6 +223,10 @@ static constexpr unsigned long long ANT_CHILD_HEAD_BY_MINER_SIZE = 2ULL * MAX_NU // simply the lowest N scores of the epoch - not one per identity, and not tied to the ranking static constexpr unsigned int ANT_EXPORT_MAX_SOLUTIONS = NUMBER_OF_COMPUTORS; +// Serial scratch for the save/load header (meta + anchor ring + export set) and the solution export. +// In case of this grow to large, consider use the one in common buffer +static constexpr unsigned long long ANT_SNAPSHOT_SCRATCH_BYTES = 2ULL * 1024 * 1024; // 2MB + // Anchor digests for recent ticks, indexed by tick & (size - 1). Smallest power of two holding // 2*(N+1) entries so a lookup inside the freshness window can never be aliased by a newer tick. static constexpr unsigned int antAnchorRingSize(unsigned int window) @@ -558,6 +562,9 @@ class AntColony ReplayEntry* _replayCache; volatile char _replayCacheLock; + + // Serial scratch for save/load and the solution export + unsigned char* _snapshotScratch; unsigned int _replayCacheOccupancy; unsigned int _solutionCount; @@ -620,6 +627,11 @@ inline bool AntColony::init() { return false; } + if (!allocPoolWithErrorLog(L"AntColony::_snapshotScratch", + ANT_SNAPSHOT_SCRATCH_BYTES, (void**)&_snapshotScratch, __LINE__)) + { + return false; + } reset(); clearReplayCache(); @@ -629,6 +641,10 @@ inline bool AntColony::init() template inline void AntColony::deinit() { + if (_snapshotScratch) + { + freePool(_snapshotScratch); + } if (_replayCache) { freePool(_replayCache); @@ -889,13 +905,10 @@ inline bool AntColony::exportBestSolutions(unsigned short epoch, CHAR16* == (long long)sizeof(header); } + static_assert(sizeof(AntColonyExportHeader) + (unsigned long long)ANT_EXPORT_MAX_SOLUTIONS * sizeof(Entry) + <= ANT_SNAPSHOT_SCRATCH_BYTES, "ant solution export exceeds the scratch buffer"); const unsigned long long totalBytes = sizeof(header) + (unsigned long long)set.count * sizeof(Entry); - unsigned char* buffer = nullptr; - if (!allocPoolWithErrorLog(L"AntColony::export", totalBytes, (void**)&buffer, __LINE__)) - { - logToConsole(L"[ant-colony] no memory for the solution export, harvest for this epoch is lost"); - return false; - } + unsigned char* buffer = _snapshotScratch; copyMem(buffer, &header, sizeof(header)); Entry* out = (Entry*)(buffer + sizeof(header)); @@ -909,7 +922,6 @@ inline bool AntColony::exportBestSolutions(unsigned short epoch, CHAR16* } const long long saved = save(ANT_COLONY_SOLUTIONS_EOE_FILENAME, totalBytes, buffer, directory); - freePool(buffer); if (saved != (long long)totalBytes) { @@ -947,7 +959,7 @@ inline bool AntColony::getAnchorDigest(unsigned int tick, m256i& digest) return false; } const unsigned int slot = tick & (ANT_ANCHOR_RING_SIZE - 1); - // Seqlock read: the tick is loaded atomically before and after the digest copy, so a re-check + // Seqlock read, the tick is loaded atomically before and after the digest copy, so a re-check // that still sees the same tick means the digest was not overwritten mid-copy. if ((unsigned int)ATOMIC_LOAD32(_anchors->ticks[slot]) != tick) { diff --git a/src/mining/ant_colony/ant_colony_snapshot.h b/src/mining/ant_colony/ant_colony_snapshot.h index f9af7973..b9db63b4 100644 --- a/src/mining/ant_colony/ant_colony_snapshot.h +++ b/src/mining/ant_colony/ant_colony_snapshot.h @@ -80,19 +80,15 @@ inline bool AntColony::saveSnapshot(unsigned short epoch, CHAR16* direct meta.rootSeed = _rootSeed; // The meta, anchor ring and export set share one file. The file API writes a single contiguous - // buffer, so the three are gathered into a scratch block: meta, then anchors, then export. + // buffer, so the three are gathered into the serial scratch: meta, then anchors, then export. const unsigned long long headerBytes = sizeof(meta) + sizeof(AnchorRing) + sizeof(ExportSet); - unsigned char* headerBuffer = nullptr; - if (!allocPoolWithErrorLog(L"AntColony::snapshotHeader", headerBytes, (void**)&headerBuffer, __LINE__)) - { - return false; - } + static_assert(sizeof(AntColonySnapshotMeta) + sizeof(AnchorRing) + sizeof(ExportSet) <= ANT_SNAPSHOT_SCRATCH_BYTES, + "ant snapshot header exceeds the scratch buffer"); + unsigned char* headerBuffer = _snapshotScratch; copyMem(headerBuffer, &meta, sizeof(meta)); copyMem(headerBuffer + sizeof(meta), _anchors, sizeof(AnchorRing)); copyMem(headerBuffer + sizeof(meta) + sizeof(AnchorRing), _exportSet, sizeof(ExportSet)); - const long long headerSaved = save(ANT_SNAPSHOT_HEADER_FILENAME, headerBytes, headerBuffer, directory); - freePool(headerBuffer); - if (headerSaved != (long long)headerBytes) + if (save(ANT_SNAPSHOT_HEADER_FILENAME, headerBytes, headerBuffer, directory) != (long long)headerBytes) { logToConsole(L"[ant-colony] failed to save snapshot header"); return false; @@ -130,15 +126,12 @@ inline bool AntColony::loadSnapshot(unsigned short epoch, CHAR16* direct // The meta, anchor ring and export set share one file. Read it whole, validate the meta before // any colony state is touched, then copy the two sections into place. const unsigned long long headerBytes = sizeof(AntColonySnapshotMeta) + sizeof(AnchorRing) + sizeof(ExportSet); - unsigned char* headerBuffer = nullptr; - if (!allocPoolWithErrorLog(L"AntColony::snapshotHeader", headerBytes, (void**)&headerBuffer, __LINE__)) - { - return false; - } + static_assert(sizeof(AntColonySnapshotMeta) + sizeof(AnchorRing) + sizeof(ExportSet) <= ANT_SNAPSHOT_SCRATCH_BYTES, + "ant snapshot header exceeds the scratch buffer"); + unsigned char* headerBuffer = _snapshotScratch; if (load(ANT_SNAPSHOT_HEADER_FILENAME, headerBytes, headerBuffer, directory) != (long long)headerBytes) { logToConsole(L"[ant-colony] failed to load snapshot header"); - freePool(headerBuffer); return false; } @@ -147,20 +140,17 @@ inline bool AntColony::loadSnapshot(unsigned short epoch, CHAR16* direct if (meta.magic != AntColonySnapshotMeta::MAGIC || meta.version != AntColonySnapshotMeta::VERSION) { antSnapshotFailure(L"bad magic/version", meta.magic, meta.version); - freePool(headerBuffer); return false; } if (meta.epoch != epoch) { antSnapshotFailure(L"epoch mismatch, file/expected", meta.epoch, epoch); - freePool(headerBuffer); return false; } // A differently sized record or ANN would parse cleanly and produce a tree that is silently wrong. if (meta.recordSizeBytes != sizeof(AntSolutionRecord) || meta.annPoolEntryBytes != sizeof(PackedAnn)) { antSnapshotFailure(L"layout mismatch, record/ann", meta.recordSizeBytes, meta.annPoolEntryBytes); - freePool(headerBuffer); return false; } // The two sections after the meta must be exactly the size this build lays them out at, or the @@ -168,13 +158,11 @@ inline bool AntColony::loadSnapshot(unsigned short epoch, CHAR16* direct if (meta.anchorRingBytes != sizeof(AnchorRing) || meta.exportSetBytes != sizeof(ExportSet)) { antSnapshotFailure(L"layout mismatch, anchors/export", meta.anchorRingBytes, meta.exportSetBytes); - freePool(headerBuffer); return false; } if (meta.solutionCount > ANT_MAX_NODES_PER_EPOCH) { antSnapshotFailure(L"solutionCount exceeds the cap, count/cap", meta.solutionCount, ANT_MAX_NODES_PER_EPOCH); - freePool(headerBuffer); return false; } // Cross-check against the node state restored alongside this file. A mismatch means the two @@ -183,13 +171,11 @@ inline bool AntColony::loadSnapshot(unsigned short epoch, CHAR16* direct if (!(meta.rootSeed == rootSeed)) { antSnapshotFailure(L"root seed does not match the restored node state, record/0", 0, 0); - freePool(headerBuffer); return false; } if (meta.errorThreshold != errorThreshold) { antSnapshotFailure(L"threshold does not match the node, file/node", meta.errorThreshold, errorThreshold); - freePool(headerBuffer); return false; } // Every selfRef.tickOffset is relative to this. Restoring against a different base would not @@ -197,7 +183,6 @@ inline bool AntColony::loadSnapshot(unsigned short epoch, CHAR16* direct if (meta.initialTick != initialTick) { antSnapshotFailure(L"initial tick does not match the node, file/node", meta.initialTick, initialTick); - freePool(headerBuffer); return false; } @@ -207,7 +192,6 @@ inline bool AntColony::loadSnapshot(unsigned short epoch, CHAR16* direct copyMem(_anchors, headerBuffer + sizeof(meta), sizeof(AnchorRing)); copyMem(_exportSet, headerBuffer + sizeof(meta) + sizeof(AnchorRing), sizeof(ExportSet)); - freePool(headerBuffer); // Read unconditionally and at the same sizing the save used, so an incomplete copy is refused // here rather than booting a node with an empty tree. From 53d324327684fbbddb671ea8c8339c8a3b077370 Mon Sep 17 00:00:00 2001 From: cyber-pc <165458555+cyber-pc@users.noreply.github.com> Date: Mon, 10 Aug 2026 09:58:45 +0700 Subject: [PATCH 36/46] Fold the ant snapshot save/load into ant_colony.h --- src/Qubic.vcxproj | 1 - src/Qubic.vcxproj.filters | 3 - src/mining/ant_colony/ant_colony.h | 394 ++++++++++++++++++- src/mining/ant_colony/ant_colony_snapshot.h | 396 -------------------- src/qubic.cpp | 1 - test/ant_colony.cpp | 1 - 6 files changed, 393 insertions(+), 403 deletions(-) delete mode 100644 src/mining/ant_colony/ant_colony_snapshot.h diff --git a/src/Qubic.vcxproj b/src/Qubic.vcxproj index d6ab201a..db075290 100644 --- a/src/Qubic.vcxproj +++ b/src/Qubic.vcxproj @@ -68,7 +68,6 @@ - diff --git a/src/Qubic.vcxproj.filters b/src/Qubic.vcxproj.filters index 636bec86..0c2a0070 100644 --- a/src/Qubic.vcxproj.filters +++ b/src/Qubic.vcxproj.filters @@ -197,9 +197,6 @@ mining\ant_colony - - mining\ant_colony - mining diff --git a/src/mining/ant_colony/ant_colony.h b/src/mining/ant_colony/ant_colony.h index 7754a2aa..08f741b5 100644 --- a/src/mining/ant_colony/ant_colony.h +++ b/src/mining/ant_colony/ant_colony.h @@ -529,7 +529,7 @@ class AntColony } // loadSnapshot() helper: rebuild the tick index, head maps and dedup set from the loaded - // records, treating them as untrusted input. Defined in ant_colony_snapshot.h. + // records, treating them as untrusted input. bool rebuildDerivedState(unsigned int initialTick); static unsigned int replaySlotOf(const ReplayKey& key) @@ -1219,3 +1219,395 @@ inline ValidityResult AntColony::commit(const AntCommitInput& in, const } return ValidityResult::Valid; } + +// Only what cannot be derived is written. The tick index, both head maps and the dedup set are +// rebuilt from the records + +struct AntColonySnapshotMeta +{ + unsigned int magic; + unsigned int version; + unsigned int epoch; + unsigned int solutionCount; + // Layout guards. A snapshot written by a build with a different record or ANN size must be + // refused rather than reinterpreted + unsigned int recordSizeBytes; + unsigned int annPoolEntryBytes; + unsigned int errorThreshold; + // The base every selfRef.tickOffset in the records file is relative to. Records address each + // other by offset, so a snapshot restored against a different base is silently mis-addressed + unsigned int initialTick; + unsigned int anchorRingBytes; + unsigned int exportSetBytes; + m256i rootSeed; + + static constexpr unsigned int MAGIC = 0x414E5443; // "ANTC" + static constexpr unsigned int VERSION = 1; +}; +static_assert(sizeof(AntColonySnapshotMeta) == 40 + 32, "AntColonySnapshotMeta unexpected padding"); + +static void antSnapshotFailure(const CHAR16* what, unsigned long long a, unsigned long long b) +{ + CHAR16 message[256]; + setText(message, L"[ant-colony] snapshot: "); + appendText(message, what); + appendText(message, L" "); + appendNumber(message, a, FALSE); + appendText(message, L" / "); + appendNumber(message, b, FALSE); + logToConsole(message); +} + +// Records and pool are sized by this, never by the raw count. At least one slot is always written, +// so no snapshot file is ever zero length +static unsigned long long antSnapshotSlotCount(unsigned int solutionCount) +{ + return (solutionCount > 0) ? (unsigned long long)solutionCount : 1ULL; +} + +static void antSnapshotNameForEpoch(unsigned short epoch) +{ + addEpochToFileName(ANT_SNAPSHOT_HEADER_FILENAME, sizeof(ANT_SNAPSHOT_HEADER_FILENAME) / sizeof(ANT_SNAPSHOT_HEADER_FILENAME[0]), epoch); + addEpochToFileName(ANT_SNAPSHOT_RECORDS_FILENAME, sizeof(ANT_SNAPSHOT_RECORDS_FILENAME) / sizeof(ANT_SNAPSHOT_RECORDS_FILENAME[0]), epoch); + addEpochToFileName(ANT_SNAPSHOT_POOL_FILENAME, sizeof(ANT_SNAPSHOT_POOL_FILENAME) / sizeof(ANT_SNAPSHOT_POOL_FILENAME[0]), epoch); +} + +template +inline bool AntColony::saveSnapshot(unsigned short epoch, CHAR16* directory, + unsigned int initialTick) const +{ + ASSERT(_records != nullptr); + ASSERT(_annPool != nullptr); + ASSERT(_anchors != nullptr); + + antSnapshotNameForEpoch(epoch); + + AntColonySnapshotMeta meta; + setMem(&meta, sizeof(meta), 0); + meta.magic = AntColonySnapshotMeta::MAGIC; + meta.version = AntColonySnapshotMeta::VERSION; + meta.epoch = epoch; + meta.solutionCount = _solutionCount; + meta.recordSizeBytes = (unsigned int)sizeof(AntSolutionRecord); + meta.annPoolEntryBytes = (unsigned int)sizeof(PackedAnn); + meta.errorThreshold = _errorThreshold; + meta.initialTick = initialTick; + meta.anchorRingBytes = (unsigned int)sizeof(AnchorRing); + meta.exportSetBytes = (unsigned int)sizeof(ExportSet); + meta.rootSeed = _rootSeed; + + // The meta, anchor ring and export set share one file. The file API writes a single contiguous + // buffer, so the three are gathered into the serial scratch: meta, then anchors, then export. + const unsigned long long headerBytes = sizeof(meta) + sizeof(AnchorRing) + sizeof(ExportSet); + static_assert(sizeof(AntColonySnapshotMeta) + sizeof(AnchorRing) + sizeof(ExportSet) <= ANT_SNAPSHOT_SCRATCH_BYTES, + "ant snapshot header exceeds the scratch buffer"); + unsigned char* headerBuffer = _snapshotScratch; + copyMem(headerBuffer, &meta, sizeof(meta)); + copyMem(headerBuffer + sizeof(meta), _anchors, sizeof(AnchorRing)); + copyMem(headerBuffer + sizeof(meta) + sizeof(AnchorRing), _exportSet, sizeof(ExportSet)); + if (save(ANT_SNAPSHOT_HEADER_FILENAME, headerBytes, headerBuffer, directory) != (long long)headerBytes) + { + logToConsole(L"[ant-colony] failed to save snapshot header"); + return false; + } + + // Written even when the colony is empty, so the operator's snapshot is always the same number of files + const unsigned long long slots = antSnapshotSlotCount(_solutionCount); + const unsigned long long recordBytes = slots * sizeof(AntSolutionRecord); + const unsigned long long poolBytes = slots * sizeof(PackedAnn); + if (saveLargeFile(ANT_SNAPSHOT_RECORDS_FILENAME, recordBytes, (unsigned char*)_records, directory, false) + != (long long)recordBytes) + { + logToConsole(L"[ant-colony] failed to save snapshot records"); + return false; + } + if (saveLargeFile(ANT_SNAPSHOT_POOL_FILENAME, poolBytes, (unsigned char*)_annPool, directory, false) + != (long long)poolBytes) + { + logToConsole(L"[ant-colony] failed to save snapshot pool"); + return false; + } + return true; +} + +template +inline bool AntColony::loadSnapshot(unsigned short epoch, CHAR16* directory, + const m256i& rootSeed, unsigned int errorThreshold, unsigned int initialTick) +{ + ASSERT(_records != nullptr); + ASSERT(_annPool != nullptr); + ASSERT(_anchors != nullptr); + + antSnapshotNameForEpoch(epoch); + + // The meta, anchor ring and export set share one file. Read it whole, validate the meta before + // any colony state is touched, then copy the two sections into place. + const unsigned long long headerBytes = sizeof(AntColonySnapshotMeta) + sizeof(AnchorRing) + sizeof(ExportSet); + static_assert(sizeof(AntColonySnapshotMeta) + sizeof(AnchorRing) + sizeof(ExportSet) <= ANT_SNAPSHOT_SCRATCH_BYTES, + "ant snapshot header exceeds the scratch buffer"); + unsigned char* headerBuffer = _snapshotScratch; + if (load(ANT_SNAPSHOT_HEADER_FILENAME, headerBytes, headerBuffer, directory) != (long long)headerBytes) + { + logToConsole(L"[ant-colony] failed to load snapshot header"); + return false; + } + + AntColonySnapshotMeta meta; + copyMem(&meta, headerBuffer, sizeof(meta)); + if (meta.magic != AntColonySnapshotMeta::MAGIC || meta.version != AntColonySnapshotMeta::VERSION) + { + antSnapshotFailure(L"bad magic/version", meta.magic, meta.version); + return false; + } + if (meta.epoch != epoch) + { + antSnapshotFailure(L"epoch mismatch, file/expected", meta.epoch, epoch); + return false; + } + // A differently sized record or ANN would parse cleanly and produce a tree that is silently wrong. + if (meta.recordSizeBytes != sizeof(AntSolutionRecord) || meta.annPoolEntryBytes != sizeof(PackedAnn)) + { + antSnapshotFailure(L"layout mismatch, record/ann", meta.recordSizeBytes, meta.annPoolEntryBytes); + return false; + } + // The two sections after the meta must be exactly the size this build lays them out at, or the + // copies below would read them at the wrong offset. + if (meta.anchorRingBytes != sizeof(AnchorRing) || meta.exportSetBytes != sizeof(ExportSet)) + { + antSnapshotFailure(L"layout mismatch, anchors/export", meta.anchorRingBytes, meta.exportSetBytes); + return false; + } + if (meta.solutionCount > ANT_MAX_NODES_PER_EPOCH) + { + antSnapshotFailure(L"solutionCount exceeds the cap, count/cap", meta.solutionCount, ANT_MAX_NODES_PER_EPOCH); + return false; + } + // Cross-check against the node state restored alongside this file. A mismatch means the two + // snapshots are not from the same moment - loading the tree anyway would derive every root from + // a seed the rest of the network is not using. + if (!(meta.rootSeed == rootSeed)) + { + antSnapshotFailure(L"root seed does not match the restored node state, record/0", 0, 0); + return false; + } + if (meta.errorThreshold != errorThreshold) + { + antSnapshotFailure(L"threshold does not match the node, file/node", meta.errorThreshold, errorThreshold); + return false; + } + // Every selfRef.tickOffset is relative to this. Restoring against a different base would not + // fail anywhere - it would resolve parent references to the wrong records + if (meta.initialTick != initialTick) + { + antSnapshotFailure(L"initial tick does not match the node, file/node", meta.initialTick, initialTick); + return false; + } + + // Everything above only read the meta, so a refusal there leaves the colony untouched. From here + // on the state is being overwritten + reset(); + + copyMem(_anchors, headerBuffer + sizeof(meta), sizeof(AnchorRing)); + copyMem(_exportSet, headerBuffer + sizeof(meta) + sizeof(AnchorRing), sizeof(ExportSet)); + + // Read unconditionally and at the same sizing the save used, so an incomplete copy is refused + // here rather than booting a node with an empty tree. + const unsigned long long slots = antSnapshotSlotCount(meta.solutionCount); + const unsigned long long recordBytes = slots * sizeof(AntSolutionRecord); + const unsigned long long poolBytes = slots * sizeof(PackedAnn); + if (loadLargeFile(ANT_SNAPSHOT_RECORDS_FILENAME, recordBytes, (unsigned char*)_records, directory) + != (long long)recordBytes) + { + logToConsole(L"[ant-colony] failed to load snapshot records"); + reset(); + return false; + } + if (loadLargeFile(ANT_SNAPSHOT_POOL_FILENAME, poolBytes, (unsigned char*)_annPool, directory) + != (long long)poolBytes) + { + logToConsole(L"[ant-colony] failed to load snapshot pool"); + reset(); + return false; + } + if (_exportSet->count > ANT_EXPORT_MAX_SOLUTIONS) + { + antSnapshotFailure(L"export count exceeds the cap, count/cap", _exportSet->count, ANT_EXPORT_MAX_SOLUTIONS); + reset(); + return false; + } + for (unsigned int i = 0; i < _exportSet->count; i++) + { + const unsigned int slot = _exportSet->order[i]; + if (slot >= _exportSet->count) + { + antSnapshotFailure(L"export order out of range, position/slot", i, slot); + reset(); + return false; + } + for (unsigned int j = 0; j < i; j++) + { + if (_exportSet->order[j] == slot) + { + antSnapshotFailure(L"export order duplicate, position/slot", i, slot); + reset(); + return false; + } + } + } + + // The caller's values, which the checks above proved the file agrees with. + _rootSeed = rootSeed; + _errorThreshold = errorThreshold; + _solutionCount = meta.solutionCount; + + // Rebuild the intermediate data + if (!rebuildDerivedState(initialTick)) + { + reset(); + return false; + } + return true; +} + +template +inline bool AntColony::rebuildDerivedState(unsigned int initialTick) +{ + // Hoisted out of the loop: unpacking each stored network to verify its hash needs somewhere to + // put it, and this path runs once at boot. + Ann annBuffer; + + for (unsigned int i = 0; i < _solutionCount; i++) + { + const AntSolutionRecord& rec = _records[i]; + + if (rec.selfRef.tickOffset >= MAX_NUMBER_OF_TICKS_PER_EPOCH) + { + antSnapshotFailure(L"tickOffset out of range, record/tickOffset", i, rec.selfRef.tickOffset); + return false; + } + // commit() writes the record and its network at the same index, and findIndexBySolutionRef + // and annOfNonRoot both rely on it + // annStateSlot point to the ANN that must have similar index to the record + if (rec.annStateSlot != i) + { + antSnapshotFailure(L"annStateSlot is not the record index, record/slot", i, rec.annStateSlot); + return false; + } + + // The freshness rule validateChild() applied at admission, re-checked here. This is the only + // guard on anchorTick, which is consensus-relevant: siblingFloor() compares a stored record's + // anchorTick against a later child's, so a corrupt one changes which siblings compete and + // therefore which solutions the node accepts. publishTick was not stored because it does not + // need to be - commit() sets tickOffset to (publishTick - initialTick), so it inverts exactly + const unsigned int publishTick = initialTick + rec.selfRef.tickOffset; + if (rec.anchorTick > publishTick + || publishTick - rec.anchorTick > ANT_PUBLISH_WINDOW_TICKS) + { + antSnapshotFailure(L"anchor tick outside the freshness window, record/anchorTick", i, rec.anchorTick); + return false; + } + + // Rebuild the tick index + AntTickSlot& tslot = _tickIndex[rec.selfRef.tickOffset]; + if (tslot.count == 0) + { + tslot.startIdx = i; + } + else if (tslot.startIdx + tslot.count != i) + { + antSnapshotFailure(L"record breaks tick contiguity, record/tickOffset", i, rec.selfRef.tickOffset); + return false; + } + tslot.count++; + + // The parent must be ROOT or an EARLIER record. The tick index built so far covers only + // records before this one, so a forward or self reference fails to resolve - which is what + // rejects a cycle. + const AntSolutionRecord* parentRec = nullptr; + if (!rec.parentRef.isRoot()) + { + const long long parentIdx = findIndexBySolutionRef(rec.parentRef); + if (parentIdx == ANT_INVALID_INDEX || (unsigned long long)parentIdx >= i) + { + antSnapshotFailure(L"parent not an earlier record, record/parentTickOffset", i, rec.parentRef.tickOffset); + return false; + } + parentRec = &_records[parentIdx]; + if (!(parentRec->pubkey == rec.pubkey)) + { + antSnapshotFailure(L"parent belongs to another identity, record", i, 0); + return false; + } + } + const unsigned int expectedDepth = (parentRec != nullptr) ? (parentRec->depth + 1) : 1; + if (rec.depth != expectedDepth) + { + antSnapshotFailure(L"depth does not match the parent, record/depth", i, rec.depth); + return false; + } + + // The two score rules validateChild() enforced when this record was admitted. A corrupt + // score would otherwise set a wrong sibling floor and a wrong bar for its own children. + if (rec.score > _errorThreshold) + { + antSnapshotFailure(L"score above the epoch threshold, record/score", i, rec.score); + return false; + } + if (parentRec != nullptr && rec.score >= parentRec->score) + { + antSnapshotFailure(L"score does not beat the parent, record/score", i, rec.score); + return false; + } + + // Re-derive the hash from the stored network + _annPool[i].unpack(annBuffer.lut); + unsigned int annHash; + KangarooTwelve(&annBuffer, sizeof(annBuffer), &annHash, sizeof(annHash)); + if (annHash != rec.childAnnHash) + { + antSnapshotFailure(L"stored network does not match childAnnHash, record", i, 0); + return false; + } + + const AntDedupKey key{ rec.pubkey, rec.nonce, rec.parentRef }; + if (_dedup->contains(key)) + { + antSnapshotFailure(L"duplicate solution, record", i, 0); + return false; + } + if (_dedup->add(key) == QPI::NULL_INDEX) + { + antSnapshotFailure(L"dedup set full, record", i, 0); + return false; + } + + // Rebuild the _childHeadByMiner and _childHeadByParent + unsigned int prevHead = NO_SIBLING; + if (rec.parentRef.isRoot()) + { + _childHeadByMiner->get(rec.pubkey, prevHead); + _records[i].nextSiblingIdx = prevHead; + if (_childHeadByMiner->set(rec.pubkey, i) == QPI::NULL_INDEX) + { + antSnapshotFailure(L"miner index full, record", i, 0); + return false; + } + } + else + { + _childHeadByParent->get(rec.parentRef, prevHead); + _records[i].nextSiblingIdx = prevHead; + _childHeadByParent->set(rec.parentRef, i); + } + + // Restore the stats also + _stats.acceptedSolutions++; + if (rec.depth > _stats.treeDepthMax) + { + _stats.treeDepthMax = rec.depth; + } + } + + _stats.treeSizeCurrent = _solutionCount; + return true; +} diff --git a/src/mining/ant_colony/ant_colony_snapshot.h b/src/mining/ant_colony/ant_colony_snapshot.h deleted file mode 100644 index b9db63b4..00000000 --- a/src/mining/ant_colony/ant_colony_snapshot.h +++ /dev/null @@ -1,396 +0,0 @@ -#pragma once - -#include "mining/ant_colony/ant_colony_bpp9000.h" -#include "platform/file_io.h" - -// Only what cannot be derived is written. The tick index, both head maps and the dedup set are -// rebuilt from the records - -struct AntColonySnapshotMeta -{ - unsigned int magic; - unsigned int version; - unsigned int epoch; - unsigned int solutionCount; - // Layout guards. A snapshot written by a build with a different record or ANN size must be - // refused rather than reinterpreted - unsigned int recordSizeBytes; - unsigned int annPoolEntryBytes; - unsigned int errorThreshold; - // The base every selfRef.tickOffset in the records file is relative to. Records address each - // other by offset, so a snapshot restored against a different base is silently mis-addressed - unsigned int initialTick; - unsigned int anchorRingBytes; - unsigned int exportSetBytes; - m256i rootSeed; - - static constexpr unsigned int MAGIC = 0x414E5443; // "ANTC" - static constexpr unsigned int VERSION = 1; -}; -static_assert(sizeof(AntColonySnapshotMeta) == 40 + 32, "AntColonySnapshotMeta unexpected padding"); - -static void antSnapshotFailure(const CHAR16* what, unsigned long long a, unsigned long long b) -{ - CHAR16 message[256]; - setText(message, L"[ant-colony] snapshot: "); - appendText(message, what); - appendText(message, L" "); - appendNumber(message, a, FALSE); - appendText(message, L" / "); - appendNumber(message, b, FALSE); - logToConsole(message); -} - -// Records and pool are sized by this, never by the raw count. At least one slot is always written, -// so no snapshot file is ever zero length -static unsigned long long antSnapshotSlotCount(unsigned int solutionCount) -{ - return (solutionCount > 0) ? (unsigned long long)solutionCount : 1ULL; -} - -static void antSnapshotNameForEpoch(unsigned short epoch) -{ - addEpochToFileName(ANT_SNAPSHOT_HEADER_FILENAME, sizeof(ANT_SNAPSHOT_HEADER_FILENAME) / sizeof(ANT_SNAPSHOT_HEADER_FILENAME[0]), epoch); - addEpochToFileName(ANT_SNAPSHOT_RECORDS_FILENAME, sizeof(ANT_SNAPSHOT_RECORDS_FILENAME) / sizeof(ANT_SNAPSHOT_RECORDS_FILENAME[0]), epoch); - addEpochToFileName(ANT_SNAPSHOT_POOL_FILENAME, sizeof(ANT_SNAPSHOT_POOL_FILENAME) / sizeof(ANT_SNAPSHOT_POOL_FILENAME[0]), epoch); -} - -template -inline bool AntColony::saveSnapshot(unsigned short epoch, CHAR16* directory, - unsigned int initialTick) const -{ - ASSERT(_records != nullptr); - ASSERT(_annPool != nullptr); - ASSERT(_anchors != nullptr); - - antSnapshotNameForEpoch(epoch); - - AntColonySnapshotMeta meta; - setMem(&meta, sizeof(meta), 0); - meta.magic = AntColonySnapshotMeta::MAGIC; - meta.version = AntColonySnapshotMeta::VERSION; - meta.epoch = epoch; - meta.solutionCount = _solutionCount; - meta.recordSizeBytes = (unsigned int)sizeof(AntSolutionRecord); - meta.annPoolEntryBytes = (unsigned int)sizeof(PackedAnn); - meta.errorThreshold = _errorThreshold; - meta.initialTick = initialTick; - meta.anchorRingBytes = (unsigned int)sizeof(AnchorRing); - meta.exportSetBytes = (unsigned int)sizeof(ExportSet); - meta.rootSeed = _rootSeed; - - // The meta, anchor ring and export set share one file. The file API writes a single contiguous - // buffer, so the three are gathered into the serial scratch: meta, then anchors, then export. - const unsigned long long headerBytes = sizeof(meta) + sizeof(AnchorRing) + sizeof(ExportSet); - static_assert(sizeof(AntColonySnapshotMeta) + sizeof(AnchorRing) + sizeof(ExportSet) <= ANT_SNAPSHOT_SCRATCH_BYTES, - "ant snapshot header exceeds the scratch buffer"); - unsigned char* headerBuffer = _snapshotScratch; - copyMem(headerBuffer, &meta, sizeof(meta)); - copyMem(headerBuffer + sizeof(meta), _anchors, sizeof(AnchorRing)); - copyMem(headerBuffer + sizeof(meta) + sizeof(AnchorRing), _exportSet, sizeof(ExportSet)); - if (save(ANT_SNAPSHOT_HEADER_FILENAME, headerBytes, headerBuffer, directory) != (long long)headerBytes) - { - logToConsole(L"[ant-colony] failed to save snapshot header"); - return false; - } - - // Written even when the colony is empty, so the operator's snapshot is always the same number of files - const unsigned long long slots = antSnapshotSlotCount(_solutionCount); - const unsigned long long recordBytes = slots * sizeof(AntSolutionRecord); - const unsigned long long poolBytes = slots * sizeof(PackedAnn); - if (saveLargeFile(ANT_SNAPSHOT_RECORDS_FILENAME, recordBytes, (unsigned char*)_records, directory, false) - != (long long)recordBytes) - { - logToConsole(L"[ant-colony] failed to save snapshot records"); - return false; - } - if (saveLargeFile(ANT_SNAPSHOT_POOL_FILENAME, poolBytes, (unsigned char*)_annPool, directory, false) - != (long long)poolBytes) - { - logToConsole(L"[ant-colony] failed to save snapshot pool"); - return false; - } - return true; -} - -template -inline bool AntColony::loadSnapshot(unsigned short epoch, CHAR16* directory, - const m256i& rootSeed, unsigned int errorThreshold, unsigned int initialTick) -{ - ASSERT(_records != nullptr); - ASSERT(_annPool != nullptr); - ASSERT(_anchors != nullptr); - - antSnapshotNameForEpoch(epoch); - - // The meta, anchor ring and export set share one file. Read it whole, validate the meta before - // any colony state is touched, then copy the two sections into place. - const unsigned long long headerBytes = sizeof(AntColonySnapshotMeta) + sizeof(AnchorRing) + sizeof(ExportSet); - static_assert(sizeof(AntColonySnapshotMeta) + sizeof(AnchorRing) + sizeof(ExportSet) <= ANT_SNAPSHOT_SCRATCH_BYTES, - "ant snapshot header exceeds the scratch buffer"); - unsigned char* headerBuffer = _snapshotScratch; - if (load(ANT_SNAPSHOT_HEADER_FILENAME, headerBytes, headerBuffer, directory) != (long long)headerBytes) - { - logToConsole(L"[ant-colony] failed to load snapshot header"); - return false; - } - - AntColonySnapshotMeta meta; - copyMem(&meta, headerBuffer, sizeof(meta)); - if (meta.magic != AntColonySnapshotMeta::MAGIC || meta.version != AntColonySnapshotMeta::VERSION) - { - antSnapshotFailure(L"bad magic/version", meta.magic, meta.version); - return false; - } - if (meta.epoch != epoch) - { - antSnapshotFailure(L"epoch mismatch, file/expected", meta.epoch, epoch); - return false; - } - // A differently sized record or ANN would parse cleanly and produce a tree that is silently wrong. - if (meta.recordSizeBytes != sizeof(AntSolutionRecord) || meta.annPoolEntryBytes != sizeof(PackedAnn)) - { - antSnapshotFailure(L"layout mismatch, record/ann", meta.recordSizeBytes, meta.annPoolEntryBytes); - return false; - } - // The two sections after the meta must be exactly the size this build lays them out at, or the - // copies below would read them at the wrong offset. - if (meta.anchorRingBytes != sizeof(AnchorRing) || meta.exportSetBytes != sizeof(ExportSet)) - { - antSnapshotFailure(L"layout mismatch, anchors/export", meta.anchorRingBytes, meta.exportSetBytes); - return false; - } - if (meta.solutionCount > ANT_MAX_NODES_PER_EPOCH) - { - antSnapshotFailure(L"solutionCount exceeds the cap, count/cap", meta.solutionCount, ANT_MAX_NODES_PER_EPOCH); - return false; - } - // Cross-check against the node state restored alongside this file. A mismatch means the two - // snapshots are not from the same moment - loading the tree anyway would derive every root from - // a seed the rest of the network is not using. - if (!(meta.rootSeed == rootSeed)) - { - antSnapshotFailure(L"root seed does not match the restored node state, record/0", 0, 0); - return false; - } - if (meta.errorThreshold != errorThreshold) - { - antSnapshotFailure(L"threshold does not match the node, file/node", meta.errorThreshold, errorThreshold); - return false; - } - // Every selfRef.tickOffset is relative to this. Restoring against a different base would not - // fail anywhere - it would resolve parent references to the wrong records - if (meta.initialTick != initialTick) - { - antSnapshotFailure(L"initial tick does not match the node, file/node", meta.initialTick, initialTick); - return false; - } - - // Everything above only read the meta, so a refusal there leaves the colony untouched. From here - // on the state is being overwritten - reset(); - - copyMem(_anchors, headerBuffer + sizeof(meta), sizeof(AnchorRing)); - copyMem(_exportSet, headerBuffer + sizeof(meta) + sizeof(AnchorRing), sizeof(ExportSet)); - - // Read unconditionally and at the same sizing the save used, so an incomplete copy is refused - // here rather than booting a node with an empty tree. - const unsigned long long slots = antSnapshotSlotCount(meta.solutionCount); - const unsigned long long recordBytes = slots * sizeof(AntSolutionRecord); - const unsigned long long poolBytes = slots * sizeof(PackedAnn); - if (loadLargeFile(ANT_SNAPSHOT_RECORDS_FILENAME, recordBytes, (unsigned char*)_records, directory) - != (long long)recordBytes) - { - logToConsole(L"[ant-colony] failed to load snapshot records"); - reset(); - return false; - } - if (loadLargeFile(ANT_SNAPSHOT_POOL_FILENAME, poolBytes, (unsigned char*)_annPool, directory) - != (long long)poolBytes) - { - logToConsole(L"[ant-colony] failed to load snapshot pool"); - reset(); - return false; - } - if (_exportSet->count > ANT_EXPORT_MAX_SOLUTIONS) - { - antSnapshotFailure(L"export count exceeds the cap, count/cap", _exportSet->count, ANT_EXPORT_MAX_SOLUTIONS); - reset(); - return false; - } - for (unsigned int i = 0; i < _exportSet->count; i++) - { - const unsigned int slot = _exportSet->order[i]; - if (slot >= _exportSet->count) - { - antSnapshotFailure(L"export order out of range, position/slot", i, slot); - reset(); - return false; - } - for (unsigned int j = 0; j < i; j++) - { - if (_exportSet->order[j] == slot) - { - antSnapshotFailure(L"export order duplicate, position/slot", i, slot); - reset(); - return false; - } - } - } - - // The caller's values, which the checks above proved the file agrees with. - _rootSeed = rootSeed; - _errorThreshold = errorThreshold; - _solutionCount = meta.solutionCount; - - // Rebuild the intermediate data - if (!rebuildDerivedState(initialTick)) - { - reset(); - return false; - } - return true; -} - -template -inline bool AntColony::rebuildDerivedState(unsigned int initialTick) -{ - // Hoisted out of the loop: unpacking each stored network to verify its hash needs somewhere to - // put it, and this path runs once at boot. - Ann annBuffer; - - for (unsigned int i = 0; i < _solutionCount; i++) - { - const AntSolutionRecord& rec = _records[i]; - - if (rec.selfRef.tickOffset >= MAX_NUMBER_OF_TICKS_PER_EPOCH) - { - antSnapshotFailure(L"tickOffset out of range, record/tickOffset", i, rec.selfRef.tickOffset); - return false; - } - // commit() writes the record and its network at the same index, and findIndexBySolutionRef - // and annOfNonRoot both rely on it - // annStateSlot point to the ANN that must have similar index to the record - if (rec.annStateSlot != i) - { - antSnapshotFailure(L"annStateSlot is not the record index, record/slot", i, rec.annStateSlot); - return false; - } - - // The freshness rule validateChild() applied at admission, re-checked here. This is the only - // guard on anchorTick, which is consensus-relevant: siblingFloor() compares a stored record's - // anchorTick against a later child's, so a corrupt one changes which siblings compete and - // therefore which solutions the node accepts. publishTick was not stored because it does not - // need to be - commit() sets tickOffset to (publishTick - initialTick), so it inverts exactly - const unsigned int publishTick = initialTick + rec.selfRef.tickOffset; - if (rec.anchorTick > publishTick - || publishTick - rec.anchorTick > ANT_PUBLISH_WINDOW_TICKS) - { - antSnapshotFailure(L"anchor tick outside the freshness window, record/anchorTick", i, rec.anchorTick); - return false; - } - - // Rebuild the tick index - AntTickSlot& tslot = _tickIndex[rec.selfRef.tickOffset]; - if (tslot.count == 0) - { - tslot.startIdx = i; - } - else if (tslot.startIdx + tslot.count != i) - { - antSnapshotFailure(L"record breaks tick contiguity, record/tickOffset", i, rec.selfRef.tickOffset); - return false; - } - tslot.count++; - - // The parent must be ROOT or an EARLIER record. The tick index built so far covers only - // records before this one, so a forward or self reference fails to resolve - which is what - // rejects a cycle. - const AntSolutionRecord* parentRec = nullptr; - if (!rec.parentRef.isRoot()) - { - const long long parentIdx = findIndexBySolutionRef(rec.parentRef); - if (parentIdx == ANT_INVALID_INDEX || (unsigned long long)parentIdx >= i) - { - antSnapshotFailure(L"parent not an earlier record, record/parentTickOffset", i, rec.parentRef.tickOffset); - return false; - } - parentRec = &_records[parentIdx]; - if (!(parentRec->pubkey == rec.pubkey)) - { - antSnapshotFailure(L"parent belongs to another identity, record", i, 0); - return false; - } - } - const unsigned int expectedDepth = (parentRec != nullptr) ? (parentRec->depth + 1) : 1; - if (rec.depth != expectedDepth) - { - antSnapshotFailure(L"depth does not match the parent, record/depth", i, rec.depth); - return false; - } - - // The two score rules validateChild() enforced when this record was admitted. A corrupt - // score would otherwise set a wrong sibling floor and a wrong bar for its own children. - if (rec.score > _errorThreshold) - { - antSnapshotFailure(L"score above the epoch threshold, record/score", i, rec.score); - return false; - } - if (parentRec != nullptr && rec.score >= parentRec->score) - { - antSnapshotFailure(L"score does not beat the parent, record/score", i, rec.score); - return false; - } - - // Re-derive the hash from the stored network - _annPool[i].unpack(annBuffer.lut); - unsigned int annHash; - KangarooTwelve(&annBuffer, sizeof(annBuffer), &annHash, sizeof(annHash)); - if (annHash != rec.childAnnHash) - { - antSnapshotFailure(L"stored network does not match childAnnHash, record", i, 0); - return false; - } - - const AntDedupKey key{ rec.pubkey, rec.nonce, rec.parentRef }; - if (_dedup->contains(key)) - { - antSnapshotFailure(L"duplicate solution, record", i, 0); - return false; - } - if (_dedup->add(key) == QPI::NULL_INDEX) - { - antSnapshotFailure(L"dedup set full, record", i, 0); - return false; - } - - // Rebuild the _childHeadByMiner and _childHeadByParent - unsigned int prevHead = NO_SIBLING; - if (rec.parentRef.isRoot()) - { - _childHeadByMiner->get(rec.pubkey, prevHead); - _records[i].nextSiblingIdx = prevHead; - if (_childHeadByMiner->set(rec.pubkey, i) == QPI::NULL_INDEX) - { - antSnapshotFailure(L"miner index full, record", i, 0); - return false; - } - } - else - { - _childHeadByParent->get(rec.parentRef, prevHead); - _records[i].nextSiblingIdx = prevHead; - _childHeadByParent->set(rec.parentRef, i); - } - - // Restore the stats also - _stats.acceptedSolutions++; - if (rec.depth > _stats.treeDepthMax) - { - _stats.treeDepthMax = rec.depth; - } - } - - _stats.treeSizeCurrent = _solutionCount; - return true; -} diff --git a/src/qubic.cpp b/src/qubic.cpp index fa8e2ed0..c5b52dcd 100644 --- a/src/qubic.cpp +++ b/src/qubic.cpp @@ -80,7 +80,6 @@ #include "oracle_core/oracle_engine.h" #include "oracle_core/net_msg_impl.h" #include "oracle_core/snapshot_files.h" -#include "mining/ant_colony/ant_colony_snapshot.h" #include "mining/ant_colony/ant_pending_solutions.h" #include "oracle_core/oracle_interfaces_def.h" #include "qpi/impl/qpi_oracle_impl.h" diff --git a/test/ant_colony.cpp b/test/ant_colony.cpp index 707a50c7..46369e19 100644 --- a/test/ant_colony.cpp +++ b/test/ant_colony.cpp @@ -6,7 +6,6 @@ // The bound colony, not the bare template: these tests check bpp9000's binding as well as the rules. #include "../src/mining/ant_colony/ant_colony_bpp9000.h" -#include "../src/mining/ant_colony/ant_colony_snapshot.h" #include From 96cfa3752773c2e1edec15c16215f67269eb2261 Mon Sep 17 00:00:00 2001 From: cyber-pc <165458555+cyber-pc@users.noreply.github.com> Date: Mon, 10 Aug 2026 10:08:49 +0700 Subject: [PATCH 37/46] Gather the ant colony file-scope constants at the top of ant_colony.h --- src/mining/ant_colony/ant_colony.h | 71 +++++++++++++++--------------- 1 file changed, 36 insertions(+), 35 deletions(-) diff --git a/src/mining/ant_colony/ant_colony.h b/src/mining/ant_colony/ant_colony.h index 08f741b5..ed91cdcc 100644 --- a/src/mining/ant_colony/ant_colony.h +++ b/src/mining/ant_colony/ant_colony.h @@ -14,6 +14,42 @@ #include "mining/mining.h" #include "mining/trit_pack.h" +// The keyed structures are twice the population they index. +static constexpr unsigned long long ANT_DEDUP_SIZE = 2ULL * ANT_MAX_NODES_PER_EPOCH; +// At most one key per record, and records are capped, so load stays at or below 50% and set() cannot fail. +static constexpr unsigned long long ANT_CHILD_HEAD_BY_PARENT_SIZE = 2ULL * ANT_MAX_NODES_PER_EPOCH; +static_assert(ANT_CHILD_HEAD_BY_PARENT_SIZE >= 2ULL * ANT_MAX_NODES_PER_EPOCH, + "child-head-by-parent map must stay at or below 50% load so its set() cannot fail"); +// One entry per identity holding a tree. Unlike the map above, nothing caps how many identities +// submit, so this one CAN fill - commit() fails closed with RejectMinerIndexFull. +static constexpr unsigned long long ANT_CHILD_HEAD_BY_MINER_SIZE = 2ULL * MAX_NUMBER_OF_MINERS; + +// How many ANN the epoch's harvest keeps. The target is the LUT with the best error, so this is +// simply the lowest N scores of the epoch - not one per identity, and not tied to the ranking +static constexpr unsigned int ANT_EXPORT_MAX_SOLUTIONS = NUMBER_OF_COMPUTORS; + +// Serial scratch for the save/load header (meta + anchor ring + export set) and the solution export. +// In case of this grow to large, consider use the one in common buffer +static constexpr unsigned long long ANT_SNAPSHOT_SCRATCH_BYTES = 2ULL * 1024 * 1024; // 2MB + +static constexpr unsigned int NO_SIBLING = 0xFFFFFFFFU; +static constexpr unsigned int WORST_SCORE = 0xFFFFFFFFU; +static constexpr long long ANT_INVALID_INDEX = -1; + +// Anchor digests for recent ticks, indexed by tick & (size - 1). Smallest power of two holding +// 2*(N+1) entries so a lookup inside the freshness window can never be aliased by a newer tick. +static constexpr unsigned int antAnchorRingSize(unsigned int window) +{ + unsigned int size = 1; + while (size < 2u * (window + 1u)) + { + size <<= 1; + } + return size; +} +static constexpr unsigned int ANT_ANCHOR_RING_SIZE = antAnchorRingSize(ANT_PUBLISH_WINDOW_TICKS); +static constexpr unsigned int ANT_ANCHOR_TICK_NONE = 0xFFFFFFFFU; + // (tickOffset, solutionIndexInTick), epoch-relative tick plus the solution transaction's index in tick struct SolutionRef { @@ -60,9 +96,6 @@ struct AntSolutionRecord unsigned int annStateSlot; // index into the ANN pool; always equals the record index unsigned int nextSiblingIdx; // next child of the same parent, NO_SIBLING terminates }; -static constexpr unsigned int NO_SIBLING = 0xFFFFFFFFu; -static constexpr unsigned int WORST_SCORE = 0xFFFFFFFFu; -static constexpr long long ANT_INVALID_INDEX = -1; static_assert(sizeof(AntSolutionRecord) == 104, "AntSolutionRecord unexpected padding"); // ANN that will be saved for the epoch @@ -209,38 +242,6 @@ struct AntCommitInput unsigned int publishTick; // ABSOLUTE }; -// The keyed structures are twice the population they index. -static constexpr unsigned long long ANT_DEDUP_SIZE = 2ULL * ANT_MAX_NODES_PER_EPOCH; -// At most one key per record, and records are capped, so load stays at or below 50% and set() cannot fail. -static constexpr unsigned long long ANT_CHILD_HEAD_BY_PARENT_SIZE = 2ULL * ANT_MAX_NODES_PER_EPOCH; -static_assert(ANT_CHILD_HEAD_BY_PARENT_SIZE >= 2ULL * ANT_MAX_NODES_PER_EPOCH, - "child-head-by-parent map must stay at or below 50% load so its set() cannot fail"); -// One entry per identity holding a tree. Unlike the map above, nothing caps how many identities -// submit, so this one CAN fill - commit() fails closed with RejectMinerIndexFull. -static constexpr unsigned long long ANT_CHILD_HEAD_BY_MINER_SIZE = 2ULL * MAX_NUMBER_OF_MINERS; - -// How many ANN the epoch's harvest keeps. The target is the LUT with the best error, so this is -// simply the lowest N scores of the epoch - not one per identity, and not tied to the ranking -static constexpr unsigned int ANT_EXPORT_MAX_SOLUTIONS = NUMBER_OF_COMPUTORS; - -// Serial scratch for the save/load header (meta + anchor ring + export set) and the solution export. -// In case of this grow to large, consider use the one in common buffer -static constexpr unsigned long long ANT_SNAPSHOT_SCRATCH_BYTES = 2ULL * 1024 * 1024; // 2MB - -// Anchor digests for recent ticks, indexed by tick & (size - 1). Smallest power of two holding -// 2*(N+1) entries so a lookup inside the freshness window can never be aliased by a newer tick. -static constexpr unsigned int antAnchorRingSize(unsigned int window) -{ - unsigned int size = 1; - while (size < 2u * (window + 1u)) - { - size <<= 1; - } - return size; -} -static constexpr unsigned int ANT_ANCHOR_RING_SIZE = antAnchorRingSize(ANT_PUBLISH_WINDOW_TICKS); -static constexpr unsigned int ANT_ANCHOR_TICK_NONE = 0xFFFFFFFFU; - struct AnchorRing { unsigned int ticks[ANT_ANCHOR_RING_SIZE]; From 6e31a45ea68d167bd7939a50b98af680305af039 Mon Sep 17 00:00:00 2001 From: cyber-pc <165458555+cyber-pc@users.noreply.github.com> Date: Mon, 10 Aug 2026 11:14:02 +0700 Subject: [PATCH 38/46] Replace the ant sibling floor with a per-parent child cap. --- src/mining/ant_colony/ant_colony.h | 126 +++++++++------------- src/mining/mining.h | 2 +- src/network_messages/ant_colony_message.h | 8 +- src/public_settings.h | 6 +- src/qubic.cpp | 20 ++-- test/ant_colony.cpp | 87 ++++++++------- 6 files changed, 114 insertions(+), 135 deletions(-) diff --git a/src/mining/ant_colony/ant_colony.h b/src/mining/ant_colony/ant_colony.h index ed91cdcc..8cddc36a 100644 --- a/src/mining/ant_colony/ant_colony.h +++ b/src/mining/ant_colony/ant_colony.h @@ -90,7 +90,7 @@ struct AntSolutionRecord SolutionRef parentRef; // this solution's parent, or ROOT_REF SolutionRef selfRef; // this solution's own address (RELATIVE tick inside) unsigned int score; // error count, lower is better - unsigned int anchorTick; // ABSOLUTE. tick whose digest seeded the RNG; clock for the sibling floor + unsigned int anchorTick; // ABSOLUTE. tick whose digest seeded the RNG unsigned int depth; // a child of the root is depth 1; the root itself is never stored unsigned int childAnnHash; // K12 of the canonical ANN at commit; digest-fold input unsigned int annStateSlot; // index into the ANN pool; always equals the record index @@ -129,7 +129,7 @@ enum ValidityResult RejectWrongTree, // parent belongs to a different identity RejectBelowThreshold, // score above the per-epoch error bound RejectLeParent, // did not strictly beat the parent - RejectBelowSiblingFloor, // did not strictly beat the best sibling anchored more than N earlier + RejectMaxChildrenPerParent, // the parent already holds ANT_MAX_CHILDREN_PER_PARENT children RejectTickOutOfRange, RejectReplay, // (pubkey, parentRef, nonce) already committed this epoch RejectDedupFull, @@ -144,7 +144,7 @@ struct AntColonyDiagnostics unsigned long long rejectWrongTree; unsigned long long rejectThreshold; unsigned long long rejectLeParent; - unsigned long long rejectSiblingFloor; + unsigned long long rejectMaxChildren; unsigned long long rejectTickOutOfRange; unsigned long long rejectReplay; unsigned long long rejectDedupFull; @@ -185,8 +185,8 @@ struct AntColonyDiagnostics appendNumber(message, rejectThreshold, TRUE); appendText(message, L", leParent "); appendNumber(message, rejectLeParent, TRUE); - appendText(message, L", floor "); - appendNumber(message, rejectSiblingFloor, TRUE); + appendText(message, L", maxChildren "); + appendNumber(message, rejectMaxChildren, TRUE); appendText(message, L", tickRange "); appendNumber(message, rejectTickOutOfRange, TRUE); appendText(message, L", replay "); @@ -208,7 +208,7 @@ struct AntColonyDiagnostics case ValidityResult::RejectWrongTree: rejectWrongTree++; break; case ValidityResult::RejectBelowThreshold: rejectThreshold++; break; case ValidityResult::RejectLeParent: rejectLeParent++; break; - case ValidityResult::RejectBelowSiblingFloor: rejectSiblingFloor++; break; + case ValidityResult::RejectMaxChildrenPerParent: rejectMaxChildren++; break; case ValidityResult::RejectTickOutOfRange: rejectTickOutOfRange++; break; case ValidityResult::RejectReplay: rejectReplay++; break; case ValidityResult::RejectDedupFull: rejectDedupFull++; break; @@ -389,9 +389,9 @@ class AntColony // extraction. MUST be called between endEpoch() and the reset that starts the next epoch bool exportBestSolutions(unsigned short epoch, CHAR16* directory); - // Get the sibling floor for querry purpose. Note that it return the best at current time - unsigned int siblingFloorForQuery(const SolutionRef& parentRef, const m256i& childPubkey, - unsigned int childAnchorTick) + // Children already recorded under this parent, capped at ANT_MAX_CHILDREN_PER_PARENT, for query + // purposes. Off-thread safe: the head map is read under the lock, the chain walk after it is not. + unsigned int childCountForQuery(const SolutionRef& parentRef, const m256i& childPubkey) { unsigned int head = NO_SIBLING; // Take the latest child first with lock to touch the head map @@ -399,12 +399,12 @@ class AntColony LockGuard guard(_headMapLock); if (!chainHead(parentRef, childPubkey, head)) { - return WORST_SCORE; + return 0; } } - // Escape the lock, then read from head, in which the record is imutable - return siblingFloorFromHead(head, childAnchorTick); + // Escape the lock, then count from head, in which the record is imutable + return childCountFromHead(head); } // Anchor digests. Both take an ABSOLUTE system tick, never an epoch-relative tickOffset. @@ -446,34 +446,25 @@ class AntColony ValidityResult tryGetParent(const SolutionRef& parentRef, const AntSolutionRecord** outParentRec) const; - // Admission rules for a proposed child: freshness, tree ownership, threshold, parent, sibling - // floor. Static and pure, so the rule set is testable without a colony. Lower score is better. + // Admission rules for a proposed child: freshness, tree ownership, threshold, parent, per-parent + // child cap. Static and pure, so the rule set is testable without a colony. Lower score is better. static ValidityResult validateChild(const ChildCandidate& child, - const AntSolutionRecord* parentRecord, unsigned int siblingFloorScore, unsigned int threshold); + const AntSolutionRecord* parentRecord, unsigned int childCount, unsigned int threshold); // Validates and, if accepted, appends the record and its network to the store. ValidityResult commit(const AntCommitInput& in, const AntSolutionRecord* parentRec, unsigned int score, const Ann& childAnn, unsigned int childAnnHash); private: - // Computes the bar a new child must beat, beyond just beating its parent: the best (lowest) - // score among the siblings that count as competition, or WORST_SCORE when there is none. - // - // PRIVATE ON PURPOSE - tick processor only. It is the only reader of the two head maps, and - // QPI::HashMap has no reader/writer protocol: set() makes a slot's key visible before its value, - // so a reader asking for the key being inserted can get a garbage index. Everything else public - // here is safe off-thread because records/annPool are append-only behind the _solutionCount - // barrier, but these maps are mutated in place. If a request path ever needs this value (see - // RespondAntMineableParents), serve it from a snapshot the tick processor computed, do not - // recompute it on the request thread. - unsigned int siblingFloor(const SolutionRef& parentRef, const m256i& childPubkey, - unsigned int childAnchorTick /* ABSOLUTE */) const; - - // The map read, and the ONLY part of a floor computation that touches a head map. Split out - // because the walk that follows it does not, which is what lets the query hold the lock for a - // constant time instead of a whole chain. - // Depth-1 nodes chain per identity, so one miner's never raise another's floor; deeper nodes - // chain per parent, which is single-identity by the wrong-tree check. + // Children already recorded under this parent, capped at ANT_MAX_CHILDREN_PER_PARENT. Root + // children are keyed by miner, deeper ones by parent. + unsigned int countChildren(const SolutionRef& parentRef, const m256i& childPubkey) const; + + // The map read, and the ONLY part of a child count that touches a head map. Split out because the + // walk that follows it does not, which is what lets the query hold the lock for a constant time + // instead of a whole chain. + // Depth-1 nodes chain per identity; deeper nodes chain per parent, which is single-identity by the + // wrong-tree check. bool chainHead(const SolutionRef& parentRef, const m256i& childPubkey, unsigned int& out) const { if (parentRef.isRoot()) @@ -486,7 +477,7 @@ class AntColony // The walk half, from a chain head already in hand. Takes no lock: _records is append-only and a // record's nextSiblingIdx is written once at commit and never touched again, so a chain is stable // to follow even while the tick processor is appending elsewhere. - unsigned int siblingFloorFromHead(unsigned int head, unsigned int childAnchorTick) const; + unsigned int childCountFromHead(unsigned int head) const; // Offers a solution to the epoch's best-N set. Called for EVERY solution that passes the rules void noteExportCandidate(const m256i& pubkey, unsigned int score, unsigned int depth, const Ann& ann) @@ -553,7 +544,7 @@ class AntColony // slot's key visible before its value, so a reader asking for the key being inserted can get a // garbage index volatile char _headMapLock; - // Both give siblingFloor() a parent's children without scanning the store: the value is the + // Both give countChildren() a parent's children without scanning the store: the value is the // newest child's record index, and nextSiblingIdx chains back to the older ones. // parent's address -> newest child QPI::HashMap* _childHeadByParent; @@ -995,37 +986,26 @@ inline long long AntColony::findIndexBySolutionRef(const SolutionRef& re } template -inline unsigned int AntColony::siblingFloor(const SolutionRef& parentRef, const m256i& childPubkey, - unsigned int childAnchorTick) const +inline unsigned int AntColony::countChildren(const SolutionRef& parentRef, const m256i& childPubkey) const { // Tick processor only, so the head map read needs no lock here - nothing else writes it. unsigned int head = NO_SIBLING; if (!chainHead(parentRef, childPubkey, head)) { - return WORST_SCORE; + return 0; } - return siblingFloorFromHead(head, childAnchorTick); + return childCountFromHead(head); } template -inline unsigned int AntColony::siblingFloorFromHead(unsigned int head, - unsigned int childAnchorTick) const +inline unsigned int AntColony::childCountFromHead(unsigned int head) const { - // A competing sibling anchors more than N ticks earlier, so guard the subtraction. This only - // fires during the network's first N ticks, when there are no siblings to compete with anyway. - if (childAnchorTick <= ANT_SIBLING_NOCOMPETE_TICKS) - { - return WORST_SCORE; - } - const unsigned int boundary = childAnchorTick - ANT_SIBLING_NOCOMPETE_TICKS; - - // The bar is the BEST score among competing siblings, because lower is better. The chain is - // finite and terminates on its own, and there is deliberately no hop limit: entries are - // newest-first and only the older ones compete, so cutting from the head would remove exactly - // the siblings that set the floor - wrong, not merely approximate. - unsigned int floor = WORST_SCORE; + // Count only up to the cap: past it the child is rejected regardless, so the walk and with it + // the whole per-commit cost, is bounded by ANT_MAX_CHILDREN_PER_PARENT. A cap of 0 (unbound) + // leaves the loop empty and returns 0, since the count is then never used. + unsigned int count = 0; unsigned int idx = head; - while (idx != NO_SIBLING) + while (idx != NO_SIBLING && count < ANT_MAX_CHILDREN_PER_PARENT) { // NOT a redundant bounds check - it is the publication barrier this walk relies on. commit() // points the head map at a new index BEFORE it writes that record, and only bumps @@ -1036,14 +1016,10 @@ inline unsigned int AntColony::siblingFloorFromHead(unsigned int head, { break; } - const AntSolutionRecord& s = _records[idx]; - if (s.anchorTick < boundary && s.score < floor) - { - floor = s.score; - } - idx = s.nextSiblingIdx; + count++; + idx = _records[idx].nextSiblingIdx; } - return floor; + return count; } template @@ -1072,7 +1048,7 @@ inline ValidityResult AntColony::tryGetParent(const SolutionRef& parentR template inline ValidityResult AntColony::validateChild(const ChildCandidate& child, - const AntSolutionRecord* parentRecord, unsigned int siblingFloorScore, unsigned int threshold) + const AntSolutionRecord* parentRecord, unsigned int childCount, unsigned int threshold) { // Freshness, the anchor cannot be in the future, and publication cannot lag it by more than N. if (child.anchorTick > child.publishTick @@ -1101,9 +1077,10 @@ inline ValidityResult AntColony::validateChild(const ChildCandidate& chi { return ValidityResult::RejectLeParent; } - if (child.score >= siblingFloorScore) + // Per-parent breadth cap. 0 means unbound - no cap. + if (ANT_MAX_CHILDREN_PER_PARENT != 0 && childCount >= ANT_MAX_CHILDREN_PER_PARENT) { - return ValidityResult::RejectBelowSiblingFloor; + return ValidityResult::RejectMaxChildrenPerParent; } return ValidityResult::Valid; } @@ -1112,10 +1089,10 @@ template inline ValidityResult AntColony::commit(const AntCommitInput& in, const AntSolutionRecord* parentRec, unsigned int score, const Ann& childAnn, unsigned int childAnnHash) { - const unsigned int floor = siblingFloor(in.parentRef, in.pubkey, in.anchorTick); + const unsigned int childCount = countChildren(in.parentRef, in.pubkey); const ChildCandidate child{ in.pubkey, score, in.anchorTick, in.publishTick }; - const ValidityResult result = validateChild(child, parentRec, floor, _errorThreshold); + const ValidityResult result = validateChild(child, parentRec, childCount, _errorThreshold); if (result != ValidityResult::Valid) { recordReject(result); @@ -1172,8 +1149,8 @@ inline ValidityResult AntColony::commit(const AntCommitInput& in, const if (!headClaimed) { // Fail closed. Degrading instead, accepting the node but leaving the identity without a - // chain head, would silently drop its sibling floor. Released first: this touches _dedup, - // which must not be reached under the head-map lock. + // chain head, would leave its children uncounted and silently disable its cap. Released + // first: this touches _dedup, which must not be reached under the head-map lock. _dedup->remove(dedupKey); recordReject(ValidityResult::RejectMinerIndexFull); return ValidityResult::RejectMinerIndexFull; @@ -1495,11 +1472,10 @@ inline bool AntColony::rebuildDerivedState(unsigned int initialTick) return false; } - // The freshness rule validateChild() applied at admission, re-checked here. This is the only - // guard on anchorTick, which is consensus-relevant: siblingFloor() compares a stored record's - // anchorTick against a later child's, so a corrupt one changes which siblings compete and - // therefore which solutions the node accepts. publishTick was not stored because it does not - // need to be - commit() sets tickOffset to (publishTick - initialTick), so it inverts exactly + // The freshness rule validateChild() applied at admission, re-checked here so a tampered + // snapshot cannot smuggle in a record that was never admissible. anchorTick seeds the score's + // RNG, so it is consensus-relevant. publishTick was not stored because it does not need to be - + // commit() sets tickOffset to (publishTick - initialTick), so it inverts exactly const unsigned int publishTick = initialTick + rec.selfRef.tickOffset; if (rec.anchorTick > publishTick || publishTick - rec.anchorTick > ANT_PUBLISH_WINDOW_TICKS) @@ -1548,7 +1524,7 @@ inline bool AntColony::rebuildDerivedState(unsigned int initialTick) } // The two score rules validateChild() enforced when this record was admitted. A corrupt - // score would otherwise set a wrong sibling floor and a wrong bar for its own children. + // score would otherwise set a wrong bar for its own children. if (rec.score > _errorThreshold) { antSnapshotFailure(L"score above the epoch threshold, record/score", i, rec.score); diff --git a/src/mining/mining.h b/src/mining/mining.h index dd7799f0..ef2492d6 100644 --- a/src/mining/mining.h +++ b/src/mining/mining.h @@ -388,7 +388,7 @@ struct AntColonyMiningSolutionTransaction : public Transaction unsigned int parentTickOffset; // epoch-relative tick of the parent ref unsigned int parentSolutionIndexInTick; // dense within-tick index of the parent ref - unsigned int anchorTick; // tick whose digest the solution anchored to (sibling-floor clock + freshness) + unsigned int anchorTick; // tick whose digest the solution anchored to (RNG seed + freshness) // The score the submitter claims this solution reaches. The deposit is refunded only when it // matches the score the node computes unsigned int claimedScore; diff --git a/src/network_messages/ant_colony_message.h b/src/network_messages/ant_colony_message.h index 13c742e9..9a9210d5 100644 --- a/src/network_messages/ant_colony_message.h +++ b/src/network_messages/ant_colony_message.h @@ -47,9 +47,9 @@ constexpr unsigned int ANT_MINEABLE_PARENTS_SCAN_BUDGET = 1024; // ref a child sets as its own parentRef to extend this node; parentTickOffset/parentSolutionIndexInTick // is this node's OWN parent - (0, 0xFFFFFFFF) means the root - so paging every node of a pubkey // reconstructs the whole tree, edges included, without fetching any network bytes. -// The score is an error count, so smaller is better: a child must score strictly below score and -// strictly below siblingFloor (the best sibling more than N ticks earlier, computed for a child -// anchoring at the current tick). +// The score is an error count, so smaller is better: a child must score strictly below score. +// childCount is how many children this node already holds, capped at ANT_MAX_CHILDREN_PER_PARENT; at +// the cap it takes no more children (0 for the cap means unbound). struct AntMineableParent { unsigned int selfTickOffset; @@ -57,7 +57,7 @@ struct AntMineableParent unsigned int parentTickOffset; unsigned int parentSolutionIndexInTick; unsigned int score; - unsigned int siblingFloor; + unsigned int childCount; unsigned int anchorTick; // this node's own anchor tick number unsigned int depth; }; diff --git a/src/public_settings.h b/src/public_settings.h index c4c4b240..b35103ff 100644 --- a/src/public_settings.h +++ b/src/public_settings.h @@ -136,8 +136,10 @@ static constexpr unsigned int BPP9000_SOLUTION_THRESHOLD_DEFAULT = 3838; // Ant colony: a solution must be published within this many ticks of the anchor its walk seeded from. static constexpr unsigned int ANT_PUBLISH_WINDOW_TICKS = 16000; -// Sibling no-compete band: a child only has to beat siblings anchored more than N ticks earlier. -static constexpr unsigned int ANT_SIBLING_NOCOMPETE_TICKS = 676; +// Per-parent child cap: a parent accepts at most this many children - a miner's parallel branches +// off one node. 0 means unbound (no cap). A child's score must still strictly beat its parent's. +// A child over the cap is rejected without a refund, so miners should stop submitting to a full parent. +static constexpr unsigned int ANT_MAX_CHILDREN_PER_PARENT = 32; // Ant colony: tree nodes recorded per epoch; one per accepted solution. static constexpr unsigned int ANT_MAX_NODES_PER_EPOCH = 1u << 23; diff --git a/src/qubic.cpp b/src/qubic.cpp index c5b52dcd..f51922ef 100644 --- a/src/qubic.cpp +++ b/src/qubic.cpp @@ -578,12 +578,11 @@ static void queueAntSolution(unsigned long long processorNumber, const m256i& co return; } - // The floor moves as siblings age into competition, so passing now is not a promise it will pass - // at publication - the publisher re-checks. Failing now is final enough to refuse the slot. - const unsigned int floor = gAntColony.siblingFloorForQuery(parentRef, computorPublicKey, - payload.anchorTick); + // The child count only grows, so passing now is not a promise it will pass at publication - the + // publisher re-checks. Failing now is final enough to refuse the slot. + const unsigned int childCount = gAntColony.childCountForQuery(parentRef, computorPublicKey); const ChildCandidate candidate{ computorPublicKey, childScore, payload.anchorTick, system.tick }; - if (AntColonyBpp9000T::validateChild(candidate, parentRec, floor, + if (AntColonyBpp9000T::validateChild(candidate, parentRec, childCount, gAntColony.errorThreshold()) != ValidityResult::Valid) { antDebugPoolDrop(L"unacceptable", payload); @@ -1847,10 +1846,7 @@ static void processRequestAntMineableParents(unsigned long long processorNumber, setMem(&response, sizeof(response), 0); response.header.itemSize = (unsigned int)sizeof(AntMineableParent); - // Sampled once: it only grows, and a child mined against this frontier anchors at or after the - // tick this answer describes. const unsigned int total = gAntColony.solutionCount(); - const unsigned int anchorTick = system.tick; unsigned int idx = request->fromIndex; unsigned int scanned = 0; @@ -1876,7 +1872,7 @@ static void processRequestAntMineableParents(unsigned long long processorNumber, item.parentTickOffset = rec->parentRef.tickOffset; item.parentSolutionIndexInTick = rec->parentRef.solutionIndexInTick; item.score = rec->score; - item.siblingFloor = gAntColony.siblingFloorForQuery(rec->selfRef, rec->pubkey, anchorTick); + item.childCount = gAntColony.childCountForQuery(rec->selfRef, rec->pubkey); item.anchorTick = rec->anchorTick; item.depth = rec->depth; response.header.count++; @@ -3971,10 +3967,10 @@ static void publishAntSolutionFor(unsigned long long processorNumber, unsigned i // Judged at the tick the transaction will EXECUTE in, not the current one, so this gate sees // what commit will see - a solution at the window boundary now would commit stale. const unsigned int publishTick = system.tick + MIN_MINING_SOLUTIONS_PUBLICATION_OFFSET; - const unsigned int floor = gAntColony.siblingFloorForQuery(entry.parentRef, - entry.computorPublicKey, entry.anchorTick); + const unsigned int childCount = gAntColony.childCountForQuery(entry.parentRef, + entry.computorPublicKey); const ChildCandidate candidate{ entry.computorPublicKey, entry.score, entry.anchorTick, publishTick }; - if (AntColonyBpp9000T::validateChild(candidate, parentRec, floor, + if (AntColonyBpp9000T::validateChild(candidate, parentRec, childCount, gAntColony.errorThreshold()) != ValidityResult::Valid) { gAntPendingSolutions.markObsoleteGateRejected(idx); diff --git a/test/ant_colony.cpp b/test/ant_colony.cpp index 46369e19..0b978b75 100644 --- a/test/ant_colony.cpp +++ b/test/ant_colony.cpp @@ -46,9 +46,9 @@ static ChildCandidate makeChild(const m256i& owner, unsigned int score, // Every test runs at TEST_THRESHOLD, so wrapping it keeps the assertions on one line. static ValidityResult admit(const ChildCandidate& child, const AntSolutionRecord* parent, - unsigned int siblingFloorScore) + unsigned int childCount) { - return AntColonyBpp9000T::validateChild(child, parent, siblingFloorScore, TEST_THRESHOLD); + return AntColonyBpp9000T::validateChild(child, parent, childCount, TEST_THRESHOLD); } // The packing itself is generic and tested exhaustively @@ -80,11 +80,11 @@ TEST(TestAntColonyValidate, ThresholdIsAnUpperBoundOnError) // A parent that would otherwise admit anything, so only the threshold can reject. const AntSolutionRecord looseParent = makeParent(me, WORST_SCORE); - EXPECT_EQ(admit(makeChild(me, 3839), &looseParent, WORST_SCORE), + EXPECT_EQ(admit(makeChild(me, 3839), &looseParent, 0), ValidityResult::RejectBelowThreshold); // Exactly at the bound is accepted: the rule is score > threshold, not >=. - EXPECT_EQ(admit(makeChild(me, TEST_THRESHOLD), &looseParent, WORST_SCORE), ValidityResult::Valid); + EXPECT_EQ(admit(makeChild(me, TEST_THRESHOLD), &looseParent, 0), ValidityResult::Valid); } TEST(TestAntColonyValidate, MustStrictlyBeatParent) @@ -92,9 +92,9 @@ TEST(TestAntColonyValidate, MustStrictlyBeatParent) const m256i me = makeKey(2); const AntSolutionRecord parent = makeParent(me, 3800); - EXPECT_EQ(admit(makeChild(me, 3799), &parent, WORST_SCORE), ValidityResult::Valid); - EXPECT_EQ(admit(makeChild(me, 3800), &parent, WORST_SCORE), ValidityResult::RejectLeParent); - EXPECT_EQ(admit(makeChild(me, 3801), &parent, WORST_SCORE), ValidityResult::RejectLeParent); + EXPECT_EQ(admit(makeChild(me, 3799), &parent, 0), ValidityResult::Valid); + EXPECT_EQ(admit(makeChild(me, 3800), &parent, 0), ValidityResult::RejectLeParent); + EXPECT_EQ(admit(makeChild(me, 3801), &parent, 0), ValidityResult::RejectLeParent); } // A root has no score of its own, so any threshold-passing child improves on it. This is what lets a @@ -103,9 +103,9 @@ TEST(TestAntColonyValidate, RootParentAdmitsAnyPassingScore) { const m256i me = makeKey(3); - EXPECT_EQ(admit(makeChild(me, TEST_THRESHOLD), nullptr, WORST_SCORE), ValidityResult::Valid); - EXPECT_EQ(admit(makeChild(me, 0), nullptr, WORST_SCORE), ValidityResult::Valid); - EXPECT_EQ(admit(makeChild(me, TEST_THRESHOLD + 1), nullptr, WORST_SCORE), + EXPECT_EQ(admit(makeChild(me, TEST_THRESHOLD), nullptr, 0), ValidityResult::Valid); + EXPECT_EQ(admit(makeChild(me, 0), nullptr, 0), ValidityResult::Valid); + EXPECT_EQ(admit(makeChild(me, TEST_THRESHOLD + 1), nullptr, 0), ValidityResult::RejectBelowThreshold); } @@ -116,24 +116,29 @@ TEST(TestAntColonyValidate, CannotBranchFromAnotherIdentity) const m256i someoneElse = makeKey(5); const AntSolutionRecord theirNode = makeParent(someoneElse, 3800); - EXPECT_EQ(admit(makeChild(me, 3700), &theirNode, WORST_SCORE), ValidityResult::RejectWrongTree); + EXPECT_EQ(admit(makeChild(me, 3700), &theirNode, 0), ValidityResult::RejectWrongTree); const AntSolutionRecord myNode = makeParent(me, 3800); - EXPECT_EQ(admit(makeChild(me, 3700), &myNode, WORST_SCORE), ValidityResult::Valid); + EXPECT_EQ(admit(makeChild(me, 3700), &myNode, 0), ValidityResult::Valid); } -// Sibling floor -TEST(TestAntColonyValidate, MustStrictlyBeatSiblingFloor) +// Per-parent child cap. The cap is compile-time; 0 means unbound. +TEST(TestAntColonyValidate, RejectsAtTheChildCap) { const m256i me = makeKey(6); const AntSolutionRecord parent = makeParent(me, WORST_SCORE); - EXPECT_EQ(admit(makeChild(me, 3799), &parent, 3800), ValidityResult::Valid); - EXPECT_EQ(admit(makeChild(me, 3800), &parent, 3800), ValidityResult::RejectBelowSiblingFloor); - EXPECT_EQ(admit(makeChild(me, 3801), &parent, 3800), ValidityResult::RejectBelowSiblingFloor); + // Below the cap - and always, when unbound - a passing child is admitted. + EXPECT_EQ(admit(makeChild(me, 3799), &parent, 0), ValidityResult::Valid); - // No competing sibling yet: the floor is WORST_SCORE and anything passing gets through. - EXPECT_EQ(admit(makeChild(me, TEST_THRESHOLD), &parent, WORST_SCORE), ValidityResult::Valid); + // At the cap it is refused. Skipped when unbound (0). The runtime copy keeps the compile-time + // zero from tripping a constant-condition warning. + const unsigned int cap = ANT_MAX_CHILDREN_PER_PARENT; + if (cap != 0) + { + EXPECT_EQ(admit(makeChild(me, 3799), &parent, cap), + ValidityResult::RejectMaxChildrenPerParent); + } } // Freshness @@ -144,22 +149,22 @@ TEST(TestAntColonyValidate, FreshnessWindowBoundaries) const unsigned int anchor = 100000; // Published in the same tick it anchored to: the tightest legal case. - EXPECT_EQ(admit(makeChild(me, 3700, anchor, anchor), &parent, WORST_SCORE), + EXPECT_EQ(admit(makeChild(me, 3700, anchor, anchor), &parent, 0), ValidityResult::Valid); // Exactly at the window edge is still legal; one past it is not. EXPECT_EQ(admit(makeChild(me, 3700, anchor, anchor + ANT_PUBLISH_WINDOW_TICKS), - &parent, WORST_SCORE), ValidityResult::Valid); + &parent, 0), ValidityResult::Valid); EXPECT_EQ(admit(makeChild(me, 3700, anchor, anchor + ANT_PUBLISH_WINDOW_TICKS + 1), - &parent, WORST_SCORE), ValidityResult::RejectStale); + &parent, 0), ValidityResult::RejectStale); // An anchor in the future is rejected rather than wrapping the unsigned subtraction. - EXPECT_EQ(admit(makeChild(me, 3700, anchor + 1, anchor), &parent, WORST_SCORE), + EXPECT_EQ(admit(makeChild(me, 3700, anchor + 1, anchor), &parent, 0), ValidityResult::RejectStale); } // Order of checks: Freshness first, then tree isolation, then threshold, then -// parent, then sibling floor. +// parent, then the child cap. TEST(TestAntColonyValidate, ReportsTheFirstFailingRule) { const m256i me = makeKey(8); @@ -170,24 +175,24 @@ TEST(TestAntColonyValidate, ReportsTheFirstFailingRule) const AntSolutionRecord theirs = makeParent(other, 3000); // Stale AND wrong tree AND above threshold AND worse than parent -> reports Stale. - EXPECT_EQ(admit(makeChild(me, 9999, anchor, stalePublish), &theirs, 100), + EXPECT_EQ(admit(makeChild(me, 9999, anchor, stalePublish), &theirs, 0), ValidityResult::RejectStale); // Fresh, but wrong tree AND above threshold -> reports WrongTree. - EXPECT_EQ(admit(makeChild(me, 9999, anchor, anchor), &theirs, 100), + EXPECT_EQ(admit(makeChild(me, 9999, anchor, anchor), &theirs, 0), ValidityResult::RejectWrongTree); // Own tree, above threshold AND worse than parent -> reports the threshold. const AntSolutionRecord mine = makeParent(me, 3000); - EXPECT_EQ(admit(makeChild(me, 9999, anchor, anchor), &mine, 100), + EXPECT_EQ(admit(makeChild(me, 9999, anchor, anchor), &mine, 0), ValidityResult::RejectBelowThreshold); - // Passes the threshold, but worse than parent AND below the floor -> reports the parent. - EXPECT_EQ(admit(makeChild(me, 3500, anchor, anchor), &mine, 100), + // Passes the threshold but worse than parent -> reports the parent. + EXPECT_EQ(admit(makeChild(me, 3500, anchor, anchor), &mine, 0), ValidityResult::RejectLeParent); } -// For a fixed parent, floor and threshold, acceptance +// For a fixed parent and threshold, acceptance // must be monotone in the score - every score at or below the tightest bound is accepted, every // score above it is rejected. An inverted comparison anywhere breaks this even if the individual // boundary tests above were adjusted to match it. @@ -195,16 +200,15 @@ TEST(TestAntColonyValidate, AcceptanceIsMonotoneInScore) { const m256i me = makeKey(10); const unsigned int parentScore = 3800; - const unsigned int floor = 3750; const AntSolutionRecord parent = makeParent(me, parentScore); - // Tightest of: <= threshold, < parent, < floor. - const unsigned int bestRejected = (parentScore < floor) ? parentScore : floor; + // Tightest of: <= threshold, < parent. + const unsigned int bestRejected = parentScore; bool sawAccept = false; for (unsigned int score = 3700; score <= 3900; score++) { - const ValidityResult r = admit(makeChild(me, score), &parent, floor); + const ValidityResult r = admit(makeChild(me, score), &parent, 0); const bool accepted = (r == ValidityResult::Valid); if (score < bestRejected && score <= TEST_THRESHOLD) { @@ -306,8 +310,8 @@ static long long commitChild(AntColonyBpp9000T* colony, const m256i& owner, cons return landsAt; } -// commit() head-inserts, so children run newest to oldest. siblingFloor() relies on it: only the -// older ones compete, so they sit at the tail. +// commit() head-inserts, so children chain from newest to oldest. countChildren() walks this chain +// from the head, so it must stay intact and terminate. TEST(TestAntColonyStore, SiblingsChainNewestFirst) { AntColonyBpp9000T* colony = freshColony(); @@ -629,7 +633,7 @@ TEST(TestAntColonySnapshot, CorruptedPoolIsRefused) EXPECT_EQ(colony->solutionCount(), 0u); } -// anchorTick is the sibling-floor clock and has no other guard, so the load re-derives publishTick +// anchorTick seeds the score's RNG and has no other guard, so the load re-derives publishTick // from the record's own address and re-checks the freshness rule. A record anchored 100000 ticks // after the tick it claims to sit in cannot have passed that rule when it was admitted. TEST(TestAntColonySnapshot, RecordOutsideItsFreshnessWindowIsRefused) @@ -855,11 +859,12 @@ TEST(TestAntColonyExport, KeepsTheLowestScoresInOrder) AntColonyBpp9000T* colony = freshColony(); ASSERT_NE(colony, nullptr) << "colony init failed; needs ~6.6 GB"; - const m256i me = makeKey(1); constexpr unsigned int COMMITTED = ANT_EXPORT_MAX_SOLUTIONS + 24; + // One identity per solution so the per-parent child cap never binds - the export set is what is + // under test here, not the tree shape. for (unsigned int i = 0; i < COMMITTED; i++) { - ASSERT_NE(commitRootChild(colony, me, 3000 + i, i, 5000 + i), ANT_INVALID_INDEX) << "commit " << i; + ASSERT_NE(commitRootChild(colony, makeKey(1 + i), 3000 + i, i, 5000 + i), ANT_INVALID_INDEX) << "commit " << i; } ASSERT_TRUE(colony->exportBestSolutions(TEST_EPOCH, NULL)); @@ -889,11 +894,11 @@ TEST(TestAntColonyExport, OrdersFewerThanTheCap) AntColonyBpp9000T* colony = freshColony(); ASSERT_NE(colony, nullptr) << "colony init failed; needs ~6.6 GB"; - const m256i me = makeKey(2); constexpr unsigned int COUNT = 40; + // One identity per solution so the per-parent child cap never binds. for (unsigned int i = 0; i < COUNT; i++) { - ASSERT_NE(commitRootChild(colony, me, 3800 - i, i, 6000 + i), ANT_INVALID_INDEX); + ASSERT_NE(commitRootChild(colony, makeKey(2000 + i), 3800 - i, i, 6000 + i), ANT_INVALID_INDEX); } ASSERT_TRUE(colony->exportBestSolutions(TEST_EPOCH, NULL)); From 0c9319423ecdad5bc8de34b0c02dcb0dbeaa72c0 Mon Sep 17 00:00:00 2001 From: cyber-pc <165458555+cyber-pc@users.noreply.github.com> Date: Mon, 10 Aug 2026 11:30:19 +0700 Subject: [PATCH 39/46] Rename the request-respond for get mine nodes. --- src/network_messages/ant_colony_message.h | 52 ++++++++++----------- src/network_messages/network_message_type.h | 4 +- src/qubic.cpp | 28 +++++------ 3 files changed, 41 insertions(+), 43 deletions(-) diff --git a/src/network_messages/ant_colony_message.h b/src/network_messages/ant_colony_message.h index 9a9210d5..1e24b68f 100644 --- a/src/network_messages/ant_colony_message.h +++ b/src/network_messages/ant_colony_message.h @@ -10,7 +10,7 @@ // nonce would put a polling miner in contention with every other operator command. // // Paginated via fromIndex / nextIndex. -struct RequestAntMineableParents +struct RequestAntIdentityTree { // Whose tree to report. Usually the caller's own. m256i pubkey; @@ -19,10 +19,10 @@ struct RequestAntMineableParents unsigned int padding; static constexpr unsigned char type() { - return REQUEST_ANT_MINEABLE_PARENTS; + return REQUEST_ANT_IDENTITY_TREE; } }; -static_assert(sizeof(RequestAntMineableParents) == 40, "RequestAntMineableParents unexpected size"); +static_assert(sizeof(RequestAntIdentityTree) == 40, "RequestAntIdentityTree unexpected size"); // A pool miner hands its computor a solution over BroadcastMessage(MESSAGE_TYPE_ANT_SOLUTION); this // is the payload that follows the header. @@ -36,12 +36,12 @@ struct AntSolutionBroadcastPayload }; static_assert(sizeof(AntSolutionBroadcastPayload) == 48, "AntSolutionBroadcastPayload unexpected size"); -// Max mineable-parent entries returned per response. Miners page through the +// Max identity-tree nodes returned per response. Miners page through the // rest via the nextIndex cursor. -constexpr unsigned int ANT_MINEABLE_PARENTS_PER_RESPONSE = 64; +constexpr unsigned int ANT_IDENTITY_TREE_NODES_PER_RESPONSE = 64; // Max records scanned per request -constexpr unsigned int ANT_MINEABLE_PARENTS_SCAN_BUDGET = 1024; +constexpr unsigned int ANT_IDENTITY_TREE_SCAN_BUDGET = 1024; // One stored node of the requested identity's tree. selfTickOffset/selfSolutionIndexInTick is the // ref a child sets as its own parentRef to extend this node; parentTickOffset/parentSolutionIndexInTick @@ -50,7 +50,7 @@ constexpr unsigned int ANT_MINEABLE_PARENTS_SCAN_BUDGET = 1024; // The score is an error count, so smaller is better: a child must score strictly below score. // childCount is how many children this node already holds, capped at ANT_MAX_CHILDREN_PER_PARENT; at // the cap it takes no more children (0 for the cap means unbound). -struct AntMineableParent +struct AntIdentityTreeNode { unsigned int selfTickOffset; unsigned int selfSolutionIndexInTick; @@ -61,36 +61,36 @@ struct AntMineableParent unsigned int anchorTick; // this node's own anchor tick number unsigned int depth; }; -static_assert(sizeof(AntMineableParent) == 32, "AntMineableParent unexpected size"); +static_assert(sizeof(AntIdentityTreeNode) == 32, "AntIdentityTreeNode unexpected size"); -// Metadata header only; followed by count * AntMineableParent (count * itemSize +// Metadata header only; followed by count * AntIdentityTreeNode (count * itemSize // bytes). itemSize lets the receiver validate the payload without hardcoding the // entry size. -struct RespondAntMineableParentsHeader +struct RespondAntIdentityTreeHeader { - // Number of AntMineableParent entries that follow this header. + // Number of AntIdentityTreeNode entries that follow this header. unsigned int count; - // Size in bytes of one AntMineableParent entry. + // Size in bytes of one AntIdentityTreeNode entry. unsigned int itemSize; // Resume cursor for the next request; 0 means no more records. unsigned int nextIndex; static constexpr unsigned char type() { - return RESPOND_ANT_MINEABLE_PARENTS; + return RESPOND_ANT_IDENTITY_TREE; } }; -static_assert(sizeof(RespondAntMineableParentsHeader) == 12, "RespondAntMineableParentsHeader unexpected size"); +static_assert(sizeof(RespondAntIdentityTreeHeader) == 12, "RespondAntIdentityTreeHeader unexpected size"); -// The largest a mineable-parents response can be, the header followed by a full page of entries -struct AntMineableParentsResponse +// The largest an identity-tree response can be, the header followed by a full page of entries +struct AntIdentityTreeResponse { - RespondAntMineableParentsHeader header; - AntMineableParent items[ANT_MINEABLE_PARENTS_PER_RESPONSE]; + RespondAntIdentityTreeHeader header; + AntIdentityTreeNode items[ANT_IDENTITY_TREE_NODES_PER_RESPONSE]; }; -static_assert(sizeof(AntMineableParentsResponse) - == sizeof(RespondAntMineableParentsHeader) - + ANT_MINEABLE_PARENTS_PER_RESPONSE * sizeof(AntMineableParent), - "AntMineableParentsResponse must have no padding between the header and the items"); +static_assert(sizeof(AntIdentityTreeResponse) + == sizeof(RespondAntIdentityTreeHeader) + + ANT_IDENTITY_TREE_NODES_PER_RESPONSE * sizeof(AntIdentityTreeNode), + "AntIdentityTreeResponse must have no padding between the header and the items"); // RespondAntParentAnnHeader.status values. constexpr unsigned char ANT_PARENT_ANN_STATUS_OK = 0; // ANN bytes follow the header @@ -98,8 +98,8 @@ constexpr unsigned char ANT_PARENT_ANN_STATUS_NOT_FOUND = 1; // parentRef has no constexpr unsigned char ANT_PARENT_ANN_STATUS_IS_ROOT = 2; // ROOT_REF; no ANN payload - miner derives its own per-identity root // ONE tree node's stored network, named by parentRef - the ANN state a miner mutates to extend -// that node. The tree itself is listed by mineable-parents; this fetches the material for a single -// chosen parent. +// that node. The tree itself is listed by the identity-tree query; this fetches the material for a +// single chosen parent. // Operator-signed: the request payload is followed by SIGNATURE_SIZE bytes signed by // operatorPublicKey struct RequestAntParentAnn @@ -153,9 +153,7 @@ struct RespondAntEpochContext m256i spectrumDigest; // score threshold for this epoch unsigned int threshold; - // ANT_PUBLISH_WINDOW_TICKS: publish within this many ticks of the anchor. The sibling - // no-compete band is a separate constant and is not reported here - mineable-parents already - // returns the computed floor per parent, so a miner never needs to derive it. + // ANT_PUBLISH_WINDOW_TICKS: publish within this many ticks of the anchor. unsigned int freshnessWindow; // accepted solutions so far this epoch unsigned int solutionCount; diff --git a/src/network_messages/network_message_type.h b/src/network_messages/network_message_type.h index 94d16214..e297d37b 100644 --- a/src/network_messages/network_message_type.h +++ b/src/network_messages/network_message_type.h @@ -51,8 +51,8 @@ enum NetworkMessageType : unsigned char BROADCAST_CUSTOM_MINING_SOLUTION = 69, REQUEST_REVENUE_DATA = 70, RESPOND_REVENUE_DATA = 71, - REQUEST_ANT_MINEABLE_PARENTS = 72, - RESPOND_ANT_MINEABLE_PARENTS = 73, + REQUEST_ANT_IDENTITY_TREE = 72, + RESPOND_ANT_IDENTITY_TREE = 73, REQUEST_ANT_PARENT_ANN = 74, RESPOND_ANT_PARENT_ANN = 75, REQUEST_ANT_EPOCH_CONTEXT = 76, diff --git a/src/qubic.cpp b/src/qubic.cpp index f51922ef..29a36eb3 100644 --- a/src/qubic.cpp +++ b/src/qubic.cpp @@ -1782,7 +1782,7 @@ static void processRequestContractFunction(Peer* peer, const unsigned long long } // One response buffer per processor -static AntMineableParentsResponse gAntMineableParentsResponseBuffer[MAX_NUMBER_OF_PROCESSORS]; +static AntIdentityTreeResponse gAntIdentityTreeResponseBuffer[MAX_NUMBER_OF_PROCESSORS]; struct AntParentAnnResponse { @@ -1815,23 +1815,23 @@ static void processRequestAntEpochContext(Peer* peer, RequestResponseHeader* hea // caller can never use. // // Paged, because the store holds millions and a response is one datagram - the cursor is a record -// index, and ANT_MINEABLE_PARENTS_SCAN_BUDGET caps how far one request may scan, so a caller cannot +// index, and ANT_IDENTITY_TREE_SCAN_BUDGET caps how far one request may scan, so a caller cannot // walk the whole store in a single call. Sweeping a full store therefore costs many requests; the // alternative, walking the identity's tree through the head maps, needs a resumable cursor that does // not fit a record index, and the scan is cache-friendly where a chain walk is not. // // Operator-signed -static void processRequestAntMineableParents(unsigned long long processorNumber, Peer* peer, RequestResponseHeader* header) +static void processRequestAntIdentityTree(unsigned long long processorNumber, Peer* peer, RequestResponseHeader* header) { if (processorNumber >= MAX_NUMBER_OF_PROCESSORS) { return; } - if (header->size() != sizeof(RequestResponseHeader) + sizeof(RequestAntMineableParents) + SIGNATURE_SIZE) + if (header->size() != sizeof(RequestResponseHeader) + sizeof(RequestAntIdentityTree) + SIGNATURE_SIZE) { return; } - const RequestAntMineableParents* request = header->getPayload(); + const RequestAntIdentityTree* request = header->getPayload(); // Signature check unsigned char digest[32]; @@ -1842,17 +1842,17 @@ static void processRequestAntMineableParents(unsigned long long processorNumber, return; } - AntMineableParentsResponse& response = gAntMineableParentsResponseBuffer[processorNumber]; + AntIdentityTreeResponse& response = gAntIdentityTreeResponseBuffer[processorNumber]; setMem(&response, sizeof(response), 0); - response.header.itemSize = (unsigned int)sizeof(AntMineableParent); + response.header.itemSize = (unsigned int)sizeof(AntIdentityTreeNode); const unsigned int total = gAntColony.solutionCount(); unsigned int idx = request->fromIndex; unsigned int scanned = 0; while (idx < total - && response.header.count < ANT_MINEABLE_PARENTS_PER_RESPONSE - && scanned < ANT_MINEABLE_PARENTS_SCAN_BUDGET) + && response.header.count < ANT_IDENTITY_TREE_NODES_PER_RESPONSE + && scanned < ANT_IDENTITY_TREE_SCAN_BUDGET) { const AntSolutionRecord* rec = gAntColony.recordAt(idx); if (rec == nullptr) @@ -1866,7 +1866,7 @@ static void processRequestAntMineableParents(unsigned long long processorNumber, continue; } - AntMineableParent& item = response.items[response.header.count]; + AntIdentityTreeNode& item = response.items[response.header.count]; item.selfTickOffset = rec->selfRef.tickOffset; item.selfSolutionIndexInTick = rec->selfRef.solutionIndexInTick; item.parentTickOffset = rec->parentRef.tickOffset; @@ -1883,8 +1883,8 @@ static void processRequestAntMineableParents(unsigned long long processorNumber, response.header.nextIndex = (idx < total) ? idx : 0; enqueueResponse(peer, - (unsigned int)sizeof(response.header) + response.header.count * (unsigned int)sizeof(AntMineableParent), - RespondAntMineableParentsHeader::type(), header->dejavu(), &response); + (unsigned int)sizeof(response.header) + response.header.count * (unsigned int)sizeof(AntIdentityTreeNode), + RespondAntIdentityTreeHeader::type(), header->dejavu(), &response); } // One stored node's network, for the pool that is about to mine a child of it @@ -2722,9 +2722,9 @@ static void requestProcessor(void* ProcedureArgument) } break; - case RequestAntMineableParents::type(): + case RequestAntIdentityTree::type(): { - processRequestAntMineableParents(processorNumber, peer, header); + processRequestAntIdentityTree(processorNumber, peer, header); } break; From 1a3fff1df42cac2d8cb419c153955cd47586a55f Mon Sep 17 00:00:00 2001 From: cyber-pc <165458555+cyber-pc@users.noreply.github.com> Date: Tue, 11 Aug 2026 10:17:00 +0700 Subject: [PATCH 40/46] Support request the max number of children per ant node. --- src/network_messages/ant_colony_message.h | 7 +++++-- src/qubic.cpp | 1 + 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/network_messages/ant_colony_message.h b/src/network_messages/ant_colony_message.h index 1e24b68f..6f515977 100644 --- a/src/network_messages/ant_colony_message.h +++ b/src/network_messages/ant_colony_message.h @@ -142,7 +142,8 @@ struct RequestAntEpochContext }; // Per-epoch ant-colony parameters a miner needs to start building solutions: -// the score threshold, the freshness window, the epoch's root seed, and pool occupancy. +// the score threshold, the freshness window, the epoch's root seed, pool occupancy, +// and the per-parent child cap. // The anchor digest is not included; a miner derives it from the standard // protocol as K12(anchorTick || transactionDigest), with transactionDigest taken from the // anchor tick's quorum votes (REQUEST_QUORUM_TICK). @@ -159,6 +160,8 @@ struct RespondAntEpochContext unsigned int solutionCount; // free slots in the live ANN pool unsigned int freeAnnSlotsCount; + // ANT_MAX_CHILDREN_PER_PARENT: max children a parent takes; 0 = unbound + unsigned int maxChildrenPerParent; // epoch this context is for unsigned short epoch; unsigned short padding; @@ -169,4 +172,4 @@ struct RespondAntEpochContext } }; #pragma pack(pop) -static_assert(sizeof(RespondAntEpochContext) == 52, "RespondAntEpochContext unexpected size"); +static_assert(sizeof(RespondAntEpochContext) == 56, "RespondAntEpochContext unexpected size"); diff --git a/src/qubic.cpp b/src/qubic.cpp index 29a36eb3..8406656c 100644 --- a/src/qubic.cpp +++ b/src/qubic.cpp @@ -1805,6 +1805,7 @@ static void processRequestAntEpochContext(Peer* peer, RequestResponseHeader* hea respond.freshnessWindow = ANT_PUBLISH_WINDOW_TICKS; respond.solutionCount = gAntColony.solutionCount(); respond.freeAnnSlotsCount = gAntColony.freeAnnSlotsCount(); + respond.maxChildrenPerParent = ANT_MAX_CHILDREN_PER_PARENT; respond.epoch = system.epoch; enqueueResponse(peer, sizeof(respond), RespondAntEpochContext::type(), header->dejavu(), &respond); From 65087fd20aad5abb9f01645382055556a06363f5 Mon Sep 17 00:00:00 2001 From: cyber-pc <165458555+cyber-pc@users.noreply.github.com> Date: Wed, 12 Aug 2026 15:38:36 +0700 Subject: [PATCH 41/46] Update documentation for ant colony. --- doc/ant_colony_mining.md | 361 +++++++++++++++++++++++++++++++++++++++ doc/protocol.md | 1 + 2 files changed, 362 insertions(+) create mode 100644 doc/ant_colony_mining.md diff --git a/doc/ant_colony_mining.md b/doc/ant_colony_mining.md new file mode 100644 index 00000000..303599c4 --- /dev/null +++ b/doc/ant_colony_mining.md @@ -0,0 +1,361 @@ +# Ant Colony Mining + +This document has two parts: + +- **Part 1 - Overview**: what ant-colony mining is. +- **Part 2 - Miner / pool integration guide**: the exact on-the-wire contract. + +> Config values (threshold, freshness window, child cap) are per-epoch and can change between epochs. +> Always read the live values from the epoch-context query (Part 2, section 2.7a). Numbers quoted in +> this document are current defaults, not constants to hard-code. + +> **Reading this doc.** Part 1 explains the split between the ant-colony *structure* and the mining +> *algorithm*. The parts specific to today's algorithm - the score's range and direction, the nonce +> knobs and their ranges, the mutation walk, and the numeric constants - are labelled **bpp9000** below; +> do not treat them as fixed ant-colony rules. + +--- + +## Part 1 - Overview + +**Ant colony is not a mining algorithm - it is a search *structure*.** It does not decide what a good +solution looks like or how to score one - a **mining algorithm** does. `nonce[0]` selects which +algorithm runs; today the only one is **bpp9000**, which the ant colony runs by default. A future +algorithm could be added the same way, in the same structure. So "ant colony" and "bpp9000" are two +separate things: the structure, and the algorithm currently running in it. + +| The ant-colony **structure** provides (any algorithm) | The **algorithm** provides (bpp9000 today) | +|---|---| +| a per-identity tree of solutions, grown from parents | what a solution *is* | +| the accept rules, the deposit, and the ranking | how to derive a child solution from a parent | +| the request / response queries | how to score a solution | + +With that split in mind: mining is a search for a **solution** that does well on a fixed task, and the +algorithm defines what a solution is and how it scores. Under **bpp9000** a solution is a neural network +(an "ANN"), scored by an **error count** over the task's data windows - range `[0, 8088]`, **lower is +better** (a flawless network makes zero mistakes). The rest of this overview uses bpp9000's terms, but +the tree structure around them is identical for any algorithm. + +Under bpp9000 the network's **wiring** (which neuron reads which) is **global** - the same graph for +everyone, from the epoch's task file - and a miner searches over each neuron's **lookup table (LUT)**, +the ternary function it computes. + +Standalone mining searches alone: every attempt starts from scratch. Ant-colony mining searches +**together, as a tree**: + +- Every **mining identity** (a computor or candidate public key) owns its **own tree** - the colony is + a per-identity forest. A pool's workers extend the tree of the computor they mine for. +- Each tree starts from a **virtual root**: a starting solution derived from that identity's public key + and the epoch's spectrum digest. It is fixed for the epoch, identical every time you derive it, and + is never stored or submitted. +- To mine, you pick a **parent** (the root, or any node already in your tree), **inherit** it, vary it + under your nonce, and score the result. +- If the result **strictly beats the parent** and clears the epoch **threshold**, you **submit** it. On + acceptance it becomes a new tree node that you - or anyone - can extend further. + +So the colony converges toward better solutions: each accepted node beats its parent, and the next +miner starts from there instead of from scratch. The goal of the epoch is the single best solution +found anywhere in the forest. + +``` + virtual root (per identity, not stored) + | + +----+----+ + | | + node A node B each node beats its parent + | | and clears the threshold + node C node D + | + node E <-- best in this tree so far +``` + +Concretely, error gates every attachment: it only falls down a branch (a child must beat its parent), +and a *start* - a depth-1 child of the root - must clear the threshold. + +``` + error = error count, lower is better threshold = 3838 + + root ~4044 raw a fresh root sits above 3838; a start must mutate below it + | + +-- A 3790 <= threshold ACCEPT (depth-1 start) + | | + | +-- B 3540 < 3790, beats A ACCEPT + | | | + | | +-- D 3120 < 3540, beats B ACCEPT + | | +-- E 3560 not < 3540 REJECT (must beat parent) + | | + | +-- C 3700 < 3790, beats A ACCEPT + | + +-- X 3900 > threshold REJECT (over threshold) + + Error only falls as you go deeper. The epoch winner is the single lowest-error node + found in any identity's forest. +``` + +At epoch end the node ranks every identity by its **single best** score and **harvests the top 676** +(the number of computors). + +**Anti-spam deposit.** Each solution a computor publishes on-chain carries a **refundable +1,000,000 QU deposit**, funded by the computor - not the worker. It is returned when the solution is +accepted **and** its claimed score matches the node's recompute; otherwise it is kept. So a computor +only publishes solutions it has already validated, and an honest, correct one costs nothing. + +--- + +## Part 2 - Miner / pool integration guide + +**In short.** A miner works one identity's tree. It reads the epoch context, takes a **parent** (the +identity's virtual root, or a node already in the tree), picks a canonical **nonce**, inherits the +parent's network, and **mutates and scores** it - reproducing the node's score exactly. If the result +**beats its parent** and **clears the threshold**, it hands the solution to the **computor**, which +re-checks it and **publishes it on-chain**; every node then recomputes the score, folds it into +consensus, attaches the node to the tree, and refunds the deposit. A miner's entire job is an **exact +scorer** - the tree, gates, deposit, and queries are the wrapper around it. + +### 2.1 The mining loop + +1. **Epoch context** - `REQUEST_ANT_EPOCH_CONTEXT` (public). Read the threshold, freshness window, + epoch spectrum digest, and child cap for this epoch. +2. **Get a starting point** - derive your identity's virtual root, or fetch an existing node you want + to extend (`REQUEST_ANT_PARENT_ANN`). +3. **Pick a parent** - the root, or any node in your own tree. +4. **Search** - choose a nonce (section 2.2), inherit the parent LUT, run the mutation walk, score + (section 2.3). +5. **Submit** - if it passes the local rules (section 2.4), send `AntSolutionBroadcastPayload` to the + computor you mine for (section 2.6, stage 1). +6. **Publish + confirm** - the computor validates and scores it, then publishes it on-chain as an + `AntColonyMiningSolutionTransaction` (section 2.6, stage 2). Every node processes that transaction - + recompute, fold the digest, commit to the computor's tree, refund the deposit. Query the tree + (section 2.7b) to see accepted nodes and extend them. + +### 2.2 The nonce (32 bytes) + +| Byte(s) | Meaning | Valid range | +|-------------|---------|-------------| +| `nonce[0]` | algorithm selector (must select bpp9000) | - | +| `nonce[1]` | `L` = LUT entries rewritten per mutation step | `[1, 10]` | +| `nonce[2]` | `K` = number of **explore** steps | `[0, 100]` | +| `nonce[3..31]` | the walk seed (the actual search space) | any | + +**Canonical-nonce rule.** The scorer **refuses** any non-canonical nonce - it returns no score, and the +node rejects the submission with `RejectNonCanonicalNonce`. For an ant solution the rule is: + +``` +algo == bpp9000 && nonce[1] in [1, 10] && nonce[2] in [0, 100] +``` + +`nonce[0..2]` (the algo / `L` / `K` knobs) are **excluded from the RNG seed** - zeroed before hashing - +so `L` and `K` can be chosen without changing the walk seed. This makes the score-relevant bytes equal +to the identity/dedup bytes: there is no malleability room, and two nonces that differ only in these +knobs are not two solutions. + +### 2.3 Scoring - bpp9000 (must be bit-exact) + +Throughout, `publicKey` is the **mining identity you are extending** - the computor you mine for, which +becomes the transaction's `sourcePublicKey`. Derive the root and the mutation seed from **that** key, +not your worker key, or the node's recompute will not match yours. + +**Root.** `deriveRootANN(publicKey, epochPool)`: `K12(publicKey)` seeds a per-neuron LUT from the +epoch's random pool (the pool comes from the epoch-start spectrum digest). No mutation walk. Never +stored. The same every time for the epoch. + +**Child.** `computeScoreFromParent(parentLUT, publicKey, nonce, anchorTickDigest)`: + +1. Inherit `parentLUT`. +2. `mutationSeed = K12(publicKey || nonce[3..31] || anchorTickDigest)` (`nonce[0..2]` zeroed). +3. Walk `numberOfMutations = 100` steps. Each step rewrites `L` LUT entries. For the first `K` steps + accept a worse-or-equal result (**explore**); after that accept only better-or-equal (**exploit**); + one-step rollback on reject. Keep and return the **best** score seen. The best is seeded with the + inherited network's own score, so a child that fails to improve on its parent is rejected (see + `RejectLeParent`). + +**Anchor digest.** `anchorTickDigest = K12(anchorTick || transactionDigest)`, where `transactionDigest` +is taken from the anchor tick's quorum votes (`REQUEST_QUORUM_TICK`). This binds a solution to a tick. + +Score is an error count in `[0, 8088]`; lower is better. + +### 2.4 Accept rules + +A submission is accepted (`Valid` or `ValidNotStored`) only if **all** of these hold. The node checks +in this order; the first failure is the reject reason: + +| Check | Reject reason if it fails | +|-------|---------------------------| +| Parent record exists | `RejectParentNotRegistered` | +| Parent is in the **same** identity's tree (the tx `sourcePublicKey`) | `RejectWrongTree` | +| Nonce is canonical | `RejectNonCanonicalNonce` | +| Anchor not in the future, published within `freshnessWindow` ticks of it | `RejectStale` / `RejectTickOutOfRange` | +| Score `<=` epoch threshold | `RejectBelowThreshold` | +| Score **strictly** below the parent's score | `RejectLeParent` | +| Parent holds fewer than `maxChildrenPerParent` children (`0` = unbounded) | `RejectMaxChildrenPerParent` | +| `(publicKey, parentRef, nonce)` not already committed this epoch | `RejectReplay` | +| Store and miner index not full | `RejectDedupFull` / `RejectMinerIndexFull` | + +Separately, the **claimed score must equal the node's recompute** - if not, the solution may still be +recorded but the **deposit is kept** and the miner is **not ranked**. + +**Starting a tree.** The root's record score is the worst possible value, so a first (depth-1) child +passes the "beats parent" check trivially - the **threshold is the only score gate** for starting a +tree. A random root scores far above the threshold, so a start still requires real mutation. + +**`ValidNotStored`.** Accepted, refunded, and ranked exactly like `Valid`, but the per-epoch store was +full so the node was not persisted for others to extend. Ranking and refund are unaffected. + +### 2.5 The deposit + +Every on-chain `AntColonyMiningSolutionTransaction` carries a **1,000,000 QU** deposit +(`SOLUTION_SECURITY_DEPOSIT`), funded by the **computor** that publishes it - not the miner (see 2.6). +It is refunded **iff** the solution is accepted (`Valid` / `ValidNotStored`) **and** the claimed score +equals the node's recompute; otherwise it is kept. So a computor risks its own deposit and therefore +pre-validates each solution before publishing; a worker posts nothing on-chain. Keep the computor +identity funded above the deposit. + +### 2.6 Submission: broadcast (off-chain), then transaction (on-chain) + +A solution travels in two stages - an off-chain hand-off to a computor, then the on-chain transaction +that is the actual consensus record. + +**Stage 1 - P2P broadcast (`AntSolutionBroadcastPayload`, 48 bytes).** The miner hands its solution to +the computor it mines for, inside the standard `BroadcastMessage` envelope (network type +`BROADCAST_MESSAGE`), message type `MESSAGE_TYPE_ANT_SOLUTION` (`3`): + +``` +BroadcastMessage { // 96-byte envelope + m256i sourcePublicKey; // the worker key - pool off-chain accounting only, NOT the tree + m256i destinationPublicKey; // the COMPUTOR you mine for - THIS identity owns the tree and funds the deposit + m256i gammingNonce; // first gamming byte selects MESSAGE_TYPE_ANT_SOLUTION (3) +} +// then the payload: +AntSolutionBroadcastPayload { // 48 bytes + unsigned int parentTickOffset; + unsigned int parentSolutionIndexInTick; + unsigned int anchorTick; // ABSOLUTE tick number + unsigned int claimedScore; + m256i nonce; // the 32-byte nonce from 2.2 +} +``` + +The computor scores and validates on receipt (a non-canonical or already-seen solution is dropped for +free). Nothing is on-chain yet. + +**Stage 2 - on-chain transaction (`AntColonyMiningSolutionTransaction`, `inputType` 12).** When the +computor publishes, it emits a standard transaction into tick data under **its own** key and funds the +deposit from **its own** balance - the computor pays, not the miner. This transaction is the consensus +record: every node processes it in `processTick`, recomputes the score, folds it into +`resourceTestingDigest`, commits the node to the computor's tree, and refunds or keeps the deposit. + +``` +AntColonyMiningSolutionTransaction : Transaction { // 80-byte header + 48-byte payload + 64-byte signature + // --- Transaction header --- + m256i sourcePublicKey; // the COMPUTOR (tree owner); signs the tx and funds the deposit + m256i destinationPublicKey; // zero (NULL_ID) + long long amount; // SOLUTION_SECURITY_DEPOSIT = 1,000,000 QU + unsigned int tick; // publish tick + unsigned short inputType; // ANT_COLONY_MINING_SOLUTION_INPUT_TYPE = 12 + unsigned short inputSize; // 48 + // --- payload (48 bytes) --- + unsigned int parentTickOffset; + unsigned int parentSolutionIndexInTick; + unsigned int anchorTick; // ABSOLUTE + unsigned int claimedScore; + m256i nonce; + // --- 64-byte signature over header + payload --- +} +``` + +`parentRef = (parentTickOffset, parentSolutionIndexInTick) = (0, 0xFFFFFFFF)` means the **virtual root** +(a depth-1 child). + +**For a pool.** The tree belongs to the **computor** - `destinationPublicKey` of the broadcast, which +becomes `sourcePublicKey` of the transaction. Workers hold no tree and post no on-chain deposit; they +hand solutions to the pool's computor, which pre-validates them and risks its own deposit only on +solutions it expects to be accepted and refunded. Fund the computor identity, not the workers. + +### 2.7 Read queries + +Three request/response pairs. **Identity tree** and **parent ANN** are **operator-signed**; **epoch +context** is **public**. + +Operator signing: the request payload is followed by a **64-byte signature** over `K12(payload)`, +verified against the node's configured **operator public key**. This lets a pool route and filter its +own miners' reads while keeping untrusted parties from spamming the node. There is no monotonic nonce - +replaying a read only costs a duplicate answer. + +``` +digest = K12(requestPayload) +signature = sign(operatorSubseed, operatorPublicKey, digest) // 64 bytes, appended to the payload +``` + +**(a) Epoch context** - `REQUEST_ANT_EPOCH_CONTEXT` (76) / `RESPOND_ANT_EPOCH_CONTEXT` (77). **Public.** + +Request: empty. Response `RespondAntEpochContext` (56 bytes, packed): + +``` +m256i spectrumDigest; // epoch-start spectrum digest (seeds every root) +unsigned int threshold; // per-epoch accept bound +unsigned int freshnessWindow; // publish within this many ticks of the anchor +unsigned int solutionCount; // accepted solutions so far this epoch +unsigned int freeAnnSlotsCount; // free slots in the live ANN pool +unsigned int maxChildrenPerParent; // per-parent child cap; 0 = unbounded +unsigned short epoch; +unsigned short padding; +``` + +**(b) Identity tree** - `REQUEST_ANT_IDENTITY_TREE` (72) / `RESPOND_ANT_IDENTITY_TREE` (73). +**Operator-signed.** Paginated. + +Request `RequestAntIdentityTree` (40 bytes) + 64-byte signature: + +``` +m256i pubkey; // whose tree to report (usually your own) +unsigned int fromIndex; // resume cursor, 0 on the first call +unsigned int padding; +``` + +Response: `RespondAntIdentityTreeHeader` (12 bytes: `count`, `itemSize`, `nextIndex`) followed by +`count` x `AntIdentityTreeNode`. Up to 64 nodes per response; page with `nextIndex` until it is `0`. + +``` +AntIdentityTreeNode { // 32 bytes + unsigned int selfTickOffset; // set these two as your parentRef to extend THIS node + unsigned int selfSolutionIndexInTick; + unsigned int parentTickOffset; // this node's own parent; (0, 0xFFFFFFFF) = root + unsigned int parentSolutionIndexInTick; + unsigned int score; // error count; a child must score strictly below this + unsigned int childCount; // children already attached (compare vs maxChildrenPerParent) + unsigned int anchorTick; + unsigned int depth; +} +``` + +Paging every node of a pubkey reconstructs the whole tree, edges included, with no further fetches. + +**(c) Parent ANN** - `REQUEST_ANT_PARENT_ANN` (74) / `RESPOND_ANT_PARENT_ANN` (75). **Operator-signed.** + +Request `RequestAntParentAnn` (8 bytes) + 64-byte signature: + +``` +unsigned int parentRefTickOffset; +unsigned int parentRefSolutionIndexInTick; +``` + +Response `RespondAntParentAnnHeader` (16 bytes), then `annSizeBytes` of **canonical ANN** (one trit per +byte, the exact form the scorer consumes - no unpacking needed): + +``` +unsigned int parentRefTickOffset; +unsigned int parentRefSolutionIndexInTick; +unsigned int annSizeBytes; // ANN LUT size when status is OK, else 0 +unsigned char status; // 0 = OK, 1 = NOT_FOUND, 2 = IS_ROOT (derive your own root instead) +unsigned char padding[3]; +``` + +### 2.8 Network message + transaction types + +| Type | Value | Signed | Direction | +|------|-------|--------|-----------| +| `BROADCAST_MESSAGE` + `MESSAGE_TYPE_ANT_SOLUTION` | `3` | envelope-signed | miner -> computor (submit) | +| `AntColonyMiningSolutionTransaction` (`inputType`) | `12` | computor-signed | computor -> tick data (consensus) | +| `REQUEST_ANT_IDENTITY_TREE` / `RESPOND_ANT_IDENTITY_TREE` | `72` / `73` | operator | read tree | +| `REQUEST_ANT_PARENT_ANN` / `RESPOND_ANT_PARENT_ANN` | `74` / `75` | operator | read one node's ANN | +| `REQUEST_ANT_EPOCH_CONTEXT` / `RESPOND_ANT_EPOCH_CONTEXT` | `76` / `77` | public | read epoch params | diff --git a/doc/protocol.md b/doc/protocol.md index 70fcc0c8..85121758 100644 --- a/doc/protocol.md +++ b/doc/protocol.md @@ -29,6 +29,7 @@ The following transaction types (`tx->inputType`) are defined: - `ExecutionFeeReportTransactionPrefix`, type 9, defined in `src/network_messages/execution_fees.h`. - `OracleUserQueryTransactionPrefix`, type 10, defined in `src/oracle_core/oracle_transactions.h`. - `DogeMiningShareTransaction`, type 11, defined in `src/mining/mining.h`. +- `AntColonyMiningSolutionTransaction`, type 12, defined in `src/mining/mining.h`. ## Peer Sharing From 524ff25a2c051ba80f5f33f88b6122328b058f29 Mon Sep 17 00:00:00 2001 From: cyber-pc <165458555+cyber-pc@users.noreply.github.com> Date: Fri, 14 Aug 2026 15:33:22 +0700 Subject: [PATCH 42/46] Set the number of children to unbound. --- src/public_settings.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/public_settings.h b/src/public_settings.h index b35103ff..fe55126a 100644 --- a/src/public_settings.h +++ b/src/public_settings.h @@ -139,7 +139,7 @@ static constexpr unsigned int ANT_PUBLISH_WINDOW_TICKS = 16000; // Per-parent child cap: a parent accepts at most this many children - a miner's parallel branches // off one node. 0 means unbound (no cap). A child's score must still strictly beat its parent's. // A child over the cap is rejected without a refund, so miners should stop submitting to a full parent. -static constexpr unsigned int ANT_MAX_CHILDREN_PER_PARENT = 32; +static constexpr unsigned int ANT_MAX_CHILDREN_PER_PARENT = 0; // Ant colony: tree nodes recorded per epoch; one per accepted solution. static constexpr unsigned int ANT_MAX_NODES_PER_EPOCH = 1u << 23; From 20768f6e4eefc4d48a7b99934ad2591e359ca194 Mon Sep 17 00:00:00 2001 From: cyber-pc <165458555+cyber-pc@users.noreply.github.com> Date: Thu, 13 Aug 2026 14:06:52 +0700 Subject: [PATCH 43/46] Update the antEpochContext with task file's digest. --- doc/ant_colony_mining.md | 13 +++++++++++-- src/network_messages/ant_colony_message.h | 5 ++++- src/qubic.cpp | 2 ++ 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/doc/ant_colony_mining.md b/doc/ant_colony_mining.md index 303599c4..965ecd0b 100644 --- a/doc/ant_colony_mining.md +++ b/doc/ant_colony_mining.md @@ -115,7 +115,8 @@ scorer** - the tree, gates, deposit, and queries are the wrapper around it. ### 2.1 The mining loop 1. **Epoch context** - `REQUEST_ANT_EPOCH_CONTEXT` (public). Read the threshold, freshness window, - epoch spectrum digest, and child cap for this epoch. + epoch spectrum digest, and child cap for this epoch, and **verify your task file** against the + returned `topologyHash` / `dataHash` (section 2.7a) before doing any work. 2. **Get a starting point** - derive your identity's virtual root, or fetch an existing node you want to extend (`REQUEST_ANT_PARENT_ANN`). 3. **Pick a parent** - the root, or any node in your own tree. @@ -288,10 +289,12 @@ signature = sign(operatorSubseed, operatorPublicKey, digest) // 64 bytes, appe **(a) Epoch context** - `REQUEST_ANT_EPOCH_CONTEXT` (76) / `RESPOND_ANT_EPOCH_CONTEXT` (77). **Public.** -Request: empty. Response `RespondAntEpochContext` (56 bytes, packed): +Request: empty. Response `RespondAntEpochContext` (120 bytes, packed): ``` m256i spectrumDigest; // epoch-start spectrum digest (seeds every root) +m256i topologyHash; // canonical task topology-block hash (BPP9000_TOPOLOGY_HASH) +m256i dataHash; // canonical task data-block hash (BPP9000_DATA_HASH) unsigned int threshold; // per-epoch accept bound unsigned int freshnessWindow; // publish within this many ticks of the anchor unsigned int solutionCount; // accepted solutions so far this epoch @@ -301,6 +304,12 @@ unsigned short epoch; unsigned short padding; ``` +`topologyHash` / `dataHash` identify the exact task the node scores against. After loading your task +file, recompute K12 over your own topology and data blocks and compare against these. If either +differs you are holding the wrong task: **stop**. Mining a stale task wastes work, and the mismatched +score forfeits the computor's deposit on every submission (the node scores with *its* task, so your +claimed score never matches). + **(b) Identity tree** - `REQUEST_ANT_IDENTITY_TREE` (72) / `RESPOND_ANT_IDENTITY_TREE` (73). **Operator-signed.** Paginated. diff --git a/src/network_messages/ant_colony_message.h b/src/network_messages/ant_colony_message.h index 6f515977..8ebfdfdb 100644 --- a/src/network_messages/ant_colony_message.h +++ b/src/network_messages/ant_colony_message.h @@ -152,6 +152,9 @@ struct RespondAntEpochContext { // The epoch-start spectrum digest m256i spectrumDigest; + // confirm its task file matches the one the node scores against. + m256i topologyHash; + m256i dataHash; // score threshold for this epoch unsigned int threshold; // ANT_PUBLISH_WINDOW_TICKS: publish within this many ticks of the anchor. @@ -172,4 +175,4 @@ struct RespondAntEpochContext } }; #pragma pack(pop) -static_assert(sizeof(RespondAntEpochContext) == 56, "RespondAntEpochContext unexpected size"); +static_assert(sizeof(RespondAntEpochContext) == 120, "RespondAntEpochContext unexpected size"); diff --git a/src/qubic.cpp b/src/qubic.cpp index 8406656c..dbe53fc3 100644 --- a/src/qubic.cpp +++ b/src/qubic.cpp @@ -1807,6 +1807,8 @@ static void processRequestAntEpochContext(Peer* peer, RequestResponseHeader* hea respond.freeAnnSlotsCount = gAntColony.freeAnnSlotsCount(); respond.maxChildrenPerParent = ANT_MAX_CHILDREN_PER_PARENT; respond.epoch = system.epoch; + respond.topologyHash = *(const m256i*)BPP9000_TOPOLOGY_HASH; + respond.dataHash = *(const m256i*)BPP9000_DATA_HASH; enqueueResponse(peer, sizeof(respond), RespondAntEpochContext::type(), header->dejavu(), &respond); } From 88a6a377644139a0fcbf977f3eab6ab5d2a8bea9 Mon Sep 17 00:00:00 2001 From: cyber-pc <165458555+cyber-pc@users.noreply.github.com> Date: Thu, 13 Aug 2026 23:24:29 +0700 Subject: [PATCH 44/46] Ant colony parent tick uses the absolute tick for consistency. --- doc/ant_colony_mining.md | 21 +++--- src/logging/logging.h | 2 +- src/mining/ant_colony/ant_colony.h | 83 ++++++++++++++--------- src/mining/mining.h | 6 +- src/network_messages/ant_colony_message.h | 21 +++--- src/qubic.cpp | 32 ++++----- test/ant_colony.cpp | 49 +++++-------- test/ant_pending_solutions.cpp | 4 +- 8 files changed, 112 insertions(+), 106 deletions(-) diff --git a/doc/ant_colony_mining.md b/doc/ant_colony_mining.md index 965ecd0b..b1c1336e 100644 --- a/doc/ant_colony_mining.md +++ b/doc/ant_colony_mining.md @@ -171,7 +171,7 @@ stored. The same every time for the epoch. `RejectLeParent`). **Anchor digest.** `anchorTickDigest = K12(anchorTick || transactionDigest)`, where `transactionDigest` -is taken from the anchor tick's quorum votes (`REQUEST_QUORUM_TICK`). This binds a solution to a tick. +is `K12(TickData)` of the anchor tick's `TickData` (`REQUEST_TICK_DATA`). This binds a solution to a tick. Score is an error count in `[0, 8088]`; lower is better. @@ -228,7 +228,7 @@ BroadcastMessage { // 96-byte envelope } // then the payload: AntSolutionBroadcastPayload { // 48 bytes - unsigned int parentTickOffset; + unsigned int parentTick; // ABSOLUTE tick of the parent node (0 with the index below = root) unsigned int parentSolutionIndexInTick; unsigned int anchorTick; // ABSOLUTE tick number unsigned int claimedScore; @@ -255,7 +255,7 @@ AntColonyMiningSolutionTransaction : Transaction { // 80-byte header + 48-byte unsigned short inputType; // ANT_COLONY_MINING_SOLUTION_INPUT_TYPE = 12 unsigned short inputSize; // 48 // --- payload (48 bytes) --- - unsigned int parentTickOffset; + unsigned int parentTick; // ABSOLUTE tick of the parent node unsigned int parentSolutionIndexInTick; unsigned int anchorTick; // ABSOLUTE unsigned int claimedScore; @@ -264,9 +264,14 @@ AntColonyMiningSolutionTransaction : Transaction { // 80-byte header + 48-byte } ``` -`parentRef = (parentTickOffset, parentSolutionIndexInTick) = (0, 0xFFFFFFFF)` means the **virtual root** +`parentRef = (parentTick, parentSolutionIndexInTick) = (0, 0xFFFFFFFF)` means the **virtual root** (a depth-1 child). +**Every tick in the protocol is absolute.** `parentTick`, `selfTick` and `anchorTick` are all real +system tick numbers - the same values `getCurrentTick` or a tick-data query returns. A parent is named +by the absolute tick it was committed in (plus its index within that tick), so the ref you copy from +the identity tree is used verbatim - there is no epoch-relative offset to convert. + **For a pool.** The tree belongs to the **computor** - `destinationPublicKey` of the broadcast, which becomes `sourcePublicKey` of the transaction. Workers hold no tree and post no on-chain deposit; they hand solutions to the pool's computor, which pre-validates them and risks its own deposit only on @@ -326,9 +331,9 @@ Response: `RespondAntIdentityTreeHeader` (12 bytes: `count`, `itemSize`, `nextIn ``` AntIdentityTreeNode { // 32 bytes - unsigned int selfTickOffset; // set these two as your parentRef to extend THIS node + unsigned int selfTick; // ABSOLUTE; set these two as your parentRef to extend THIS node unsigned int selfSolutionIndexInTick; - unsigned int parentTickOffset; // this node's own parent; (0, 0xFFFFFFFF) = root + unsigned int parentTick; // this node's own parent (ABSOLUTE); (0, 0xFFFFFFFF) = root unsigned int parentSolutionIndexInTick; unsigned int score; // error count; a child must score strictly below this unsigned int childCount; // children already attached (compare vs maxChildrenPerParent) @@ -344,7 +349,7 @@ Paging every node of a pubkey reconstructs the whole tree, edges included, with Request `RequestAntParentAnn` (8 bytes) + 64-byte signature: ``` -unsigned int parentRefTickOffset; +unsigned int parentRefTick; unsigned int parentRefSolutionIndexInTick; ``` @@ -352,7 +357,7 @@ Response `RespondAntParentAnnHeader` (16 bytes), then `annSizeBytes` of **canoni byte, the exact form the scorer consumes - no unpacking needed): ``` -unsigned int parentRefTickOffset; +unsigned int parentRefTick; unsigned int parentRefSolutionIndexInTick; unsigned int annSizeBytes; // ANN LUT size when status is OK, else 0 unsigned char status; // 0 = OK, 1 = NOT_FOUND, 2 = IS_ROOT (derive your own root instead) diff --git a/src/logging/logging.h b/src/logging/logging.h index d7eeda00..9d6a5d18 100644 --- a/src/logging/logging.h +++ b/src/logging/logging.h @@ -199,7 +199,7 @@ struct AntSolutionLogMessage unsigned long long _type; // CUSTOM_MESSAGE_ANT_SOLUTION m256i sourcePublicKey; m256i nonce; - unsigned int parentTickOffset; + unsigned int parentTick; unsigned int parentSolutionIndexInTick; unsigned int anchorTick; unsigned int score; diff --git a/src/mining/ant_colony/ant_colony.h b/src/mining/ant_colony/ant_colony.h index 8cddc36a..195eb36b 100644 --- a/src/mining/ant_colony/ant_colony.h +++ b/src/mining/ant_colony/ant_colony.h @@ -50,20 +50,21 @@ static constexpr unsigned int antAnchorRingSize(unsigned int window) static constexpr unsigned int ANT_ANCHOR_RING_SIZE = antAnchorRingSize(ANT_PUBLISH_WINDOW_TICKS); static constexpr unsigned int ANT_ANCHOR_TICK_NONE = 0xFFFFFFFFU; -// (tickOffset, solutionIndexInTick), epoch-relative tick plus the solution transaction's index in tick +// (tick, solutionIndexInTick), ABSOLUTE system tick plus the solution transaction's index in tick. +// Every tick in the subsystem is absolute; slotOf() is the only place a tick becomes a tick-index offset. struct SolutionRef { - unsigned int tickOffset; // RELATIVE to the epoch start, never an absolute system tick + unsigned int tick; // ABSOLUTE system tick unsigned int solutionIndexInTick; bool operator==(const SolutionRef& other) const { - return (tickOffset == other.tickOffset) && (solutionIndexInTick == other.solutionIndexInTick); + return (tick == other.tick) && (solutionIndexInTick == other.solutionIndexInTick); } bool isRoot() const { - return (tickOffset == 0) && (solutionIndexInTick == 0xFFFFFFFFu); + return (tick == 0) && (solutionIndexInTick == 0xFFFFFFFFu); } }; @@ -88,7 +89,7 @@ struct AntSolutionRecord m256i pubkey; m256i nonce; SolutionRef parentRef; // this solution's parent, or ROOT_REF - SolutionRef selfRef; // this solution's own address (RELATIVE tick inside) + SolutionRef selfRef; // this solution's own address (ABSOLUTE tick inside) unsigned int score; // error count, lower is better unsigned int anchorTick; // ABSOLUTE. tick whose digest seeded the RNG unsigned int depth; // a child of the root is depth 1; the root itself is never stored @@ -108,7 +109,7 @@ struct AntExportSlotT PackedAnnT ann; }; -// tickOffset -> the run of records committed in that tick, so findIndexBySolutionRef() resolves a +// slotOf(tick) -> the run of records committed in that tick, so findIndexBySolutionRef() resolves a // SolutionRef without scanning the store. The run is unbroken because the store is append-only and // a tick's solutions all commit while that tick is processed struct AntTickSlot @@ -229,9 +230,8 @@ struct ChildCandidate unsigned int publishTick; // ABSOLUTE }; -// Carries BOTH tick bases. selfRef/parentRef hold epoch-RELATIVE ticks, anchorTick/publishTick are -// ABSOLUTE system ticks. They are all unsigned int and all named "tick", so comparing one against -// the other compiles silently and is meaningless - on mainnet that is ~2,000,000 against ~70,000,000. +// Every tick here is an ABSOLUTE system tick: selfRef/parentRef ticks, anchorTick and publishTick all +// share one basis (publishTick equals selfRef.tick), so no cross-basis comparison can slip in. struct AntCommitInput { m256i pubkey; @@ -320,11 +320,12 @@ class AntColony // Wipe the whole tree. A new epoch starts empty and reseeded. void reset(); - void beginEpoch(const m256i& rootSeed) + void beginEpoch(const m256i& rootSeed, unsigned int initialTick) { reset(); clearReplayCache(); _rootSeed = rootSeed; + _initialTick = initialTick; } const m256i& rootSeed() const @@ -407,7 +408,7 @@ class AntColony return childCountFromHead(head); } - // Anchor digests. Both take an ABSOLUTE system tick, never an epoch-relative tickOffset. + // Anchor digests. Both take an ABSOLUTE system tick. // Called from tick processor only void recordAnchorDigest(unsigned int tick, const m256i& digest); // Can be called from any processors @@ -521,8 +522,8 @@ class AntColony } // loadSnapshot() helper: rebuild the tick index, head maps and dedup set from the loaded - // records, treating them as untrusted input. - bool rebuildDerivedState(unsigned int initialTick); + // records, treating them as untrusted input. Uses _initialTick, set by the caller beforehand. + bool rebuildDerivedState(); static unsigned int replaySlotOf(const ReplayKey& key) { @@ -531,6 +532,19 @@ class AntColony return (unsigned int)(digest & (ANT_REPLAY_CACHE_SIZE - 1)); } + // The sole place an absolute tick becomes a tick-index offset. Returns false when the tick falls + // outside this epoch's window (before initialTick, or past the per-epoch tick cap); callers treat + // that as "no such record" - RejectParentNotRegistered on lookup, RejectTickOutOfRange on commit. + bool slotOf(unsigned int tick, unsigned int& slot) const + { + if (tick < _initialTick) + { + return false; + } + slot = tick - _initialTick; + return slot < MAX_NUMBER_OF_TICKS_PER_EPOCH; + } + AntSolutionRecord* _records; PackedAnn* _annPool; AntTickSlot* _tickIndex; @@ -561,6 +575,8 @@ class AntColony unsigned int _solutionCount; unsigned int _errorThreshold; + // This epoch's first tick. slotOf() maps an absolute tick to a tick-index offset against it. + unsigned int _initialTick; m256i _rootSeed; AntColonyDiagnostics _stats; }; @@ -964,12 +980,13 @@ inline bool AntColony::getAnchorDigest(unsigned int tick, m256i& digest) template inline long long AntColony::findIndexBySolutionRef(const SolutionRef& ref) const { - if (ref.isRoot() || ref.tickOffset >= MAX_NUMBER_OF_TICKS_PER_EPOCH) + unsigned int tickSlot = 0; + if (ref.isRoot() || !slotOf(ref.tick, tickSlot)) { return ANT_INVALID_INDEX; } // Start from tick' begin index in the record and loop total of record in the tick - const AntTickSlot& slot = _tickIndex[ref.tickOffset]; + const AntTickSlot& slot = _tickIndex[tickSlot]; for (unsigned int i = 0; i < slot.count; i++) { const unsigned int idx = slot.startIdx + i; @@ -1115,7 +1132,8 @@ inline ValidityResult AntColony::commit(const AntCommitInput& in, const _stats.acceptedNotStored++; return ValidityResult::ValidNotStored; } - if (in.selfRef.tickOffset >= MAX_NUMBER_OF_TICKS_PER_EPOCH) + unsigned int selfSlot = 0; + if (!slotOf(in.selfRef.tick, selfSlot)) { recordReject(ValidityResult::RejectTickOutOfRange); return ValidityResult::RejectTickOutOfRange; @@ -1172,7 +1190,7 @@ inline ValidityResult AntColony::commit(const AntCommitInput& in, const newRec.annStateSlot = newIdx; newRec.nextSiblingIdx = prevHead; - AntTickSlot& tslot = _tickIndex[in.selfRef.tickOffset]; + AntTickSlot& tslot = _tickIndex[selfSlot]; if (tslot.count == 0) { tslot.startIdx = newIdx; @@ -1212,15 +1230,15 @@ struct AntColonySnapshotMeta unsigned int recordSizeBytes; unsigned int annPoolEntryBytes; unsigned int errorThreshold; - // The base every selfRef.tickOffset in the records file is relative to. Records address each - // other by offset, so a snapshot restored against a different base is silently mis-addressed + // This epoch's first tick. Records hold absolute ticks; the base must still match so slotOf() maps + // them into this node's tick index, and a snapshot from another epoch is refused rather than mis-read. unsigned int initialTick; unsigned int anchorRingBytes; unsigned int exportSetBytes; m256i rootSeed; static constexpr unsigned int MAGIC = 0x414E5443; // "ANTC" - static constexpr unsigned int VERSION = 1; + static constexpr unsigned int VERSION = 1; // SolutionRef holds absolute ticks }; static_assert(sizeof(AntColonySnapshotMeta) == 40 + 32, "AntColonySnapshotMeta unexpected padding"); @@ -1373,8 +1391,8 @@ inline bool AntColony::loadSnapshot(unsigned short epoch, CHAR16* direct antSnapshotFailure(L"threshold does not match the node, file/node", meta.errorThreshold, errorThreshold); return false; } - // Every selfRef.tickOffset is relative to this. Restoring against a different base would not - // fail anywhere - it would resolve parent references to the wrong records + // Records hold absolute ticks; slotOf() maps them against initialTick, so a snapshot taken at a + // different base would resolve parent references to the wrong records. Refuse it. if (meta.initialTick != initialTick) { antSnapshotFailure(L"initial tick does not match the node, file/node", meta.initialTick, initialTick); @@ -1437,9 +1455,10 @@ inline bool AntColony::loadSnapshot(unsigned short epoch, CHAR16* direct _rootSeed = rootSeed; _errorThreshold = errorThreshold; _solutionCount = meta.solutionCount; + _initialTick = initialTick; // Rebuild the intermediate data - if (!rebuildDerivedState(initialTick)) + if (!rebuildDerivedState()) { reset(); return false; @@ -1448,7 +1467,7 @@ inline bool AntColony::loadSnapshot(unsigned short epoch, CHAR16* direct } template -inline bool AntColony::rebuildDerivedState(unsigned int initialTick) +inline bool AntColony::rebuildDerivedState() { // Hoisted out of the loop: unpacking each stored network to verify its hash needs somewhere to // put it, and this path runs once at boot. @@ -1458,9 +1477,10 @@ inline bool AntColony::rebuildDerivedState(unsigned int initialTick) { const AntSolutionRecord& rec = _records[i]; - if (rec.selfRef.tickOffset >= MAX_NUMBER_OF_TICKS_PER_EPOCH) + unsigned int selfSlot = 0; + if (!slotOf(rec.selfRef.tick, selfSlot)) { - antSnapshotFailure(L"tickOffset out of range, record/tickOffset", i, rec.selfRef.tickOffset); + antSnapshotFailure(L"tick out of range, record/tick", i, rec.selfRef.tick); return false; } // commit() writes the record and its network at the same index, and findIndexBySolutionRef @@ -1474,9 +1494,8 @@ inline bool AntColony::rebuildDerivedState(unsigned int initialTick) // The freshness rule validateChild() applied at admission, re-checked here so a tampered // snapshot cannot smuggle in a record that was never admissible. anchorTick seeds the score's - // RNG, so it is consensus-relevant. publishTick was not stored because it does not need to be - - // commit() sets tickOffset to (publishTick - initialTick), so it inverts exactly - const unsigned int publishTick = initialTick + rec.selfRef.tickOffset; + // RNG, so it is consensus-relevant. The record's own absolute tick is its publish tick. + const unsigned int publishTick = rec.selfRef.tick; if (rec.anchorTick > publishTick || publishTick - rec.anchorTick > ANT_PUBLISH_WINDOW_TICKS) { @@ -1485,14 +1504,14 @@ inline bool AntColony::rebuildDerivedState(unsigned int initialTick) } // Rebuild the tick index - AntTickSlot& tslot = _tickIndex[rec.selfRef.tickOffset]; + AntTickSlot& tslot = _tickIndex[selfSlot]; if (tslot.count == 0) { tslot.startIdx = i; } else if (tslot.startIdx + tslot.count != i) { - antSnapshotFailure(L"record breaks tick contiguity, record/tickOffset", i, rec.selfRef.tickOffset); + antSnapshotFailure(L"record breaks tick contiguity, record/tick", i, rec.selfRef.tick); return false; } tslot.count++; @@ -1506,7 +1525,7 @@ inline bool AntColony::rebuildDerivedState(unsigned int initialTick) const long long parentIdx = findIndexBySolutionRef(rec.parentRef); if (parentIdx == ANT_INVALID_INDEX || (unsigned long long)parentIdx >= i) { - antSnapshotFailure(L"parent not an earlier record, record/parentTickOffset", i, rec.parentRef.tickOffset); + antSnapshotFailure(L"parent not an earlier record, record/parentTick", i, rec.parentRef.tick); return false; } parentRec = &_records[parentIdx]; diff --git a/src/mining/mining.h b/src/mining/mining.h index ef2492d6..f4dd84ba 100644 --- a/src/mining/mining.h +++ b/src/mining/mining.h @@ -375,7 +375,7 @@ struct AntColonyMiningSolutionTransaction : public Transaction static constexpr unsigned short minInputSize() { - return sizeof(parentTickOffset) + sizeof(parentSolutionIndexInTick) + sizeof(anchorTick) + sizeof(claimedScore) + sizeof(nonce); // 4 + 4 + 4 + 4 + 32 = 48 bytes + return sizeof(parentTick) + sizeof(parentSolutionIndexInTick) + sizeof(anchorTick) + sizeof(claimedScore) + sizeof(nonce); // 4 + 4 + 4 + 4 + 32 = 48 bytes } static bool isSolutionTransaction(const Transaction* tx) @@ -386,9 +386,9 @@ struct AntColonyMiningSolutionTransaction : public Transaction && tx->inputSize == minInputSize(); } - unsigned int parentTickOffset; // epoch-relative tick of the parent ref + unsigned int parentTick; // ABSOLUTE tick of the parent ref unsigned int parentSolutionIndexInTick; // dense within-tick index of the parent ref - unsigned int anchorTick; // tick whose digest the solution anchored to (RNG seed + freshness) + unsigned int anchorTick; // ABSOLUTE tick whose digest the solution anchored to (RNG seed + freshness) // The score the submitter claims this solution reaches. The deposit is refunded only when it // matches the score the node computes unsigned int claimedScore; diff --git a/src/network_messages/ant_colony_message.h b/src/network_messages/ant_colony_message.h index 8ebfdfdb..98b123eb 100644 --- a/src/network_messages/ant_colony_message.h +++ b/src/network_messages/ant_colony_message.h @@ -28,7 +28,7 @@ static_assert(sizeof(RequestAntIdentityTree) == 40, "RequestAntIdentityTree unex // is the payload that follows the header. struct AntSolutionBroadcastPayload { - unsigned int parentTickOffset; + unsigned int parentTick; // ABSOLUTE unsigned int parentSolutionIndexInTick; unsigned int anchorTick; // ABSOLUTE unsigned int claimedScore; @@ -43,8 +43,8 @@ constexpr unsigned int ANT_IDENTITY_TREE_NODES_PER_RESPONSE = 64; // Max records scanned per request constexpr unsigned int ANT_IDENTITY_TREE_SCAN_BUDGET = 1024; -// One stored node of the requested identity's tree. selfTickOffset/selfSolutionIndexInTick is the -// ref a child sets as its own parentRef to extend this node; parentTickOffset/parentSolutionIndexInTick +// One stored node of the requested identity's tree. selfTick/selfSolutionIndexInTick is the +// ref a child sets as its own parentRef to extend this node; parentTick/parentSolutionIndexInTick // is this node's OWN parent - (0, 0xFFFFFFFF) means the root - so paging every node of a pubkey // reconstructs the whole tree, edges included, without fetching any network bytes. // The score is an error count, so smaller is better: a child must score strictly below score. @@ -52,13 +52,13 @@ constexpr unsigned int ANT_IDENTITY_TREE_SCAN_BUDGET = 1024; // the cap it takes no more children (0 for the cap means unbound). struct AntIdentityTreeNode { - unsigned int selfTickOffset; + unsigned int selfTick; unsigned int selfSolutionIndexInTick; - unsigned int parentTickOffset; + unsigned int parentTick; unsigned int parentSolutionIndexInTick; unsigned int score; unsigned int childCount; - unsigned int anchorTick; // this node's own anchor tick number + unsigned int anchorTick; // this node's own anchor tick number (ABSOLUTE) unsigned int depth; }; static_assert(sizeof(AntIdentityTreeNode) == 32, "AntIdentityTreeNode unexpected size"); @@ -104,7 +104,7 @@ constexpr unsigned char ANT_PARENT_ANN_STATUS_IS_ROOT = 2; // ROOT_REF; no ANN // operatorPublicKey struct RequestAntParentAnn { - unsigned int parentRefTickOffset; + unsigned int parentRefTick; unsigned int parentRefSolutionIndexInTick; static constexpr unsigned char type() { @@ -119,7 +119,7 @@ static_assert(sizeof(RequestAntParentAnn) == 8, "RequestAntParentAnn unexpected // blob by annSizeBytes. struct RespondAntParentAnnHeader { - unsigned int parentRefTickOffset; + unsigned int parentRefTick; unsigned int parentRefSolutionIndexInTick; // Bytes of canonical ANN that follow this header: ANN LUT size when status is Ok, 0 for every other // status. @@ -144,9 +144,8 @@ struct RequestAntEpochContext // Per-epoch ant-colony parameters a miner needs to start building solutions: // the score threshold, the freshness window, the epoch's root seed, pool occupancy, // and the per-parent child cap. -// The anchor digest is not included; a miner derives it from the standard -// protocol as K12(anchorTick || transactionDigest), with transactionDigest taken from the -// anchor tick's quorum votes (REQUEST_QUORUM_TICK). +// The anchor digest is not included; a miner derives it from the anchor tick's TickData +// (REQUEST_TICK_DATA): transactionDigest = K12(TickData), then K12(anchorTick || transactionDigest). #pragma pack(push, 1) struct RespondAntEpochContext { diff --git a/src/qubic.cpp b/src/qubic.cpp index dbe53fc3..ba5c3319 100644 --- a/src/qubic.cpp +++ b/src/qubic.cpp @@ -314,7 +314,7 @@ static void antDebugPoolDrop(const CHAR16* reason, const AntSolutionBroadcastPay setText(msg, L"[ant-colony] pool drop "); appendText(msg, reason); appendText(msg, L": parent="); - appendNumber(msg, payload.parentTickOffset, FALSE); + appendNumber(msg, payload.parentTick, FALSE); appendText(msg, L"/"); appendNumber(msg, payload.parentSolutionIndexInTick, FALSE); appendText(msg, L" anchor="); @@ -336,7 +336,7 @@ static void antDebugPending(const CHAR16* outcome, const AntPendingSolution& ent setText(msg, L"[ant-colony] "); appendText(msg, outcome); appendText(msg, L": parent="); - appendNumber(msg, entry.parentRef.tickOffset, FALSE); + appendNumber(msg, entry.parentRef.tick, FALSE); appendText(msg, L"/"); appendNumber(msg, entry.parentRef.solutionIndexInTick, FALSE); appendText(msg, L" anchor="); @@ -510,7 +510,7 @@ static void queueAntSolution(unsigned long long processorNumber, const m256i& co // The same bits the commit path checks before RejectReplay, so a hit here is a solution this // node has already processed on-chain - publishing it again would forfeit the deposit. The // filter is restored from the snapshot, so unlike this buffer the check survives a restart. - const SolutionRef parentRef = { payload.parentTickOffset, payload.parentSolutionIndexInTick }; + const SolutionRef parentRef = { payload.parentTick, payload.parentSolutionIndexInTick }; unsigned int seenFlagIndices[2]; computeAntSolutionFlagIndices(computorPublicKey, payload.nonce, parentRef, seenFlagIndices); if (isAntSolutionSeen(seenFlagIndices)) @@ -602,7 +602,7 @@ static void antColonyBeginEpoch() gAntDebugPrintBudget = ANT_DEBUG_PRINTS_PER_EPOCH; #endif gAntPendingSolutions.reset(); - gAntColony.beginEpoch(score->currentRandomSeed); + gAntColony.beginEpoch(score->currentRandomSeed, system.initialTick); gAntColony.setErrorThreshold((unsigned int)getSolutionThreshold(score_engine::AlgoType::Bpp9000)); // Every identity's root derives from this one value, so a node that seeded differently builds a @@ -1401,7 +1401,7 @@ static void processBroadcastTransaction(Peer* peer, RequestResponseHeader* heade && AntColonyMiningSolutionTransaction::isSolutionTransaction(request)) { const AntColonyMiningSolutionTransaction* antTx = (const AntColonyMiningSolutionTransaction*)request; - const SolutionRef preParentRef = { antTx->parentTickOffset, antTx->parentSolutionIndexInTick }; + const SolutionRef preParentRef = { antTx->parentTick, antTx->parentSolutionIndexInTick }; unsigned int preFlagIndices[2]; computeAntSolutionFlagIndices(antTx->sourcePublicKey, antTx->nonce, preParentRef, preFlagIndices); const int spectrumIdx = spectrumIndex(antTx->sourcePublicKey); @@ -1870,9 +1870,9 @@ static void processRequestAntIdentityTree(unsigned long long processorNumber, Pe } AntIdentityTreeNode& item = response.items[response.header.count]; - item.selfTickOffset = rec->selfRef.tickOffset; + item.selfTick = rec->selfRef.tick; item.selfSolutionIndexInTick = rec->selfRef.solutionIndexInTick; - item.parentTickOffset = rec->parentRef.tickOffset; + item.parentTick = rec->parentRef.tick; item.parentSolutionIndexInTick = rec->parentRef.solutionIndexInTick; item.score = rec->score; item.childCount = gAntColony.childCountForQuery(rec->selfRef, rec->pubkey); @@ -1914,10 +1914,10 @@ static void processRequestAntParentAnn(unsigned long long processorNumber, Peer* AntParentAnnResponse& response = gAntParentAnnResponseBuffer[processorNumber]; setMem(&response, sizeof(response), 0); - response.header.parentRefTickOffset = request->parentRefTickOffset; + response.header.parentRefTick = request->parentRefTick; response.header.parentRefSolutionIndexInTick = request->parentRefSolutionIndexInTick; - const SolutionRef ref = { request->parentRefTickOffset, request->parentRefSolutionIndexInTick }; + const SolutionRef ref = { request->parentRefTick, request->parentRefSolutionIndexInTick }; if (ref.isRoot()) { // Roots are never stored; the miner derives its own from the epoch context's seed. @@ -3356,7 +3356,7 @@ static void logAntSolutionOutcome(const AntColonyMiningSolutionTransaction* tran logMsg._type = CUSTOM_MESSAGE_ANT_SOLUTION; logMsg.sourcePublicKey = transaction->sourcePublicKey; logMsg.nonce = transaction->nonce; - logMsg.parentTickOffset = transaction->parentTickOffset; + logMsg.parentTick = transaction->parentTick; logMsg.parentSolutionIndexInTick = transaction->parentSolutionIndexInTick; logMsg.anchorTick = transaction->anchorTick; logMsg.score = score; @@ -3373,7 +3373,7 @@ static void processTickTransactionAntColonySolution( AntColonyBpp9000T::Ann& parentAnnScratch = gAntParentAnnScratch[processorNumber]; AntColonyBpp9000T::Ann& childAnnScratch = gAntChildAnnScratch[processorNumber]; - const SolutionRef parentRef = { transaction->parentTickOffset, transaction->parentSolutionIndexInTick }; + const SolutionRef parentRef = { transaction->parentTick, transaction->parentSolutionIndexInTick }; // Already looked at, accepted or not. Marked BEFORE the walk below, so one solution costs at // most one walk for the whole epoch - _dedup alone would only cover the accepted ones. @@ -3389,7 +3389,7 @@ static void processTickTransactionAntColonySolution( // Reject a parent ref into the current or a later tick: a real parent is always on-chain from an // earlier tick. Root is exempt, it is derived rather than stored. - if (!parentRef.isRoot() && parentRef.tickOffset >= (unsigned int)(system.tick - system.initialTick)) + if (!parentRef.isRoot() && parentRef.tick >= system.tick) { gAntColony.recordReject(ValidityResult::RejectParentNotRegistered); logAntSolutionOutcome(transaction, 0, ValidityResult::RejectParentNotRegistered); @@ -3470,9 +3470,9 @@ static void processTickTransactionAntColonySolution( transaction->sourcePublicKey, transaction->nonce, parentRef, - { system.tick - system.initialTick, transactionIndex }, // selfRef, epoch-RELATIVE tick + { system.tick, transactionIndex }, // selfRef, ABSOLUTE tick transaction->anchorTick, // ABSOLUTE - system.tick }; // ABSOLUTE + system.tick }; // publishTick, ABSOLUTE (== selfRef.tick) // Seeing this transaction execute, that mean those solution is recognized on-chain, mark them as recorded for (unsigned int ownIdx = 0; ownIdx < computorSeedsCount; ownIdx++) { @@ -3989,7 +3989,7 @@ static void publishAntSolutionFor(unsigned long long processorNumber, unsigned i payload.tick = publishTick; payload.inputType = AntColonyMiningSolutionTransaction::transactionType(); payload.inputSize = AntColonyMiningSolutionTransaction::minInputSize(); - payload.parentTickOffset = entry.parentRef.tickOffset; + payload.parentTick = entry.parentRef.tick; payload.parentSolutionIndexInTick = entry.parentRef.solutionIndexInTick; payload.anchorTick = entry.anchorTick; payload.claimedScore = entry.score; @@ -4140,7 +4140,7 @@ static void processTick(unsigned long long processorNumber) AntScoreTaskPayload task; task.pubkey = antTx->sourcePublicKey; task.nonce = antTx->nonce; - task.parentRef.tickOffset = antTx->parentTickOffset; + task.parentRef.tick = antTx->parentTick; task.parentRef.solutionIndexInTick = antTx->parentSolutionIndexInTick; task.anchorTick = antTx->anchorTick; task.txIdx = transactionIndex; diff --git a/test/ant_colony.cpp b/test/ant_colony.cpp index 0b978b75..abda9c9b 100644 --- a/test/ant_colony.cpp +++ b/test/ant_colony.cpp @@ -10,6 +10,10 @@ #include static constexpr unsigned int TEST_THRESHOLD = 3838; // BPP9000_SOLUTION_THRESHOLD_DEFAULT +// Ticks are absolute. A commit at TEST_PUBLISH_TICK lands in tick-index slot (TEST_PUBLISH_TICK - +// TEST_INITIAL_TICK), which must stay under MAX_NUMBER_OF_TICKS_PER_EPOCH (3005 on the testnet setting). +static constexpr unsigned int TEST_INITIAL_TICK = 99000; +static constexpr unsigned int TEST_PUBLISH_TICK = 100000; static m256i makeKey(unsigned long long n) { @@ -242,7 +246,7 @@ static AntColonyBpp9000T* freshColony() return nullptr; } - colony.beginEpoch(makeKey(999)); + colony.beginEpoch(makeKey(999), TEST_INITIAL_TICK); colony.setErrorThreshold(TEST_THRESHOLD); return &colony; } @@ -256,7 +260,7 @@ static long long commitRootChild(AntColonyBpp9000T* colony, const m256i& owner, in.pubkey = owner; in.nonce = makeKey(nonceSeed); in.parentRef = ROOT_REF; - in.selfRef.tickOffset = 7000; + in.selfRef.tick = tick; in.selfRef.solutionIndexInTick = txIdx; in.anchorTick = tick; in.publishTick = tick; @@ -291,7 +295,7 @@ static long long commitChild(AntColonyBpp9000T* colony, const m256i& owner, cons in.pubkey = owner; in.nonce = makeKey(nonceSeed); in.parentRef = parentRef; - in.selfRef.tickOffset = 7000; + in.selfRef.tick = tick; in.selfRef.solutionIndexInTick = txIdx; in.anchorTick = tick; in.publishTick = tick; @@ -341,11 +345,11 @@ TEST(TestAntColonyStore, SolutionRefResolvesToItsRecord) const long long idx = commitRootChild(colony, me, 3800, 42, 600); ASSERT_NE(idx, ANT_INVALID_INDEX); - const SolutionRef ref = { 7000, 42 }; + const SolutionRef ref = { TEST_PUBLISH_TICK,42 }; EXPECT_EQ(colony->findIndexBySolutionRef(ref), idx); // An uncommitted ref must not resolve to a neighbour. - const SolutionRef missing = { 7000, 43 }; + const SolutionRef missing = { TEST_PUBLISH_TICK,43 }; EXPECT_EQ(colony->findIndexBySolutionRef(missing), ANT_INVALID_INDEX); } @@ -373,7 +377,7 @@ TEST(TestAntColonyStore, RootRefResolvesToValidWithNoRecord) EXPECT_EQ(colony->tryGetParent(ROOT_REF, &parent), ValidityResult::Valid); EXPECT_EQ(parent, nullptr); - const SolutionRef missing = { 7000, 0 }; + const SolutionRef missing = { TEST_PUBLISH_TICK,0 }; EXPECT_EQ(colony->tryGetParent(missing, &parent), ValidityResult::RejectParentNotRegistered); } @@ -426,7 +430,7 @@ TEST(TestAntColonyStore, EpochResetClearsTheRing) m256i out = m256i::zero(); ASSERT_TRUE(colony->getAnchorDigest(100000, out)); - colony->beginEpoch(makeKey(999)); + colony->beginEpoch(makeKey(999), TEST_INITIAL_TICK); EXPECT_FALSE(colony->getAnchorDigest(100000, out)); EXPECT_FALSE(colony->getAnchorDigest(0, out)); } @@ -437,11 +441,6 @@ TEST(TestAntColonyStore, EpochResetClearsTheRing) static constexpr unsigned short TEST_EPOCH = 200; static const m256i TEST_ROOT_SEED = makeKey(999); // what freshColony() seeds with -// commitRootChild() writes tickOffset 7000 and publishes at tick 100000, so this is the base those -// two agree on. The load re-derives publishTick as initialTick + tickOffset and re-checks freshness, -// so a mismatched base makes every record look stale. -static constexpr unsigned int TEST_INITIAL_TICK = 100000 - 7000; - // Save, wipe, load. beginEpoch() clears everything the load has to bring back, so anything that // survives came out of the files. static bool saveWipeLoad(AntColonyBpp9000T* colony) @@ -450,7 +449,7 @@ static bool saveWipeLoad(AntColonyBpp9000T* colony) { return false; } - colony->beginEpoch(TEST_ROOT_SEED); + colony->beginEpoch(TEST_ROOT_SEED, TEST_INITIAL_TICK); colony->setErrorThreshold(TEST_THRESHOLD); return colony->loadSnapshot(TEST_EPOCH, NULL, TEST_ROOT_SEED, TEST_THRESHOLD, TEST_INITIAL_TICK); } @@ -478,7 +477,7 @@ TEST(TestAntColonySnapshot, RoundTripRestoresTheTree) EXPECT_TRUE(colony->recordAt(1)->pubkey == me); // The tick index is derived too, so resolving a logical ref proves it was rebuilt. - const SolutionRef ref = { 7000, 1 }; + const SolutionRef ref = { TEST_PUBLISH_TICK,1 }; EXPECT_EQ(colony->findIndexBySolutionRef(ref), 1LL); } @@ -633,22 +632,6 @@ TEST(TestAntColonySnapshot, CorruptedPoolIsRefused) EXPECT_EQ(colony->solutionCount(), 0u); } -// anchorTick seeds the score's RNG and has no other guard, so the load re-derives publishTick -// from the record's own address and re-checks the freshness rule. A record anchored 100000 ticks -// after the tick it claims to sit in cannot have passed that rule when it was admitted. -TEST(TestAntColonySnapshot, RecordOutsideItsFreshnessWindowIsRefused) -{ - AntColonyBpp9000T* colony = freshColony(); - ASSERT_NE(colony, nullptr) << "colony init failed; needs ~6.2 GB"; - - // Published at tick 200000 but still written at tickOffset 7000, so the load re-derives 100000. - ASSERT_NE(commitRootChild(colony, makeKey(7), 3800, 0, 1100, 200000), ANT_INVALID_INDEX); - ASSERT_TRUE(colony->saveSnapshot(TEST_EPOCH, NULL, TEST_INITIAL_TICK)); - - EXPECT_FALSE(colony->loadSnapshot(TEST_EPOCH, NULL, TEST_ROOT_SEED, TEST_THRESHOLD, TEST_INITIAL_TICK)); - EXPECT_EQ(colony->solutionCount(), 0u); -} - // --------------------------------------------------------------------------------------------- // Replay cache @@ -743,7 +726,7 @@ TEST(TestAntColonyReplayCache, BeginEpochClearsIt) colony->putReplayScore(key, 3800, makeAnn(2)); ASSERT_EQ(colony->replayCacheOccupancy(), 1u); - colony->beginEpoch(TEST_ROOT_SEED); + colony->beginEpoch(TEST_ROOT_SEED, TEST_INITIAL_TICK); unsigned int score = 0; AntColonyBpp9000T::Ann out; @@ -945,7 +928,7 @@ TEST(TestAntColonyExport, CarriesTheNetworkAndItsDepth) const m256i me = makeKey(5); ASSERT_NE(commitRootChild(colony, me, 3800, 0, 6200), ANT_INVALID_INDEX); - const SolutionRef aRef = { 7000, 0 }; + const SolutionRef aRef = { TEST_PUBLISH_TICK,0 }; ASSERT_NE(commitChild(colony, me, aRef, 3700, 1, 6201), ANT_INVALID_INDEX); ASSERT_TRUE(colony->exportBestSolutions(TEST_EPOCH, NULL)); @@ -999,7 +982,7 @@ TEST(TestAntColonyExport, BeginEpochClearsIt) ASSERT_NE(colony, nullptr) << "colony init failed; needs ~6.6 GB"; ASSERT_NE(commitRootChild(colony, makeKey(7), 3500, 0, 6400), ANT_INVALID_INDEX); - colony->beginEpoch(TEST_ROOT_SEED); + colony->beginEpoch(TEST_ROOT_SEED, TEST_INITIAL_TICK); ASSERT_TRUE(colony->exportBestSolutions(TEST_EPOCH, NULL)); AntColonyExportHeader header; diff --git a/test/ant_pending_solutions.cpp b/test/ant_pending_solutions.cpp index 02607a5d..d26c084c 100644 --- a/test/ant_pending_solutions.cpp +++ b/test/ant_pending_solutions.cpp @@ -11,10 +11,10 @@ static m256i key(unsigned long long n) return k; } -static SolutionRef ref(unsigned int tickOffset, unsigned int idx) +static SolutionRef ref(unsigned int tick, unsigned int idx) { SolutionRef r; - r.tickOffset = tickOffset; + r.tick = tick; r.solutionIndexInTick = idx; return r; } From 519147e93ccfcda466b3243c0f364fcb5e7eb119 Mon Sep 17 00:00:00 2001 From: cyber-pc <165458555+cyber-pc@users.noreply.github.com> Date: Fri, 14 Aug 2026 15:20:00 +0700 Subject: [PATCH 45/46] Update unit test for ant colony. --- test/data/bpp9000.task | Bin 0 -> 44744 bytes test/data/gt_ant_production.csv | 65 +++++++++++ test/data/gt_production.csv | 97 +++++++++++++++++ test/score.cpp | 184 +++++++++++++++++++++++++++++++- 4 files changed, 343 insertions(+), 3 deletions(-) create mode 100644 test/data/bpp9000.task create mode 100644 test/data/gt_ant_production.csv create mode 100644 test/data/gt_production.csv diff --git a/test/data/bpp9000.task b/test/data/bpp9000.task new file mode 100644 index 0000000000000000000000000000000000000000..8ffa5e17a95960f758ddb469c1df8bb8f95821e4 GIT binary patch literal 44744 zcmYkFJB%G&mzIxl575Sk+YAg$8TZf7<||;taA&v-5FmjNLaI-05N){#2sxHdfGr;) z0ull)84?;qP67!55fRCdfkcD|kqAf#2#9!|XVv%rW9hWdrD|W+Ue|Z6z3Z<(y?_7W zbUOWZUjF|}KhGcj{Uu&6dHwYN`d1%*@mK%mzxnnn|KV@{t-teE|I9D`++Y6Z|MH*y z+wcC*|M+MBc>P6Qzk}DG=Jo4%{XSm5m)CFL^{06K9$vqd*CVgr z#p~yI{dryv>9_IvC0@Uumu=aG|Na~=+gN%1!tt5^PQRI#eJ{Lh-**2fFZ=)H^}t{=N1O^ZG?zUi+GtW4hz@z{~6Z2(MSX?s@$|UVnzy@8Q`_ zKjP*6&f#ZxIfviL%d!0dUbcDW^&5Hpab7>-^@n)*+YjUQv-f!KPxG>W=j$9DkK^>; z8(z}mxA1aYUTeF4FU@RUT1XSyavhw5ZMfcE_fx!V|3|!>qu)7h*F~Dyj@P&z{$?9* zdHt|{wriWt`A59uE!(kAY2?4}cu99@?l|q=Ik*nq>wD?)f|uiUuF_aK_}w4lWxI~c z`Pr`Yajon}zHl6l!E3x;dfTUM{fL*>$#?eUnmGsO=QYya?|zn-WBQZ4Y+D-Huix3e zYX@ZH6>KKbYFM0;;-;F$?57cX96iS0Z3z1imS^(F6E{o8GS&1c84y{F~nvtx0& zPuxc5uekS{pDz4xzB2mD>CAV}e78K>%FQvaFZlDy89hkj_x|wOo){2)$d8|W2UTkR zcDs1n@`^uixnuj_n7>M#jV^Dm{Nb}>zB#lTW7$4C{ZGtyTb=Yv?pSxHaBWI9|H4N7 zbo%^~>o#V7dt;Yh{PNo?ZaeXVM7*tUUh;$UIK8&z*DtvE=BmB*x-ZOkec{JvNAPI# zPtNU0V!yNHMbh=d7pKp{F>vpA?Zu7igG8*$&o=*_F4vD<_k4839S-e<)0Y!Zuk8GT z6J%K-r{8_0%hM~Ha-~mKFS+yAcarI2i|id==zPokuEaG?ZIii zw{vcT1$cu%@~)ZFQoPVqZ@2GhVE@R_?iPo3&&TzVkNYZ_7%593oo#b@BTc2+_C$K9Kkg%Oc)U z_{EvuE#KRarU++mlnnvvM0Wmj}YM*UgHO5^t8+T zFL`(UASGj7Ms;xe>x0-`g4jE)sQW#ajAnTD5@rOKNbT>GlvmQ~JKK9=xgTC4M+;nx zBugtgI=z(J&!1h;2g&rE)4G!;pBk>TdI5EB?8M)E*9>xXp-vwah-mQM_T=Z&8>k_{=*TG~ejmLfwb@wSG3oUigIr!R`pd?st{?0z zj(c>Cb`;tAmD}i>A0AwI1e;;-@%o-0&S(DIzIV;PNAi}hF5W#eIN9T^m-bPaULRfBj}T&4J#5YGK@z{? zw*8s0TsE7(*$Q?O`boOo_GJ0nH)*fF-=3h5@&>m{s~65qQ8>Mqn@~6C2uQMiv-$75 z_buO_-a+v%XW8+=0m)&knafC1mwQ`=8t5FkFPCPebKK5^T_OBUqudw<0W9i!KexV()2VigB|=Lp_-9i+pU z)4Wt;E%|=?V9P4dnXdz1-ZgM3BynyZxOKZZH$96B?9B?&-o;g?qXVqebOcKzO! z!3K+xmS? zLBEG9LtAJH_Lj#XY|_I#Y~_vjYWQ4K90xCXAv3fJi0;_8?T&s|mG6F`y~{^hUYQ!q zWk*;)zLY1uZcOS$j`(y?7cD7G;gZ{jvvQ=){;Cu6r1V@~FpPUui;fK(b_Lb3aoOw%HGQdhIP^NxQ7|w z2&u+*d?Rtb`7XuN8fE-ZT8A?sk0->Zk52W8k*@gyUtJVTR!&SNLFZGGC53cnZqE>L zy=Rc>joXT4IxP%$TDdLq)xG+UMsBM*d;Q*b_;3fqDW=;KN}f=Rj8B@f+*(Bz0M=`6kq)W#WBL!vj&bm#CV$nOY`O7_c|4CX z$8y6-4U8-?0#&ZgcKU#YvP)g@l>y%FT!j^H4qIz`*xFIPr@|2oWQ@rKT`kqkiRHn8 zbZ)uR__pI~xfhp|T1*4s4iVmZ_9gp$06U}0EE~(9s{C1W7yldP%xEo@6d?KC)nG*ml^E$ z^59rnTJ77!u!QTVK4<<2alXES@9(2aE*+W!z2U{hYRM{|+-M6mdVx+s&Ea$9HRF&D zE8{TI0B$J0p3H9`v0|bLp%~DjUCj|*nFrQDp?hWX50GSgFRw`^!{LcoPPmA~AAUz@ zg!FKQ@zJ=5w{Lv6XRY+j&bodpVHyFJEgKjs?D5sU=6uOxi!XjkulOVMqEA zV>)u_K1dfCeQ}UCLzz)kdECV=Y0S?O!i9=e3;_zcnW7}o>r1H~YF!nsHypquB0${D zb&D937AXgJAco7$;f6~UZ+&z1n*yQW5o>&L;3~^dq048QKNC zyd|#N!3u7x4qv=+-*Oj6=@@DQ3L}eH5C|ICgmSjfEnFe)mKn7BH<$?0*{_gU(ApV6 zIxVRdL-YGQbAq_rn#0%fh~6)G6vfUFjF_F_$~pWGYt#I$t_v~TVQR)l69sJ)B%6?3zrp@8&t!8&O_Zfdc&~!Ml2cj0E753 zNDo&RlE?>uKPWPP&?ueMlR61p=Lig|#;WPe>iPjqN|M~IH?ntr6EuaeHWikEtILF) zTO`k4!$b#c5dhkhKK0F*a{v;R`GB`7s6kG0UA{`cF=Gi6U9=v;S*SEWKH0CH)}WUL zPNEu!l|QIn4H|G8lRrlVH^SabaQogFsqkQXrIDGh>eJKAO&SD1V9Eh0xpz;GgE6MZ zkz~asogMbz;tiYt9!r(>sTYL&}^g{vpuo^zCkN>#7K7{ z5k_jG3K`pOlq>~+S7ecLODRKky3Kp9; zDnFvxEDl~X+%8EnvpNp9Gy{tVogIC+eDSpIhpU<*gPhJ%LDVQQ5k78<)7LTF)n>aM za?_VJQdPHWn7K~dmf1GO*@&aKP`U_KkC>)PKT8M<21Zk@r*ks)w=VMv(%weVYa}^4 zeLPS|#~W#SaA&c>oxct!s~*Qk8n@#1GVSe0Jv>ofZu4Wd4F3S_=pA~8c&o>|Ro!F^ zhNeMIZm<2d2m}3+0)Q?~A>qulX<|0*Dxo1H!Erb|6ufYZKkW(Xpc$ktCZFYzR-(E$ zqsm!inqqlq;7l+&sFhS3rdi8Pq(ECO{p=@uJL?7rf1-?Bt#JN(n<}&9>IVnj5w#7O zuJ4?54jLmsc`(&o{(A}#Lf+t5xlVf|5s9ZAeJ)ez0w^Lf!>_v$q;3>S-^lfx#N;uA9(kNLA-P(b}(~rZ>IIcV8McHUM4)9Z9Y34nBGzl6_NDe z$4da6e3{?AY4S1v7Agpv%pW7KOz-LWt%4|GSaN7XNf7bT-Xg;MMWmzE+D!mp*u79y zP*_l~{KjbrFs&--i&DF!n@l@hv^YD z29F2M%L14k{wx7%nJi)YiL$gkdm2Q`Ij%0D%z+OX|E>m3C!Gm{Z()_W4q2cTP+SJ( zGJdC%`dLioLGOCG!nPN(1zC4oFb63faYjrJjU>7nNPPMv!9<>(R6&cN@mVWroCn73 z%yA{e{vJjt}6NWqgq08<`a_F@C%+RL$2Trn`O6VOijVvBZKu5ZjfB zZOdGQK%zX-?U0F8FlWfX`D)X}DT}v!q5Utf8P&5nk!Kvf+k*l5f@NaTi3|Lqbe}G= zXyMJCQ?4pK6_cbfL+u5Z7d@lrvVC5+nB+Q}w>syh5FZVfEM_Qm)tDh`zLe6%b;)@@ z#NPPr0;&O->^$Rz%D~g*p2SYzcb604D-7#}lK^B?Z2qrZyQKDdmf=NCPIO_XUXN;Mc*(u9d~Zi>F%S+C$; zG|wFP=~4dl&aY9>IAP!%JVw~u?!#p&gqMpkPJa-Xmj99$)(87xKJn>1p+dy z=Em0ITBpB@t6WlhMaNEN;CG^v)4fTH2Ruvcf>QiszXas0AdOyfh4P!qARxNv2$)c` z5T~3~9i^&{FeePN+PrHtDK!8g2JUr&&{JkWKpt8_5F!t$>&~z;T7U-5dP}OW+XPsI z*F^aW#1CZ#c-#(B=W`ji*4~5{hv7c)N$8K*%!SGU$HWx5;&-YUNSaN&bq|HO*M-m9 zRJ{t(QN$QwBz$O-*)!LW^7VL`;oPXE5~PT$`&pKNioc{m+V^#GQUoNiYsyY zz;~0IAfTKWOvDfh@$;e+vxPx9`rUNyq@g8yX>v9Ak(_Kj{5Ul%jyZl_gdL_!p##Tv z*Zi=!m`Dd%rSs?)M_7_^I0;1W6Yy@t`-Bt>d=I)0^MF+tvjnq%;bw*dxuQMe z=`i@DYWpPZMUFRoA7md+obRZ+nJo7+dNMJ9>trMWjU~@WYoN8mDOdaw63wyxBqC(X zMU_U|+XsfR;vQkZZKdcqEyW1Jhi(t*gR|zY%y&VXBgN-(p#V7&Q_PUx0ek?ssQ+}R z%xWbhu%i;-@~lExr)-Ztp$;3a5X9aAlX50T7w;;!g_!98B}zb*1uYgb8=iW|%Nto6w(3%Dc_BD(01ny!WK69`2-uuH4UKS!WQ=K8&~Uve0S`<6Zc zTS7J4UYgLpgQLsILiC*Khu%C%%hG+@gn;49o&QmXbSDum$3J|(0D_XhD2Y*@`<6#t4%;zuySswY~@ zJ>^?5ib*}g^_Di%)(S#wo2R5Ih=y2~v23K6iU$!F6`nrbCVwX`HxiKy%i)G^pk$zd z%}2yf^f4KjQZpqO&0(x84eRDg7YgR7s+v#c=!YG3iH8ZrrNX$+kMgOhqln&N4nB^%d58cv7Pt%M$qsgzz?BWF1c<4I^ z4lB=8r#iu_$L{CYSZ;E7JPy~T5Nyb|<)b2$_O2|d&^N1*ufpobr{hWIcg~{FW3R|S=634j4pxW;RExohXM#G z54%w}jSe$tpkqd=0jkhp5J;W!8n|I6;0;-dEJ}zCe1$-4c?#x3*F4Ko0oLrS5jGw- zDqoGZ(G#>6NdmSEWXFjy6tqSJcfnZ(Q5!>44Y^f*QKgB&z{KL(ji8wE{#wE4jF zD5>YRrj;2AUZj)JP@O5EJ+#WB#MTy>hlMw2PkY246$~Y zV4TD@dGM&~w-*)+j;?x;h%4SdKg-_W)CZ6!d|cwSddD2~2b27oBJc-p8($zmNDpc_ z%p$v$0mS|MmY|@9aS|$@HTS~lQwTr?0 zHIOz5uE1Q;6fTCe#uonaZCEC-RjomC`aDfnu1TF}pdD z0k#GMsOlLlMI`6UgHw&wZdP2XPgs4`Yq*|Z;i>G1GdGNk_N=d#>5IRnT7|tPMHj|x znh4yN1qdm!6lp+&#V%!A>x;4Ha%V+TqT*J`Q+4lHDBU>aGwgT%AnDdJVzP3%A`!?5 z918okDnj*NcH^jfb>f4vaIy$yDHTvI%Mu0i(^v>6Q$(P9xgpL-ih~a9k`D$m|04fo zPQ)1sf=}|=TG~kq+ql(Fb!OYi8YvguX3dJ|TfGAmbW^bn?qVgNO3@mnzSqoXn$ zxk;Q2J&xe`uIkkC_F}$MeHSjEqL`$VB2+5x>0)Psr330jI#D%MA2P!olhFDxNv4$b zlcHO#5V-dtWtwiYJbdKGlc<3d&mtn-eA-AXO=ad3{RmkEz)rX{6*G1Tv%;M&O1`q- z&v;cC;pzYizVqfNu!s-f7yB(+uuDmRH~O@xN8Y#_VbsmkQwuZ>gJ_9$nNkIVKg(-H zuG}WA3WM?r)Hxeqbq+4HbInWUSWJ<-TJ`aiWpVn%Rs2TO5>hAI9(jJ}YX;+rk6Qv20bLmoaf^ zH$mQK?fC-O6}Uq4bZeM_O?EB$5oU0F6}&u2ri!(yJ}Fic@xRYBkDo12n9NuY~sY+2IP4cJES}!w4uGcyG-sux(db7Od<#DG3NhBLf?{96vGGg2ttp<>6;p-BmH4Hq(|0=xd69=&Yu?`lVrh*6u zCXk5Z50&%|dKRl+!jTNBP1fO{(eEV@H;9{3ev%+`$spZ;Q&@kf!@M9fFiRmod29v3 zy%cCd>3rdn>y1K|;sig)pxUBh3TwB_;~)k0YjNZn1MAz);?V$$Cvh+~Wt?X5W=CXs z$=r{`IbSLWoj~G%4W}H&u?R{``EB99g(4P!(2>1~8QpRmGLYeB;mT##+eOD1VGAY2 z$Z(vymsdS|2h;&M$p~vAmSIrl=N4D=!H#&>ENYQ?w zkBYC)%mdvi(>U|lzJa_kDn%$L2VH_`bGS(34^XlFR(jepwjLJbh?Wh%SZcu~ikcD3 zlqQziBvt-n6{4{mhV_-#Dy<7m(X{P_8D&fa7MuqL1NzNQ z5DWCODJwWXVXh0mJE#EcGQ3TuoG^*{wEO`Mq@VLLL`hE8U7IyO{Hsdla%+i+u_-QN z8|XqMVW|K$DCDRNfTxzp<&GpvZ0o4*E#VG*GbX8YgT8j&AM*;M*$>Z{Tcwrrma zNIgD~vNYfp22`pnJdEg$X}KPyFUXfw4vhtK-9)zc#K0gaS9~x_OKx(-tt@0L&RpG8 zN$#F&pu*e4z8(mYa%>JsQC82Eo?5O$`05BC(~;>iX4*3e=5?(@L7#H*y}sz0osknK z@j$r5S}J9^GZ}DWag%j`I+CF9RZg_v0A6cUve`vu$T>0jT%f2Eo$~}A_+1HA1ToMF z6{LuZm+aw4wWk6&RVkv^YyoDwV41OEUr}}2WI|UTB_d8yE(zK~S=9{>h>!C$-beJQ zyKhGfL;C3tZBzn+|G8RKFlm`8eKy%SVsOtTRO(0})A-j)Mo4xd$&Rlz(lNAvRXX{D z7pI#7DOED13w5$sH@Qx`y0!DKh@kSbl0)Xj)t%J1vRc;NaED_SGTW$jAo}!UAxb$a zE4G4ED#8-*!$L?zP)?Xr9x6d$wbDfIm`VnuX(K&=Qm_FxW~L&?-%W(V|0-_Vt9CoWtSJs?3-Z6uvp?sw?sh+9d zs0_?aSA#ZML43Lx0G?g_$CBt3&JqwE5rzdSR_)?e@YU4y1-Mk{RD5kE!WE?&I}X`T zl?hd~D?8@WJ%$Qt#XI`o9bbY>K+{LUa2zG&+**xPRhvoYXaUEvXf&TScT+7Kyr|5i zkwica_Nf@wRu*?H>?r`hvv77?u*&-@o&eI!4bgX^a1c z3vtxR&vVeT>8v&&AXk z((Ff&1(FxWGg;87_E#fKK~n`oF;_7T0-RIM!bt3>n+*U(ggWrW#*q+5q*)(T)EeUL zz{IUSyA0Hrxv=WUSkZ z0$81yMeH4cP8+^BedOU`S5rnd!VkgLZR9X#IL12Mf|wn;{4k78GjKr%Trp{j4doJZ z>fS!fW}v$;8)f6uP(+=v@Qu;qVUhfS&Qd5M zruEElSaAt+DV#1vE|c&b{HOTk4#p9=J?)td*0MGncthT8p!J106yps;MjT%+1xF)lQsBJgtAv`=ZocF@= zSVS*L@stSH=1uGeK!^NlvIY`hq!UP3Z8+uMm3?$c>a~?Fp4O9@f|4lN&O~>kPMJ%d zc~Povs3V7J_RClz9hO*ARH<^L^Q;ZC8L+p*Fyk4_Y>n`!hY@FMVys_-(d7;G#%$TC zoJi=!16oi3@=NhxNelCI%LlGr0+U4jGJ$)g-uhP^RAG7)M)YG^KKy`raM($s@(%$_ z0TD!W1p06lE(#YF&kRCku9r((WHRnBp*)`bfY;z%rm$-z+Xhu=nIc*!K>>C}7PesZ z&s-_P*iw?dm{xL5(89=m*8aOT46Q#e)eyP zpjB)+XA?AOU6-jz&G#`wWZpg$wo25ZLujgcji0%rR8OqOh+NvL{;Uyk@BhJ*apxyj zMjUwd2DNW2MH^079aIH%6~$R`Is0ewEE|TElts*o!oF8;mb_^a28c>hfLR#0>N;ei zktR_A)9snkcMRh$w>|2?*l%`#g8h?ab27Q1f~^}UCy|KLx=5E0pz@NczJa)K7D;x> z?wI0p^&%DozJC@wkO z2Q;B{LYL`tqRzEqlZ$`%x`aTTY2CRV!o5qz z%NYAhxr9e41hjzyU>LjEuPM$|0c1`rdgLfAe~1~<==gPq5NCfa37R8z3~Da!amplg z=v;@AjjRSkws;uV{9TsEB`ZpB2VQ}RNpHl!F$XXuZ=Ir0Mlo5?tyn}1JK7>3WkTsN zlw6YB#sUPxmN|N;iewV(QJ{Xxn9hK zx+K>gww>^%T@}b2KcvjtuNE?4;B*w^_^$DlUkGf-6))gT37W!I6Eb?4A>*Z5cUu)a z37vHcU;%%9&F|`QX2g$`a(P$+SKATGc-S`#AB*P?IgnMEwF4?{D)Sg11pN?pr$Fp{ zIP~K8w+0zIdQ5~NF+9l?b24LmH5$$aN=PTVj&aIn#KTUi(kVWSBg9Zd*@rEr5+?WP zlOsSe@+U1|D@--ZiGT0V(%ELKHp7g-0sS0Er|jp(7g`#%jgEzVj*5?_ij&2KNY6N9 zu2!P!rVZ%lq|>5ho71d$((OADml_pmV)F*<5J$k&^szoUf-IdFfXRDQi_9nSEWu>bs-50T7E#W^~`b|XUuS$xHN#NGco~C3H+(xC+7|Up=GCPntUnOM(o^@ zv08qYM1@Y5P|zZh!Lem{d=4iuD^o7tjoM@6I-u0eCp51tKpc2iimPbnOuL|aSb~*i z_0^ccF^9e?1VHh?3~~ew&xT)<0Pv8v8Ss{A^>DvKBbAo0z9b-T3Io$?V%H~%efz`3 zNhqHN_fGjL_a{&={(4>ZBZ#^m(!e_9W2hRHZY??sg6ZAhr1Md#DGdCP3A13Oz{&xI zgJ>MuIpo$JG>y_&j=Mo+{Xcf3PgL`OOHO(Zs^lf!%4h2tskmXm1O)5{IUFcDHW=Lwl zpf-6nfuyC}VXc{Nu>or_yrLVH5n}m?u5JmFd^(cC({1K=#EbUmH!6=lE zcT}d)Wd>B~p?JL4E~i}EE+-2rB+yZ0FD*v)n5I#sBM>IN ztLYdhHdQs-9-NVUKIJA^m$EksnaqXqkX7=?gh{P~NBsD6Zwb^r$+dBIG*4&-miBXn zkY*&b1_W4&xQW+=stA;Vf7Hbn{PuC83-wDrd-CvL*I7TK_W`!7(rfE9z?-Yu{6T9= z8>4_7WW}J_fDa1YgjH(W>JRFTI}h_@(W7`BtS6z*X=T{kedU0FGCJR*1$0BJxzjyr z1NMaBmVnM#YXH+|d7H{j`D7nO(b*o5UijYT+eMPqo%(FmP4Kaj7c4heOUxh-P54@& zoS@hwpp>ePm{@^827nbX$VX>x5Nu%>r+dMbspD=3k41`0MY2lw*mIY5wFs*%(R0kM zDC|R3{O&gP7|a2UkIDv>N4YVPWA}mMqce|dNP+>vwMwzhaWDhufwAdv)J=TG&^*lM zP&`V!=MYRaR)-s0Qm;y0_2Y9QvoYd*2h0`ChMed)c+MwBDX3p2N@dcX{X_!A@mWte zCt5RMVkT*4)^}oY)vH#V@6<@Bput8;{}j2lmA!I_k#mcs3zs@rXEvCM+@Up{kzE$| zDE0*=oe5Ljh~=3@KyfTwtQZ8@3Yabm&N#=x(H=Q{QH8G*I$*bGZ!wRf_n(MJ8d<^T)Gf=-qjt0z4lRQ@D6D>LvK!vU!Kj*rKnF)jZPB^i*3)>vDVNaiQj*K0{H`*pV7wz@tcwIyb9qwS zp~32#&355u&otaZ}(6*eq|0EjJm^kG@79Wr&faoeL<~E)&=c;-LOG0q6P%2jE6Wbg$pzwTlXFOO zb-;sYsODtK?Ur&UsG!5`#*+^GIWS+n$h{Pk!R^@`%UMn=#G4uxhB57Ra_Pn9J0++S zxKfs_<@Lg4OLe(~s`%|rF#Bd?y>a+Hi|o*JiKo)o_^rd4IwVui(%EqDpd>PIVMfV6 zeXgw<2iI*1D?5|THg$>fued|`K>c%xma{OoC<{Dd^xvTNL4O^X=nBqv!FP$3Nmi=~ zkYwgDVqbk2o&vnax+w;RE{v*5&s?Fh!-lUF&*5-$Nrtoq$UN!#&3?L?PjTm?o37qF zrqlS+bN!BdR12z>UyWVXFHVqvW5(w>^uzqvOupOsl#l6gXZRK>yqwgk`3greq{>3) z+v>gI%u4%$GxIZ3Hba+aU{$I4+OzO+r#j9)j4XR(s`>mYt5e&}F#xt*PRkYXR*7%? zmf^ENj|pEow46h+~w$dR)966 zhnN;Es(~lnpT|i56`^8#qArp=`{_)y`Lj2Elg zD)@sgj@|@*C*#XMT{t8JHO%)vKJ6%&kI%KDa3*w9r{ENVMY7$-V6*G85}p9(DrU?S z&N&@D&`9j{D8*zf#-T7P%h;hF4XDBdQ`0*24oC(Ouj`l+cM&s7HKDbkb2fp%xhP~7 z8*11`s}%&|;O785)_hpG9CSyO@N9azHv`mlR&P zUVrUmJVQ68)xcepgb9ccI{RHx!BCEV$rbU=K^mXW%$31`pWF)ZERIwi)UD!NO1;s* zEPM}`2#x1VbuQ6!yR-|NF|#~;HT#K2RqT3{MoW8kk`|n zKs$~#qoYmWVtqZ!?88e2AyAIyq*k}YOcp9%G-t=O;sTPzh8{&+h8s<2?rEN$lk|vK zPk}{7uB@OHuyc-KHbAF$_N13VurS8mY=_3Q@X44ne&(zzx&-Cpqk`z%Fe1q+i^o|a zjI_)Y>IRRDKf3|oIaS(bs-n+uhEEP{(hc6rjMCYKj2VB-qU@NMrmB9jj2##;r!BZL zv)4lvR460rTtVI9#-ytFJ?woBopWb4Ad@U#wY7fxl9N@G8nCZd)n1u)Q;wsas~dEX zD_+gShaZcixSofak4W~|kY_j5okMU73(AruZI_OM7I^7~H@beRYev=glFo(o{>l24WfN_ecQujZIK9hwX%;6FleN zLm{Ui5#bnN+)$821+bCE|#onqn%u_K-gwsRV9M$x3AVd{Ewsn-O0*j zpesCX_hX@NNy3hgv>bY>V+6qF(BY)pU~fXQis7|09kUzI-L{cDOKsy}T#LmEk@XZh z9V=xiR7K&KVxJ$8OG3FsRrlCD*C=yGZ2;?`j?ka%@Z=rXiv$1EG`SycO5EB{_vm$) z9YAeqp9M4iZ5x!2nL$ET;cTqAoo%H@3-#2GDdf}Ij6=|(R3BzpMb#%g27?p8 z(!PCiW``@QC|n<~3tlEtk`s#-)x{l9RXx{i+eG{ri;6;ss}$z|@s&AOd7&@RdB~Ua zq%@eLxWx)~HTq@Ix&+LWM%ZPf;;oVlVrhE^`jpiVc4bEk9sqYu@%59-J*wWZl%+Kv zFa`mbaDHGNGrmR88Q6 zsu+O|8mJK3jC)_38RZf^f1a8XX6QUKqj2IT(V^=0!Z>G0+UM2g-++ZS8ft} zm%uYmtj{p~H|U_HlXDu0RVtG=NO|D!F#-m8w@4-x?UWNWD)-Kvqp701;g+#RE*^_A zwJz$hG(XJhaLd^k@VZwO?dUMn78h194;lAO=}G9gf`jd7@2X!?{(PRz<{-~fNRSQs zIU_P`M|782eB`S+VV;>oN)1Jl#tld4pdpNpxIwASbNPSI&=^ zv~eY5vjx{ns>R9MtT?R-uzYIs$v*pn5hxm#Ia_Lr#KSbtm3IXMsASGkZ7S2-|JHol zSyu=ZfM~GigF%iw6Xd*IQM4v$@#RaP;6|2^IqK!ds7sQ}=Lhet)}?|(Ru#I?ip2_J zyxs@o=?Pk6jic_$lzUHAnPb5=Kr2V<;bn})mGQeQPS1)m?#AHK=vslJNzq>e{F-Q) zQ`-f4bjC_!VRmeMEWtc~sDjuj4zI8%4{wgcvH`vI;~W}4rjRQe^t8q) z>_DwE7I~;LKaH!|0=Qj5>K_dt``%UnQvm(csd`0!KJsRv-Mw+jnb7|*p zTTP)HP02_e*zS^*qbfm*7Vx9YKiZ;{MnyV>8ER^4z;n;FsC>*#i8xg@6#7>A-NGjU zWf~0nvCJ-jJXxbC1saf?VQC@`cAmJflrY15to|iY(;-czksoCqa3_qUBI^Mnkffk6 zlSOo5Qv!K&*1fUN4u|2+*iq9jHZ{;*UMp5e9@bv{CtZ;gMjzAk|U4G%qP>IM^=$G$S3n{G76{9hn>24}BnOw#om6eU7j%iQ_kmWT3ROag}@s?8t;1wJP?gE)vee||F{#lhv&`~NO*$=Wu!Sv z&*qU2);AGyDcUhofgXNILW2M7SVCMaww&{&un?r3{Ft*-EnPuoR;Y5%dMl2S;lR3R zJ}f*9l9=L;=Zze4N%=q=!OAN*fx%F%7>oeW$pn;T2%2h2tGYQim24fsLT?8=%Wg5| zMg#NYwn?6sVy^b*jZtDO&e*-Rr?$%3oG@sN=esb10WO*3S@Nf;d&= z7<%F3**H*PmYAhB6Gh>lrcZJg{zEQzijttS_g0@M#^XWV=p1geGZjv%Im1+(p>0~WJ1^Vy@a}yH;i_zv! z4Rz5&&{U<6veZ$(j9AGpvq#4zL*){cH)$t>8bQvzq(lEZSVxC6{+mrh%K6#70QY6^ zwfjZ>O9;k%XL}$4$;UKW*0OWP=E+KB=g`H(YA%ZUWejOfU@0rBJ9yYHtza+t!|Ioo zeb((RQ!NYvon;HH)jWQ-swUj&Q5ii$byA3$4i5>YKbI_|BQUOwqhVz6T4e!Mymc(q zPwZeTm5wR1M3oJ-9O1FenkL7edsk7bgD8Vvz5G*KE6l)bQhW_+ z54Xkt%hx;hoHYXV&}3jb=?s1WqIO25qBQjLXyWu=nJMD}70Q&p?7DOVJG3<~>QZ?| zlqo2=s)`2O%*^vIT`kN>xKtigF%z9j@kb?~QUQFp@I>T|DfGyUTP3O# zC2_Y4+WviA_8vBW6&1RgCG&YoqyBr4?;N!$qmL_uQwhId5`277feEY#oeibuX(Q#{ z=ketLBuFx&@97ae3v85vw%ZaB)PrL#!Ki>4i?eA1)^o>yT8~L9jlg_IzCtjr-QUI# z^T1kvJ*+`$By(aPRV{a}=OFMP5Z6MJ;$sdMfIWokq1 zgiC38#_aAhMz0AB1}_}eQR)cmC3k@-9EL^bb511167#T8Nc=!T0UIhMRq)k&tEj1m za9UI41F=2ehFFN+I_~zR_QTPEGD)WD89vb7lRm@cgIsKHn~xTnMGgzn8=ePqS#2|W zA56?mo&>cCC261&>8IQ-X28!L_@v{nD<-?-w9ygEqkMG28n6v+w<;stjF2MK53S57 z?sbnYAvPMXw<@CaecI%>wjZY?e+nrluzWxTLuTyCtL;_K z>LXpOJTs4j{*xNTRvCIj0OJ6-36hLq*2K&K3P3(&X(9IBHvKSZ!=PP9bG72v1FrF! zl}6R(?m_3B9O()7V|uNiJ3UvOib})ZP#P-*(fjKBu&E?>zHKm_P5j(m)OI_|2%^c{ z=33`Bb)JICZ4Pk~rnjO8eZVk$bR+1N-&Le&9>Nk;yj5ioKk;zs=;83fO>1{7%N)a6 zw%NRp&t`)zjC3(K;Uj&=?zgW)2LqL>!&mk-3R%LFW~oNbhgUVrHXxFu;o3!aX16xa zeZqZ2AY{=@f*noNOXi2u7MRD?T-Kh8Z4h^=+Ep#k9&%0ZsGClkUf*)XlwU9oCTN#N zG1c>%qC;T$t`d|E=G|5L3dwBvu=)-utML?-Y2RWw-q*dxDUoR+84m;Tj8ijY& z4<+`xwH+s07NFB0=HPTOg?Zo`F)^A{5eo2Yhb8LzbZP5M7}+P4j2xDkH9?Vkju)ia z(HWa?&OOFH5V6<~=R`#&E^4V1|CBPpfoJi~peBUEd-XvIGCNApI%%PeW4;9!ZF>NO z&dhf5NZvY%LIt3?gQp>4QEHV4@t;_DCr4TkK(>mpSfPejAa7J!mvomoa|hm%XG!`N z5z+_~Zc-=5M|?^Yo=9MZVA6YLGTHmdKmCX&a&pH*id*E0ebr`pE!Jc)a48gHCWv|- zWd+?k0@a*{BM_QQZ60`g`*r1}G4H0{dd}GjnGljD6HPOaE=o|R46EN+{K4@-jNoh? z)SLp)Aqc@3C`+6yelT$jNNFpWr=vV6*k1emgEj^Wx=+zh5vkX00h~n%R!0xIU``hwf?ow{=Yi+Yo`KtF=u99&;FJz? zT5lfF8hBfcL*=sNag>y$=P#I$xmcM#4{AYhi2NlA>eS-oB*=*!gVZ_9%DJk9KUMj(V}#iptM!Qw=P)V;Ia(mC zf*W;U0PXPv;B0disBvyAN zw{^PR7oq{Eet9T+WY}_PAAK-JPhk;>erG@la)qyF(NquXg0|e2iOv+2TFo<`!XlYL z7C=vMoyz>3;kBDYagG_)+OchV<`t!0#8n#UEVnfd4+bk|k>hLLrkl~uLJ=m5@rWV8 zrQ_!?xZ_i&7%u?^`NdRGLuc-odhfhfOM&~Ck#qMvK9W3{4C>44T_Y~cJm0M21p@)+ zsj#62VT>X9YV;LjIQqGxK{}wNM}N$L_nF>m0)00f$pY?un39pJvutx)&vNP67gf$% zO~oi!+ny5W**o|L(m zqk@FU+Cl21&cX8)GjX?(cp2Kr1GYE&7K2X5WZP;u;;Z@e3Rj8|6rV0QUH@c_{Cj4 zm(ksis1YY9Hqt4ZPwnsSJYVxTi(o^m>Um4pb0}1)~izgCsA({iB^ zJt+ZgOz(V^!2TA`W2DYupk>WX*IWy~46CLro@~n@gGK&&;q!PyqS-@SI$p*}pYpEp zcUdS|FPB8rbWXN(oB0+E)VrQDn;*ft!*F-R@gxBC+_a73eh9cs?YU9bxa{#WLQHS2 zV1k%xPVtCd3_4SZn>?hGs&=JOOY1rv%koEP#S|X2gT*sv73ej;NvOD8Z@SZr-l;N_ zp233`t^zPs>mx^Q55gTH>P$vQq}-0k#GLCVG_>o+pKU|dWAwogug>F^7 zgf<$q?phMe8gr{s%=MUjo+%s;x57bF@r0Xwu{~XYd00&_WN5dB`D~cmGX$p^(yfx` z3jR7;VA4ctTRC>h5G_{dJhDo6eS8tIi>>7084z_C%WU5@XYFAGs0?|i^s-2dWy$C# zmhk~!H1`s+({j()d=eMvAqO5#H+)q>rbsEoYqp^n6`d0UZOX3eqT+#_8BkDI)pExM zPMMWlD*t>}CdBR3kEn1k6pea0@X!?EGBlEM>A)9G9zJB%E(Xxm9PdFJ4x`6Nb{_2| ze60Cb&JxljR7?8w5M_ysVO}_pO_GgL{I<32){R)fGn?lX$!| z5frrA@e>cnp9vi)ur?03AEIoFHM6CJILwBA?;5j)bQfUS#DY5Q=)%x&G~t5>8}PLK z@gkqX^#p%>iYs(R0z77yP>5EV!h?CM@#UR95Z1l8@w62QbhOOc^YF(3`Vl1bFKZr<~@TMQCQ!r?~|%m8?6W zoiKnD9?X^U1@cx;s_(Dk>$!sM5727#z?nI4NIW~AE6#Su2!%Ql0yabF7+QeeJXAz? zp~Juz0oeP{>FQqHcvhj!JdM$~Vs?Pa2Xiu`a6;P!eBL(VrQF46u+{YIqGfCDJhkcN*qNWdU%y9dc5i! z8vdlZ(Tluf!5nxy7{@6xj}6wvFe{ro0d&2I8n=+ue{p{h+g$ z$K7G1$Z7>D^hEQsy|YBAZsR;E0pH>XUp19JASPF%t%IO5EYiwd(!?RU^>D0>GDOkLZJwZ}le&oX_6V`CD#N+_1C*ewTme!bM-G~B@43no zm446Z@`=!cY|rNCB0kl8hvxPGw9yA0R3>CQ^#>Y5Pbh|IoM2`)q$o%^lpURoTV?a& zSJtUw-;n7!&|Ac&Q^GrzN;e9AwmqY^*~H-7ijRbW!1d(uHWsR1%m5PIIbS2|bA}-7 z3mpNSY5r`c#)yyf9!&F8Tq{&EN63}G3=)ukr;Z1u6+fdL`DzYOt*nQ&gsB~0ei$QT zxO}Q+Vz%>7Na2?z=B!AWplgurkOW=CdDzb&!ar6r>DcU+b1tKUsM0{5VC4hW(H>9d zk`TbSs7Mus>@yoA$ud&E!F->C^My6qh3sg6Q`bI})=v*4-` z=^Uki(CRH1OXxW%7h7Rvz@;QfXjv%(99(j3{5U`|2qeShQwO{XB3OaPcLlPVqH0~n zoM0PSP*rn^40M6s{4N`6R%YzhAh>FUs*25jR8jk0AOZI9cf^>9o?0@z5lj)CWdXyDs7sq3WwtN;9dgUHw<|Uhz$FXpx}_g*KY_ zn2*wgbTn_0kdbttGU<~= zMx`N~HkJ7dd@QFVpN`-_rlKNx^IfrXwlN{DJgEXgohdAQ>PjG<{)t`zc)0T=Y zG?}#auwKWtez92kT`{Z()$V|HsLSY`G?$Kp0YmUH+E?k6G9TWx`HH1OzXP4B=W+{> zN7chB(#3eX<2?d|=3&tIbtM0o;DhPvkhTY1gDm^KIxGrJESYzmQJ4QHc%$WjC=p?I zHBKE$Pa@3u?2P2rqbrdB+bfVng2p2xSj`Th3DH}gErygnp_ z1@JoE0RGL#gU3U5tPvTIs9HKoMO!jf&>wOP6TcD^==M_kh_mdNt6N89#4c)!bW?=w zpy|AaDyOs>8pGNCGGQ9{^8gU42li>&cj%xBV4fA4oF@x)+4j>Qi!%5D8u6~SqOGg5 z0c=L-4nM=MTw5q1ce(7P# zWoMrV9@L>8+PB^KOu5VvF;0JW)h2Y+LSVtzx&`ZbzEsbGV0)9jRrL&BHw!-?j7;OP zHmo2_C?{qwQ49)kJ6hu1$_xTq)gTjs7FSel8@UGXb$YNx!uO6QrIvSOAt2s^@0(c;rfQxCq^8@G%e=JE z#xkgt{vmI$k26XFD$WQZlvzh(#{AVYpifcou7WG$5Dsfqv=d$zd>r&Qf}@g2Pa3tg zX>A^j138h37G9M*S*Rq%ublHrOy9F@Kw?Y=-opP1)SdO;S z#EFeBkeqjibuM}r__$T67_oaIQE?dx>c}h$5_LkDiDtA4lDwqE;8=iem}bmz+^Ih_ z4|Pra1cNi5Ap~=HK&Gvus`w3;B-`kpjEI|0e6$cIMpa4(iZAMg)~2YBj}6lqjNA&c S6i4*g_`=aktmok3Wc`288d~50 literal 0 HcmV?d00001 diff --git a/test/data/gt_ant_production.csv b/test/data/gt_ant_production.csv new file mode 100644 index 00000000..664c7574 --- /dev/null +++ b/test/data/gt_ant_production.csv @@ -0,0 +1,65 @@ +chain, depth, pubkey, nonce, anchor, seed, score +2, 0, 886ef7f14d42287f8a74aaa92a409eaf901e25b6f9cfbedb5f72e1297bcad551, 01084ed07b9200fe2bb401df72b52ba819fd5c09e436f5f185594f8b8afcbc66, 54ba8fded70f55d660977d169d6a2ab6d7711b11f2b49596fbab1d5bf968a961, 8b12add89bc264e01038b61b784e0778a03cd3025e1faeedda6598f197ceccc0, 4576 +5, 0, 2df29076f8969739636dff083eb1f54bfe7e5d8fcac5b2946d0871d40ba495aa, 0106216c6da947657f74a629a59d5b81da7a4a6ecce8a3abb4b23079b5f1c56e, 21c6ec71261f8027ee7fe4629982e12c50facf8d0acc1f8f297264db6bc2dc2c, 8b12add89bc264e01038b61b784e0778a03cd3025e1faeedda6598f197ceccc0, 6400 +11, 0, 83b379182516db53587b3c553468e1af0ca4c21f13b95150024e68ebb2bb9550, 01085ebfe74c8324c42214dde9c3c71f691e327cd8a764e4c396d7431deaae03, 12545454084db67b036475332b9a7ebd7c7ed73b13e64bc17d87ec2e758dcb22, 8b12add89bc264e01038b61b784e0778a03cd3025e1faeedda6598f197ceccc0, 5308 +1, 0, 17620b9920ef596dfa46753b355247b1641eaeda1246fa7bc43884630cf61bf2, 010523bbe90fa21b263946830cc53e8744e270cf6e944b1cf255e85ac74a7887, eae546338da0b54a3b4e10f859b6293f1d285a0e51e4881f10d1379ff511f819, 8b12add89bc264e01038b61b784e0778a03cd3025e1faeedda6598f197ceccc0, 6295 +0, 0, 4657de22da43ad6402c8cfdd45ba54542213486481988bdd28c82182ec2714af, 01023378b9bf89fae25fdcd165c8739da9dbba04ff92982345a623031d23b0fb, 91a1d4fc06689515127b01f116fd64c4c2ca4c02a4692675c1b0dd4fc1bf3589, 8b12add89bc264e01038b61b784e0778a03cd3025e1faeedda6598f197ceccc0, 4800 +8, 0, e0b3b0f9b66fe475eaf17c6be38a2759ff254debd5485c4d4a27bc45486fa0ee, 010733ccce2e806a4aea54c0dcc386ebfd4efc6f3027f63c1de301686508f403, a2050b44a20027fb98984b156632263a8bccefd34e4c1a06d516ac807ea19433, 8b12add89bc264e01038b61b784e0778a03cd3025e1faeedda6598f197ceccc0, 4414 +6, 0, dc40c51265eff259556066d427dbeebb4109a7e79690d61d6349ef650b29dcb9, 010a33855fd20d0f1c910f0f7f7bc4eef7dfde506c7184d12bb6e50dc095b9de, 0509c75356fc65087c969b8eb8602a28c903a34d8f0648394e29dbeaa9cb4892, 8b12add89bc264e01038b61b784e0778a03cd3025e1faeedda6598f197ceccc0, 4206 +10, 0, eda9ead70a463d6bf5858faf2a8caa3c3d20b3c3e0e994167e685200bc750276, 01080bf6551a06f4826c9dfb047814381042f281b322ab9452623183daab9dd3, a0651819278fad70d8d2e24db8fa595f305cb879b400fb0c270ecad1361cc25c, 8b12add89bc264e01038b61b784e0778a03cd3025e1faeedda6598f197ceccc0, 4352 +4, 0, e5e05648b1c70c4deea4c7d7aefccd49b24defadceb45dd033caf0f16407808b, 0105337415ffe5aae3f1e4816712ba7ca6e4df3db6b10276868d4e2861cb69e8, a18598ffcfd37339a922658ea450e94f2e4cd083c4e64d5013d002c4fcb17743, 8b12add89bc264e01038b61b784e0778a03cd3025e1faeedda6598f197ceccc0, 4379 +6, 1, dc40c51265eff259556066d427dbeebb4109a7e79690d61d6349ef650b29dcb9, 010a54a6afb54d321417a986b3e8ce05e51823ea0aceef2681392fcda42010b0, bb849545500e6716bdbbf6fbbdcf6bfe1fd2b4cc20c1024cb419be77dae27df0, 8b12add89bc264e01038b61b784e0778a03cd3025e1faeedda6598f197ceccc0, 4206 +5, 1, 2df29076f8969739636dff083eb1f54bfe7e5d8fcac5b2946d0871d40ba495aa, 010354eaf1f371ea113434301d5e15b04d24b92fc11dd20f5e03fb6e6f497d30, e9bf8e92a20da221725f0897cf111f326c7754ead360f7aba074459ad99ec7f9, 8b12add89bc264e01038b61b784e0778a03cd3025e1faeedda6598f197ceccc0, 5998 +3, 0, 072fef44ac2f7667c366f067b374016bdd39581dbe8acb0f8aadf98502d13646, 010329371769a20be8033c7868d60327ad8b345b190f69f6a6b54f55c7acde8c, a3e0ad1361d551b2ce13025624602a4ebbcb0bc37c93348a88d23d394c155bd9, 8b12add89bc264e01038b61b784e0778a03cd3025e1faeedda6598f197ceccc0, 4569 +2, 1, 886ef7f14d42287f8a74aaa92a409eaf901e25b6f9cfbedb5f72e1297bcad551, 01081abdbc10a2b70d301fe0bb0afa298f90a5d9bdd0777d24949228abc63106, 02724836ecf8a9bd54e8f78867ea5b4e5c13dc6c41566094a6b41fa36e219f09, 8b12add89bc264e01038b61b784e0778a03cd3025e1faeedda6598f197ceccc0, 4576 +8, 1, e0b3b0f9b66fe475eaf17c6be38a2759ff254debd5485c4d4a27bc45486fa0ee, 010229cda723c097587f19e30ee090700b7da9bfc9a24e2fb73d83d55b4ba107, c55bea5399c1ca80efe9b9b6c51991ce99b6cc34f804984957cc7a110a1e03cd, 8b12add89bc264e01038b61b784e0778a03cd3025e1faeedda6598f197ceccc0, 4373 +0, 1, 4657de22da43ad6402c8cfdd45ba54542213486481988bdd28c82182ec2714af, 010615c66cb4c65a74382fce9000de43248a4425ced3bc79929139502e97c705, 4a564c2984b914fda7e68a1c80fd1bfdf6a75ac2443f348e98645d42fbaa052a, 8b12add89bc264e01038b61b784e0778a03cd3025e1faeedda6598f197ceccc0, 4450 +10, 1, eda9ead70a463d6bf5858faf2a8caa3c3d20b3c3e0e994167e685200bc750276, 010a5e540d6e35170556e51d0a25400b8e7dce3a21872d56e397633411fc7656, eb23ade51e8662688b5e069f59e64c9049c7896058ee4460bab704180f8be6e6, 8b12add89bc264e01038b61b784e0778a03cd3025e1faeedda6598f197ceccc0, 4352 +1, 1, 17620b9920ef596dfa46753b355247b1641eaeda1246fa7bc43884630cf61bf2, 01050a3c2127a028962e38ab80d9eb6848790106a3809637f8b1eb849c261a1b, 8dc4c241e049f0be5341d03b0d1023ae5340cd8cc506dd6f03a998743e2fed75, 8b12add89bc264e01038b61b784e0778a03cd3025e1faeedda6598f197ceccc0, 4663 +2, 2, 886ef7f14d42287f8a74aaa92a409eaf901e25b6f9cfbedb5f72e1297bcad551, 010246c5bb2fa79a2bc5f2ed1fbb2877ba2de90011ee439383347c684cfb80a6, ef9bab5a83f63baadbd79d9e3680a36eeb65d8c9c1c52c6dce0e47f807ccb465, 8b12add89bc264e01038b61b784e0778a03cd3025e1faeedda6598f197ceccc0, 4394 +11, 1, 83b379182516db53587b3c553468e1af0ca4c21f13b95150024e68ebb2bb9550, 01025ded1ea31c0763dd89595fce37c703c886203ef104ccea9979bf6041b511, 967e0547550da817764ba0a7b51e33556c2435e9a910e4b16d9300bd785a06e1, 8b12add89bc264e01038b61b784e0778a03cd3025e1faeedda6598f197ceccc0, 5308 +4, 1, e5e05648b1c70c4deea4c7d7aefccd49b24defadceb45dd033caf0f16407808b, 0106282d2480193df99350d4ef0c3b31b9a4314b38cc3d0e86c3c9564b9fc77b, 9ba91514fec4c80a40201e7fbc9bf7d08ee446b8f3efaff16e32ae54c5afdc92, 8b12add89bc264e01038b61b784e0778a03cd3025e1faeedda6598f197ceccc0, 4310 +6, 2, dc40c51265eff259556066d427dbeebb4109a7e79690d61d6349ef650b29dcb9, 01052e993e876de4beeed6b7e5f674acf08d15f44a41dc59e080b50b5e9ee9ee, a570a04a90af0b23bf9ea5b4be54314a853f517a060c39df1d983e9665f4c45b, 8b12add89bc264e01038b61b784e0778a03cd3025e1faeedda6598f197ceccc0, 4206 +3, 1, 072fef44ac2f7667c366f067b374016bdd39581dbe8acb0f8aadf98502d13646, 0109595f50ab0a4d9c0c2209dc2e020058a6a3fcaa4ab687f5338d46a4259031, b8a420a950c50a97f8876a589cc1a4f2822eed80efc2e7ef6bd44b2c780061c6, 8b12add89bc264e01038b61b784e0778a03cd3025e1faeedda6598f197ceccc0, 4569 +5, 2, 2df29076f8969739636dff083eb1f54bfe7e5d8fcac5b2946d0871d40ba495aa, 01030cc6084cbef2f92c8fc481db9b3b36ec91723770bb5c6aaea1dfb2104376, b3310ce38053f82c73d26eed58ad00897a8d0de995d60c4eeb8d4755afc518c9, 8b12add89bc264e01038b61b784e0778a03cd3025e1faeedda6598f197ceccc0, 4836 +10, 2, eda9ead70a463d6bf5858faf2a8caa3c3d20b3c3e0e994167e685200bc750276, 01040809aeb747d430f914d0ceb1970d3ff7e50b44ac1db1de8ac6a910795423, 5df3e71634f680908f5954ce538b65f470d65779dfcae051f1d42b43f1527abe, 8b12add89bc264e01038b61b784e0778a03cd3025e1faeedda6598f197ceccc0, 4241 +10, 3, eda9ead70a463d6bf5858faf2a8caa3c3d20b3c3e0e994167e685200bc750276, 01075358c48d3c17682f8c69578d2db1710168ebad922b591b764b0937353be2, 190832364108015065a051e38badc3535be4faff42a319132ebfecea4bf03416, 8b12add89bc264e01038b61b784e0778a03cd3025e1faeedda6598f197ceccc0, 4241 +8, 2, e0b3b0f9b66fe475eaf17c6be38a2759ff254debd5485c4d4a27bc45486fa0ee, 010307ae588e0d66befcfb11099713189200bfeab97cb37d4f18bfa0d877cd2a, 2032dcba5a6c53d1cb68ad800ae8c7b455aef9aa90e1c14cb5591bbe2c8e1927, 8b12add89bc264e01038b61b784e0778a03cd3025e1faeedda6598f197ceccc0, 4307 +6, 3, dc40c51265eff259556066d427dbeebb4109a7e79690d61d6349ef650b29dcb9, 01014f1dc6bcc0311403fe50177fe2b69d702b1109db30aab04be2b6cd964375, 7f2fbd2dca697fee9169049bd06a5cadb6932db37cc8190b7f781e2859a13ddf, 8b12add89bc264e01038b61b784e0778a03cd3025e1faeedda6598f197ceccc0, 4206 +0, 2, 4657de22da43ad6402c8cfdd45ba54542213486481988bdd28c82182ec2714af, 01011280b730b2317044f434b8e56ca881f7efeaf8dd4b29b12e877ea43fb7a1, d260aab327663b1197f602584a74c80dfdc3915d487440e21d59b6d795ba284c, 8b12add89bc264e01038b61b784e0778a03cd3025e1faeedda6598f197ceccc0, 4450 +2, 3, 886ef7f14d42287f8a74aaa92a409eaf901e25b6f9cfbedb5f72e1297bcad551, 0107159b5267736895605a21efe7bfa20f676707dd2bfdccabc6d67ed211d511, 32fa4cc87d7f7ba35dfcc9fad0256dd64bd1c3a340e1fb1ce92a2e3102641be8, 8b12add89bc264e01038b61b784e0778a03cd3025e1faeedda6598f197ceccc0, 4291 +11, 2, 83b379182516db53587b3c553468e1af0ca4c21f13b95150024e68ebb2bb9550, 01040f59f2e3d85b897c1997ae4cd6a1aa20f0397795106ec35742efa2f60207, 70efb4b8a77579ec350179a71f89e517c378ec87896a526d5bca985e45e6a2fc, 8b12add89bc264e01038b61b784e0778a03cd3025e1faeedda6598f197ceccc0, 4581 +1, 2, 17620b9920ef596dfa46753b355247b1641eaeda1246fa7bc43884630cf61bf2, 0103333ea8e0bc20c0f2cfb7cc93e9d3ae302f4b5e933e605ca8d5f2b101f7c8, 39cb97cbe391fff147ecffe6bc7b6cfcf920726974c9ad5394b54e7a86d3e752, 8b12add89bc264e01038b61b784e0778a03cd3025e1faeedda6598f197ceccc0, 4663 +14, 0, 8c2cce72fa295e618dada27cd9dce2a8f24a4596d676edf11e096bcf6f5c078f, 0103553c86f3dcc0e0a8c63a9004bc5719b09b04348d77a9de594f5b8bdffaae, db7e8851e5e2b45c072bf7cd15040a9bc313b6d4e8e568f7a50a62810a181842, 8b12add89bc264e01038b61b784e0778a03cd3025e1faeedda6598f197ceccc0, 5808 +5, 3, 2df29076f8969739636dff083eb1f54bfe7e5d8fcac5b2946d0871d40ba495aa, 01022aa2e9c6bf0d46e240165a772be18804f052b8f76111e0c22af538b332e6, ed0147d60ab884ab994eca1b25f319349437e4106b21068d0f8ae9c357102e14, 8b12add89bc264e01038b61b784e0778a03cd3025e1faeedda6598f197ceccc0, 4779 +4, 2, e5e05648b1c70c4deea4c7d7aefccd49b24defadceb45dd033caf0f16407808b, 01080ccfc0cce11c4229f4dd023630330c17f3f495c3db73a30ed50107882a5e, eb7729568d616b19004a42dab7f8b237defd6eb3a0a5448037ecf4ec56044bd7, 8b12add89bc264e01038b61b784e0778a03cd3025e1faeedda6598f197ceccc0, 4137 +3, 2, 072fef44ac2f7667c366f067b374016bdd39581dbe8acb0f8aadf98502d13646, 01013064dacd09cf7f1898ac0b669d25f4c0db3c2bcea9b3c9cd51f3fc8a7414, 5a107488a0caf086ae94acfefca9b36f6ae84691b11a001ef3999a673d7b160b, 8b12add89bc264e01038b61b784e0778a03cd3025e1faeedda6598f197ceccc0, 4394 +0, 3, 4657de22da43ad6402c8cfdd45ba54542213486481988bdd28c82182ec2714af, 0109151f9d11847474e333460ea73646a31663c84162313db3cea328a74efa82, c10184404849ef460dd734d49d22cad1b25aafa77bbd3f22073dc88885f644ec, 8b12add89bc264e01038b61b784e0778a03cd3025e1faeedda6598f197ceccc0, 4450 +4, 3, e5e05648b1c70c4deea4c7d7aefccd49b24defadceb45dd033caf0f16407808b, 01044b182869be4ba5670092661abac68606a2f4b06130d101882d236e482358, b7fc5ca1cb869e33b3725a1f3a4c96e8545200d0f263d099351016b6baf46039, 8b12add89bc264e01038b61b784e0778a03cd3025e1faeedda6598f197ceccc0, 4137 +14, 1, 8c2cce72fa295e618dada27cd9dce2a8f24a4596d676edf11e096bcf6f5c078f, 01076440117d3ff4d99c831ad310105cb1217c9bfe4bbd7a8d98df63fde9dac1, c01ee3f86edc2dbb38b5fcabf8dca2e5f63e80cfc7161b897783b71fb24eb07b, 8b12add89bc264e01038b61b784e0778a03cd3025e1faeedda6598f197ceccc0, 5808 +8, 3, e0b3b0f9b66fe475eaf17c6be38a2759ff254debd5485c4d4a27bc45486fa0ee, 01092583572dbd57774dc64402af3254c4e9cfc97a76b686d690e728f4946882, c1003cacbb633a682fa1587cc0a59535501e3d8511b896df7431c320da790724, 8b12add89bc264e01038b61b784e0778a03cd3025e1faeedda6598f197ceccc0, 4294 +11, 3, 83b379182516db53587b3c553468e1af0ca4c21f13b95150024e68ebb2bb9550, 010522c21f13bb19c26a46e5798d2ce597bfd035261c4a3e9e2d38963910dba2, e692e800bb6e620f5108e70950e6f65c46997a3dd8aef402a30d44870ba94440, 8b12add89bc264e01038b61b784e0778a03cd3025e1faeedda6598f197ceccc0, 4344 +12, 0, 2814b082c6683e9490338df3e42c8d89ca9e2f889735013dcbb7ddbdf7d846c4, 010618c41d7d80fdeb077f54165537de721e39ee32e5f8d11dd16e5edff70d91, 9c65303cf21df1f289cd5d7e1d431ddd79016d87c6e8ec57b2c23c15c69c119f, 8b12add89bc264e01038b61b784e0778a03cd3025e1faeedda6598f197ceccc0, 4862 +1, 3, 17620b9920ef596dfa46753b355247b1641eaeda1246fa7bc43884630cf61bf2, 0108598ac27cff46dd5635d43a3ba7b32bf7c0f844c95418fb359f4bb8b14a48, cdb0470fe9f772124a6d158869b4bed2451b4445293ffa2cc56f96917b108ebc, 8b12add89bc264e01038b61b784e0778a03cd3025e1faeedda6598f197ceccc0, 4663 +12, 1, 2814b082c6683e9490338df3e42c8d89ca9e2f889735013dcbb7ddbdf7d846c4, 010a33039aba1207b1d63d559fbe40220eaa30b56fd25e5d0767eed2538ed35c, 27ee5997236575efdb19c9845b330da3e969ab8b69af32b6cbace636afbca8cc, 8b12add89bc264e01038b61b784e0778a03cd3025e1faeedda6598f197ceccc0, 4862 +13, 0, e36507d27632e2d6cc70480f6322a4469d20773ff0d047f6f70285e9d44beb5b, 010a602d4d374fe77a551445a50be97aa8ff00a8e6d1bc87eb6a05a7ca9d5bac, 5e65aac82ee69c59e00bd8bc6b688b45c6a30fc14a97c50f631f6255eaaf7327, 8b12add89bc264e01038b61b784e0778a03cd3025e1faeedda6598f197ceccc0, 4916 +14, 2, 8c2cce72fa295e618dada27cd9dce2a8f24a4596d676edf11e096bcf6f5c078f, 010203df7f6c6ec32fb907ddd1bfe342d0e638248cf885e02534d40ee5b0fdc7, 7f25490bb25c2a15a4ae4b09834af66b32f6c747cd410f205ebb5f26157628b3, 8b12add89bc264e01038b61b784e0778a03cd3025e1faeedda6598f197ceccc0, 5475 +3, 3, 072fef44ac2f7667c366f067b374016bdd39581dbe8acb0f8aadf98502d13646, 01011fba8c525ade36b44b91c10f763cc452b9f359138a17b7095defba3de81f, ddbcfaeaeefae22924db28ad1ac6dbab0878009449fa125d1e072edfd29bfe29, 8b12add89bc264e01038b61b784e0778a03cd3025e1faeedda6598f197ceccc0, 4337 +12, 2, 2814b082c6683e9490338df3e42c8d89ca9e2f889735013dcbb7ddbdf7d846c4, 01081ae266672ca194549f553909f744cc0043134958ecc17e1cb23b8b321b81, eabf4471ea17eeb0e6b8ef2868bdf533e538e9117d89ee7be0506b5b87100c31, 8b12add89bc264e01038b61b784e0778a03cd3025e1faeedda6598f197ceccc0, 4862 +13, 1, e36507d27632e2d6cc70480f6322a4469d20773ff0d047f6f70285e9d44beb5b, 01061e74c28eab539b083cc334b3d8f49d500d3a0efaf0ddacfeac3c3d7db59c, a1f90ec26c1e922437b5dce2cb7d5aa74a73dd6b2a633e0aa1a87e181ef4e493, 8b12add89bc264e01038b61b784e0778a03cd3025e1faeedda6598f197ceccc0, 4183 +12, 3, 2814b082c6683e9490338df3e42c8d89ca9e2f889735013dcbb7ddbdf7d846c4, 0107093cee10017f16cccbff4c1bf264ad41d8416654ce13f71d06d40a068d45, 4728ca3016635dbb5fed672ad264689e97f39372d87fdc8751d24d305147e50f, 8b12add89bc264e01038b61b784e0778a03cd3025e1faeedda6598f197ceccc0, 4808 +15, 0, dac78edf70e526e26a94ac4982c2abded02fe049c5038196738d66d80849ca3d, 010a4622db8bdc6f2d2a53c4d093a7db2eed1511435c0c9d5f3ad99ccc89461f, 782f1d06c5a143065b84a674c8c957ec1f6c335b9b9d1818aa440358490f9d42, 8b12add89bc264e01038b61b784e0778a03cd3025e1faeedda6598f197ceccc0, 5165 +15, 1, dac78edf70e526e26a94ac4982c2abded02fe049c5038196738d66d80849ca3d, 010758cd460961f2865307025c35b5c371a152aa887aa79a5ed5db1562554b51, d949da8e7b84d066ea4e0d44064dfc4d8c58b4c4f6580a817de564a74500ce2e, 8b12add89bc264e01038b61b784e0778a03cd3025e1faeedda6598f197ceccc0, 5165 +14, 3, 8c2cce72fa295e618dada27cd9dce2a8f24a4596d676edf11e096bcf6f5c078f, 01051655e2f51a514371801ef25660a391477f0d6c492b613c5a83bb30f490f6, 66473bfc883091c7d6b1ddcb8753db715e7a9568aca4b2f870f155edf0b952b3, 8b12add89bc264e01038b61b784e0778a03cd3025e1faeedda6598f197ceccc0, 4839 +15, 2, dac78edf70e526e26a94ac4982c2abded02fe049c5038196738d66d80849ca3d, 01034a2930d5d739d27f0a4b65bee1bd19ee6dccc2da5612aa3e380d60e263a3, 61cda7fa7dd9cb6b817139a51b7d17f3fef1ddb31db2a506082cd49411d0df57, 8b12add89bc264e01038b61b784e0778a03cd3025e1faeedda6598f197ceccc0, 4541 +13, 2, e36507d27632e2d6cc70480f6322a4469d20773ff0d047f6f70285e9d44beb5b, 0102482e077b5bbcf0c1f5da9a77803663ade42ccf6667a2d07366ec25c05373, 7cdb704cdc5df918699d7bd399008f249f79ab8b4ca08689f8d3010c5813de38, 8b12add89bc264e01038b61b784e0778a03cd3025e1faeedda6598f197ceccc0, 4183 +15, 3, dac78edf70e526e26a94ac4982c2abded02fe049c5038196738d66d80849ca3d, 0104501feb2927fd13760f5c39dc277827c125b069804c6ac18e6021b1811868, 61078520e97db6e828ce6465404fe3456dc27deb16516cfdc3c34c8293c52fd0, 8b12add89bc264e01038b61b784e0778a03cd3025e1faeedda6598f197ceccc0, 4541 +13, 3, e36507d27632e2d6cc70480f6322a4469d20773ff0d047f6f70285e9d44beb5b, 01024f0896557a0084a1d9a2937130ccb2711a8cb810e512ec9e2c97c3c56db6, d4cbdaa2fb4e3c1e8ef809ed628f0abf16d3bf4a93f81ce03bf77b68e07fb516, 8b12add89bc264e01038b61b784e0778a03cd3025e1faeedda6598f197ceccc0, 4183 +9, 0, a14d543d43592ce38ef38f600a2dba801557fd924638de966d2725dabc5e2472, 0102152a605e81991fe51e4c8964a98462f6a78ab03dfabfd30552d39e9d4eeb, c17c5c2242fd08302a00dddf39bfe32952a319f4816dbc540b991d8ee33180b4, 8b12add89bc264e01038b61b784e0778a03cd3025e1faeedda6598f197ceccc0, 5099 +9, 1, a14d543d43592ce38ef38f600a2dba801557fd924638de966d2725dabc5e2472, 01012c56d755fe8e1c3c18ce6c13e4fb74a62f4dbf6b4d88a4d4d5f12a82111f, aec69cdd848a1b7ff0f89c9ea97feb759270ed6719ec8a675ceba9cc0877a1de, 8b12add89bc264e01038b61b784e0778a03cd3025e1faeedda6598f197ceccc0, 5035 +9, 2, a14d543d43592ce38ef38f600a2dba801557fd924638de966d2725dabc5e2472, 010918e449d18c7ea382562025280bd33f3dea08e299eb92eaa7413d9b42c367, 1cb9b9ffb834abc7b8bbfeb01e006fd3cc412717f8b9456e5e4d663466417e99, 8b12add89bc264e01038b61b784e0778a03cd3025e1faeedda6598f197ceccc0, 4281 +9, 3, a14d543d43592ce38ef38f600a2dba801557fd924638de966d2725dabc5e2472, 010a107f47f98b2357d358f56f2a9cb9759714f3bec8ae28dee41e9ce170a5db, c6d51cd991be84979a28aa4b4e51de3ef0f585aadbab7b66a0593805a5085546, 8b12add89bc264e01038b61b784e0778a03cd3025e1faeedda6598f197ceccc0, 4132 +7, 0, 158b59e2f807c4b05160ad5dae50f96f079b79a7f7467e11d86c37a808515c1a, 010303ab82e286a8adbe84e8b7d72f1c627058d749eb7fee957227ae354736a9, e1b75477f812f034862401a8ecf7c9b7ffed63e904ebff99b4662bb3157d55f7, 8b12add89bc264e01038b61b784e0778a03cd3025e1faeedda6598f197ceccc0, 4382 +7, 1, 158b59e2f807c4b05160ad5dae50f96f079b79a7f7467e11d86c37a808515c1a, 01061f0f60aa46bb3d3db810b004f0d63a0666f4304c7902c2d0de7116fd4c3a, a6453d96ed2963d2f8073faf29405223c3b9de7b9f0bf925ad5b5807f0df29ec, 8b12add89bc264e01038b61b784e0778a03cd3025e1faeedda6598f197ceccc0, 4382 +7, 2, 158b59e2f807c4b05160ad5dae50f96f079b79a7f7467e11d86c37a808515c1a, 010a01e51b9910f599cf59ad2c563f0dedb61dbc3c0226bad673b48d1b538419, 1485b5332779be0d64bd08c69344580358b320cf01b9f38ce70badc069e94ef1, 8b12add89bc264e01038b61b784e0778a03cd3025e1faeedda6598f197ceccc0, 4032 +7, 3, 158b59e2f807c4b05160ad5dae50f96f079b79a7f7467e11d86c37a808515c1a, 01050c4baf9613939235ebdaa17d71ffd8523d368247815fb47fa0fa6334e99b, 182c36a0398009d6b6e58301e13c805cd5acfc37412f55cadee180e90b27afdc, 8b12add89bc264e01038b61b784e0778a03cd3025e1faeedda6598f197ceccc0, 4032 diff --git a/test/data/gt_production.csv b/test/data/gt_production.csv new file mode 100644 index 00000000..80569d03 --- /dev/null +++ b/test/data/gt_production.csv @@ -0,0 +1,97 @@ +pubkey, nonce, miningseed, score +039cc1f1560aa96daa994a2b296f22d7f2fc9503ce95321d0b8193079e5f93dc, bee1e55fc2c967601827dd80f09a6b0a47ceab37b920b57efbd23c8fdc49e38f, 8b12add89bc264e01038b61b784e0778a03cd3025e1faeedda6598f197ceccc0, 4254 +9b3d041660f08f692ae1005118f85c35bec491c33b51c94ef126663f4402fb1e, 1a314eb596f55aa79e7f61334072380042ea4e351ca59dc09f536f8224a39afa, 8b12add89bc264e01038b61b784e0778a03cd3025e1faeedda6598f197ceccc0, 4787 +5ddde3533e040840b737304dd92b4b48d62ab209419a8bf4506e7b0497e9c614, 419569517e0908f9bae5b07159d322204053afd4564b4079fe160f775e214993, 8b12add89bc264e01038b61b784e0778a03cd3025e1faeedda6598f197ceccc0, 4329 +66950904cb50245726a50f9039e2d384bb878ce3e39e0575f64eb61bf9963325, 158191c4be8a6382d9eff13a448550c7742568909abc76d8213c958e85bb02f5, 8b12add89bc264e01038b61b784e0778a03cd3025e1faeedda6598f197ceccc0, 4021 +beb0cbbaab3cc54e87e4b6ed4a5f6c4af2fa4f452a02252a8ec910c43fb71a43, 96725650ea0a2052e3175c0a1047f51fcc7031f1455fe168630f0ecbfce1a180, 8b12add89bc264e01038b61b784e0778a03cd3025e1faeedda6598f197ceccc0, 4642 +c7f5024050c450389d341eabcea04b16e79b7a51f18bcb59284eaf40000cb7a6, 77b3965cec126453cfeb23b5b44f20d652058f7726f0e43e8c265eafba6e8418, 8b12add89bc264e01038b61b784e0778a03cd3025e1faeedda6598f197ceccc0, 4138 +a6f49eb0c3fd08eeb56b4ab83bbfb0b415b02233101baff5eec26bb2d1455e34, ac8db67e608af98f13f70b611c71f527dbb626afe41128cc6dbb27da921a6db5, 8b12add89bc264e01038b61b784e0778a03cd3025e1faeedda6598f197ceccc0, 4323 +5c49beb4e29c921d8fad67d9b1e09fd693254f9275dfd77ded5a66acf721b4fd, 5e80e9f095fadb379b12130c4796f74ec967fc526b1face4af813a13b029fcd3, 8b12add89bc264e01038b61b784e0778a03cd3025e1faeedda6598f197ceccc0, 4596 +d8470f83e18ebd6bd221423f03b0e17a87872f6ef25ca8909f28078a6c300ff0, 7989ab5f03db1a93b51a8629ac6d569ff0299ee1744ddce813013e9a64ff6497, 8b12add89bc264e01038b61b784e0778a03cd3025e1faeedda6598f197ceccc0, 4254 +4c83417da5f166accd29107292d5c0b7502811b4e0eef6d252c01474dce157bb, 59245fdd6fb52df651c4b3228596175e327aaaf4f4405cd37bf84ac1a9e51747, 8b12add89bc264e01038b61b784e0778a03cd3025e1faeedda6598f197ceccc0, 4476 +aee5c8edcf3dfdab7ec84b7c580be8a64c0b902be6b365fe916733f05e238ac2, 56dddea0c9052587a47db7f5deaa16727663725edcbd0c5d78f9b1193bef12da, 8b12add89bc264e01038b61b784e0778a03cd3025e1faeedda6598f197ceccc0, 4179 +f2112f60356004b9fe52840e06e9047d2020ba228b7e1987663491a7d90e3e85, 4893d1aeb7591ce464e26ed43e34501acf450a6d6ff3a4dbb85a3920d9011308, 8b12add89bc264e01038b61b784e0778a03cd3025e1faeedda6598f197ceccc0, 4227 +050a8c2acdcc99c71b1f6e04f3785291f2980fcfbc0a2355a200eec5287a18d8, cd2744b32506fc199fb1aaf0b9edaa553d05f0f4c66e9f7d2e47140fbc7973e6, 8b12add89bc264e01038b61b784e0778a03cd3025e1faeedda6598f197ceccc0, 4112 +5bb6da0fd6e14135dcaee492185a223c5495936b29cabecf415094e1cf0b64a4, 1b169a9f717bd61508b009a9e149c3bf3bf192acbc2a11af6da31214ca0c1be2, 8b12add89bc264e01038b61b784e0778a03cd3025e1faeedda6598f197ceccc0, 4367 +ce103ac663da7295f0013f33b1eadb5295e32dbf8392f67b8085caca9d8291da, 44fdb9bc59d865bd9d87f049ea4a7a702d1a0fca20cddb7172bb10d7c17dcb06, 8b12add89bc264e01038b61b784e0778a03cd3025e1faeedda6598f197ceccc0, 4373 +da39c1bc6effaa3edb88cc805e6e7bd7aad1726ade6b0dd38d54b52957f14125, 50ee325b50af29c82a0ac06b3ec85885f9e36e08f1342a938acfd9a002ce524e, 8b12add89bc264e01038b61b784e0778a03cd3025e1faeedda6598f197ceccc0, 4538 +2f2a57841de709ea79ec678d1848a2dc13cb1d96d1d029278181e925ce6ce281, 29b9f9db6a5001480686637bb3f187868d23a8881b32482000a4e3b217074d54, 8b12add89bc264e01038b61b784e0778a03cd3025e1faeedda6598f197ceccc0, 5044 +7bf6c1e4d203b633f0f5f5421d2d16e66db924cccc51092f1e89ededa54ad322, 490f7927f8f91dbeab6434e4f0ca660642d9157267613ded72a8315df821d838, 8b12add89bc264e01038b61b784e0778a03cd3025e1faeedda6598f197ceccc0, 4272 +a6f0ae22305a9a0336a2c7d97e1568697cdb62e1aedfc9874a1ac1915b4e3c67, 332f47bf272e90de5befc330c0ae65b88a981c6c28819c9df8bbc7fccd7176c8, 8b12add89bc264e01038b61b784e0778a03cd3025e1faeedda6598f197ceccc0, 5126 +1f710bfadc49f5a1bed75fb10054979a3b4856a48e3b75184f7b056d3a23ac41, 6d5adaad8e0ea9208390e5649d2e0c6365ee84cc1611ae5f765424d7992b8322, 8b12add89bc264e01038b61b784e0778a03cd3025e1faeedda6598f197ceccc0, 4227 +36f621af4c726d3e5bc3cce3d26a4418c2272024fd8177110a6a64fcc9071afd, 6fa998c1ee8efba299b8b51e865a13b939df3189d94c0b939f86c8b2d9fb7893, 8b12add89bc264e01038b61b784e0778a03cd3025e1faeedda6598f197ceccc0, 4002 +8117882a9cbfce4ca0df28f72a818773ba80377417e8c73d2c0a56e8c6e4ae22, c762e691c4918cc826e72b5277fa2c11f4ff9cb2a01efefabe6ab8f6c8c5be3e, 8b12add89bc264e01038b61b784e0778a03cd3025e1faeedda6598f197ceccc0, 4124 +a7a69484aeb270fda2ce28d9b6d4ef6f1e73edde9adea5404f81b7ae265a24bc, 4b18c6bf59aeaf1f96a11956eba1c2ac7bbddbed8e0f12ec5a94dcc8107a5cb6, 8b12add89bc264e01038b61b784e0778a03cd3025e1faeedda6598f197ceccc0, 4500 +a84e3c009575dd77299c18d600d0252f9d0480d34c5da189a7625a0274f83cc8, e0c96c11ba7a7137aa7fe213dd34dee838a598acf0d76776e5c3778307c1e631, 8b12add89bc264e01038b61b784e0778a03cd3025e1faeedda6598f197ceccc0, 4334 +dc100c8d058c0cf6df88dbf4fe01f6023b173be3bedd6b86dee4b8eecc72e058, 179fdf1f611d6c27a08eb533b826d169a0bd3d33e6c329ded1eee76fcefcd2dd, 8b12add89bc264e01038b61b784e0778a03cd3025e1faeedda6598f197ceccc0, 4259 +087fa2a7ada94299393d118b19c6360699845db46f559eba3ee5b8bc494f2a8f, 0f200d7799483ecf52a4af5c3a69111fd29a14bd7a03a82f9f39f1913b29e5f8, 8b12add89bc264e01038b61b784e0778a03cd3025e1faeedda6598f197ceccc0, 4347 +8f9069a8eb962dc47afac9ea7d4a77d7ab801c3e932183f9dcda9c8cd7fdaf9b, ab4f54740589f62c4e35c2b7a2508a3f7fb23872afd62771e57deea64bc540d9, 8b12add89bc264e01038b61b784e0778a03cd3025e1faeedda6598f197ceccc0, 4477 +ecbce54faf01fbf9015cf9b19be9bbf42e9530be81fe09fc92c666eace8bfc15, 86acf64f19dc27151febf20c2549d49b05f4dc87290e108e935ebd6d49950cee, 8b12add89bc264e01038b61b784e0778a03cd3025e1faeedda6598f197ceccc0, 4513 +36e28fe48780115b126255d893208b16f04505687e9aafd17ba73dbe543a8319, ab908ba2a064b7381f26c7a02cfaa2d8b7689d5f24877aaad99e25e0714ca19b, 8b12add89bc264e01038b61b784e0778a03cd3025e1faeedda6598f197ceccc0, 4711 +ee4fca45aa951e2a2c0e535fddda71cd1c0722f1b30bf74416d9ac6df8787e52, b53606d34971e204c260e1c5c2bcb88450f429b76340d0860046bf2a67cc359f, 8b12add89bc264e01038b61b784e0778a03cd3025e1faeedda6598f197ceccc0, 4634 +25b3ace18ee066c5cb561d32bfc89f69f20199741519d148a1d00312191e1f0f, 06ab93f88f0fe2aaa54efe1abf60cceef2b21d15c378d66096398c8b82947c7d, 8b12add89bc264e01038b61b784e0778a03cd3025e1faeedda6598f197ceccc0, 4032 +a7149ab5f6c9f3ec2dc997564839a050de78dcf54e255d0019b4d81c3675bb56, accab93bf12ddf0a8b2ee43bcb94fe01d265eacd6df6aa8bcf133a5d359a79f6, 8b12add89bc264e01038b61b784e0778a03cd3025e1faeedda6598f197ceccc0, 4964 +d4aaeaf020007d1349590d77e2f779ad48f9a6cf720d04ca6a65fbbad81dc050, 7a40d7032c899ac35f224bd0a7be2c12244e9b2b4a32852b2ec880d526929ece, 25b115eca9f2c1c0b3ada40faa46c248f2919d8e5f6e7fa2866d4d96ca1c0eaf, 5357 +5fd98a26b2bcd498daef005d5035336777164fd594690d083ffc07c2750a00bf, f8c4c01377b01fb07d914d37301d2eee2349e015128adac766c676913f7c056e, 25b115eca9f2c1c0b3ada40faa46c248f2919d8e5f6e7fa2866d4d96ca1c0eaf, 4538 +acc5d85d4bdb5d3e63879ee9975db8dd89474eaa143fd3a3b0dd9b406e6fb19a, 2097e387f6f27d6f630532f3407189adfebfae9bfe4308f0a4a718b72756cb64, 25b115eca9f2c1c0b3ada40faa46c248f2919d8e5f6e7fa2866d4d96ca1c0eaf, 4251 +5329b924cde1d5be74ae7b98fd5dcd6c6cfc497abaabcfd13ba6ad76b3e80a73, 409da8d39041e54f20851bc0ae095cb8b80ba393af8531ea6f1977e14e900bca, 25b115eca9f2c1c0b3ada40faa46c248f2919d8e5f6e7fa2866d4d96ca1c0eaf, 4435 +bd50121a1a9982b2e6f1becff5beced13d3aeb3b8292b6d64eaa641a85c6af36, e2c0de9cf3488fb4db674d9b6bdb7261044556a5ff21d66781c975316624ce8b, 25b115eca9f2c1c0b3ada40faa46c248f2919d8e5f6e7fa2866d4d96ca1c0eaf, 4064 +f09fa2dd45236ebd36dfca4443787119dcefdd152723cf5f4346f16c33611ca4, ab4f459cae0455dcee65258b8c8340e3429cb43d826001a52542b0b10b2d3051, 25b115eca9f2c1c0b3ada40faa46c248f2919d8e5f6e7fa2866d4d96ca1c0eaf, 4618 +5df6390b68e67e1cff09fea890e14b204f8b5e9ef847b14a05b7b32f83576c73, d2c779399ff772a44024e4991f32463193e7d21e958b926ead5186fad44d276e, 25b115eca9f2c1c0b3ada40faa46c248f2919d8e5f6e7fa2866d4d96ca1c0eaf, 4658 +0c5c814529d514ed2084f87554a1278e303aa627c720c21502509e6453b46e75, 4d2317b4d7645a12954929dd48e1541ab6c333eeb590fed06bc795876d399b86, 25b115eca9f2c1c0b3ada40faa46c248f2919d8e5f6e7fa2866d4d96ca1c0eaf, 4805 +1ec0460a6d6ecba87d61bde51c2b27c758cefecc568cd807b5d34c51d4d9f427, d0aaeea09c9dee00f7062fabcf47fd61f2c288b80295a820ec4222a18c2f67cd, 25b115eca9f2c1c0b3ada40faa46c248f2919d8e5f6e7fa2866d4d96ca1c0eaf, 5385 +e91795a4cc4ea7a134da695bd2ca1638e3510d2ed21a6207c34b25c920925556, 2cfdfbd4047379ca02f323115287c8132cbd1ab84ae94bb4142147ccfaabcde9, 25b115eca9f2c1c0b3ada40faa46c248f2919d8e5f6e7fa2866d4d96ca1c0eaf, 4104 +547ec5ed8156d06f369b8642d81f542aeb06d8be0fddfb82d499722def67ab8d, 58315e20f5e05abcbb1c225435e985dc08edb01a51bb472e5a2caa1ebea54636, 25b115eca9f2c1c0b3ada40faa46c248f2919d8e5f6e7fa2866d4d96ca1c0eaf, 4293 +d8e5cac99aa0e4d53d13a2a5efcb44533def7116cd8cab23b3e8ffe2e6e36033, db5d8b11d57d382bb4a174aeaeea1934089ca1ce87f695b02e969aa2d804323b, 25b115eca9f2c1c0b3ada40faa46c248f2919d8e5f6e7fa2866d4d96ca1c0eaf, 4490 +11e6f5455923f425403174d26957f3c4343082676f2786a504b175a37e095257, 3d2b359ee811655af3afa43d4b6bcef022d9b086bf7b90196f8d2fd319cef2e9, 25b115eca9f2c1c0b3ada40faa46c248f2919d8e5f6e7fa2866d4d96ca1c0eaf, 4101 +3c5904c5eef6ae6bd1c5f04b329bf8263f5de55ed27b416caec5d1c7ba8a1563, 4595621029e319dd6a712f40b4694bb41a198b97fcb5e1431e36b43d2a49f70c, 25b115eca9f2c1c0b3ada40faa46c248f2919d8e5f6e7fa2866d4d96ca1c0eaf, 4229 +bd57f4064ecfd309f405a10536844d1ba5ee33ef367100f8a7bb8c0d30564c09, 3e3fe3eb8a8e57fd02bf0aed9362a8fa922792c23466f23910a07f5ecd6a5891, 25b115eca9f2c1c0b3ada40faa46c248f2919d8e5f6e7fa2866d4d96ca1c0eaf, 4399 +8cd870e8a74f72c9293b449aff6f2703eee771a2e804ab3b0e85e90110a70ebe, e4043488f279cd9c732d5074e615a25c2684e84c82d86c742627ea7a5eacca33, 25b115eca9f2c1c0b3ada40faa46c248f2919d8e5f6e7fa2866d4d96ca1c0eaf, 4427 +2aad58dee6a39af963379f65fe80379c9787fa0ef39ac6cfca83883b39d88f80, fe7024fbe4ed4989b6004ecc78ef92db203631b606d3bcc0266406bc2dc2c95d, 25b115eca9f2c1c0b3ada40faa46c248f2919d8e5f6e7fa2866d4d96ca1c0eaf, 4211 +2944a74d098bc8cb2c49c7058fa5c1749b6b0762e7953d2909d081a621322bc9, a4251d12a25da7049ac54781cfcf729548fba470abd9aab5071854aa33651f48, 25b115eca9f2c1c0b3ada40faa46c248f2919d8e5f6e7fa2866d4d96ca1c0eaf, 4392 +59423970ab4e01dcd75b21c9bb28039cc754cbd9504f1baf7690ad67f653d9bc, 8cb0ca3bc6616437eac5c040fcbc82de45a9374a686c68be73e1ece507fbe7a2, 25b115eca9f2c1c0b3ada40faa46c248f2919d8e5f6e7fa2866d4d96ca1c0eaf, 4234 +4111344f7654a20f63f205251e233949ced281978307fe80f5994101ba30fff5, e0cb33733e52c5cfe2f342b834e6be21a4c8ba142184a5a712fbe01d2f321ca0, 25b115eca9f2c1c0b3ada40faa46c248f2919d8e5f6e7fa2866d4d96ca1c0eaf, 4192 +cb2e9d7b14e92a7acd3606295181a85b3a3834812c85a58c5d8e8fb966d52e80, ffe82f2f47efff7e3c7c1f8489126be98246f789a0d5cff4e62e251d7e9637b5, 25b115eca9f2c1c0b3ada40faa46c248f2919d8e5f6e7fa2866d4d96ca1c0eaf, 4246 +6acfa4e805379c4ad2c8fed920eb94bea5c66753f76c9e8903cb9f061b9a8568, 3291da84832c5e9fd2ec14ab4bc3852a9f501635962490595f92fccdb66104aa, 25b115eca9f2c1c0b3ada40faa46c248f2919d8e5f6e7fa2866d4d96ca1c0eaf, 4277 +5950a4ccc1ee66af76e4ddec06caa7a0992cb2f59b7fe0468edd64f7bf7ac3e5, 8f6afeae5b25d78e7a9105fe200525e0012cd20ccf172dd0f610dbf804c1d731, 25b115eca9f2c1c0b3ada40faa46c248f2919d8e5f6e7fa2866d4d96ca1c0eaf, 4140 +971e704c6ef92be3bedbdf77b8829804ee21011c6a2a3e79a68b1d4ad1cf826f, 19be39d709b2b1cd553f4ade9553d982a5ebc2028c7d61fa2693ce7c701ccf2b, 25b115eca9f2c1c0b3ada40faa46c248f2919d8e5f6e7fa2866d4d96ca1c0eaf, 4122 +95ebe78a1bec9a12aa57462a7818c047b9cae0376666d0640905e81ff6e9e356, 7a418455ac94a63d6726f6fdecbf32d9426dd990ba0548e31e710703639980f2, 25b115eca9f2c1c0b3ada40faa46c248f2919d8e5f6e7fa2866d4d96ca1c0eaf, 4418 +8782eea2fe0934ef90467ece2754218f16e02f731277ec7f244217f0ee0bb764, 7904e219e0265db109364c873d81f45e7e7522e881f9646faeba35fcc49985af, 25b115eca9f2c1c0b3ada40faa46c248f2919d8e5f6e7fa2866d4d96ca1c0eaf, 4189 +7097a76021713afceeaa610bb79cffa218a8f6235ea1aefe078e957210170a47, 4068216c2a24e21043646f0f6b411628892c9350517451f528e632dc757ddabd, 25b115eca9f2c1c0b3ada40faa46c248f2919d8e5f6e7fa2866d4d96ca1c0eaf, 4174 +e2c85076c6767b991f71921fa6e3bdd669a3e1e739385b4f0330250c828bf8ec, 017471a30b0129216db7a28a5da7dd2b16c4bc856c85d5135985119e4a99e6d3, 25b115eca9f2c1c0b3ada40faa46c248f2919d8e5f6e7fa2866d4d96ca1c0eaf, 4336 +983e19c755f902f0026754de7fc89dbfba6ccc17f161a6f69c94e27ae5f3b8f4, 63301609c9ae6610af2aa1282c70531380a6d90e2b87d36ced6a84108f64755f, 25b115eca9f2c1c0b3ada40faa46c248f2919d8e5f6e7fa2866d4d96ca1c0eaf, 4686 +66f9328e9c1a79595d52798a25d3459552afa6af9aa52ceb7ec76f38a8f3bd39, 0b71ad39ef27577b06bceab5f264201b2e8087143e0df90c69b0e206042470f1, 25b115eca9f2c1c0b3ada40faa46c248f2919d8e5f6e7fa2866d4d96ca1c0eaf, 4307 +befe30abf1c5e24965d39b7ff3f2fc33fc4698be8a33c87aa4f8536ddb8a1996, ad87664522487ee7986f5ce7c26ff962b5feba171551b15b047d9a7053dedca5, 25b115eca9f2c1c0b3ada40faa46c248f2919d8e5f6e7fa2866d4d96ca1c0eaf, 4235 +0363966c6d6ea832ddb1df165841a3479df9ec8d2ce0cea8a9400360f3cecc34, 391a80f81e72894605743026c249995103834322ce94fd953107a781583c2f23, 25b115eca9f2c1c0b3ada40faa46c248f2919d8e5f6e7fa2866d4d96ca1c0eaf, 4472 +2cfd43630593e07902ff52948ce387aaf0e2bb0e11952f8a0c91a33672203bcc, 157ebdf68c866bef7f93adafa1490ce1c15c3b951f23461f5327b53b3546726a, 96d417dd9476db171dfdb190e1e2890fcc8ac78965ad0a65d7c2d2705d49d5d5, 4874 +f99a06858d3d9dd741b0d1ce08556aab5c4b383b8199920d776daea35634b1b6, 494feb7026395e0ce15b603a2e424b79cea1d46da11fead0d84c29cae57299a5, 96d417dd9476db171dfdb190e1e2890fcc8ac78965ad0a65d7c2d2705d49d5d5, 4657 +09bf69a84c357ff346577e5d17748c2b3d5b3ed84e9329759b131c2b94e7f4b5, 108075e0dfc82821119e0f323ab789ef809dc39b203db23ee3242f3a42ec0a80, 96d417dd9476db171dfdb190e1e2890fcc8ac78965ad0a65d7c2d2705d49d5d5, 4728 +7ba937604ac1bdd1cdda1954b6b6a93eda265f468a49f4941f8de0980caed213, eaf8c0959fdd124b1756144818ba32a60f4fe12454bc342e25a74b13b745241a, 96d417dd9476db171dfdb190e1e2890fcc8ac78965ad0a65d7c2d2705d49d5d5, 4274 +060f7e1a8c8cb6f938b3116ff25884b6eb87fafe9ec957d07ca28ee58fea68ee, fdead1f49e206d1892dc77b38a8b0fc8a286d01683ee994f7fe591c6f66b2bdd, 96d417dd9476db171dfdb190e1e2890fcc8ac78965ad0a65d7c2d2705d49d5d5, 4212 +377741f3abd82ac5b90df9edf3d59ca9ab1fea5376dadb40242c87f55a6c287a, 9669b5d4bf3ef9184daac77ab44f0e3ce4e46752e2a7fd68ebecfbcf6109fe3a, 96d417dd9476db171dfdb190e1e2890fcc8ac78965ad0a65d7c2d2705d49d5d5, 5152 +7a4fcbb032e872b47f2a33fbb2d27fa06965165a91ce284a8d29a43bb4d118aa, edd6bd4e7bff26c7e441011422fcfe753bded55005a5221046884efb2afafaa9, 96d417dd9476db171dfdb190e1e2890fcc8ac78965ad0a65d7c2d2705d49d5d5, 4144 +6ceb9db060f76ff7a170037cf3fb0413f3bcb5b2cbdf7f42106d30fe6516be95, 9c83bb2e063e73e7338944f8babdfd18d01ed1d13c9baa43fa7489fb52f176c2, 96d417dd9476db171dfdb190e1e2890fcc8ac78965ad0a65d7c2d2705d49d5d5, 3956 +43e55c8788cad0c02cdf142a7f7e4683b332ed6a8efcc831c580aab3e9a1b27e, d9990b01cbe2746aafedc17d14f5a84675938f952eaeee0b3b5764e4bf054074, 96d417dd9476db171dfdb190e1e2890fcc8ac78965ad0a65d7c2d2705d49d5d5, 4236 +cd0e79b31ed4e699811ca0fcbaf81cb3b0e08188acfc38b6a98e3f6d077e6fe7, 3625fb00a429fd89c4ce1c4f92303cececfbccfd7bc10b1f00d98e8e8540fbd6, 96d417dd9476db171dfdb190e1e2890fcc8ac78965ad0a65d7c2d2705d49d5d5, 4421 +61217450605f85dfd56bb5c1c046db8018bec5c533f3d5dd92732ffdf8786e60, a165d267e0fa10de03d6149b6853930ba3f9a244f3dd07a66b202f064535dc6a, 96d417dd9476db171dfdb190e1e2890fcc8ac78965ad0a65d7c2d2705d49d5d5, 4330 +c7f29ca4e434a0283049a26b8dba34072bf7a90e2116a5374171b0137789fa34, cce63e8a939e4dbbcff17212af5f390f7b5d5c7ac7c1c4bdc9db353b324b092c, 96d417dd9476db171dfdb190e1e2890fcc8ac78965ad0a65d7c2d2705d49d5d5, 5074 +19c0127c42618672cda53ef8998cf7a79bce5eda48aba533c4c157db73bf1430, 63f09dee35a7ad227266f20295f406311a3563765e51dcab2135f589127c5750, 96d417dd9476db171dfdb190e1e2890fcc8ac78965ad0a65d7c2d2705d49d5d5, 4916 +93970bbe1875f9bd9766a225f95aa3d3e748ae4ff7a629e8d8067582ae6874be, 4e9fc8003d5384be5dde14ed04c9bb256dfc34882ae7c51873a74aba28e300c5, 96d417dd9476db171dfdb190e1e2890fcc8ac78965ad0a65d7c2d2705d49d5d5, 4419 +6b17204cc22a12de5171f59978a5598908a3778e9d635e925190e312f27a5104, eb631efad97aa19034488b9b19fd26ac670fb584ed0f7eeb0bff2d4f1e29f0c4, 96d417dd9476db171dfdb190e1e2890fcc8ac78965ad0a65d7c2d2705d49d5d5, 4692 +d0d2996accf533fac438f273ab65edc50eb5a756a727fa9c51308f8b8608583a, fd0ad8a263a4cae7cc1cba87fa8c5af6b0c74694cf6dcc9c56e4867d1467b53e, 96d417dd9476db171dfdb190e1e2890fcc8ac78965ad0a65d7c2d2705d49d5d5, 4544 +f075dc821b7f48bb638dd898b9c1336abe8ac4b8ba5c964b5900c8b26fb89223, 88ed66223b41c277d464e33e1eca9e022df91b476a316050c9fe296c2d27a55a, 96d417dd9476db171dfdb190e1e2890fcc8ac78965ad0a65d7c2d2705d49d5d5, 4127 +c14ff23a8dd058c87e49edb5b6e0433e24238911ec1c7c179cd8338ad3f1eea5, c35e26a31a309a160cd348f3540aef3277f3ca07eb869c59634b6986f44ae8d0, 96d417dd9476db171dfdb190e1e2890fcc8ac78965ad0a65d7c2d2705d49d5d5, 4488 +7baf3e8717e443d1f3a2459c6e4368b79a6233f75e236aa6367b5eaaa2deb8cc, 853d8bc62f96344aed6f69a683ff7bb15f82839fecadbbad8b2eeda3713b73d6, 96d417dd9476db171dfdb190e1e2890fcc8ac78965ad0a65d7c2d2705d49d5d5, 5043 +aaab6229bd572c77a7a89b3b9a7bd17cfd085528ac5471fc358b1a4c6d011c4a, 1276013c419de03614ac9da61dc28c732b5d706529e5492caab405db613d6340, 96d417dd9476db171dfdb190e1e2890fcc8ac78965ad0a65d7c2d2705d49d5d5, 4216 +f73ab8675d3eef76ffd330ad2ef8c2dbdb2f5fe8e76bb7c5458639f2e2cdb7b4, 5c3dba3a97e7cf0d9b92e5cbb8228d5b6ea3f9dc54fba6c288e6181b03ba369f, 96d417dd9476db171dfdb190e1e2890fcc8ac78965ad0a65d7c2d2705d49d5d5, 4014 +adede9708eecf496012fb63c6dba5926c896d077ff1bd2e8a911b4eced495d82, 66cbfcfbd434f0a4c066c6e42c54c8a5ff544eb81834c14781850421d3b87d28, 96d417dd9476db171dfdb190e1e2890fcc8ac78965ad0a65d7c2d2705d49d5d5, 4549 +0c5a86ecee90b2275137aead69f1005ef9f1edc420845fa667ea020f98c8d064, b033d35f89a05b58388d4cfd3f2cb855ae4a3925353926b6f267a06d63fc9420, 96d417dd9476db171dfdb190e1e2890fcc8ac78965ad0a65d7c2d2705d49d5d5, 4144 +f2ce52d93723e7c386d4604876a3c3ae7a06a295411ea2e3719f0d5b5e2a5e44, f3b1935402df86ee59dd2913a11fb327451542645a62bb8e5217a459e242e820, 96d417dd9476db171dfdb190e1e2890fcc8ac78965ad0a65d7c2d2705d49d5d5, 4309 +f067e50164c5eab1194c3b77cc119365c01ec9501a7adf5becc7804c46695dff, f342f24d14e52fb7441f409386ab457e353caa06ee395beaa25e27be5e1a7c7b, 96d417dd9476db171dfdb190e1e2890fcc8ac78965ad0a65d7c2d2705d49d5d5, 4517 +7a33c852aef3009f1a8096124c4e166115fd14c1619bf8a1275b94292787c274, 1f704e695d2b807cd9b3f877ab12fa6d5cabe77127a589fb2ae6df94470e7717, 96d417dd9476db171dfdb190e1e2890fcc8ac78965ad0a65d7c2d2705d49d5d5, 4352 +34cd3c37f0cbda82af0b62b0b3c75887a1332c5a855218c75fe47e2d6bc6aeec, 35df8cd36c809e50e74990932a316fe5ecefcbb19fe3f4a8d027fff912202998, 96d417dd9476db171dfdb190e1e2890fcc8ac78965ad0a65d7c2d2705d49d5d5, 4042 +dc679985b8b7d22ca9dcf52c947f826d9cf6f36582b70f7282ac1b67a0c46ae8, 80c2186977f4180ad43db66bb7d5596d7e42410dbaf7e84734b71d589f46724e, 96d417dd9476db171dfdb190e1e2890fcc8ac78965ad0a65d7c2d2705d49d5d5, 4054 +17f3b29ae0d930361208d16169894ee4ed9b5846bd01147d0c7bcf05ae7d25e4, 5ef965812a3918a2dec24cedfb23a306a0305e40e52d9b38ff00b2a0d2ebe616, 96d417dd9476db171dfdb190e1e2890fcc8ac78965ad0a65d7c2d2705d49d5d5, 4480 +836eb6226f84ecc4a270ea559f2b3418cdc0bb9eb66a26bcceaa7e45c802cdee, 9a7512dc2a55c148ee1900e85f9bec223bdd73ba25b650873433164d196f2349, 96d417dd9476db171dfdb190e1e2890fcc8ac78965ad0a65d7c2d2705d49d5d5, 4849 +6e8090bf760bbd8dd5146ebe3420223b8be6b30b65e714220c27d1c2ab62423e, 29ad5c73e1f39167a370020bc2ed3af84e640bfcde167a6981f0114723fd7ac9, 96d417dd9476db171dfdb190e1e2890fcc8ac78965ad0a65d7c2d2705d49d5d5, 4296 +9c256502b40844cd9f2b11ed9eb5472441731b66797542837dbc68ee2524cb98, 5627705fd251600ddff839d02077e646f85dbb08fc08947e9098d0e2c289022a, 96d417dd9476db171dfdb190e1e2890fcc8ac78965ad0a65d7c2d2705d49d5d5, 4454 diff --git a/test/score.cpp b/test/score.cpp index 0c300063..2fe37b43 100644 --- a/test/score.cpp +++ b/test/score.cpp @@ -35,13 +35,17 @@ static const std::string TASK_FILE_NAME = "data/example_task_bpp9000.bin"; static const std::string SAMPLES_FILE_NAME = "data/samples_bpp9000.csv"; static const std::string SCORES_FILE_NAME = "data/scores_bpp9000.csv"; +static const std::string PRODUCTION_TASK_FILE_NAME = "data/bpp9000.task"; +static const std::string PRODUCTION_FILE_NAME = "data/gt_production.csv"; +static const std::string PRODUCTION_ANT_FILE_NAME = "data/gt_ant_production.csv"; + // true = ALSO run the engine-vs-reference cross-check on random tasks, for isolating a divergence. static bool gCompareReference = false; // Samples run per config static constexpr unsigned long long TEST_NUMBER_OF_SAMPLES = 32; -// Worker threads for the parallel path; effective count = min(this, hardware_concurrency, numSamples). -static constexpr unsigned int TEST_NUMBER_OF_THREADS = 0; +// Worker threads for the parallel path; min with hardware_concurrency and numSamples. 0 falls back to 1 (serial). +static constexpr unsigned int TEST_NUMBER_OF_THREADS = 4; // Samples and worker threads for the Bpp9000Profile timing run. static constexpr unsigned long long PROFILING_NUMBER_OF_SAMPLES = 48; @@ -199,7 +203,8 @@ static unsigned int workerThreadCount(unsigned long long numSamples) { hw = 1; } - unsigned int chosen = (hw < TEST_NUMBER_OF_THREADS) ? hw : TEST_NUMBER_OF_THREADS; // min(hardware_concurrency, chosen) + unsigned int requested = (TEST_NUMBER_OF_THREADS == 0) ? 1u : TEST_NUMBER_OF_THREADS; // 0 falls back to 1 (serial) + unsigned int chosen = (hw < requested) ? hw : requested; // min(hardware_concurrency, requested) return (unsigned int)((numSamples < (unsigned long long)chosen) ? numSamples : (unsigned long long)chosen); // no more than one thread per sample } @@ -381,6 +386,179 @@ TEST(TestQubicScoreFunction, Bpp9000Regression) runRegression(seeds, pubkeys, nonces, taskBytes, golden); } +TEST(TestQubicScoreFunction, Bpp9000ProductionRegression) +{ + auto rows = readCSV(PRODUCTION_FILE_NAME); + ASSERT_GT(rows.size(), 1u) << "missing/empty " << PRODUCTION_FILE_NAME; + + std::vector pubkeys; + std::vector nonces; + std::vector golden; + std::vector uniqueSeeds; // distinct mining seeds -> one pool each + std::vector poolIndex; // per row: index into uniqueSeeds + for (unsigned long long i = 1; i < rows.size(); ++i) + { + pubkeys.push_back(hexTo32Bytes(trim(rows[i][0]), 32)); + nonces.push_back(hexTo32Bytes(trim(rows[i][1]), 32)); + const m256i seed = hexTo32Bytes(trim(rows[i][2]), 32); + golden.push_back((unsigned int)std::stoul(trim(rows[i][3]))); + + unsigned int idx = (unsigned int)uniqueSeeds.size(); + for (unsigned int k = 0; k < uniqueSeeds.size(); ++k) + { + if (memcmp(uniqueSeeds[k].m256i_u8, seed.m256i_u8, 32) == 0) + { + idx = k; + break; + } + } + if (idx == uniqueSeeds.size()) + { + uniqueSeeds.push_back(seed); + } + poolIndex.push_back(idx); + } + ASSERT_FALSE(pubkeys.empty()); + + std::vector> pools(uniqueSeeds.size()); + for (size_t k = 0; k < uniqueSeeds.size(); ++k) + { + generatePool(uniqueSeeds[k], pools[k]); + } + + // The production task + auto taskBytes = readBinaryFile(PRODUCTION_TASK_FILE_NAME); + ASSERT_GT(taskBytes.size(), sizeof(score_task_file::TaskFileHeader)) << "missing/short " << PRODUCTION_TASK_FILE_NAME; + const TaskBlocks tb = taskSubview(taskBytes); + + runWorkers(workerThreadCount(pubkeys.size()), [&](unsigned int threadIdx, unsigned int numThreads) + { + auto engine = makeEngine(tb.topo, tb.data); + if (!engine) + { + return; + } + for (unsigned long long s = threadIdx; s < pubkeys.size(); s += numThreads) + { + const unsigned int score = engine->computeScore(pubkeys[s].m256i_u8, nonces[s].m256i_u8, pools[poolIndex[s]].data()); + EXPECT_EQ(score, golden[s]) << "gt_production row " << s; + } + }); +} + +// Ant-colony score seam (deriveRootANN + computeScoreFromParent) +TEST(TestQubicScoreFunction, Bpp9000AntColonyRegression) +{ + // Group by chain each chain is a lineage - level 0 extends the derived root, level i extends level i-1's bestANN. + auto rows = readCSV(PRODUCTION_ANT_FILE_NAME); + ASSERT_GT(rows.size(), 1u) << "missing/empty " << PRODUCTION_ANT_FILE_NAME; + + struct AntNode + { + m256i nonce; + m256i anchor; + unsigned int score; + }; + struct AntChain + { + m256i pubkey; + unsigned int poolIndex; + std::vector nodes; // indexed by depth + }; + std::vector chains; + std::vector chainIds; // chain id per slot, first-seen order + std::vector uniqueSeeds; + + for (unsigned long long i = 1; i < rows.size(); ++i) + { + const int chainId = std::stoi(trim(rows[i][0])); + const int depth = std::stoi(trim(rows[i][1])); + const m256i pubkey = hexTo32Bytes(trim(rows[i][2]), 32); + const m256i seed = hexTo32Bytes(trim(rows[i][5]), 32); + + unsigned int sidx = (unsigned int)uniqueSeeds.size(); + for (unsigned int k = 0; k < uniqueSeeds.size(); ++k) + { + if (memcmp(uniqueSeeds[k].m256i_u8, seed.m256i_u8, 32) == 0) + { + sidx = k; + break; + } + } + if (sidx == uniqueSeeds.size()) + { + uniqueSeeds.push_back(seed); + } + + size_t cidx = chains.size(); + for (size_t k = 0; k < chainIds.size(); ++k) + { + if (chainIds[k] == chainId) + { + cidx = k; + break; + } + } + if (cidx == chains.size()) + { + chainIds.push_back(chainId); + AntChain created; + created.pubkey = pubkey; + created.poolIndex = sidx; + chains.push_back(created); + } + + AntNode node; + node.nonce = hexTo32Bytes(trim(rows[i][3]), 32); + node.anchor = hexTo32Bytes(trim(rows[i][4]), 32); + node.score = (unsigned int)std::stoul(trim(rows[i][6])); + + AntChain& chain = chains[cidx]; + if ((size_t)depth >= chain.nodes.size()) + { + chain.nodes.resize((size_t)depth + 1); + } + chain.nodes[(size_t)depth] = node; + } + ASSERT_FALSE(chains.empty()); + + std::vector> pools(uniqueSeeds.size()); + for (size_t k = 0; k < uniqueSeeds.size(); ++k) + { + generatePool(uniqueSeeds[k], pools[k]); + } + + auto taskBytes = readBinaryFile(PRODUCTION_TASK_FILE_NAME); + ASSERT_GT(taskBytes.size(), sizeof(score_task_file::TaskFileHeader)) << "missing/short " << PRODUCTION_TASK_FILE_NAME; + const TaskBlocks tb = taskSubview(taskBytes); + + // Thread across chains; a chain is sequential (each node's bestANN feeds the next depth's parent). + runWorkers(workerThreadCount(chains.size()), [&](unsigned int threadIdx, unsigned int numThreads) + { + auto engine = makeEngine(tb.topo, tb.data); + if (!engine) + { + return; + } + for (size_t ci = threadIdx; ci < chains.size(); ci += numThreads) + { + const AntChain& chain = chains[ci]; + const unsigned char* pool = pools[chain.poolIndex].data(); + + score_engine::ScoreBpp9000::ANN parent; + engine->deriveRootANN(chain.pubkey.m256i_u8, pool, parent); // depth 0's parent = the derived root + for (size_t d = 0; d < chain.nodes.size(); ++d) + { + const AntNode& node = chain.nodes[d]; + const unsigned int score = engine->computeScoreFromParent( + parent, chain.pubkey.m256i_u8, node.nonce.m256i_u8, node.anchor.m256i_u8, pool); + EXPECT_EQ(score, node.score) << "gt_ant chain " << ci << " depth " << d; + engine->getBestANN(parent); // this node becomes the next depth's parent + } + } + }); +} + // TestBpp9000, internal score vs the score reference from Qiner TEST(TestQubicScoreFunction, Bpp9000EngineVsReference) { From 9bcb561a76446e9145137362d4434a9fb13d76c2 Mon Sep 17 00:00:00 2001 From: cyber-pc <165458555+cyber-pc@users.noreply.github.com> Date: Fri, 14 Aug 2026 19:16:31 +0700 Subject: [PATCH 46/46] Disable standalone bpp9000 --- src/qubic.cpp | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/qubic.cpp b/src/qubic.cpp index ba5c3319..6dbc35fa 100644 --- a/src/qubic.cpp +++ b/src/qubic.cpp @@ -986,6 +986,12 @@ static void processBroadcastMessage(const unsigned long long processorNumber, Re const m256i& solution_miningSeed = *(m256i*)((unsigned char*)request + sizeof(BroadcastMessage)); const m256i& solution_nonce = *(m256i*)((unsigned char*)request + sizeof(BroadcastMessage) + 32); + // standalone mining disabled for bpp9000 + if (score_engine::getAlgoType(solution_nonce.m256i_u8) == score_engine::AlgoType::Bpp9000) + { + break; + } + const unsigned int solution_claimedScore = *(unsigned int*)((unsigned char*)request + sizeof(BroadcastMessage) + 64); unsigned int k; for (k = 0; k < system.numberOfSolutions; k++) @@ -3233,6 +3239,11 @@ static void processTickTransactionSolution(const MiningSolutionTransaction* tran ASSERT(transaction->amount >=MiningSolutionTransaction::minAmount() && transaction->inputSize == MiningSolutionTransaction::minInputSize() && transaction->inputType == MiningSolutionTransaction::transactionType()); + // Standalone mining is disabled; bpp9000 is mined through the ant colony only. + if (score_engine::getAlgoType(transaction->nonce.m256i_u8) == score_engine::AlgoType::Bpp9000) + { + return; + } m256i data[3] = { transaction->sourcePublicKey, transaction->miningSeed, transaction->nonce }; static_assert(sizeof(data) == 3 * 32, "Unexpected array size");