diff --git a/src/registry/normalize.ts b/src/registry/normalize.ts index 63982a566f..2e25683690 100644 --- a/src/registry/normalize.ts +++ b/src/registry/normalize.ts @@ -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; @@ -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. diff --git a/src/types.ts b/src/types.ts index 54286799b6..a24f480f6c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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; @@ -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; }; diff --git a/test/unit/registry.test.ts b/test/unit/registry.test.ts index 8e12cd29a6..429c95ccd9 100644 --- a/test/unit/registry.test.ts +++ b/test/unit/registry.test.ts @@ -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"; @@ -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( {