From f7f3e2424ef0a9152c6e950c0dfe78db3cac9d4c Mon Sep 17 00:00:00 2001 From: glorydavid03023 Date: Tue, 21 Jul 2026 02:58:56 -0500 Subject: [PATCH] feat(registry): add CustomerPoolAssociation, parallel to RepoPoolAssociation (#7679) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wave 5's "two demographics" (#4778) includes customer-funded pools — a paying customer, not necessarily a Bittensor subnet — that still pay out to gittensor-registered contributors as emissions. RepoPoolAssociation requires a `subnetId`, which is meaningless for a non-subnet customer, so a customer-funded pool has no data-model representation today. Adds a separate `CustomerPoolAssociation = { poolId; funderAccount }`, parallel to RepoPoolAssociation rather than overloading it with an optional subnetId, so the two funding models stay structurally distinct (different funding source, same payout mechanism). Threaded through exactly the sites RepoPoolAssociation touches, mirroring #6320's precedent: - types.ts: the new type + an OPTIONAL `customerPoolAssociation?` on RegistryRepoConfig, with the same null-safety convention poolAssociation uses — absent = a repo with no customer-funded pool, byte-identical to today. - registry/normalize.ts: parseCustomerPoolAssociation reads the flat `pool_id`/`funder_account` fields (both required; a partial one collapses to null, like parsePoolAssociation), threaded into normalizeRepo beside parsePoolAssociation; a getCustomerPoolAssociation accessor mirrors getRepoPoolAssociation. The two stay independent: a subnet-funded repo (pool_id + subnet_id, no funder_account) parses to a poolAssociation but a null customerPoolAssociation, and vice-versa. Type-and-plumbing only, no economics or payout logic. Tests (registry.test.ts, mirroring #6320's poolAssociation test): a full customer association; a subnet-funded repo confirmed to have NO customer association (distinctness); an organic repo (null); partial pool-only and funder-only (both null); and the getCustomerPoolAssociation accessor over present/null/undefined/key-absent configs. Both present and absent branches covered. --- src/registry/normalize.ts | 22 ++++++++++++++++++- src/types.ts | 17 +++++++++++++++ test/unit/registry.test.ts | 43 +++++++++++++++++++++++++++++++++++++- 3 files changed, 80 insertions(+), 2 deletions(-) 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( {