diff --git a/generated/graphql/schema.ts b/generated/graphql/schema.ts index 35d248243f..79855d02d4 100644 --- a/generated/graphql/schema.ts +++ b/generated/graphql/schema.ts @@ -4440,6 +4440,40 @@ type GapsArtifactGaps { gaps: Gaps! gap_severity: String gap_priority: Int + schema_parity: GapsArtifactGapsSchemaParity +} + +type GapsArtifactGapsSchemaParity { + """ + The capture lane's declared cadence in hours. Compare it against the backing entries' \`snapshot.observed_at\` (GET /api/v1/schemas) to judge whether this measurement rests on a current capture. No age is baked: this document is served for hours after it is built, so a build-stamped age would be wrong on arrival. + """ + capture_cadence_hours: Float! + + """Captured machine-readable specs backing this measurement.""" + captured_schema_count: Int! + + """ + Paths the subnet's captured spec(s) declare, summed across captured specs. + """ + captured_path_count: Int! + + """ + Declared POST/PUT/PATCH/DELETE operations. NULL when the captured entries predate the capture-time stamp -- unmeasured, not zero. + """ + declared_non_get_count: Int + + """ + Registered concrete route surfaces (subnet-api/sse/data-artifact; the openapi spec surface itself is not a route). + """ + registered_route_surface_count: Int! + + """Registered surfaces declaring a non-GET method (#11146 phase 3).""" + registered_non_get_count: Int! + + """ + True when the subnet declares more paths than the catalogue registers as routes -- a caller reading only the registry cannot tell which routes are missing. Judge its currency from the entry's \`observed_at\` against the schema index's \`capture_cadence_hours\`. + """ + flagged: Boolean! } type EvidenceList { diff --git a/generated/graphql/types.ts b/generated/graphql/types.ts index 9a93858d6f..e02159d089 100644 --- a/generated/graphql/types.ts +++ b/generated/graphql/types.ts @@ -2745,9 +2745,28 @@ export type GapsArtifactGaps = { gaps: Gaps; name: Scalars['String']['output']; netuid: Scalars['Int']['output']; + schema_parity?: Maybe; slug: Scalars['String']['output']; }; +export type GapsArtifactGapsSchemaParity = { + __typename?: 'GapsArtifactGapsSchemaParity'; + /** The capture lane's declared cadence in hours. Compare it against the backing entries' `snapshot.observed_at` (GET /api/v1/schemas) to judge whether this measurement rests on a current capture. No age is baked: this document is served for hours after it is built, so a build-stamped age would be wrong on arrival. */ + capture_cadence_hours: Scalars['Float']['output']; + /** Paths the subnet's captured spec(s) declare, summed across captured specs. */ + captured_path_count: Scalars['Int']['output']; + /** Captured machine-readable specs backing this measurement. */ + captured_schema_count: Scalars['Int']['output']; + /** Declared POST/PUT/PATCH/DELETE operations. NULL when the captured entries predate the capture-time stamp -- unmeasured, not zero. */ + declared_non_get_count?: Maybe; + /** True when the subnet declares more paths than the catalogue registers as routes -- a caller reading only the registry cannot tell which routes are missing. Judge its currency from the entry's `observed_at` against the schema index's `capture_cadence_hours`. */ + flagged: Scalars['Boolean']['output']; + /** Registered surfaces declaring a non-GET method (#11146 phase 3). */ + registered_non_get_count: Scalars['Int']['output']; + /** Registered concrete route surfaces (subnet-api/sse/data-artifact; the openapi spec surface itself is not a route). */ + registered_route_surface_count: Scalars['Int']['output']; +}; + /** Registry-wide interface gap report page. Mirrors GET /api/v1/gaps (and MCP list_gaps). */ export type GapsList = { __typename?: 'GapsList'; @@ -8592,6 +8611,7 @@ export type ResolversTypes = ResolversObject<{ Float: ResolverTypeWrapper; Gaps: ResolverTypeWrapper; GapsArtifactGaps: ResolverTypeWrapper; + GapsArtifactGapsSchemaParity: ResolverTypeWrapper; GapsList: ResolverTypeWrapper; GlobalHealth: ResolverTypeWrapper; GlobalIncidentSurface: ResolverTypeWrapper; @@ -9061,6 +9081,7 @@ export type ResolversParentTypes = ResolversObject<{ Float: Scalars['Float']['output']; Gaps: Gaps; GapsArtifactGaps: GapsArtifactGaps; + GapsArtifactGapsSchemaParity: GapsArtifactGapsSchemaParity; GapsList: GapsList; GlobalHealth: GlobalHealth; GlobalIncidentSurface: GlobalIncidentSurface; @@ -11475,9 +11496,20 @@ export type GapsArtifactGapsResolvers; name?: Resolver; netuid?: Resolver; + schema_parity?: Resolver, ParentType, ContextType>; slug?: Resolver; }>; +export type GapsArtifactGapsSchemaParityResolvers = ResolversObject<{ + capture_cadence_hours?: Resolver; + captured_path_count?: Resolver; + captured_schema_count?: Resolver; + declared_non_get_count?: Resolver, ParentType, ContextType>; + flagged?: Resolver; + registered_non_get_count?: Resolver; + registered_route_surface_count?: Resolver; +}>; + export type GapsListResolvers = ResolversObject<{ cursor?: Resolver; gaps?: Resolver, ParentType, ContextType>; @@ -14748,6 +14780,7 @@ export type Resolvers = ResolversObject<{ FailureReasonsDay?: FailureReasonsDayResolvers; Gaps?: GapsResolvers; GapsArtifactGaps?: GapsArtifactGapsResolvers; + GapsArtifactGapsSchemaParity?: GapsArtifactGapsSchemaParityResolvers; GapsList?: GapsListResolvers; GlobalHealth?: GlobalHealthResolvers; GlobalIncidentSurface?: GlobalIncidentSurfaceResolvers; diff --git a/packages/contract/index.d.ts b/packages/contract/index.d.ts index 0371256b2a..8de93ba0e1 100644 --- a/packages/contract/index.d.ts +++ b/packages/contract/index.d.ts @@ -8636,6 +8636,22 @@ export interface components { gaps: components["schemas"]["Gaps"]; name: string; netuid: number; + schema_parity?: { + /** @description The capture lane's declared cadence in hours. Compare it against the backing entries' `snapshot.observed_at` (GET /api/v1/schemas) to judge whether this measurement rests on a current capture. No age is baked: this document is served for hours after it is built, so a build-stamped age would be wrong on arrival. */ + capture_cadence_hours: number; + /** @description Paths the subnet's captured spec(s) declare, summed across captured specs. */ + captured_path_count: number; + /** @description Captured machine-readable specs backing this measurement. */ + captured_schema_count: number; + /** @description Declared POST/PUT/PATCH/DELETE operations. NULL when the captured entries predate the capture-time stamp -- unmeasured, not zero. */ + declared_non_get_count: number | null; + /** @description True when the subnet declares more paths than the catalogue registers as routes -- a caller reading only the registry cannot tell which routes are missing. Judge its currency from the entry's `observed_at` against the schema index's `capture_cadence_hours`. */ + flagged: boolean; + /** @description Registered surfaces declaring a non-GET method (#11146 phase 3). */ + registered_non_get_count: number; + /** @description Registered concrete route surfaces (subnet-api/sse/data-artifact; the openapi spec surface itself is not a route). */ + registered_route_surface_count: number; + }; slug: string; }[]; generated_at: string; @@ -10347,7 +10363,10 @@ export interface components { schema_version: 1; schemas: { content_type?: string | null; - /** @enum {string} */ + /** + * @description How this capture compares to the PREVIOUS capture of the same surface by this registry -- NOT to the subnet's live spec. `unchanged` means 'our snapshot is what it was', never 'the upstream API has not changed'. Judge currency by comparing this entry's `snapshot.observed_at` against `capture_cadence_hours` on the subnet's /api/v1/gaps `schema_parity` block. + * @enum {string} + */ drift_status: "new" | "changed" | "unchanged" | "not-captured" | "missing-after-previous-capture"; error?: string | null; hash?: string | null; @@ -10370,6 +10389,7 @@ export interface components { generated_at?: string | null; hash?: string | null; netuid?: number | null; + non_get_operation_count?: number; observed_at?: string | null; openapi_version?: string | null; path_count?: number; diff --git a/public/metagraph/openapi.json b/public/metagraph/openapi.json index 679e08ea25..ddb709b9ef 100644 --- a/public/metagraph/openapi.json +++ b/public/metagraph/openapi.json @@ -34250,6 +34250,67 @@ "minimum": 0, "type": "integer" }, + "schema_parity": { + "additionalProperties": false, + "properties": { + "capture_cadence_hours": { + "description": "The capture lane's declared cadence in hours. Compare it against the backing entries' `snapshot.observed_at` (GET /api/v1/schemas) to judge whether this measurement rests on a current capture. No age is baked: this document is served for hours after it is built, so a build-stamped age would be wrong on arrival.", + "minimum": 0, + "type": "number" + }, + "captured_path_count": { + "description": "Paths the subnet's captured spec(s) declare, summed across captured specs.", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "captured_schema_count": { + "description": "Captured machine-readable specs backing this measurement.", + "maximum": 9007199254740991, + "minimum": 1, + "type": "integer" + }, + "declared_non_get_count": { + "anyOf": [ + { + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + { + "type": "null" + } + ], + "description": "Declared POST/PUT/PATCH/DELETE operations. NULL when the captured entries predate the capture-time stamp -- unmeasured, not zero." + }, + "flagged": { + "description": "True when the subnet declares more paths than the catalogue registers as routes -- a caller reading only the registry cannot tell which routes are missing. Judge its currency from the entry's `observed_at` against the schema index's `capture_cadence_hours`.", + "type": "boolean" + }, + "registered_non_get_count": { + "description": "Registered surfaces declaring a non-GET method (#11146 phase 3).", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "registered_route_surface_count": { + "description": "Registered concrete route surfaces (subnet-api/sse/data-artifact; the openapi spec surface itself is not a route).", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "capture_cadence_hours", + "captured_schema_count", + "captured_path_count", + "declared_non_get_count", + "registered_route_surface_count", + "registered_non_get_count", + "flagged" + ], + "type": "object" + }, "slug": { "type": "string" } @@ -43447,6 +43508,7 @@ ] }, "drift_status": { + "description": "How this capture compares to the PREVIOUS capture of the same surface by this registry -- NOT to the subnet's live spec. `unchanged` means 'our snapshot is what it was', never 'the upstream API has not changed'. Judge currency by comparing this entry's `snapshot.observed_at` against `capture_cadence_hours` on the subnet's /api/v1/gaps `schema_parity` block.", "enum": [ "new", "changed", @@ -43634,6 +43696,11 @@ } ] }, + "non_get_operation_count": { + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, "observed_at": { "anyOf": [ { diff --git a/public/metagraph/types.d.ts b/public/metagraph/types.d.ts index 0371256b2a..8de93ba0e1 100644 --- a/public/metagraph/types.d.ts +++ b/public/metagraph/types.d.ts @@ -8636,6 +8636,22 @@ export interface components { gaps: components["schemas"]["Gaps"]; name: string; netuid: number; + schema_parity?: { + /** @description The capture lane's declared cadence in hours. Compare it against the backing entries' `snapshot.observed_at` (GET /api/v1/schemas) to judge whether this measurement rests on a current capture. No age is baked: this document is served for hours after it is built, so a build-stamped age would be wrong on arrival. */ + capture_cadence_hours: number; + /** @description Paths the subnet's captured spec(s) declare, summed across captured specs. */ + captured_path_count: number; + /** @description Captured machine-readable specs backing this measurement. */ + captured_schema_count: number; + /** @description Declared POST/PUT/PATCH/DELETE operations. NULL when the captured entries predate the capture-time stamp -- unmeasured, not zero. */ + declared_non_get_count: number | null; + /** @description True when the subnet declares more paths than the catalogue registers as routes -- a caller reading only the registry cannot tell which routes are missing. Judge its currency from the entry's `observed_at` against the schema index's `capture_cadence_hours`. */ + flagged: boolean; + /** @description Registered surfaces declaring a non-GET method (#11146 phase 3). */ + registered_non_get_count: number; + /** @description Registered concrete route surfaces (subnet-api/sse/data-artifact; the openapi spec surface itself is not a route). */ + registered_route_surface_count: number; + }; slug: string; }[]; generated_at: string; @@ -10347,7 +10363,10 @@ export interface components { schema_version: 1; schemas: { content_type?: string | null; - /** @enum {string} */ + /** + * @description How this capture compares to the PREVIOUS capture of the same surface by this registry -- NOT to the subnet's live spec. `unchanged` means 'our snapshot is what it was', never 'the upstream API has not changed'. Judge currency by comparing this entry's `snapshot.observed_at` against `capture_cadence_hours` on the subnet's /api/v1/gaps `schema_parity` block. + * @enum {string} + */ drift_status: "new" | "changed" | "unchanged" | "not-captured" | "missing-after-previous-capture"; error?: string | null; hash?: string | null; @@ -10370,6 +10389,7 @@ export interface components { generated_at?: string | null; hash?: string | null; netuid?: number | null; + non_get_operation_count?: number; observed_at?: string | null; openapi_version?: string | null; path_count?: number; diff --git a/schemas-src/graphql/published-names.ts b/schemas-src/graphql/published-names.ts index b25f0d1e26..00668f98f9 100644 --- a/schemas-src/graphql/published-names.ts +++ b/schemas-src/graphql/published-names.ts @@ -633,6 +633,7 @@ export const PUBLISHED_TYPE_NAMES: Readonly> = { EvidenceLedgerArtifactSummary: "EvidenceLedgerArtifactSummary", Gaps: "Gaps", GapsArtifactGaps: "GapsArtifactGaps", + GapsArtifactGapsSchemaParity: "GapsArtifactGapsSchemaParity", GlobalIncidentsArtifactSummary: "GlobalIncidentsArtifactSummary", HealthHistoryArtifactSurfaces: "HealthHistoryArtifactSurfaces", HealthIncidentsArtifactSurfaces: "HealthIncidentsArtifactSurfaces", diff --git a/schemas-src/routes/curation-gaps.ts b/schemas-src/routes/curation-gaps.ts index d918f51953..588f1b9a40 100644 --- a/schemas-src/routes/curation-gaps.ts +++ b/schemas-src/routes/curation-gaps.ts @@ -47,6 +47,43 @@ export type CurationArtifact = z.infer; export const GAPS_SORT_FIELDS = API_QUERY_COLLECTIONS.gaps.sort_fields; +/** #11146 phase 4: captured spec vs registered catalogue, per subnet. Present + * only when the subnet has captured schema-index entries with stamped counts + * -- absence means "not measured", never "in parity". Not exported: the only + * consumer is GapsEntrySchema below, and an export nothing imports is what + * the unreferenced-exports ratchet counts. */ +const SchemaParitySchema = z + .object({ + capture_cadence_hours: z.number().min(0).meta({ + description: + "The capture lane's declared cadence in hours. Compare it against the backing entries' `snapshot.observed_at` (GET /api/v1/schemas) to judge whether this measurement rests on a current capture. No age is baked: this document is served for hours after it is built, so a build-stamped age would be wrong on arrival.", + }), + captured_schema_count: z.int().min(1).meta({ + description: "Captured machine-readable specs backing this measurement.", + }), + captured_path_count: z.int().min(0).meta({ + description: + "Paths the subnet's captured spec(s) declare, summed across captured specs.", + }), + declared_non_get_count: z.int().min(0).nullable().meta({ + description: + "Declared POST/PUT/PATCH/DELETE operations. NULL when the captured entries predate the capture-time stamp -- unmeasured, not zero.", + }), + registered_route_surface_count: z.int().min(0).meta({ + description: + "Registered concrete route surfaces (subnet-api/sse/data-artifact; the openapi spec surface itself is not a route).", + }), + registered_non_get_count: z.int().min(0).meta({ + description: + "Registered surfaces declaring a non-GET method (#11146 phase 3).", + }), + flagged: z.boolean().meta({ + description: + "True when the subnet declares more paths than the catalogue registers as routes -- a caller reading only the registry cannot tell which routes are missing. Judge its currency from the entry's `observed_at` against the schema index's `capture_cadence_hours`.", + }), + }) + .strict(); + export const GapsEntrySchema = z .object({ netuid: z.int().min(0), @@ -61,6 +98,7 @@ export const GapsEntrySchema = z gaps: GapsSchema, gap_severity: z.enum(ENDPOINT_INCIDENT_SEVERITY_VALUES).optional(), gap_priority: z.int().min(0).optional(), + schema_parity: SchemaParitySchema.optional(), }) .strict(); diff --git a/schemas-src/routes/subnet-profiles.ts b/schemas-src/routes/subnet-profiles.ts index 69c77d20db..b850f01331 100644 --- a/schemas-src/routes/subnet-profiles.ts +++ b/schemas-src/routes/subnet-profiles.ts @@ -113,6 +113,9 @@ export const SchemaSnapshotSchema = z auth_schemes: z.array(z.string()).optional(), hash: z.string().nullable().optional(), previous_hash: z.string().nullable().optional(), + // #11146: how many declared operations are mutations. Absent on a snapshot + // captured before the stamp -- unmeasured, not zero. + non_get_operation_count: z.int().min(0).optional(), drift_status: z.string().nullable().optional(), observed_at: z.string().nullable().optional(), generated_at: z.string().nullable().optional(), @@ -124,7 +127,17 @@ export const SchemaSnapshotSchema = z const SchemaIndexEntrySchema = z .object({ content_type: z.string().nullable().optional(), - drift_status: SchemaDriftStatusSchema, + // #11146 phase 2: the description IS the fix. This value compares our + // snapshot against OUR PREVIOUS snapshot of the same surface -- never the + // subnet's live spec -- and with the capture lane manual-only it read as + // "in sync with upstream" while 23 of 24 measurably-drifted subnets + // reported `unchanged`. Stated at the point of consumption, in the + // published contract, because the artifact itself is a deploy-owned + // capture cache a PR cannot annotate. + drift_status: SchemaDriftStatusSchema.meta({ + description: + "How this capture compares to the PREVIOUS capture of the same surface by this registry -- NOT to the subnet's live spec. `unchanged` means 'our snapshot is what it was', never 'the upstream API has not changed'. Judge currency by comparing this entry's `snapshot.observed_at` against `capture_cadence_hours` on the subnet's /api/v1/gaps `schema_parity` block.", + }), error: z.string().nullable().optional(), hash: z.string().nullable().optional(), netuid: z.int().min(0).optional(), diff --git a/scripts/build-artifacts.ts b/scripts/build-artifacts.ts index e6cd932386..684635121b 100644 --- a/scripts/build-artifacts.ts +++ b/scripts/build-artifacts.ts @@ -105,6 +105,7 @@ import { buildDatasetExports } from "./datasets.ts"; import { readGeneratedStoreJson } from "./r2-rest.ts"; import { chooseSchemaIndexBaseline, + SCHEMA_CAPTURE_CADENCE_HOURS, SCHEMA_INDEX_R2_KEY, } from "../src/schema-snapshots-sync.ts"; import { @@ -1121,8 +1122,82 @@ const curationIndex = mergedSubnets.map((subnet) => ({ surface_count: subnet.surface_count, })); +// #11146 phase 4: per-subnet schema parity -- captured spec vs registered +// catalogue, the check the SN105 report asked for. Derived from schema INDEX +// entries only, never the captured documents: documents exist solely in +// credentialed builds, and this rollup must be a function of inputs every +// build has. The counts ride each captured entry's `snapshot`, stamped at +// capture time where the document was in hand. +const capturedSchemaEntriesByNetuid = new Map(); +for (const entry of (schemaIndexArtifact.schemas as Row[]) || []) { + if (entry.status !== "captured") continue; + const list = capturedSchemaEntriesByNetuid.get(entry.netuid) || []; + list.push(entry); + capturedSchemaEntriesByNetuid.set(entry.netuid, list); +} + +// The registered side of the ledger: concrete callable route surfaces. +// `openapi` is excluded on purpose -- the spec document is how we KNOW the +// declared side, not a route of the API. +const PARITY_ROUTE_KINDS = new Set(["subnet-api", "sse", "data-artifact"]); + +function schemaParityForSubnet( + netuid: unknown, + subnetSurfaces: Row[], +): Row | null { + const captured = capturedSchemaEntriesByNetuid.get(netuid) || []; + if (captured.length === 0) return null; + const pathCounts = captured.map( + (entry) => (entry.snapshot as Row | undefined)?.path_count, + ); + // An entry predating the stamp reports absence, not zero: a parity verdict + // on a guessed denominator is the drift_status:"unchanged" mistake again. + if (!pathCounts.every((count) => Number.isInteger(count))) return null; + const capturedPathCount = pathCounts.reduce( + (sum: number, count) => sum + (count as number), + 0, + ); + const nonGetCounts = captured.map( + (entry) => (entry.snapshot as Row | undefined)?.non_get_operation_count, + ); + const registeredRouteSurfaces = subnetSurfaces.filter((surface) => + PARITY_ROUTE_KINDS.has(surface.kind as string), + ); + return { + // #11146 phase 1: the lane's declared cadence, carried HERE rather than on + // the schema index -- that artifact is a deploy-owned capture cache whose + // committed copy a PR cannot regenerate, so annotating it breaks the + // preservation gate that protects 58 captured schemas. A reader judges + // currency by comparing this against the backing entries' `observed_at`; + // no age is baked, because this document is served for hours after it is + // built and a build-stamped age is wrong on arrival. + capture_cadence_hours: SCHEMA_CAPTURE_CADENCE_HOURS, + captured_schema_count: captured.length, + captured_path_count: capturedPathCount, + // Null until a capture stamped it; both sides of the mutation ledger only + // compare once both are measured. + declared_non_get_count: nonGetCounts.every((count) => + Number.isInteger(count), + ) + ? nonGetCounts.reduce((sum: number, count) => sum + (count as number), 0) + : null, + registered_route_surface_count: registeredRouteSurfaces.length, + registered_non_get_count: registeredRouteSurfaces.filter( + (surface) => surface.method && surface.method !== "GET", + ).length, + // The flag the issue asked for: the subnet declares more paths than the + // catalogue registers as routes, and a caller reading only the registry + // cannot tell WHICH routes those are. + flagged: registeredRouteSurfaces.length < capturedPathCount, + }; +} + const gapsIndex = mergedSubnets.map((subnet) => { const missingKinds = subnet.gaps.missing_kinds || []; + const schemaParity = schemaParityForSubnet( + subnet.netuid, + surfacesByNetuidForCounts.get(subnet.netuid) || [], + ); return { coverage_level: subnet.coverage_level, curation_level: subnet.curation.level, @@ -1148,6 +1223,9 @@ const gapsIndex = mergedSubnets.map((subnet) => { ), name: subnet.name, netuid: subnet.netuid, + // #11146 phase 4: present only when the subnet has captured entries whose + // counts are stamped -- absence means "not measured", never "in parity". + ...(schemaParity ? { schema_parity: schemaParity } : {}), slug: subnet.slug, }; }); diff --git a/scripts/snapshot-openapi.ts b/scripts/snapshot-openapi.ts index bc627cfc9b..d62ad4c0bb 100644 --- a/scripts/snapshot-openapi.ts +++ b/scripts/snapshot-openapi.ts @@ -201,6 +201,11 @@ async function snapshotSurface(surface: Row): Promise { normalized.paths && typeof normalized.paths === "object" ? Object.keys(normalized.paths).length : 0, + // #11146 phase 4: the mutation half of the parity ledger, stamped here + // where the document is in hand. The rollup in the build reads index + // entries only (captured documents exist solely in credentialed builds), + // so a count it cannot find is reported as absent, never as zero. + non_get_operation_count: countNonGetOperations(normalized), component_schema_count: normalized.components?.schemas && typeof normalized.components.schemas === "object" @@ -240,6 +245,25 @@ async function snapshotSurface(surface: Row): Promise { ); } +// GET is what the registry could always represent; everything else was +// invisible until the method dimension (#11146 phase 3). `head`/`options`/ +// `trace` are deliberately excluded -- they are not application operations a +// caller would ever register as a surface. +const MUTATION_OPERATION_METHODS = ["post", "put", "patch", "delete"] as const; + +function countNonGetOperations(document: Row): number { + const paths = document?.paths; + if (!paths || typeof paths !== "object") return 0; + let count = 0; + for (const item of Object.values(paths as Record)) { + if (!item || typeof item !== "object") continue; + for (const method of MUTATION_OPERATION_METHODS) { + if ((item as Row)[method]) count += 1; + } + } + return count; +} + function unavailable( surface: Row, schemaUrl: string | null | undefined, diff --git a/src/schema-snapshots-sync.ts b/src/schema-snapshots-sync.ts index 3638984a90..2dfa023b13 100644 --- a/src/schema-snapshots-sync.ts +++ b/src/schema-snapshots-sync.ts @@ -86,6 +86,27 @@ export const SCHEMA_INDEX_ARTIFACT_PATH = "/metagraph/schemas/index.json"; */ export const SCHEMA_SNAPSHOT_RETENTION_MS = 30 * 24 * 60 * 60 * 1000; +/** + * The capture lane's declared cadence (#11146 phase 1): how old a captured + * schema may be before a reader should treat it as stale. + * + * WHY A NUMBER RATHER THAN A STAMPED VERDICT. The artifact is published once + * and served for hours, so an age computed at BUILD time is wrong the moment + * it is read -- and `buildTimestamp()` is the 1970 epoch placeholder in every + * local build, which would publish a twelve-day-old capture as fresh. So the + * payload carries the two facts a reader needs -- each entry's `observed_at` + * and this cadence -- and the subtraction happens against the reader's own + * clock, where "now" is genuinely now. + * + * The lane itself stays manual by ADR 0006. Declaring the cadence does not run + * it; it makes the lane's silence MEASURABLE, which is the difference between + * an unpublished gap and a published one. + * + * 48 hours: two days of tolerance over a daily-ish refresh, so one skipped run + * is not noise while a lane that stopped shows within two days. + */ +export const SCHEMA_CAPTURE_CADENCE_HOURS = 48; + /** Statuses that mean "we have a real document behind this entry". */ const CAPTURED_STATUS = "captured"; diff --git a/tests/artifacts.test.ts b/tests/artifacts.test.ts index 5712a23cac..0d0d16cb6b 100644 --- a/tests/artifacts.test.ts +++ b/tests/artifacts.test.ts @@ -1461,6 +1461,41 @@ test("public artifacts are internally consistent", () => { ); assert.equal(curation.curation.length, native.subnets.length); assert.equal(gaps.gaps.length, native.subnets.length); + // #11146 phase 4: the schema-parity ledger. Present exactly on subnets with + // captured schema-index entries; every block internally consistent, and the + // flag is the arithmetic it claims. At least one subnet must be measured + // (the committed index seed carries 58 captured entries) and at least one + // flagged -- under-registration is measured to exist, so a build where + // nothing is flagged means the ledger broke rather than that parity is met. + const parityRows = gaps.gaps.filter((row: Row) => row.schema_parity); + assert.equal(parityRows.length > 0, true, "no subnet measured for parity"); + for (const row of parityRows) { + const parity = row.schema_parity as Row; + assert.equal((parity.captured_schema_count as number) >= 1, true); + // #11146 phase 1: freshness is judged by the reader, so the block must + // carry the cadence rather than a build-stamped age. + assert.equal((parity.capture_cadence_hours as number) > 0, true); + assert.equal("captured_age_hours" in parity, false); + assert.equal(Number.isInteger(parity.captured_path_count), true); + assert.equal( + parity.declared_non_get_count === null || + Number.isInteger(parity.declared_non_get_count), + true, + "an unmeasured mutation count must be null, never zero", + ); + assert.equal( + parity.flagged, + (parity.registered_route_surface_count as number) < + (parity.captured_path_count as number), + `parity flag must be the arithmetic it claims (netuid ${row.netuid})`, + ); + } + assert.equal( + parityRows.some((row: Row) => (row.schema_parity as Row).flagged), + true, + "no subnet flagged -- under-registration is measured to exist (#11146)", + ); + assert.equal(verification.candidate_count, verification.results.length); assert.equal( verification.results.length <= candidates.candidates.length,