Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 45 additions & 2 deletions src/account-nominator-positions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -190,14 +220,27 @@ export function buildAccountPositions(
a.netuid - b.netuid,
);

return {
const result: AccountPositionsResult = {
schema_version: 1,
ss58,
captured_at: latestCapturedAt != null ? toIso(latestCapturedAt) : null,
position_count: positions.length,
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
Expand Down
115 changes: 115 additions & 0 deletions tests/account-nominator-positions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand Down