Tune publisher abuse pressure labels

Retune publisher-abuse aggregate scoring to v4, keep this path flag-only for rollout, clear stale aggregate nominations, and remove the direct publisher-abuse ban UI.
This commit is contained in:
Jesse Merhi
2026-06-22 10:53:33 +10:00
committed by GitHub
parent 6f28659e7b
commit a86c48ce3b
9 changed files with 981 additions and 372 deletions
+172 -25
View File
@@ -4,19 +4,26 @@ import { describe, expect, it } from "vitest";
import {
computeCurrentSkillTemporalAbuseScore,
computeHistoricalSkillTemporalAbuseScore,
computePublisherAbusePressure,
computePublisherAbuseRawScore,
computeTemporalAbuseCohortBenchmark,
computeTemporalPublisherAbuseZScore,
DEFAULT_PUBLISHER_ABUSE_MODEL_CONFIG,
labelForPublisherAbuseScore,
labelForTemporalPublisherAbuse,
labelForPublisherAbuseZScore,
scorePublisherAbuseCohort,
} from "./publisherAbuseScoring";
describe("publisher abuse scoring", () => {
it("uses a stronger superlinear output elasticity for bulk publishers", () => {
expect(DEFAULT_PUBLISHER_ABUSE_MODEL_CONFIG.modelVersion).toBe("publisher-abuse-pressure.v2");
expect(DEFAULT_PUBLISHER_ABUSE_MODEL_CONFIG.outputElasticity).toBe(1.5);
it("uses the mature catalog pivot for publisher spam abuse checks", () => {
expect(DEFAULT_PUBLISHER_ABUSE_MODEL_CONFIG.modelVersion).toBe("publisher-abuse-pressure.v4");
expect(DEFAULT_PUBLISHER_ABUSE_MODEL_CONFIG.skillPivot).toBe(200);
expect(DEFAULT_PUBLISHER_ABUSE_MODEL_CONFIG.outputElasticity).toBe(1.2);
expect(DEFAULT_PUBLISHER_ABUSE_MODEL_CONFIG.engagementElasticity).toBe(0.25);
expect(DEFAULT_PUBLISHER_ABUSE_MODEL_CONFIG.minPublishedSkillsForAggregateLabel).toBe(200);
expect(DEFAULT_PUBLISHER_ABUSE_MODEL_CONFIG.installTrustElasticity).toBe(1);
expect(DEFAULT_PUBLISHER_ABUSE_MODEL_CONFIG.starTrustElasticity).toBe(1.1);
});
it("uses the dry-run z-score thresholds", () => {
@@ -109,29 +116,170 @@ describe("publisher abuse scoring", () => {
expect(byHandle.get("peand-rover")?.rank).toBeLessThan(byHandle.get("byungkyu")?.rank ?? 0);
});
it("keeps catalog pressure linear below the bulk publisher pivot", () => {
const score50 = computePublisherAbuseRawScore(
publisher("ordinary-50", {
publishedSkills: 50,
totalInstalls: 50,
totalStars: 1.25,
totalDownloads: 12_500,
it("keeps high-adoption bulk publishers out of aggregate spam labels", () => {
const scored = scorePublisherAbuseCohort([
...Array.from({ length: 200 }, (_, index) =>
publisher(`ordinary-${index}`, {
publishedSkills: 3,
totalInstalls: 30,
totalStars: 2,
totalDownloads: 600,
}),
),
publisher("ivangdavila-shape", {
publishedSkills: 955,
totalInstalls: 84_756,
totalStars: 4_924,
totalDownloads: 2_347_109,
}),
);
const score100 = computePublisherAbuseRawScore(
publisher("ordinary-100", {
publishedSkills: 100,
totalInstalls: 100,
totalStars: 2.5,
totalDownloads: 25_000,
publisher("harrylabsj-shape", {
publishedSkills: 600,
totalInstalls: 7_521,
totalStars: 17,
totalDownloads: 201_855,
}),
);
publisher("oomol-shape", {
publishedSkills: 582,
totalInstalls: 4_153,
totalStars: 0,
totalDownloads: 111_003,
}),
publisher("justoneapi-shape", {
publishedSkills: 224,
totalInstalls: 3_164,
totalStars: 0,
totalDownloads: 83_782,
}),
publisher("ai-gaoqian-shape", {
publishedSkills: 212,
totalInstalls: 855,
totalStars: 5,
totalDownloads: 24_362,
}),
]);
expect(score50.pressure).toBeGreaterThan(0);
expect(score50.pressure / score100.pressure).toBeCloseTo(0.5);
const byHandle = new Map(scored.map((score) => [score.input.handleSnapshot, score]));
expect(byHandle.get("ivangdavila-shape")?.label).toBe("pass");
expect(byHandle.get("harrylabsj-shape")?.label).toBe("pass");
expect(byHandle.get("oomol-shape")?.label).toBe("potential_ban_candidate");
expect(byHandle.get("justoneapi-shape")?.label).toBe("review");
expect(byHandle.get("ai-gaoqian-shape")?.label).toBe("potential_ban_candidate");
});
it("increases catalog pressure faster than skill count above the pivot", () => {
it("keeps below-pivot catalogs out of aggregate spam abuse labels", () => {
const score199 = computePublisherAbuseRawScore(
publisher("ordinary-199", {
publishedSkills: 199,
totalInstalls: 0,
totalStars: 0,
totalDownloads: 50_000,
}),
);
const score200 = computePublisherAbuseRawScore(
publisher("bulk-200", {
publishedSkills: 200,
totalInstalls: 0,
totalStars: 0,
totalDownloads: 50_000,
}),
);
expect(score199.pressure).toBeGreaterThan(0);
expect(score199.logPressure).toBeGreaterThan(0);
expect(score199.reasonCodes).toEqual([]);
expect(labelForPublisherAbuseScore(score199, 3)).toBe("pass");
expect(score200.pressure).toBeGreaterThan(0);
});
it("keeps tiny catalogs out of aggregate spam abuse labels", () => {
const score6 = computePublisherAbuseRawScore(
publisher("tiny-6", {
publishedSkills: 6,
totalInstalls: 0,
totalStars: 0,
totalDownloads: 0,
}),
);
const score200 = computePublisherAbuseRawScore(
publisher("bulk-200", {
publishedSkills: 200,
totalInstalls: 0,
totalStars: 0,
totalDownloads: 0,
}),
);
expect(score6.pressure).toBeGreaterThan(0);
expect(score6.reasonCodes).toEqual([]);
expect(score200.pressure).toBeGreaterThan(0);
});
it("does not nominate publishers before the catalog reaches the bulk maturity pivot", () => {
const belowPivot = computePublisherAbuseRawScore(
publisher("spacesq-shape", {
publishedSkills: 62,
totalInstalls: 0,
totalStars: 0,
totalDownloads: 29_906,
}),
);
const abovePivot = computePublisherAbuseRawScore(
publisher("justoneapi-shape", {
publishedSkills: 224,
totalInstalls: 33,
totalStars: 0,
totalDownloads: 83_543,
}),
);
expect(labelForPublisherAbuseScore(belowPivot, 3)).toBe("pass");
expect(labelForPublisherAbuseScore(abovePivot, 3)).toBe("potential_ban_candidate");
});
it("preserves legacy configs where the skill pivot was not a label floor", () => {
const legacyConfig = {
...DEFAULT_PUBLISHER_ABUSE_MODEL_CONFIG,
modelVersion: "publisher-abuse-pressure.v2",
skillPivot: 100,
minPublishedSkillsForAggregateLabel: undefined,
};
const score99 = computePublisherAbuseRawScore(
publisher("legacy-99", {
publishedSkills: 99,
totalInstalls: 0,
totalStars: 0,
totalDownloads: 100,
}),
legacyConfig,
);
expect(labelForPublisherAbuseScore(score99, 3, legacyConfig)).toBe("potential_ban_candidate");
});
it("preserves legacy below-pivot catalog pressure for resumed stored configs", () => {
const legacyConfig = {
...DEFAULT_PUBLISHER_ABUSE_MODEL_CONFIG,
modelVersion: "publisher-abuse-pressure.v2",
skillPivot: 100,
outputElasticity: 1.5,
engagementElasticity: undefined,
minPublishedSkillsForAggregateLabel: undefined,
};
const pressure = computePublisherAbusePressure(
{
publishedSkills: 25,
totalInstalls: 50,
totalStars: 1.25,
totalDownloads: 6_250,
},
legacyConfig,
);
expect(pressure).toBeCloseTo(0.25);
});
it("increases catalog pressure when catalog grows without matching adoption", () => {
const score200 = computePublisherAbuseRawScore(
publisher("bulk-200", {
publishedSkills: 200,
@@ -143,14 +291,13 @@ describe("publisher abuse scoring", () => {
const score400 = computePublisherAbuseRawScore(
publisher("bulk-400", {
publishedSkills: 400,
totalInstalls: 400,
totalStars: 10,
totalDownloads: 50_000,
totalInstalls: 200,
totalStars: 5,
totalDownloads: 25_000,
}),
);
expect(score200.pressure).toBeGreaterThan(0);
expect(score400.pressure / score200.pressure).toBeCloseTo(2 ** 1.5);
expect(score400.pressure / score200.pressure).toBeGreaterThan(2);
});
+72 -23
View File
@@ -1,4 +1,4 @@
export const PUBLISHER_ABUSE_MODEL_VERSION = "publisher-abuse-pressure.v2";
export const PUBLISHER_ABUSE_MODEL_VERSION = "publisher-abuse-pressure.v4";
export const PUBLISHER_TEMPORAL_ABUSE_MODEL_VERSION = "publisher-abuse-temporal.v1";
export type PublisherAbuseLabel = "pass" | "review" | "potential_ban_candidate";
@@ -10,6 +10,8 @@ export type PublisherAbuseModelConfig = {
starsPerSkillPivot: number;
downloadsPerSkillPivot: number;
outputElasticity: number;
engagementElasticity?: number;
minPublishedSkillsForAggregateLabel?: number;
installTrustElasticity: number;
starTrustElasticity: number;
downloadDemandElasticity: number;
@@ -99,15 +101,17 @@ export type TemporalAbuseCohortBenchmark = {
export const DEFAULT_PUBLISHER_ABUSE_MODEL_CONFIG = {
modelVersion: PUBLISHER_ABUSE_MODEL_VERSION,
skillPivot: 100,
skillPivot: 200,
// Two installs per skill is only a rough review calibration point. It can be
// the author plus one friend, so it is not proof of legitimacy or abuse.
installsPerSkillPivot: 2,
starsPerSkillPivot: 0.05,
downloadsPerSkillPivot: 250,
outputElasticity: 1.5,
installTrustElasticity: 0.8,
starTrustElasticity: 1,
outputElasticity: 1.2,
engagementElasticity: 0.25,
minPublishedSkillsForAggregateLabel: 200,
installTrustElasticity: 1,
starTrustElasticity: 1.1,
downloadDemandElasticity: 0.2,
minInstallsPerSkill: 0.05,
minStarsPerSkill: 0.02,
@@ -139,6 +143,30 @@ export function labelForPublisherAbuseZScore(
return "pass";
}
export function labelForPublisherAbuseScore(
score: Pick<PublisherAbuseRawScore, "publishedSkills">,
zScore: number,
config: PublisherAbuseModelConfig = DEFAULT_PUBLISHER_ABUSE_MODEL_CONFIG,
): PublisherAbuseLabel {
if (!isPublisherAbuseCheckEligible(score, config)) return "pass";
if (zScore < config.reviewZThreshold) return "pass";
if (
zScore >= config.potentialBanCandidateZThreshold &&
isPublisherAbuseCheckEligible(score, config)
) {
return "potential_ban_candidate";
}
return "review";
}
export function isPublisherAbuseCheckEligible(
score: Pick<PublisherAbuseRawScore, "publishedSkills">,
config: PublisherAbuseModelConfig = DEFAULT_PUBLISHER_ABUSE_MODEL_CONFIG,
) {
const minPublishedSkills = Math.max(1, config.minPublishedSkillsForAggregateLabel ?? 1);
return score.publishedSkills >= minPublishedSkills;
}
export function computeTemporalPublisherAbuseZScore(input: {
label: PublisherAbuseLabel;
highTemporalSkillCount: number;
@@ -169,9 +197,9 @@ export function computePublisherAbuseRawScore(
const pressure = computePublisherAbusePressure(
{
publishedSkills,
installsPerSkill,
starsPerSkill,
downloadsPerSkill,
totalInstalls,
totalStars,
totalDownloads,
},
config,
);
@@ -200,34 +228,51 @@ export function computePublisherAbuseRawScore(
export function computePublisherAbusePressure(
input: {
publishedSkills: number;
installsPerSkill: number;
starsPerSkill: number;
downloadsPerSkill: number;
totalInstalls: number;
totalStars: number;
totalDownloads: number;
},
config: PublisherAbuseModelConfig = DEFAULT_PUBLISHER_ABUSE_MODEL_CONFIG,
): number {
if (input.publishedSkills <= 0) return 0;
const skills = Math.max(1, input.publishedSkills);
const skillPivot = Math.max(1, config.skillPivot);
const installsPerSkill = Math.max(config.minInstallsPerSkill, input.installsPerSkill);
const installsPerSkillPivot = Math.max(config.minInstallsPerSkill, config.installsPerSkillPivot);
const starsPerSkill = Math.max(config.minStarsPerSkill, input.starsPerSkill);
const starsPerSkillPivot = Math.max(config.minStarsPerSkill, config.starsPerSkillPivot);
const downloadsPerSkill = Math.max(config.minDownloadsPerSkill, input.downloadsPerSkill);
const downloadsPerSkillPivot = Math.max(
config.minDownloadsPerSkill,
config.downloadsPerSkillPivot,
);
const skillOutputRatio = skills / skillPivot;
const catalogPressure =
skillOutputRatio <= 1 ? skillOutputRatio : skillOutputRatio ** config.outputElasticity;
const usesWholePublisherEngagement = typeof config.engagementElasticity === "number";
const catalogPressure = usesWholePublisherEngagement
? skillOutputRatio ** config.outputElasticity
: skillOutputRatio <= 1
? skillOutputRatio
: skillOutputRatio ** config.outputElasticity;
const engagementScale = skillOutputRatio ** (config.engagementElasticity ?? 1);
const installBenchmark = installsPerSkillPivot * skillPivot * engagementScale;
const starBenchmark = starsPerSkillPivot * skillPivot * engagementScale;
const downloadBenchmark = downloadsPerSkillPivot * skillPivot * engagementScale;
const totalInstalls = Math.max(
config.minInstallsPerSkill * skillPivot * engagementScale,
input.totalInstalls,
);
const totalStars = Math.max(
config.minStarsPerSkill * skillPivot * engagementScale,
input.totalStars,
);
const totalDownloads = Math.max(
config.minDownloadsPerSkill * skillPivot * engagementScale,
input.totalDownloads,
);
return (
catalogPressure *
(installsPerSkillPivot / installsPerSkill) ** config.installTrustElasticity *
(starsPerSkillPivot / starsPerSkill) ** config.starTrustElasticity *
(downloadsPerSkillPivot / downloadsPerSkill) ** config.downloadDemandElasticity
(installBenchmark / totalInstalls) ** config.installTrustElasticity *
(starBenchmark / totalStars) ** config.starTrustElasticity *
(downloadBenchmark / totalDownloads) ** config.downloadDemandElasticity
);
}
@@ -236,20 +281,23 @@ export function scorePublisherAbuseCohort(
config: PublisherAbuseModelConfig = DEFAULT_PUBLISHER_ABUSE_MODEL_CONFIG,
): PublisherAbuseScore[] {
const rawScores = inputs.map((input) => computePublisherAbuseRawScore(input, config));
const mean = average(rawScores.map((score) => score.logPressure));
const scoredRawScores = rawScores.filter((score) => score.publishedSkills > 0);
const mean = average(scoredRawScores.map((score) => score.logPressure));
const stdDev = standardDeviation(
rawScores.map((score) => score.logPressure),
scoredRawScores.map((score) => score.logPressure),
mean,
);
const safeStdDev = stdDev === 0 ? 1 : stdDev;
return rawScores
.map((score) => {
const zScore = (score.logPressure - mean) / safeStdDev;
const zScore = isPublisherAbuseCheckEligible(score, config)
? (score.logPressure - mean) / safeStdDev
: 0;
return {
...score,
zScore,
label: labelForPublisherAbuseZScore(zScore, config),
label: labelForPublisherAbuseScore(score, zScore, config),
rank: 0,
};
})
@@ -453,6 +501,7 @@ function reasonCodesForPublisher(input: {
}) {
const codes: string[] = [];
if (input.publishedSkills <= 0) return codes;
if (!isPublisherAbuseCheckEligible(input, input.config)) return codes;
if (input.publishedSkills >= input.config.skillPivot) codes.push("high_catalog_volume");
if (input.installsPerSkill < input.config.installsPerSkillPivot) {
codes.push("low_installs_per_skill");
+597 -75
View File
@@ -49,6 +49,7 @@ const TEST_MODEL_CONFIG = {
starsPerSkillPivot: 0.05,
downloadsPerSkillPivot: 250,
outputElasticity: 1.5,
engagementElasticity: 0.25,
installTrustElasticity: 0.8,
starTrustElasticity: 1,
downloadDemandElasticity: 0.2,
@@ -275,6 +276,7 @@ function makeNomination(
ownerUserId: string;
latestScoreId: string;
handleSnapshot: string;
modelVersion: string;
label: "potential_ban_candidate" | "review" | "pass";
status: PublisherAbuseTestTriageStatus;
lastScoredAt: number;
@@ -289,7 +291,7 @@ function makeNomination(
ownerUserId: fields.ownerUserId,
handleSnapshot: fields.handleSnapshot ?? "owner",
latestScoreId: fields.latestScoreId ?? "publisherAbuseScores:score",
modelVersion: "publisher-abuse-pressure.v2",
modelVersion: fields.modelVersion ?? "publisher-abuse-pressure.v2",
label: fields.label ?? "potential_ban_candidate",
status: fields.status ?? "pending",
openedAt: 1,
@@ -666,7 +668,7 @@ describe("publisher abuse dry-run persistence", () => {
expect(insert).not.toHaveBeenCalled();
});
it("bans the linked owner and resolves the nomination in one mutation", async () => {
it("rejects publisher abuse ban enforcement while nominations are flag-only", async () => {
vi.mocked(requireUser).mockResolvedValue({
userId: "users:moderator",
user: { _id: "users:moderator", role: "moderator" },
@@ -703,73 +705,9 @@ describe("publisher abuse dry-run persistence", () => {
expectedUpdatedAt: 1,
reason: " confirmed spam ",
}),
).resolves.toEqual({ ok: true, status: "banned" });
expect(runMutation).toHaveBeenCalledWith(expect.anything(), {
actorUserId: "users:moderator",
targetUserId: "users:owner",
reason: "confirmed spam",
});
expect(patch).toHaveBeenCalledWith(
"publisherAbuseReviewNominations:nomination",
expect.objectContaining({
status: "banned",
reviewedByUserId: "users:moderator",
notes: "confirmed spam",
}),
);
expect(insert).toHaveBeenCalledWith(
"publisherAbuseReviewEvents",
expect.objectContaining({
eventType: "triage_status_changed",
previousStatus: "pending",
nextStatus: "banned",
notes: "confirmed spam",
}),
);
});
it("does not resolve the nomination when linked owner ban fails", async () => {
vi.mocked(requireUser).mockResolvedValue({
userId: "users:moderator",
user: { _id: "users:moderator", role: "moderator" },
} as never);
const runMutation = vi.fn(async () => {
throw new Error("Ban failed");
});
const patch = vi.fn(async () => null);
const insert = vi.fn(async (table: string) => `${table}:new`);
const ctx = {
runMutation,
db: {
get: vi.fn(async (id: string) => {
if (id === "publisherAbuseReviewNominations:nomination") {
return {
_id: "publisherAbuseReviewNominations:nomination",
ownerKey: "user:owner",
ownerUserId: "users:owner",
latestScoreId: "publisherAbuseScores:score",
label: "potential_ban_candidate",
status: "pending",
updatedAt: 1,
};
}
return null;
}),
insert,
patch,
},
};
await expect(
banPublisherAbuseOwnerHandler(ctx, {
nominationId: "publisherAbuseReviewNominations:nomination",
expectedLatestScoreId: "publisherAbuseScores:score",
expectedUpdatedAt: 1,
reason: "confirmed spam",
}),
).rejects.toThrow("Ban failed");
).rejects.toThrow(/publisher abuse bans are disabled/i);
expect(runMutation).not.toHaveBeenCalled();
expect(patch).not.toHaveBeenCalled();
expect(insert).not.toHaveBeenCalled();
});
@@ -881,7 +819,7 @@ describe("publisher abuse dry-run persistence", () => {
},
};
build(q);
expect(constraints.modelVersion).toBe("publisher-abuse-pressure.v2");
expect(constraints.modelVersion).toBe("publisher-abuse-pressure.v4");
return {
order: () => ({
first: async () => latestRun,
@@ -1747,6 +1685,180 @@ describe("publisher abuse dry-run persistence", () => {
);
});
it("collects finite score rows for stored configs without engagement elasticity", async () => {
const legacyModelConfig: Partial<typeof TEST_MODEL_CONFIG> = { ...TEST_MODEL_CONFIG };
delete legacyModelConfig.engagementElasticity;
const insertedScores: unknown[] = [];
const insert = vi.fn(async (table: string, doc?: unknown) => {
if (table === "publisherAbuseScores") insertedScores.push(doc);
return `${table}:new`;
});
const patch = vi.fn(async () => null);
const ctx = {
db: {
get: vi.fn(async () => ({
_id: "publisherAbuseScoreRuns:run",
modelVersion: legacyModelConfig.modelVersion,
modelConfig: legacyModelConfig,
status: "running",
phase: "collecting",
collectCursor: undefined,
scannedPublishers: 0,
scoredPublishers: 0,
sumLogPressure: 0,
sumSquaredLogPressure: 0,
})),
insert,
patch,
query: vi.fn((table: string) => {
if (table === "publishers") {
return {
withIndex: () => ({
paginate: async () => ({
page: [
{
_id: "publishers:legacy-config",
handle: "legacy-config",
linkedUserId: "users:legacy-config",
publishedSkills: 250,
publishedPackages: 0,
totalInstalls: 25,
totalStars: 1,
totalDownloads: 10_000,
},
],
isDone: true,
continueCursor: "",
}),
}),
};
}
if (table === "packages") {
return {
withIndex: () => ({
paginate: async () => ({
page: [],
isDone: true,
continueCursor: "",
}),
}),
};
}
throw new Error(`unexpected table ${table}`);
}),
},
};
await expect(collectHandler(ctx, { runId: "publisherAbuseScoreRuns:run" })).resolves.toEqual(
expect.objectContaining({ isDone: false, scanned: 1, phase: "finalizing" }),
);
expect(insertedScores).toHaveLength(1);
const [insertedScore] = insertedScores;
if (typeof insertedScore !== "object" || insertedScore === null) {
throw new Error("Expected publisher abuse score insert");
}
const pressure = Object.getOwnPropertyDescriptor(insertedScore, "pressure")?.value;
const logPressure = Object.getOwnPropertyDescriptor(insertedScore, "logPressure")?.value;
expect(Number.isFinite(pressure)).toBe(true);
expect(Number.isFinite(logPressure)).toBe(true);
expect(insertedScore).toEqual(
expect.objectContaining({
ownerKey: "publisher:publishers:legacy-config",
handleSnapshot: "legacy-config",
}),
);
});
it("preserves legacy stored config label semantics while finalizing score rows", async () => {
const legacyModelConfig: Partial<typeof TEST_MODEL_CONFIG> = { ...TEST_MODEL_CONFIG };
delete legacyModelConfig.engagementElasticity;
const insert = vi.fn(async (table: string) => `${table}:new`);
const patch = vi.fn(async () => null);
const ctx = {
db: {
get: vi.fn(async () => ({
_id: "publisherAbuseScoreRuns:legacy-run",
status: "running",
phase: "finalizing",
modelVersion: "publisher-abuse-pressure.v2",
modelConfig: legacyModelConfig,
scoredPublishers: 1,
finalizedScores: 0,
passCount: 0,
reviewCount: 0,
potentialBanCandidateCount: 0,
nominatedPublishers: 0,
sumLogPressure: 3,
sumSquaredLogPressure: 9,
})),
insert,
patch,
query: vi.fn((table: string) => {
if (table === "publisherAbuseScores") {
return {
withIndex: () => ({
order: () => ({
paginate: async () => ({
page: [
{
_id: "publisherAbuseScores:legacy-score",
ownerKey: "publisher:publishers:legacy-score",
ownerPublisherId: "publishers:legacy-score",
ownerUserId: "users:legacy-score",
handleSnapshot: "legacy-score",
modelVersion: "publisher-abuse-pressure.v2",
pressure: 1000,
logPressure: 6,
publishedSkills: 99,
totalInstalls: 0,
totalStars: 0,
totalDownloads: 100,
installsPerSkill: 0,
starsPerSkill: 0,
downloadsPerSkill: 1.01,
reasonCodes: ["low_installs_per_skill"],
},
],
isDone: true,
continueCursor: "",
}),
}),
}),
};
}
if (table === "publisherAbuseReviewNominations") {
return {
withIndex: () => ({
first: async () => null,
take: async () => [],
}),
};
}
if (table === "officialPublishers") return makeEmptyOfficialPublishersQuery();
throw new Error(`unexpected table ${table}`);
}),
},
};
await expect(
finalizeHandler(ctx, { runId: "publisherAbuseScoreRuns:legacy-run" }),
).resolves.toEqual(expect.objectContaining({ isDone: true, finalized: 1, nominations: 1 }));
expect(patch).toHaveBeenCalledWith(
"publisherAbuseScores:legacy-score",
expect.objectContaining({ label: "potential_ban_candidate", zScore: 3 }),
);
expect(insert).toHaveBeenCalledWith(
"publisherAbuseReviewNominations",
expect.objectContaining({
latestScoreId: "publisherAbuseScores:legacy-score",
modelVersion: "publisher-abuse-pressure.v2",
label: "potential_ban_candidate",
}),
);
});
it("excludes official publishers from abuse scoring even when they match abuse-pressure criteria", async () => {
const insert = vi.fn(async (table: string) => `${table}:new`);
const patch = vi.fn(async () => null);
@@ -2948,6 +3060,7 @@ describe("publisher abuse dry-run persistence", () => {
_id: "publisherAbuseReviewNominations:existing",
status: "pending",
}),
take: async () => [],
}),
};
}
@@ -2968,6 +3081,410 @@ describe("publisher abuse dry-run persistence", () => {
);
});
it("keeps below-pivot high z-score publishers out of spam abuse review", async () => {
const modelConfig = {
...TEST_MODEL_CONFIG,
modelVersion: "publisher-abuse-pressure.v4",
skillPivot: 200,
minPublishedSkillsForAggregateLabel: 200,
};
const staleV2Nomination = makeNomination({
_id: "publisherAbuseReviewNominations:stale-v2",
ownerKey: "publisher:publishers:spacesq-shape",
ownerPublisherId: "publishers:spacesq-shape",
ownerUserId: "users:spacesq-shape",
latestScoreId: "publisherAbuseScores:old-v2-score",
handleSnapshot: "spacesq-shape",
label: "potential_ban_candidate",
status: "pending",
lastScoredAt: 1,
updatedAt: 1,
});
const insert = vi.fn(async (table: string) => `${table}:new`);
const patch = vi.fn(async () => null);
const ctx = {
db: {
get: vi.fn(async () => ({
_id: "publisherAbuseScoreRuns:run",
status: "running",
phase: "finalizing",
modelVersion: modelConfig.modelVersion,
modelConfig,
scoredPublishers: 1,
finalizedScores: 0,
passCount: 0,
reviewCount: 0,
potentialBanCandidateCount: 0,
nominatedPublishers: 0,
sumLogPressure: 3,
sumSquaredLogPressure: 9,
})),
insert,
patch,
query: vi.fn((table: string) => {
if (table === "publisherAbuseScores") {
return {
withIndex: () => ({
order: () => ({
paginate: async () => ({
page: [
{
_id: "publisherAbuseScores:spacesq-shape",
ownerKey: "publisher:publishers:spacesq-shape",
ownerPublisherId: "publishers:spacesq-shape",
ownerUserId: "users:spacesq-shape",
handleSnapshot: "spacesq-shape",
modelVersion: modelConfig.modelVersion,
pressure: 1000,
logPressure: 6,
publishedSkills: 62,
totalInstalls: 0,
totalStars: 0,
totalDownloads: 29_906,
installsPerSkill: 0,
starsPerSkill: 0,
downloadsPerSkill: 482.35,
reasonCodes: ["low_installs_per_skill", "low_stars_per_skill"],
},
],
isDone: true,
continueCursor: "",
}),
}),
}),
};
}
if (table === "publisherAbuseReviewNominations") {
return {
withIndex: (
indexName: string,
build: (q: { eq: (field: string, value: unknown) => unknown }) => unknown,
) => {
expect(indexName).toBe("by_owner_key_and_model_version");
const constraints: Record<string, unknown> = {};
const q = {
eq(field: string, value: unknown) {
constraints[field] = value;
return q;
},
};
build(q);
return {
take: async () =>
constraints.ownerKey === staleV2Nomination.ownerKey ? [staleV2Nomination] : [],
};
},
};
}
if (table === "officialPublishers") return makeEmptyOfficialPublishersQuery();
throw new Error(`unexpected table ${table}`);
}),
},
};
await expect(finalizeHandler(ctx, { runId: "publisherAbuseScoreRuns:run" })).resolves.toEqual(
expect.objectContaining({ isDone: true, finalized: 1, nominations: 0 }),
);
expect(patch).toHaveBeenCalledWith(
"publisherAbuseScores:spacesq-shape",
expect.objectContaining({ label: "pass", zScore: 0 }),
);
expect(insert).not.toHaveBeenCalledWith("publisherAbuseReviewNominations", expect.anything());
expect(patch).toHaveBeenCalledWith(
staleV2Nomination._id,
expect.objectContaining({
latestScoreId: "publisherAbuseScores:spacesq-shape",
label: "pass",
lastScoredAt: expect.any(Number),
}),
);
expect(insert).toHaveBeenCalledWith(
"publisherAbuseReviewEvents",
expect.objectContaining({
nominationId: staleV2Nomination._id,
eventType: "nomination_score_updated",
previousLabel: "potential_ban_candidate",
nextLabel: "pass",
scoreId: "publisherAbuseScores:spacesq-shape",
}),
);
expect(patch).toHaveBeenCalledWith(
"publisherAbuseScoreRuns:run",
expect.objectContaining({
passCount: 1,
reviewCount: 0,
potentialBanCandidateCount: 0,
}),
);
});
it("clears stale higher-severity aggregate nominations after a downgrade to review", async () => {
const modelConfig = {
...TEST_MODEL_CONFIG,
modelVersion: "publisher-abuse-pressure.v4",
skillPivot: 200,
minPublishedSkillsForAggregateLabel: 200,
};
const staleV2Nomination = makeNomination({
_id: "publisherAbuseReviewNominations:stale-v2",
ownerKey: "publisher:publishers:downgraded",
ownerPublisherId: "publishers:downgraded",
ownerUserId: "users:downgraded",
latestScoreId: "publisherAbuseScores:old-v2-score",
handleSnapshot: "downgraded",
modelVersion: "publisher-abuse-pressure.v2",
label: "potential_ban_candidate",
status: "pending",
lastScoredAt: 1,
updatedAt: 1,
});
const insert = vi.fn(async (table: string) =>
table === "publisherAbuseReviewNominations"
? "publisherAbuseReviewNominations:current-v4"
: `${table}:new`,
);
const patch = vi.fn(async () => null);
const ctx = {
db: {
get: vi.fn(async () => ({
_id: "publisherAbuseScoreRuns:run",
status: "running",
phase: "finalizing",
modelVersion: modelConfig.modelVersion,
modelConfig,
scoredPublishers: 1,
finalizedScores: 0,
passCount: 0,
reviewCount: 0,
potentialBanCandidateCount: 0,
nominatedPublishers: 0,
sumLogPressure: 3,
sumSquaredLogPressure: 9,
})),
insert,
patch,
query: vi.fn((table: string) => {
if (table === "publisherAbuseScores") {
return {
withIndex: () => ({
order: () => ({
paginate: async () => ({
page: [
{
_id: "publisherAbuseScores:downgraded-v4",
ownerKey: "publisher:publishers:downgraded",
ownerPublisherId: "publishers:downgraded",
ownerUserId: "users:downgraded",
handleSnapshot: "downgraded",
modelVersion: modelConfig.modelVersion,
pressure: 100,
logPressure: 5,
publishedSkills: 220,
totalInstalls: 80,
totalStars: 2,
totalDownloads: 2_000,
installsPerSkill: 0.36,
starsPerSkill: 0.009,
downloadsPerSkill: 9.09,
reasonCodes: ["high_catalog_volume"],
},
],
isDone: true,
continueCursor: "",
}),
}),
}),
};
}
if (table === "publisherAbuseReviewNominations") {
return {
withIndex: (
indexName: string,
build: (q: { eq: (field: string, value: unknown) => unknown }) => unknown,
) => {
expect(indexName).toBe("by_owner_key_and_model_version");
const constraints: Record<string, unknown> = {};
const q = {
eq(field: string, value: unknown) {
constraints[field] = value;
return q;
},
};
build(q);
return {
first: async () => null,
take: async () =>
constraints.ownerKey === staleV2Nomination.ownerKey ? [staleV2Nomination] : [],
};
},
};
}
if (table === "officialPublishers") return makeEmptyOfficialPublishersQuery();
throw new Error(`unexpected table ${table}`);
}),
},
};
await expect(finalizeHandler(ctx, { runId: "publisherAbuseScoreRuns:run" })).resolves.toEqual(
expect.objectContaining({ isDone: true, finalized: 1, nominations: 1 }),
);
expect(patch).toHaveBeenCalledWith(
"publisherAbuseScores:downgraded-v4",
expect.objectContaining({ label: "review", zScore: 2 }),
);
expect(insert).toHaveBeenCalledWith(
"publisherAbuseReviewNominations",
expect.objectContaining({
latestScoreId: "publisherAbuseScores:downgraded-v4",
modelVersion: modelConfig.modelVersion,
label: "review",
status: "pending",
}),
);
expect(patch).toHaveBeenCalledWith(
staleV2Nomination._id,
expect.objectContaining({
latestScoreId: "publisherAbuseScores:downgraded-v4",
label: "pass",
lastScoredAt: expect.any(Number),
}),
);
expect(insert).toHaveBeenCalledWith(
"publisherAbuseReviewEvents",
expect.objectContaining({
nominationId: staleV2Nomination._id,
eventType: "nomination_score_updated",
previousLabel: "potential_ban_candidate",
nextLabel: "pass",
scoreId: "publisherAbuseScores:downgraded-v4",
}),
);
});
it("does not clear newer aggregate nominations when an older stored run finalizes", async () => {
const modelConfig = {
...TEST_MODEL_CONFIG,
modelVersion: "publisher-abuse-pressure.v2",
minPublishedSkillsForAggregateLabel: undefined,
};
const newerV4Nomination = makeNomination({
_id: "publisherAbuseReviewNominations:newer-v4",
ownerKey: "publisher:publishers:late-v2",
ownerPublisherId: "publishers:late-v2",
ownerUserId: "users:late-v2",
latestScoreId: "publisherAbuseScores:newer-v4-score",
handleSnapshot: "late-v2",
modelVersion: "publisher-abuse-pressure.v4",
label: "potential_ban_candidate",
status: "pending",
lastScoredAt: 2,
updatedAt: 2,
});
const insert = vi.fn(async (table: string) => `${table}:new`);
const patch = vi.fn(async () => null);
const ctx = {
db: {
get: vi.fn(async () => ({
_id: "publisherAbuseScoreRuns:late-v2",
status: "running",
phase: "finalizing",
modelVersion: modelConfig.modelVersion,
modelConfig,
scoredPublishers: 1,
finalizedScores: 0,
passCount: 0,
reviewCount: 0,
potentialBanCandidateCount: 0,
nominatedPublishers: 0,
sumLogPressure: 0,
sumSquaredLogPressure: 0,
})),
insert,
patch,
query: vi.fn((table: string) => {
if (table === "publisherAbuseScores") {
return {
withIndex: () => ({
order: () => ({
paginate: async () => ({
page: [
{
_id: "publisherAbuseScores:late-v2-score",
ownerKey: "publisher:publishers:late-v2",
ownerPublisherId: "publishers:late-v2",
ownerUserId: "users:late-v2",
handleSnapshot: "late-v2",
modelVersion: modelConfig.modelVersion,
pressure: 1,
logPressure: 0,
publishedSkills: 220,
totalInstalls: 600,
totalStars: 20,
totalDownloads: 80_000,
installsPerSkill: 2.72,
starsPerSkill: 0.09,
downloadsPerSkill: 363.64,
reasonCodes: [],
},
],
isDone: true,
continueCursor: "",
}),
}),
}),
};
}
if (table === "publisherAbuseReviewNominations") {
return {
withIndex: (
indexName: string,
build: (q: { eq: (field: string, value: unknown) => unknown }) => unknown,
) => {
expect(indexName).toBe("by_owner_key_and_model_version");
const constraints: Record<string, unknown> = {};
const q = {
eq(field: string, value: unknown) {
constraints[field] = value;
return q;
},
};
build(q);
return {
take: async () =>
constraints.ownerKey === newerV4Nomination.ownerKey ? [newerV4Nomination] : [],
};
},
};
}
if (table === "officialPublishers") return makeEmptyOfficialPublishersQuery();
throw new Error(`unexpected table ${table}`);
}),
},
};
await expect(
finalizeHandler(ctx, { runId: "publisherAbuseScoreRuns:late-v2" }),
).resolves.toEqual(expect.objectContaining({ isDone: true, finalized: 1, nominations: 0 }));
expect(patch).toHaveBeenCalledWith(
"publisherAbuseScores:late-v2-score",
expect.objectContaining({ label: "pass", zScore: 0 }),
);
expect(patch).not.toHaveBeenCalledWith(
newerV4Nomination._id,
expect.objectContaining({ label: "pass" }),
);
expect(insert).not.toHaveBeenCalledWith(
"publisherAbuseReviewEvents",
expect.objectContaining({
nominationId: newerV4Nomination._id,
nextLabel: "pass",
}),
);
});
it("does not create nominations for official publisher score rows left by an older run", async () => {
const insert = vi.fn(async (table: string) => `${table}:new`);
const patch = vi.fn(async () => null);
@@ -3116,7 +3633,7 @@ describe("publisher abuse dry-run persistence", () => {
if (table === "publisherAbuseReviewNominations") {
return {
withIndex: () => ({
first: async () => null,
take: async () => [],
}),
};
}
@@ -3214,6 +3731,7 @@ describe("publisher abuse dry-run persistence", () => {
reviewedByUserId: "users:admin",
reviewedAt: 100,
}),
take: async () => [],
}),
};
}
@@ -3314,6 +3832,7 @@ describe("publisher abuse dry-run persistence", () => {
reviewedByUserId: "users:admin",
reviewedAt: 100,
}),
take: async () => [],
}),
};
}
@@ -3413,6 +3932,7 @@ describe("publisher abuse dry-run persistence", () => {
reviewedByUserId: "users:admin",
reviewedAt: 100,
}),
take: async () => [],
}),
};
}
@@ -3492,13 +4012,15 @@ describe("publisher abuse dry-run persistence", () => {
};
}
if (table === "publisherAbuseReviewNominations") {
const existingNomination = {
_id: "publisherAbuseReviewNominations:existing",
ownerKey: "publisher:publishers:recovered",
modelVersion: "publisher-abuse-pressure.v2",
label: "review",
};
return {
withIndex: () => ({
first: async () => ({
_id: "publisherAbuseReviewNominations:existing",
ownerKey: "publisher:publishers:recovered",
label: "review",
}),
take: async () => [existingNomination],
}),
};
}
+103 -63
View File
@@ -18,11 +18,12 @@ import {
computeCurrentSkillTemporalAbuseScore,
computeHistoricalSkillTemporalAbuseScore,
computePublisherAbuseRawScore,
labelForPublisherAbuseScore,
computeTemporalAbuseCohortBenchmark,
computeTemporalPublisherAbuseZScore,
DEFAULT_PUBLISHER_ABUSE_MODEL_CONFIG,
isPublisherAbuseCheckEligible,
labelForTemporalPublisherAbuse,
labelForPublisherAbuseZScore,
PUBLISHER_TEMPORAL_ABUSE_MODEL_VERSION,
summarizePublisherAbuseLogPressure,
type PublisherAbuseInput,
@@ -40,10 +41,10 @@ const MAX_MAX_PAGES = 50;
const ACTION_CONTINUATION_DELAY_MS = 60_000;
const MAX_ACTIVE_SKILL_FALLBACK_SCAN = 500;
const MAX_ACTIVE_SKILL_FALLBACK_SCANS_PER_PAGE = 20;
const MAX_OWNER_NOMINATION_VERSION_SCAN = 20;
const MAX_REVIEW_DASHBOARD_SCAN_MULTIPLIER = 3;
const MAX_REVIEW_DASHBOARD_SCORE_SCAN_MULTIPLIER = 32;
const MAX_REVIEW_DASHBOARD_SCORE_SCAN = 2000;
const MAX_BAN_REASON_LENGTH = 500;
const DEFAULT_TEMPORAL_BATCH_SIZE = 50;
const MAX_TEMPORAL_BATCH_SIZE = 100;
const DEFAULT_TEMPORAL_CANDIDATE_LIMIT = 1000;
@@ -300,56 +301,10 @@ export const banPublisherAbuseOwner = mutation({
}
await requirePublisherAbuseNominationNotExcluded(ctx, nomination);
const reason = normalizeBanReason(args.reason);
await ctx.runMutation(internal.users.banUserInternal, {
actorUserId: user._id,
targetUserId: nomination.ownerUserId,
reason,
});
const now = Date.now();
await setPublisherAbuseReviewStatusWithActor(ctx, {
nomination,
status: "banned",
notes: reason,
actorUserId: user._id,
now,
});
return { ok: true, status: "banned" as const };
throw new Error("Publisher abuse bans are disabled while the scoring model is flag-only.");
},
});
async function setPublisherAbuseReviewStatusWithActor(
ctx: Pick<MutationCtx, "db">,
args: {
nomination: Doc<"publisherAbuseReviewNominations">;
status: TriageStatus;
notes: string | undefined;
actorUserId: Id<"users">;
now: number;
},
) {
await ctx.db.patch(args.nomination._id, {
status: args.status,
reviewedByUserId: args.status === "pending" ? undefined : args.actorUserId,
reviewedAt: args.status === "pending" ? undefined : args.now,
notes: args.notes,
updatedAt: args.now,
});
await ctx.db.insert("publisherAbuseReviewEvents", {
nominationId: args.nomination._id,
ownerKey: args.nomination.ownerKey,
actorUserId: args.actorUserId,
scoreId: args.nomination.latestScoreId,
eventType: "triage_status_changed",
previousStatus: args.nomination.status,
nextStatus: args.status,
notes: args.notes,
createdAt: args.now,
});
}
function requireFreshPublisherAbuseReviewNomination(
nomination: Doc<"publisherAbuseReviewNominations">,
expected: { expectedLatestScoreId: Id<"publisherAbuseScores">; expectedUpdatedAt: number },
@@ -643,8 +598,10 @@ export async function finalizePublisherAbuseScoresPageInternalHandler(
finalized += 1;
continue;
}
const zScore = (score.logPressure - meanLogPressure) / safeStdDev;
const label = labelForPublisherAbuseZScore(zScore, modelConfig);
const zScore = isPublisherAbuseCheckEligible(score, modelConfig)
? (score.logPressure - meanLogPressure) / safeStdDev
: 0;
const label = labelForPublisherAbuseScore(score, zScore, modelConfig);
const rank = rankedScoresSoFar + ranked + 1;
labelCounts[label] += 1;
ranked += 1;
@@ -1548,6 +1505,7 @@ async function upsertPublisherAbuseReviewNomination(
nextStatus: shouldReopen ? "pending" : undefined,
createdAt: args.now,
});
await markStaleAggregatePublisherAbuseReviewNominationsAsPass(ctx, args);
return existing._id;
}
@@ -1575,9 +1533,38 @@ async function upsertPublisherAbuseReviewNomination(
nextLabel: args.score.label,
createdAt: args.now,
});
await markStaleAggregatePublisherAbuseReviewNominationsAsPass(ctx, args);
return nominationId;
}
async function markStaleAggregatePublisherAbuseReviewNominationsAsPass(
ctx: Pick<MutationCtx, "db">,
args: {
score: ScoreDoc;
run: ScoreRun;
now: number;
},
) {
if (!isAggregatePublisherAbuseModelVersion(args.score.modelVersion)) return null;
const existingNominations = await ctx.db
.query("publisherAbuseReviewNominations")
.withIndex("by_owner_key_and_model_version", (q) => q.eq("ownerKey", args.score.ownerKey))
.take(MAX_OWNER_NOMINATION_VERSION_SCAN);
let updatedNominationId: Id<"publisherAbuseReviewNominations"> | null = null;
for (const existing of existingNominations) {
if (
!shouldClearStaleAggregatePublisherAbuseReviewNomination(existing, args.score.modelVersion)
) {
continue;
}
await markPublisherAbuseReviewNominationAsPass(ctx, { ...args, existing });
updatedNominationId ??= existing._id;
}
return updatedNominationId;
}
async function updateExistingPublisherAbuseReviewNominationForPass(
ctx: Pick<MutationCtx, "db">,
args: {
@@ -1586,15 +1573,74 @@ async function updateExistingPublisherAbuseReviewNominationForPass(
now: number;
},
) {
const existing = await ctx.db
if (!isAggregatePublisherAbuseModelVersion(args.score.modelVersion)) {
const existing = await ctx.db
.query("publisherAbuseReviewNominations")
.withIndex("by_owner_key_and_model_version", (q) =>
q.eq("ownerKey", args.score.ownerKey).eq("modelVersion", args.score.modelVersion),
)
.first();
if (!existing) return null;
await markPublisherAbuseReviewNominationAsPass(ctx, { ...args, existing });
return existing._id;
}
const existingNominations = await ctx.db
.query("publisherAbuseReviewNominations")
.withIndex("by_owner_key_and_model_version", (q) =>
q.eq("ownerKey", args.score.ownerKey).eq("modelVersion", args.score.modelVersion),
)
.first();
.withIndex("by_owner_key_and_model_version", (q) => q.eq("ownerKey", args.score.ownerKey))
.take(MAX_OWNER_NOMINATION_VERSION_SCAN);
if (!existing) return null;
let updatedNominationId: Id<"publisherAbuseReviewNominations"> | null = null;
for (const existing of existingNominations) {
if (
existing.modelVersion !== args.score.modelVersion &&
!shouldClearStaleAggregatePublisherAbuseReviewNomination(existing, args.score.modelVersion)
) {
continue;
}
await markPublisherAbuseReviewNominationAsPass(ctx, { ...args, existing });
updatedNominationId ??= existing._id;
}
return updatedNominationId;
}
function shouldClearStaleAggregatePublisherAbuseReviewNomination(
nomination: Doc<"publisherAbuseReviewNominations">,
currentModelVersion: string,
) {
const nominationVersion = aggregatePublisherAbuseModelVersionNumber(nomination.modelVersion);
const currentVersion = aggregatePublisherAbuseModelVersionNumber(currentModelVersion);
return (
nominationVersion !== null &&
currentVersion !== null &&
nominationVersion < currentVersion &&
nomination.status === "pending" &&
nomination.label !== "pass"
);
}
function isAggregatePublisherAbuseModelVersion(modelVersion: string) {
return modelVersion.startsWith("publisher-abuse-pressure.");
}
function aggregatePublisherAbuseModelVersionNumber(modelVersion: string) {
const match = /^publisher-abuse-pressure\.v(\d+)$/.exec(modelVersion);
const versionText = match?.[1];
if (!versionText) return null;
const version = Number(versionText);
return Number.isSafeInteger(version) ? version : null;
}
async function markPublisherAbuseReviewNominationAsPass(
ctx: Pick<MutationCtx, "db">,
args: {
existing: Doc<"publisherAbuseReviewNominations">;
score: ScoreDoc;
run: ScoreRun;
now: number;
},
) {
const { existing } = args;
await ctx.db.patch(existing._id, {
latestScoreId: args.score._id,
label: "pass",
@@ -1906,12 +1952,6 @@ function summarizeUserForAbuseReview(user: Doc<"users">) {
};
}
function normalizeBanReason(rawReason?: string) {
const reason = rawReason?.trim();
if (!reason) return undefined;
return reason.slice(0, MAX_BAN_REASON_LENGTH);
}
function publisherAbuseLabelSeverity(label: PublisherAbuseLabel) {
if (label === "potential_ban_candidate") return 2;
if (label === "review") return 1;
+2
View File
@@ -478,6 +478,8 @@ const publisherAbuseModelConfigValidator = v.object({
starsPerSkillPivot: v.number(),
downloadsPerSkillPivot: v.number(),
outputElasticity: v.number(),
engagementElasticity: v.optional(v.number()),
minPublishedSkillsForAggregateLabel: v.optional(v.number()),
installTrustElasticity: v.number(),
starTrustElasticity: v.number(),
downloadDemandElasticity: v.number(),
+14 -4
View File
@@ -45,10 +45,20 @@ See also: [acceptable-usage.md](./acceptable-usage.md) for the marketplace polic
- Publisher abuse scoring is a staff review signal for bulk-publishing abuse.
It must not directly ban users; staff action goes through the publisher abuse
nomination review path.
- Catalog volume pressure is linear up to the 100-skill pivot and superlinear
above it. Doubling an already-bulk catalog should raise review pressure
meaningfully more than 2x while still allowing legitimate high-engagement
publishers to stay below review thresholds.
- Publisher abuse enforcement is flag-only during the current rollout. The
scorer can open review and high-risk nominations, but the publisher-abuse
action path must not ban users until enforcement is explicitly re-enabled.
- Aggregate publisher spam-abuse labels start at the 200-skill pivot. Below
that pivot, publishers can contribute to the population baseline, but they
cannot receive aggregate spam reason codes or be nominated by this score path.
- At or above the pivot, catalog volume raises pressure multiplicatively while
installs, stars, and downloads lower it. Legitimate high-adoption publishers
with large catalogs should stay below review thresholds.
- Engagement expectations scale sublinearly with catalog size. The scorer must
not require every extra skill to have the same per-skill installs/stars as the
first 200 skills; a few successful skills can lower pressure for the broader
catalog. Catalog size still raises pressure, so large low-adoption catalogs
remain eligible for review or ban-candidate labels.
- Official publishers are excluded from publisher abuse scoring and
enforcement. An excluded publisher must not contribute to score-run cohort
statistics, receive a score label/rank, open or update a nomination, appear in
+15 -95
View File
@@ -547,14 +547,11 @@ describe("Management", () => {
expect(screen.getByText("of 42 scored")).toBeTruthy();
expect(screen.getAllByText("spammy-pub").length).toBeGreaterThanOrEqual(2);
const banReason = screen.getByPlaceholderText("Why are you taking this action? (optional)");
fireEvent.change(banReason, { target: { value: "first publisher reason" } });
expect(banReason).toHaveProperty("value", "first publisher reason");
expect(screen.getByText("Flagged for review")).toBeTruthy();
expect(screen.queryByRole("button", { name: "Ban user" })).toBeNull();
expect(screen.queryByPlaceholderText("Why are you taking this action? (optional)")).toBeNull();
fireEvent.click(screen.getByText("second-pub"));
expect(
screen.getByPlaceholderText("Why are you taking this action? (optional)"),
).toHaveProperty("value", "");
expect(screen.queryByRole("button", { name: "Ban user" })).toBeNull();
});
it("closes the abuse drawer when search hides the selected nomination", async () => {
@@ -725,9 +722,11 @@ describe("Management", () => {
expect(screen.getByText("Peer 30d P95")).toBeTruthy();
});
it("marks the abuse nomination resolved after banning its linked user", async () => {
it("shows publisher abuse ban candidates as flag-only review items", () => {
const banUser = vi.fn(async () => ({ ok: true }));
const banPublisherAbuseOwner = vi.fn(async () => ({ ok: true, status: "banned" }));
const banPublisherAbuseOwner = vi.fn(async () => {
throw new Error("Publisher abuse bans are disabled");
});
const item = makePublisherAbuseItem();
useMutationMock.mockImplementation((mutation) => {
const name = getFunctionName(mutation);
@@ -757,92 +756,13 @@ describe("Management", () => {
render(<Management />);
fireEvent.click(screen.getByText("spammy-pub"));
const banReason = screen.getByPlaceholderText("Why are you taking this action? (optional)");
expect(banReason.getAttribute("maxlength")).toBe("500");
fireEvent.change(banReason, { target: { value: "confirmed spam" } });
fireEvent.click(screen.getByRole("button", { name: "Ban user" }));
const confirmButtons = screen.getAllByRole("button", { name: "Ban user" });
fireEvent.click(confirmButtons[confirmButtons.length - 1]);
await waitFor(() => {
expect(banPublisherAbuseOwner).toHaveBeenCalledWith({
nominationId: "publisherAbuseReviewNominations:1",
expectedLatestScoreId: "publisherAbuseScores:1",
expectedUpdatedAt: 1,
reason: "confirmed spam",
});
expect(banUser).not.toHaveBeenCalled();
});
});
it("disables abuse bans for the current user", () => {
const item = makePublisherAbuseItem({
handle: "self-pub",
ownerKey: "user:admin",
ownerUserId: "users:admin",
});
useQueryMock.mockImplementation((query, args) => {
if (args === "skip") return undefined;
const name = getFunctionName(query);
if (name === "skills:listRecentVersions") return [];
if (name === "skills:listReportedSkills") return [];
if (name === "skills:listDuplicateCandidates") return [];
if (name === "publisherAbuse:listReviewDashboard") {
return {
latestRun: null,
pendingItems: [],
pendingPotentialBanCandidateItems: [item],
pendingReviewItems: [],
recentResolvedItems: [],
};
}
if (name === "users:list") return { items: [], total: 0 };
return undefined;
});
render(<Management />);
fireEvent.click(screen.getByText("self-pub"));
expect(screen.getByRole("button", { name: "Ban user" })).toHaveProperty("disabled", true);
});
it("disables abuse bans for admin-owned candidates when viewed by moderators", () => {
authUser = {
_id: "users:moderator",
handle: "moderator",
role: "moderator",
};
const item = makePublisherAbuseItem({
handle: "admin-pub",
ownerKey: "user:owner-admin",
ownerRole: "admin",
ownerUserId: "users:owner-admin",
});
useQueryMock.mockImplementation((query, args) => {
if (args === "skip") return undefined;
const name = getFunctionName(query);
if (name === "skills:listRecentVersions") return [];
if (name === "skills:listReportedSkills") return [];
if (name === "skills:listDuplicateCandidates") return [];
if (name === "publisherAbuse:listReviewDashboard") {
return {
latestRun: null,
pendingItems: [],
pendingPotentialBanCandidateItems: [item],
pendingReviewItems: [],
recentResolvedItems: [],
};
}
if (name === "users:list") return { items: [], total: 0 };
return undefined;
});
render(<Management />);
fireEvent.click(screen.getByText("admin-pub"));
expect(screen.getByRole("button", { name: "Ban user" })).toHaveProperty("disabled", true);
expect(screen.getByText("Flagged for review")).toBeTruthy();
expect(screen.getByText(/Publisher-abuse bans are disabled/i)).toBeTruthy();
expect(screen.queryByRole("button", { name: "Ban user" })).toBeNull();
expect(screen.queryByPlaceholderText("Why are you taking this action? (optional)")).toBeNull();
expect(banPublisherAbuseOwner).not.toHaveBeenCalled();
expect(banUser).not.toHaveBeenCalled();
});
it("does not show non-ban resolution controls for potential-ban nominations", () => {
@@ -876,7 +796,7 @@ describe("Management", () => {
fireEvent.click(screen.getByText("spammy-pub"));
expect(screen.getByRole("button", { name: "Ban user" })).toBeTruthy();
expect(screen.queryByRole("button", { name: "Ban user" })).toBeNull();
expect(screen.queryByRole("button", { name: "Mark reviewed" })).toBeNull();
expect(screen.queryByRole("button", { name: "False positive" })).toBeNull();
expect(screen.queryByRole("button", { name: "Needs discussion" })).toBeNull();
+6 -46
View File
@@ -1,5 +1,5 @@
import { Link } from "@tanstack/react-router";
import { Ban, Copy, ExternalLink, RefreshCcw, Search } from "lucide-react";
import { Copy, ExternalLink, RefreshCcw, Search } from "lucide-react";
import type { Id } from "../../../convex/_generated/dataModel";
import { Badge, type BadgeProps } from "../../components/ui/badge";
import { Button } from "../../components/ui/button";
@@ -11,7 +11,6 @@ import {
SheetHeader,
SheetTitle,
} from "../../components/ui/sheet";
import { Textarea } from "../../components/ui/textarea";
import {
formatRatio,
formatScore,
@@ -22,40 +21,29 @@ import {
type PublisherAbuseReviewItem,
type PublisherAbuseReviewScore,
type PublisherAbuseTab,
USER_BAN_REASON_MAX_LENGTH,
} from "./managementShared";
export function AbusePage({
admin,
currentUserId,
dashboard,
detail,
items,
notes,
search,
selectedItem,
selectedNominationId,
tab,
onBanOwner,
onChangeNotes,
onChangeSearch,
onChangeTab,
onClose,
onRefresh,
onSelect,
}: {
admin: boolean;
currentUserId: Id<"users"> | null;
dashboard: PublisherAbuseReviewDashboard | undefined;
detail: PublisherAbuseReviewDetail | undefined;
items: PublisherAbuseReviewItem[];
notes: string;
search: string;
selectedItem: PublisherAbuseReviewItem | null;
selectedNominationId: Id<"publisherAbuseReviewNominations"> | null;
tab: PublisherAbuseTab;
onBanOwner: (item: PublisherAbuseReviewItem) => void;
onChangeNotes: (value: string) => void;
onChangeSearch: (value: string) => void;
onChangeTab: (value: PublisherAbuseTab) => void;
onClose: () => void;
@@ -65,7 +53,6 @@ export function AbusePage({
const latestRun = dashboard?.latestRun ?? null;
const selectedScore = selectedItem?.latestScore ?? null;
const selectedPublisher = selectedItem?.publisher ?? null;
const canBanSelectedUser = canBanPublisherAbuseOwner(selectedItem, currentUserId, admin);
const visiblePending = dashboard ? getPublisherAbuseVisiblePendingItems(dashboard) : [];
const totalPending = visiblePending.length;
const potentialBan = visiblePending.filter(
@@ -425,26 +412,11 @@ export function AbusePage({
</section>
) : selectedItem.nomination.label === "potential_ban_candidate" ? (
<section className="pa-zone pa-review">
<div className="pa-section-label">Triage note</div>
<Textarea
maxLength={USER_BAN_REASON_MAX_LENGTH}
placeholder="Why are you taking this action? (optional)"
value={notes}
onChange={(event) => onChangeNotes(event.target.value)}
/>
<div className="pa-actions">
<Button
type="button"
variant="destructive"
size="sm"
className="pa-ban"
disabled={!canBanSelectedUser}
onClick={() => onBanOwner(selectedItem)}
>
<Ban size={14} />
Ban user
</Button>
</div>
<div className="pa-section-label">Flagged for review</div>
<p className="pa-hint">
This publisher is in the high-risk bucket. Publisher-abuse bans are disabled
while the scoring model is flag-only.
</p>
</section>
) : (
<section className="pa-zone pa-review">
@@ -607,18 +579,6 @@ function isVisiblePublisherAbuseItem(item: PublisherAbuseReviewItem) {
);
}
export function canBanPublisherAbuseOwner(
item: PublisherAbuseReviewItem | null,
currentUserId: Id<"users"> | null,
admin: boolean,
) {
const ownerUser = item?.ownerUser;
if (!ownerUser?._id) return false;
if (ownerUser._id === currentUserId) return false;
if (ownerUser.role === "admin" && !admin) return false;
return true;
}
export function getPublisherAbuseVisiblePendingItems(dashboard: PublisherAbuseReviewDashboard) {
return [...dashboard.pendingPotentialBanCandidateItems, ...dashboard.pendingReviewItems].filter(
isVisiblePublisherAbuseItem,
-41
View File
@@ -30,7 +30,6 @@ import { isAdmin, isModerator } from "../lib/roles";
import { useAuthStatus } from "../lib/useAuthStatus";
import {
AbusePage,
canBanPublisherAbuseOwner,
comparePublisherAbuseItems,
filterPublisherAbuseItems,
getPublisherAbuseItemsForTab,
@@ -47,7 +46,6 @@ import {
type ManagementUserListResult,
type ManagementView,
type PluginByNameResult,
type PublisherAbuseReviewItem,
type PublisherAbuseTab,
type RecentVersionEntry,
type ReportedSkillEntry,
@@ -233,7 +231,6 @@ export function Management() {
const setDeprecatedBadge = useMutation(api.skills.setDeprecatedBadge);
const setSkillManualOverride = useMutation(api.skills.setSkillManualOverride);
const clearSkillManualOverride = useMutation(api.skills.clearSkillManualOverride);
const banPublisherAbuseOwnerMutation = useMutation(api.publisherAbuse.banPublisherAbuseOwner);
const startPublisherAbuseScoreRun = useAction(api.publisherAbuse.startPublisherAbuseScoreRun);
const [selectedDuplicate, setSelectedDuplicate] = useState("");
@@ -251,7 +248,6 @@ export function Management() {
const [publisherAbuseTab, setPublisherAbuseTab] =
useState<PublisherAbuseTab>("potential_ban_candidate");
const [publisherAbuseSearch, setPublisherAbuseSearch] = useState("");
const [publisherAbuseNotes, setPublisherAbuseNotes] = useState("");
const [selectedPublisherAbuseNominationId, setSelectedPublisherAbuseNominationId] =
useState<Id<"publisherAbuseReviewNominations"> | null>(null);
@@ -340,10 +336,6 @@ export function Management() {
if (!stillVisible) setSelectedPublisherAbuseNominationId(null);
}, [filteredPublisherAbuseItems, selectedPublisherAbuseNominationId]);
useEffect(() => {
setPublisherAbuseNotes("");
}, [selectedPublisherAbuseNominationId]);
if (isAuthLoading) {
return <ManagementSkeleton />;
}
@@ -533,34 +525,6 @@ export function Management() {
});
};
const banPublisherAbuseOwner = (item: PublisherAbuseReviewItem) => {
const ownerUser = item.ownerUser;
if (!ownerUser || !canBanPublisherAbuseOwner(item, me?._id ?? null, admin)) return;
const label = `@${ownerUser.handle ?? ownerUser.name ?? item.nomination.handleSnapshot}`;
// The review notes box above the Ban button is the ban reason — no separate prompt.
const reason = publisherAbuseNotes.trim() || undefined;
setConfirmRequest({
title: `Ban ${label}?`,
body: "Hides their skills and personal package/plugin resources, and revokes package publish tokens.",
confirmLabel: "Ban user",
destructive: true,
onConfirm: () => {
void banPublisherAbuseOwnerMutation({
nominationId: item.nomination._id,
expectedLatestScoreId: item.nomination.latestScoreId,
expectedUpdatedAt: item.nomination.updatedAt,
reason,
})
.then(() => {
toast.success(`Banned ${label}.`);
setPublisherAbuseNotes("");
setSelectedPublisherAbuseNominationId(null);
})
.catch((error) => toast.error(formatMutationError(error)));
},
});
};
return (
<main className="management-shell">
<ManagementSidebar
@@ -585,18 +549,13 @@ export function Management() {
{activeView === "abuse" ? (
<AbusePage
admin={admin}
currentUserId={me?._id ?? null}
dashboard={publisherAbuseDashboard}
detail={selectedPublisherAbuseDetail}
items={filteredPublisherAbuseItems}
notes={publisherAbuseNotes}
search={publisherAbuseSearch}
selectedItem={selectedPublisherAbuseItem}
selectedNominationId={selectedPublisherAbuseNominationId}
tab={publisherAbuseTab}
onBanOwner={banPublisherAbuseOwner}
onChangeNotes={setPublisherAbuseNotes}
onChangeSearch={setPublisherAbuseSearch}
onChangeTab={setPublisherAbuseTab}
onRefresh={() => {