diff --git a/convex/canonicalTrending.ts b/convex/canonicalTrending.ts index 5b73f29a..a12fca4d 100644 --- a/convex/canonicalTrending.ts +++ b/convex/canonicalTrending.ts @@ -6,7 +6,6 @@ import { internalAction, internalMutation, internalQuery } from "./_generated/se import { CANONICAL_TRENDING_FIRST_PAGE_SIZE, CANONICAL_TRENDING_LANE_LIMIT, - CANONICAL_TRENDING_PUBLISHER_CAP, CANONICAL_TRENDING_RANKING_VERSION, CANONICAL_TRENDING_WINDOW_HOURS, blendCanonicalTrendingPools, @@ -17,7 +16,7 @@ import { decodeCanonicalTrendingCursor, encodeCanonicalTrendingCursor, isFreshExternalTrendingRun, - retainTopCanonicalTrendingCandidates, + retainCanonicalTrendingLaneCandidates, type CanonicalTrendingMaterializationCandidate, } from "./lib/canonicalTrending"; import { forEachCanonicalTrendingSourcePage } from "./lib/canonicalTrendingPagination"; @@ -123,11 +122,6 @@ const operationsValidator = v.object({ functionCalls: v.number(), }); -const LANE_DIVERSITY_RESERVE = { - size: CANONICAL_TRENDING_FIRST_PAGE_SIZE, - publisherCap: CANONICAL_TRENDING_PUBLISHER_CAP, -}; - export const getNativeSourceBatchInternal = internalQuery({ args: { skillIds: v.array(v.id("skills")) }, handler: async (ctx, args) => { @@ -856,17 +850,13 @@ export const materializeInternal = internalAction({ } } // The fetched batch is capped at 100, so each lane stays within 100 rows of its limit. - nativeCandidates = retainTopCanonicalTrendingCandidates( + nativeCandidates = retainCanonicalTrendingLaneCandidates( nativeCandidates, "clawhub-trending", - CANONICAL_TRENDING_LANE_LIMIT, - LANE_DIVERSITY_RESERVE, ); - risingCandidates = retainTopCanonicalTrendingCandidates( + risingCandidates = retainCanonicalTrendingLaneCandidates( risingCandidates, "clawhub-rising", - CANONICAL_TRENDING_LANE_LIMIT, - LANE_DIVERSITY_RESERVE, ); for (const skillId of skillIds) usageBySkill.delete(String(skillId)); }; @@ -997,11 +987,9 @@ export const materializeInternal = internalAction({ const candidate = buildExternalCanonicalTrendingCandidate(digest); if (candidate) externalCandidates.push(candidate); } - externalCandidates = retainTopCanonicalTrendingCandidates( + externalCandidates = retainCanonicalTrendingLaneCandidates( externalCandidates, "skills-sh-trending", - CANONICAL_TRENDING_LANE_LIMIT, - LANE_DIVERSITY_RESERVE, ); }, ); diff --git a/convex/lib/canonicalTrending.test.ts b/convex/lib/canonicalTrending.test.ts index 6f132571..03bec634 100644 --- a/convex/lib/canonicalTrending.test.ts +++ b/convex/lib/canonicalTrending.test.ts @@ -7,6 +7,7 @@ import { encodeCanonicalTrendingCursor, isFreshExternalTrendingRun, retainTopCanonicalTrendingCandidates, + retainCanonicalTrendingLaneCandidates, sortCanonicalTrendingPools, type CanonicalTrendingCandidate, } from "./canonicalTrending"; @@ -220,6 +221,21 @@ describe("canonical Trending ordering", () => { "gamma", ]); }); + + it("keeps the shared diversity reserve in addition to the bounded lane leaders", () => { + const retained = retainCanonicalTrendingLaneCandidates( + Array.from({ length: 1_001 }, (_, index) => + candidate(`external-${index}`, "skills-sh-trending", { + publisherKey: index === 1_000 ? "beta" : "alpha", + upstreamRank: index + 1, + }), + ), + "skills-sh-trending", + ); + + expect(retained).toHaveLength(1_001); + expect(retained.at(-1)?.identity).toBe("external-1000"); + }); }); describe("canonical Trending cursors", () => { diff --git a/convex/lib/canonicalTrending.ts b/convex/lib/canonicalTrending.ts index 652ca9f6..d480f52f 100644 --- a/convex/lib/canonicalTrending.ts +++ b/convex/lib/canonicalTrending.ts @@ -375,6 +375,16 @@ export function retainTopCanonicalTrendingCandidates( + candidates: readonly T[], + lane: CanonicalTrendingLane, +) { + return retainTopCanonicalTrendingCandidates(candidates, lane, CANONICAL_TRENDING_LANE_LIMIT, { + size: CANONICAL_TRENDING_FIRST_PAGE_SIZE, + publisherCap: CANONICAL_TRENDING_PUBLISHER_CAP, + }); +} + export function sortCanonicalTrendingPools( pools: CanonicalTrendingPools, ): CanonicalTrendingPools { diff --git a/convex/skillsShMirrorVisibility.test.ts b/convex/skillsShMirrorVisibility.test.ts index 4c0dad83..761a297d 100644 --- a/convex/skillsShMirrorVisibility.test.ts +++ b/convex/skillsShMirrorVisibility.test.ts @@ -534,6 +534,146 @@ describe("skills.sh mirror visibility operations", () => { }); }); + it("verifies the bounded Trending lane when eligible production rows exceed its limit", async () => { + const t = convexTest(schema, modules); + const now = Date.now(); + const rowCount = 1_001; + const { leaderboardRunId, trendingRunId } = await t.run(async (ctx) => { + const createdLeaderboardRunId = await ctx.db.insert( + "skillsShMirrorRuns", + mirrorRun({ + snapshotId: "skills-sh:leaderboard:bounded", + sourceView: "leaderboard", + sourceTotal: rowCount, + counts: mirrorRunCounts({ + observed: rowCount, + inserted: rowCount, + detailsInserted: rowCount, + }), + startedAt: now - 3_000, + completedAt: now - 2_000, + updatedAt: now - 2_000, + }), + ); + const createdTrendingRunId = await ctx.db.insert( + "skillsShMirrorRuns", + mirrorRun({ + snapshotId: "skills-sh:trending:bounded", + sourceView: "trending", + sourceTotal: rowCount, + counts: mirrorRunCounts({ + observed: rowCount, + trendingJoined: rowCount, + }), + startedAt: now - 1_500, + completedAt: now - 1_000, + updatedAt: now - 1_000, + }), + ); + for (let index = 0; index < rowCount; index += 1) { + const slug = `bounded-${index}`; + const owner = `publisher-${index}`; + await ctx.db.insert( + "skillsShMirrorDigests", + digest({ + externalId: `${owner}/skills/${slug}`, + owner, + slug, + normalizedSlug: slug, + normalizedSlugFirstToken: slug, + displayName: `Bounded ${index}`, + normalizedDisplayName: `bounded ${index}`, + normalizedDisplayNameFirstToken: "bounded", + searchText: `bounded ${index}`, + sourceUrl: `https://skills.sh/${owner}/skills/${slug}`, + canonicalRepoUrl: `https://github.com/${owner}/skills`, + githubPath: `skills/${slug}`, + lastObservedRunId: createdLeaderboardRunId, + trendingObservedRunId: createdTrendingRunId, + trendingRank: index + 1, + trendingLifetimeInstalls: rowCount - index, + trendingObservedAt: now - 1_000, + }), + ); + } + await ctx.db.insert("skillsShMirrorControls", { + key: "global", + enabled: true, + paused: false, + maxRowsPerRun: 50_000, + maxRowsPerBatch: 50, + maxDetailBytes: 65_536, + latestCompletedLeaderboardRunId: createdLeaderboardRunId, + updatedBy: "codex-test", + reason: "CLAW-603 bounded activation verification", + updatedAt: now - 2_000, + }); + return { + leaderboardRunId: createdLeaderboardRunId, + trendingRunId: createdTrendingRunId, + }; + }); + + await t.mutation(internal.canonicalTrending.startSnapshotInternal, { + snapshotId: "skills-native-bounded", + generatedAt: now - 1_000, + expiresAt: now + 24 * 60 * 60 * 1_000, + windowStartDay: 40, + windowEndDay: 40, + windowStartHour: 960, + windowEndHour: 983, + }); + await t.mutation(internal.canonicalTrending.startNativePoolInternal, { + poolId: "skills-native-bounded", + generatedAt: now - 1_000, + expiresAt: now + 24 * 60 * 60 * 1_000, + windowStartHour: 960, + windowEndHour: 983, + sealedGeneration: 1, + }); + await t.mutation(internal.canonicalTrending.finalizeNativePoolInternal, { + poolId: "skills-native-bounded", + completedAt: now - 500, + sourceCounts: { clawhubTrending: 0, clawhubRising: 0 }, + operations: { documentsRead: 10, documentsWritten: 2, functionCalls: 3 }, + }); + await t.mutation(internal.canonicalTrending.finalizeSnapshotInternal, { + snapshotId: "skills-native-bounded", + completedAt: now - 500, + totalItems: 0, + sourceCounts: { clawhubTrending: 0, clawhubRising: 0, skillsShTrending: 0 }, + operations: { documentsRead: 10, documentsWritten: 2, functionCalls: 3 }, + nativePoolId: "skills-native-bounded", + }); + + await expect( + t.action(internal.skillsShMirrorVisibility.verifyAndActivateInternal, { + actor: "codex-test", + reason: "CLAW-603 bounded production-sized activation", + confirm: "activate-skills-sh-public-test", + }), + ).resolves.toMatchObject({ + activated: true, + leaderboard: { sourceTotal: rowCount, accepted: rowCount, rejected: 0 }, + trending: { sourceTotal: rowCount, joined: rowCount, missing: 0 }, + corpus: { + activationRunAccepted: rowCount, + activationRunTrendingEligible: rowCount, + activationRunTrendingSelected: 1_000, + }, + trendingSnapshot: { + sourceCounts: { skillsShTrending: 1_000 }, + nativePool: { reused: true }, + }, + }); + const activatedRuns = await t.run(async (ctx) => ({ + leaderboard: await ctx.db.get(leaderboardRunId), + trending: await ctx.db.get(trendingRunId), + })); + expect(activatedRuns.leaderboard?.activationSnapshotId).toEqual(expect.any(String)); + expect(activatedRuns.leaderboard?.activatedTrendingRunId).toBe(trendingRunId); + }); + it("keeps the public gate closed when activation has to build the native pool", async () => { const t = convexTest(schema, modules); const now = Date.now(); diff --git a/convex/skillsShMirrorVisibility.ts b/convex/skillsShMirrorVisibility.ts index 9faa3537..3c36669a 100644 --- a/convex/skillsShMirrorVisibility.ts +++ b/convex/skillsShMirrorVisibility.ts @@ -4,6 +4,12 @@ import { internal } from "./_generated/api"; import type { Doc } from "./_generated/dataModel"; import type { ActionCtx, MutationCtx } from "./_generated/server"; import { internalAction, internalMutation, internalQuery } from "./functions"; +import { + buildExternalCanonicalTrendingCandidate, + type CanonicalTrendingCandidate, + retainCanonicalTrendingLaneCandidates, +} from "./lib/canonicalTrending"; +import { isPublicSkillsShMirrorDigest } from "./lib/skillsShMirrorPublic"; import { assertSkillsShPublicVisibilityMutationAllowed, isSkillsShMirrorSourceEligible, @@ -838,7 +844,9 @@ export const getAuditPageInternal = internalQuery({ tombstoned: 0, activationRunAccepted: 0, activationRunTrendingEligible: 0, + activationRunTrendingSelected: 0, }; + const activationRunTrendingCandidates: CanonicalTrendingCandidate[] = []; for (const digest of page.page) { const sourceEligible = isSkillsShMirrorSourceEligible(digest); const publicationFlags = skillsShMirrorPublicationFlags(digest); @@ -870,15 +878,25 @@ export const getAuditPageInternal = internalQuery({ if ( args.trendingRunId !== undefined && digest.trendingObservedRunId === args.trendingRunId && - publicationFlags.publicVisible && - publicationFlags.installable && - Number.isSafeInteger(digest.trendingRank) && - (digest.trendingRank ?? 0) >= 1 + isPublicSkillsShMirrorDigest(digest) ) { - counts.activationRunTrendingEligible += 1; + const candidate = buildExternalCanonicalTrendingCandidate(digest); + if (candidate) { + counts.activationRunTrendingEligible += 1; + activationRunTrendingCandidates.push({ + identity: candidate.identity, + lane: candidate.lane, + publisherKey: candidate.publisherKey, + installs24h: candidate.installs24h, + bookmarks24h: candidate.bookmarks24h, + createdAt: candidate.createdAt, + updatedAt: candidate.updatedAt, + upstreamRank: candidate.upstreamRank, + }); + } } } - return { ...page, page: counts }; + return { ...page, page: counts, activationRunTrendingCandidates }; }, }); @@ -907,7 +925,9 @@ async function audit( tombstoned: 0, activationRunAccepted: 0, activationRunTrendingEligible: 0, + activationRunTrendingSelected: 0, }; + let activationRunTrendingCandidates: CanonicalTrendingCandidate[] = []; while (true) { const result = (await ctx.runQuery(internal.skillsShMirrorVisibility.getAuditPageInternal, { paginationOpts: { cursor, numItems: MAX_BATCH_SIZE }, @@ -916,10 +936,15 @@ async function audit( continueCursor: string; isDone: boolean; page: typeof counts; + activationRunTrendingCandidates: CanonicalTrendingCandidate[]; }; for (const key of Object.keys(counts) as Array) { counts[key] += result.page[key]; } + activationRunTrendingCandidates = retainCanonicalTrendingLaneCandidates( + [...activationRunTrendingCandidates, ...result.activationRunTrendingCandidates], + "skills-sh-trending", + ); batches += 1; if (counts.total > MAX_ROWS) { throw new Error(`skills.sh visibility audit exceeded ${MAX_ROWS} rows`); @@ -927,6 +952,7 @@ async function audit( if (result.isDone) break; cursor = result.continueCursor; } + counts.activationRunTrendingSelected = activationRunTrendingCandidates.length; return { ok: true as const, batches, @@ -1011,7 +1037,7 @@ export const verifyAndActivateInternal = internalAction({ } if ( trendingSnapshot.sourceCounts.skillsShTrending !== - corpusAudit.counts.activationRunTrendingEligible + corpusAudit.counts.activationRunTrendingSelected ) { throw new Error("skills.sh Trending activation snapshot failed source verification"); }