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
20 changes: 19 additions & 1 deletion src/registry/normalize.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { DEFAULT_ISSUE_DISCOVERY_SHARE } from "../scoring/model";
import type { JsonValue, RegistryRepoConfig, RegistrySnapshot, RepoTimeDecayOverrides } from "../types";
import type { JsonValue, RegistryRepoConfig, RegistrySnapshot, RepoPoolAssociation, RepoTimeDecayOverrides } from "../types";

type RawRepoConfig = Record<string, JsonValue>;

Expand Down Expand Up @@ -76,10 +76,28 @@ function normalizeRepo(repo: string, config: RawRepoConfig): RegistryRepoConfig
fixedBaseScore: numberValue(config.fixed_base_score),
eligibilityMode: stringValue(config.eligibility_mode),
timeDecay: parseTimeDecayOverrides(config.scoring),
poolAssociation: parsePoolAssociation(config),
raw: config,
};
}

// Subnet-funded pool association (#6099/#6320), from the registry's flat `pool_id`/`subnet_id` fields. Both
// must be present and well-formed (non-empty pool id, finite subnet netuid) for an association to exist —
// a repo missing either (i.e. every organic repo) parses to null and stays byte-identical to today.
function parsePoolAssociation(config: RawRepoConfig): RepoPoolAssociation | null {
const poolId = stringValue(config.pool_id);
const subnetId = numberValue(config.subnet_id);
if (poolId === null || subnetId === null) return null;
return { poolId, subnetId };
}

// Read accessor for a repo's pool association (#6320): returns the association a repo was registered with, or
// null for an organic repo / a repo with no config. The read side #6314's PayoutEligibleEvent construction and
// #6099's pool-state reporting UI consume — the single place downstream code asks "is this repo pool-funded?".
export function getRepoPoolAssociation(config: RegistryRepoConfig | null | undefined): RepoPoolAssociation | null {
return config?.poolAssociation ?? null;
}

// Per-repo time-decay overrides (#703), from the registry's nested `scoring.time_decay` (the same source
// upstream reads). Each key is optional; absent/non-numeric → null (resolveTimeDecay falls back to the
// global default). Returns null when there is no usable override, so a repo without one uses all defaults.
Expand Down
14 changes: 14 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -429,6 +429,18 @@ export type RepoTimeDecayOverrides = {
minMultiplier?: number | null | undefined;
};

/**
* Subnet-funded pool association for a registered repo (#6099's entity model; part of #6101). Present only
* when the registry marks a repo as backed by a Bittensor subnet's reward pool; `poolId` matches #6098's
* `SettlementBackend.poolId`, `subnetId` is the funding subnet's netuid. Both are required for a valid
* association — a partial one (only one field) is treated as no association, so an organic (non-pool) repo
* carries no pool fields and round-trips byte-identical to today. Read it via `getRepoPoolAssociation`.
*/
export type RepoPoolAssociation = {
poolId: string;
subnetId: number;
};

export type RegistryRepoConfig = {
repo: string;
emissionShare: number;
Expand All @@ -441,6 +453,8 @@ export type RegistryRepoConfig = {
eligibilityMode?: string | null;
/** Per-repo time-decay curve overrides (#703); null/absent = use the global defaults for every field. */
timeDecay?: RepoTimeDecayOverrides | null;
/** Subnet-funded pool association (#6099); null/absent = an organic repo with no funding pool (#6320). */
poolAssociation?: RepoPoolAssociation | null;
raw: Record<string, JsonValue>;
};

Expand Down
41 changes: 40 additions & 1 deletion test/unit/registry.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { getRepository, upsertRepositoryFromGitHub } from "../../src/db/repositories";
import { normalizeRegistryPayload } from "../../src/registry/normalize";
import { getRepoPoolAssociation, normalizeRegistryPayload } from "../../src/registry/normalize";
import { DEFAULT_ISSUE_DISCOVERY_SHARE } from "../../src/scoring/model";
import { getLatestRegistrySnapshot, persistRegistrySnapshot, refreshRegistry } from "../../src/registry/sync";
import { createTestEnv } from "../helpers/d1";
Expand Down Expand Up @@ -96,6 +96,45 @@ describe("registry normalization", () => {
expect(byName["empty/decay"]!.timeDecay ?? null).toBeNull();
});

it("parses a subnet-funded pool association and leaves organic repos with none (#6320)", () => {
const snapshot = normalizeRegistryPayload(
{
// A subnet-funded repo carries both a pool id and a subnet netuid → a full association reads back intact.
"JSONbored/funded": { emission_share: 0.02, pool_id: "pool-74", subnet_id: 74 },
// An organic repo has no pool fields → no association, byte-identical to today.
"JSONbored/organic": { emission_share: 0.01 },
// A partial association (pool id but no subnet) is not a valid association → null, not a half-populated object.
"JSONbored/pool-only": { emission_share: 0.01, pool_id: "pool-9" },
// A partial association (subnet but no pool id) is likewise dropped.
"JSONbored/subnet-only": { emission_share: 0.01, subnet_id: 12 },
},
{ kind: "raw-github", url: "https://example.test/master_repositories.json" },
"2026-05-22T00:00:00.000Z",
);
const byName = Object.fromEntries(snapshot.repositories.map((r) => [r.repo, r]));
expect(byName["JSONbored/funded"]!.poolAssociation).toEqual({ poolId: "pool-74", subnetId: 74 });
expect(byName["JSONbored/organic"]!.poolAssociation ?? null).toBeNull();
expect(byName["JSONbored/pool-only"]!.poolAssociation ?? null).toBeNull();
expect(byName["JSONbored/subnet-only"]!.poolAssociation ?? null).toBeNull();
});

it("getRepoPoolAssociation reads a repo's pool association or null (#6320)", () => {
const snapshot = normalizeRegistryPayload(
{
"JSONbored/funded": { emission_share: 0.02, pool_id: "pool-74", subnet_id: 74 },
"JSONbored/organic": { emission_share: 0.01 },
},
{ kind: "raw-github", url: "https://example.test/master_repositories.json" },
"2026-05-22T00:00:00.000Z",
);
const byName = Object.fromEntries(snapshot.repositories.map((r) => [r.repo, r]));
expect(getRepoPoolAssociation(byName["JSONbored/funded"])).toEqual({ poolId: "pool-74", subnetId: 74 });
expect(getRepoPoolAssociation(byName["JSONbored/organic"])).toBeNull();
// A missing/undefined config (an unregistered repo) reads back as no association, never throws.
expect(getRepoPoolAssociation(null)).toBeNull();
expect(getRepoPoolAssociation(undefined)).toBeNull();
});

it("normalizes repository-list and array payload shapes defensively", () => {
const fromObjectMap = normalizeRegistryPayload(
{
Expand Down