diff --git a/src/registry/normalize.ts b/src/registry/normalize.ts index 624665c9f7..db9a3b1eab 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, RepoOrigin, RepoPoolAssociation, RepoTimeDecayOverrides } from "../types"; +import type { CustomerPoolAssociation, JsonValue, RegistryRepoConfig, RegistrySnapshot, RepoOrigin, RepoPoolAssociation, RepoTimeDecayOverrides } from "../types"; type RawRepoConfig = Record; @@ -77,6 +77,7 @@ function normalizeRepo(repo: string, config: RawRepoConfig): RegistryRepoConfig eligibilityMode: stringValue(config.eligibility_mode), timeDecay: parseTimeDecayOverrides(config.scoring), poolAssociation: parsePoolAssociation(config), + customerPoolAssociation: parseCustomerPoolAssociation(config), repoOrigin: parseRepoOrigin(config), raw: config, }; @@ -99,6 +100,25 @@ export function getRepoPoolAssociation(config: RegistryRepoConfig | null | undef return config?.poolAssociation ?? null; } +// Customer-funded pool association (#7679), from the registry's flat `pool_id`/`funder_account` fields — +// parallel to parsePoolAssociation, but the funding source is a paying customer's account, not a subnet netuid. +// Both must be present and non-empty for an association to exist; a repo missing either (i.e. every repo with +// no customer-funded pool, including a subnet-funded one that carries `subnet_id` but no `funder_account`) +// parses to null and stays byte-identical to today. +function parseCustomerPoolAssociation(config: RawRepoConfig): CustomerPoolAssociation | null { + const poolId = stringValue(config.pool_id); + const funderAccount = stringValue(config.funder_account); + if (poolId === null || funderAccount === null) return null; + return { poolId, funderAccount }; +} + +// Read accessor for a repo's customer-funded pool association (#7679), mirroring getRepoPoolAssociation: the +// single place downstream code asks "is this repo funded by a paying customer?", distinct from the subnet-funded +// question getRepoPoolAssociation answers. +export function getCustomerPoolAssociation(config: RegistryRepoConfig | null | undefined): CustomerPoolAssociation | null { + return config?.customerPoolAssociation ?? null; +} + // Repo provisioning origin (#7589), from the registry's flat `repo_origin` (+ `hosting_org` for APR) fields. // Only an explicit marker yields an origin: an absent field parses to null (mirroring parsePoolAssociation), // because absent means "pre-dates this field / not yet known", NOT a confirmed BYOR. An `apr` marker missing diff --git a/src/types.ts b/src/types.ts index 27fe249818..a0092c6891 100644 --- a/src/types.ts +++ b/src/types.ts @@ -449,6 +449,21 @@ export type RepoPoolAssociation = { subnetId: number; }; +/** + * Customer-funded pool association for a registered repo (#7679, Wave 5's "two demographics" per #4778). The + * parallel to {@link RepoPoolAssociation} for a pool funded by a paying CUSTOMER rather than a Bittensor subnet: + * both pay out to gittensor-registered contributors as emissions (same payout mechanism), but the funding source + * differs — so `subnetId` (meaningless for a non-subnet customer) is replaced by the funding customer's account. + * Kept a separate type rather than an optional `subnetId` on `RepoPoolAssociation` so the two funding models stay + * structurally distinct. Both fields are required for a valid association — a partial one (only one field) is + * treated as no association, so a repo with no customer-pool fields round-trips byte-identical to today. Read it + * via `getCustomerPoolAssociation`. + */ +export type CustomerPoolAssociation = { + poolId: string; + funderAccount: string; +}; + /** * Repo provisioning origin (#7589's BYOR+APR epic; #7590's hosting decision). BYOR = a customer's own * pre-existing repo; APR = a loopover-provisioned repo, carrying the GitHub org it was created under. Present @@ -473,6 +488,8 @@ export type RegistryRepoConfig = { timeDecay?: RepoTimeDecayOverrides | null; /** Subnet-funded pool association (#6099); null/absent = an organic repo with no funding pool (#6320). */ poolAssociation?: RepoPoolAssociation | null; + /** Customer-funded pool association (#7679); null/absent = a repo with no customer-funded pool. */ + customerPoolAssociation?: CustomerPoolAssociation | null; /** Repo provisioning origin (#7589); null/absent = pre-dates this field, unchanged behavior (do NOT assume BYOR). */ repoOrigin?: RepoOrigin | null; raw: Record; diff --git a/test/unit/registry.test.ts b/test/unit/registry.test.ts index adce941203..8cdd783bac 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 { getRepoOrigin, getRepoPoolAssociation, normalizeRegistryPayload } from "../../src/registry/normalize"; +import { getCustomerPoolAssociation, getRepoOrigin, getRepoPoolAssociation, normalizeRegistryPayload } from "../../src/registry/normalize"; import { DEFAULT_ISSUE_DISCOVERY_SHARE } from "../../src/scoring/model"; import { getLatestRegistrySnapshot, persistRegistrySnapshot, refreshRegistry } from "../../src/registry/sync"; import { createCloudTestEnv, createTestEnv } from "../helpers/d1"; @@ -118,6 +118,47 @@ describe("registry normalization", () => { expect(byName["JSONbored/subnet-only"]!.poolAssociation ?? null).toBeNull(); }); + it("parses a customer-funded pool association, parallel to and distinct from the subnet-funded one (#7679)", () => { + const snapshot = normalizeRegistryPayload( + { + // A customer-funded repo carries a pool id and a funder account → a full customer association reads back. + "JSONbored/customer": { emission_share: 0.02, pool_id: "pool-cust", funder_account: "acme-corp" }, + // A subnet-funded repo has pool_id + subnet_id but no funder_account → NO customer association (they stay distinct). + "JSONbored/subnet": { emission_share: 0.02, pool_id: "pool-74", subnet_id: 74 }, + // An organic repo has neither → no customer association, byte-identical to today. + "JSONbored/organic": { emission_share: 0.01 }, + // A partial customer association (pool id but no funder) is dropped, not a half-populated object. + "JSONbored/pool-only": { emission_share: 0.01, pool_id: "pool-x" }, + // A partial customer association (funder but no pool id) is likewise dropped. + "JSONbored/funder-only": { emission_share: 0.01, funder_account: "acme-corp" }, + }, + { 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/customer"]!.customerPoolAssociation).toEqual({ poolId: "pool-cust", funderAccount: "acme-corp" }); + // The subnet-funded repo has its own poolAssociation but NO customer association — the two are independent. + expect(byName["JSONbored/subnet"]!.customerPoolAssociation ?? null).toBeNull(); + expect(byName["JSONbored/subnet"]!.poolAssociation).toEqual({ poolId: "pool-74", subnetId: 74 }); + expect(byName["JSONbored/organic"]!.customerPoolAssociation ?? null).toBeNull(); + expect(byName["JSONbored/pool-only"]!.customerPoolAssociation ?? null).toBeNull(); + expect(byName["JSONbored/funder-only"]!.customerPoolAssociation ?? null).toBeNull(); + }); + + it("reads a repo's customer-funded pool via the getCustomerPoolAssociation accessor (#7679)", () => { + const snapshot = normalizeRegistryPayload( + { "JSONbored/customer": { emission_share: 0.02, pool_id: "pool-cust", funder_account: "acme-corp" } }, + { kind: "raw-github", url: "https://example.test/master_repositories.json" }, + "2026-05-22T00:00:00.000Z", + ); + const config = snapshot.repositories.find((r) => r.repo === "JSONbored/customer")!; + expect(getCustomerPoolAssociation(config)).toEqual({ poolId: "pool-cust", funderAccount: "acme-corp" }); + expect(getCustomerPoolAssociation(null)).toBeNull(); + expect(getCustomerPoolAssociation(undefined)).toBeNull(); + const { customerPoolAssociation: _omitted, ...withoutCustomerPool } = config; + expect(getCustomerPoolAssociation(withoutCustomerPool)).toBeNull(); + }); + it("parses a repo provisioning origin (BYOR/APR) and leaves unmarked repos with none (#7589)", () => { const snapshot = normalizeRegistryPayload( {