diff --git a/src/account-nominator-positions.ts b/src/account-nominator-positions.ts index 5b112971a3..9e00b858e3 100644 --- a/src/account-nominator-positions.ts +++ b/src/account-nominator-positions.ts @@ -109,6 +109,29 @@ export const POSITIONS_DEGRADED_TIER_UNAVAILABLE = "tier_unavailable"; export const POSITIONS_DEGRADED_SNAPSHOT_PREDATES_ACTIVITY = "snapshot_predates_stake_activity"; +/** + * The ledger HAS rows for this coldkey, but one or more of them could not be + * priced, so they are absent from `positions` and from `total_stake_alpha`. + * + * Positions are priced off the live `neurons` table, which carries only + * CURRENTLY-registered neurons, while the position ledger is a snapshot. A + * hotkey that has since deregistered -- or that the ledger saw and `neurons` + * has not -- prices to nothing, and the position is excluded rather than + * reported with a fabricated 0 stake_tao (#9066's rule: no unpriced values). + * + * Excluding it is right. Saying nothing about it was not. Sampling eight + * distinct hotkeys from the live ledger, only ONE was present in `neurons`, so + * a coldkey whose positions all price to nothing published + * `position_count: 0, total_stake_alpha: 0` with no marker at all -- a + * confident zero over real holdings, indistinguishable from "delegates + * nothing" (#9305). + * + * Fires whenever ANY row was dropped, not only when all of them were: a + * partially-priced total understates the account's real position just as + * silently, it is merely harder to notice. + */ +export const POSITIONS_DEGRADED_UNPRICEABLE = "positions_unpriceable"; + export interface AccountPositionsDegraded { reason: string; /** The LEDGER's own capture stamp, not this account's -- present even when @@ -152,6 +175,10 @@ export function buildAccountPositions( const positions: AccountNominatorPosition[] = []; let totalStakeAlpha = 0; let latestCapturedAt: number | null = null; + // Rows the ledger holds that no live `neurons` row could price. Counted + // rather than merely skipped so the payload can say so -- see + // POSITIONS_DEGRADED_UNPRICEABLE. + let unpriceable = 0; for (const row of rows) { const hotkey = typeof row?.hotkey === "string" ? row.hotkey : null; @@ -160,7 +187,10 @@ export function buildAccountPositions( if (!hotkey || netuid == null || fraction == null) continue; const hotkeyStake = stakeByKey.get(`${hotkey}|${netuid}`); - if (hotkeyStake == null) continue; + if (hotkeyStake == null) { + unpriceable += 1; + continue; + } const stakeTao = roundTao(fraction * hotkeyStake); if (stakeTao == null) continue; @@ -190,7 +220,7 @@ export function buildAccountPositions( a.netuid - b.netuid, ); - return { + const result: AccountPositionsResult = { schema_version: 1, ss58, captured_at: latestCapturedAt != null ? toIso(latestCapturedAt) : null, @@ -198,6 +228,19 @@ export function buildAccountPositions( total_stake_alpha: roundTao(totalStakeAlpha) ?? 0, positions, }; + // The two provenance stamps belong to the LEDGER and to this coldkey's chain + // activity, neither of which this pure builder can see -- the callers that + // do have them attach the stronger `snapshot_predates_stake_activity` reason + // over this one when it also applies, which is the right precedence: both + // say "do not trust this total", and that one says why more usefully. + if (unpriceable > 0) { + result.degraded = { + reason: POSITIONS_DEGRADED_UNPRICEABLE, + snapshot_captured_at: null, + latest_stake_event_at: null, + }; + } + return result; } // Distinct, order-stable, non-empty hotkeys referenced by a coldkey's diff --git a/tests/account-nominator-positions.test.ts b/tests/account-nominator-positions.test.ts index 7470f70b6c..646d964da9 100644 --- a/tests/account-nominator-positions.test.ts +++ b/tests/account-nominator-positions.test.ts @@ -3,6 +3,9 @@ import assert from "node:assert/strict"; import { NOMINATOR_POSITION_INSERT_COLUMNS, + POSITIONS_DEGRADED_SNAPSHOT_PREDATES_ACTIVITY, + POSITIONS_DEGRADED_UNPRICEABLE, + annotatePositionsSnapshot, buildAccountPositions, distinctHotkeys, stakeByHotkeyNetuid, @@ -134,6 +137,118 @@ describe("distinctHotkeys", () => { }); }); +describe("unpriceable positions are declared, not silently dropped (#9305)", () => { + // Positions are priced off the live `neurons` table, which carries only + // CURRENTLY-registered neurons, while the position ledger is a snapshot. A + // hotkey that has since deregistered prices to nothing. Excluding it is + // right -- #9066 forbids publishing unpriced values -- but saying nothing + // about it published `position_count: 0, total_stake_alpha: 0` over real + // ledger rows, indistinguishable from "this account delegates nothing". + // + // Live measurement behind this: sampling eight distinct hotkeys from + // chain.nominator_positions, only ONE was present in `neurons`. + const ROW = (hotkey: string, netuid: number) => ({ + coldkey: "5Cold", + hotkey, + netuid, + share_fraction: 0.5, + captured_at: 1_780_000_000_000, + }); + + test("a coldkey whose every position is unpriceable is marked degraded", () => { + const data = buildAccountPositions([ROW("5Gone", 18)], new Map(), "5Cold"); + assert.equal(data.position_count, 0); + assert.equal(data.total_stake_alpha, 0); + assert.equal( + data.degraded?.reason, + POSITIONS_DEGRADED_UNPRICEABLE, + "a zero over real ledger rows must not read as a measurement", + ); + }); + + test("a PARTIALLY priced coldkey is marked too, not just an all-zero one", () => { + // The understatement is the same defect and is harder to notice: the + // payload looks healthy because it carries positions. + const data = buildAccountPositions( + [ROW("5Hk1", 3), ROW("5Gone", 18)], + new Map([["5Hk1|3", 1000]]), + "5Cold", + ); + assert.equal(data.position_count, 1); + assert.equal(data.total_stake_alpha, 500); + assert.equal(data.degraded?.reason, POSITIONS_DEGRADED_UNPRICEABLE); + }); + + test("a fully priced coldkey carries NO degraded block", () => { + // The field's contract is that it is absent on every trustworthy answer, + // so a consumer ignoring it reads exactly what it read before. + const data = buildAccountPositions( + [ROW("5Hk1", 3)], + new Map([["5Hk1|3", 1000]]), + "5Cold", + ); + assert.equal(data.position_count, 1); + assert.equal(data.degraded, undefined); + }); + + test("an account with no ledger rows at all is not marked", () => { + // Nothing was dropped, so nothing is being hidden. That zero IS a + // measurement, and labelling it would cry wolf on every empty account. + const data = buildAccountPositions([], new Map(), "5Cold"); + assert.equal(data.position_count, 0); + assert.equal(data.degraded, undefined); + }); + + test("a malformed row is not counted as unpriceable", () => { + // A row with no hotkey/netuid/fraction is bad data, not an unpriced + // holding -- conflating them would report a pricing problem that is not + // happening. + const data = buildAccountPositions( + [{ coldkey: "5Cold", hotkey: null, netuid: 3, share_fraction: 0.5 }], + new Map(), + "5Cold", + ); + assert.equal(data.position_count, 0); + assert.equal(data.degraded, undefined); + }); + + test("the snapshot annotator's stronger reason wins when both apply", () => { + // Both mean "do not trust this total"; `snapshot_predates_stake_activity` + // says why more usefully, and carries the two stamps this pure builder + // cannot see. + const built = buildAccountPositions([ROW("5Gone", 18)], new Map(), "5Cold"); + assert.equal(built.degraded?.reason, POSITIONS_DEGRADED_UNPRICEABLE); + const annotated = annotatePositionsSnapshot(built, { + snapshotCapturedAtMs: 1_780_000_000_000, + latestStakeEventMs: 1_785_000_000_000, + }); + assert.equal( + annotated.degraded?.reason, + POSITIONS_DEGRADED_SNAPSHOT_PREDATES_ACTIVITY, + ); + assert.equal( + annotated.degraded?.latest_stake_event_at, + new Date(1_785_000_000_000).toISOString(), + ); + }); + + test("the unpriceable reason survives an annotator pass that does not contradict", () => { + // The annotator only replaces `degraded` when a newer stake event + // contradicts the zero. Otherwise this marker must not be erased. + const built = buildAccountPositions([ROW("5Gone", 18)], new Map(), "5Cold"); + const annotated = annotatePositionsSnapshot(built, { + snapshotCapturedAtMs: 1_785_000_000_000, + latestStakeEventMs: null, + }); + assert.equal(annotated.degraded?.reason, POSITIONS_DEGRADED_UNPRICEABLE); + assert.equal( + annotated.captured_at, + new Date(1_785_000_000_000).toISOString(), + "the ledger stamp is still attached", + ); + }); +}); + describe("buildAccountPositions", () => { test("joins share_fraction against live neurons stake_tao to produce stake_tao", () => { const data = buildAccountPositions(