diff --git a/apps/dashboard/src/features/growth/growth-page.tsx b/apps/dashboard/src/features/growth/growth-page.tsx index 73c0ec6..d00f3f6 100644 --- a/apps/dashboard/src/features/growth/growth-page.tsx +++ b/apps/dashboard/src/features/growth/growth-page.tsx @@ -178,11 +178,16 @@ function AccountDetail({ account }: { account: GrowthAccountDetail }) { const addSubreddit = useAddGrowthSubredditMutation(accountId); const [newSubreddit, setNewSubreddit] = useState(''); const [delayMinutes, setDelayMinutes] = useState(String(Math.round(settings.minDelayMs / 60_000))); + const [dailyLimit, setDailyLimit] = useState(settings.dailyLimit === null ? '' : String(settings.dailyLimit)); useEffect( () => setDelayMinutes(String(Math.round(settings.minDelayMs / 60_000))), [settings.minDelayMs], ); + useEffect( + () => setDailyLimit(settings.dailyLimit === null ? '' : String(settings.dailyLimit)), + [settings.dailyLimit], + ); return (
@@ -224,9 +229,17 @@ function AccountDetail({ account }: { account: GrowthAccountDetail }) {
Used in last 24h
{stats.postedLast24h} / {settings.dailyAllowance}
+
+
Daily limit
+
+ {settings.dailyLimit === null + ? `Automatic — ${settings.adaptiveAllowance}/day` + : `Set to ${settings.dailyLimit}/day`} +
+
Trust level
-
{settings.trustLevel} of 4 → {settings.dailyAllowance}/day
+
{settings.trustLevel} of 4 → {settings.adaptiveAllowance}/day earned
Comments live
@@ -247,14 +260,55 @@ function AccountDetail({ account }: { account: GrowthAccountDetail }) {

- The allowance comes from the trust level, not from a setting: level 0 permits 2 comments - a day, then 3, 5, 8 and 12. It is a rolling 24-hour window rather than a midnight reset, - so capacity frees up 24 hours after each comment. The level rises on its own while - comments survive and earn upvotes, and falls back the moment one is removed or heavily - downvoted. Two removals in a row, or a comment the public cannot see, pauses the account - for you. + Left automatic, the allowance follows the trust level: level 0 permits 2 comments a day, + then 3, 5, 8 and 12, rising while comments survive and earn upvotes and falling back the + moment one is removed or heavily downvoted. Set a daily limit to override that. Either + way it is a rolling 24-hour window rather than a midnight reset, so capacity frees up 24 + hours after each comment, and two removals in a row or a comment the public cannot see + pauses the account regardless.

+
+ + +
+ + {settings.dailyLimit !== null && settings.dailyLimit > settings.adaptiveAllowance ? ( +

+ This is above the {settings.adaptiveAllowance}/day this account has earned, so the + adaptive backoff can no longer slow it down after a bad comment. The pause on two + removals in a row, or a comment the public cannot see, still applies. +

+ ) : null} +
{ + // Null keeps the adaptive allowance; a value is an operator override. + addColumnIfNotExists(db, 'account_growth_settings', 'daily_limit', 'INTEGER'); + }, ]; let currentVersion = db.pragma('user_version', { simple: true }) as number; diff --git a/src/database/growth-repository.ts b/src/database/growth-repository.ts index 4abd08f..403a134 100644 --- a/src/database/growth-repository.ts +++ b/src/database/growth-repository.ts @@ -24,6 +24,12 @@ export interface GrowthSettingsRecord { enabled: boolean; /** Rung on the adaptive allowance ladder. Higher means more comments per day. */ trustLevel: number; + /** + * Operator override for comments per day. Null leaves the allowance to the + * adaptive ladder. A value is enforced verbatim — the ladder keeps tracking + * the account underneath, and the removal/shadowban pause still applies. + */ + dailyLimit: number | null; levelChangedAt: number | null; consecutiveRemovals: number; /** Non-null means growth is hard-paused until an operator clears it. */ @@ -38,6 +44,8 @@ export interface GrowthSettingsRecord { export interface GrowthSettingsPatch { enabled?: boolean; + /** Pass null to hand the allowance back to the adaptive ladder. */ + dailyLimit?: number | null; minDelayMs?: number; jitterMs?: number; quietHours?: GrowthQuietHours; @@ -158,6 +166,7 @@ export class AccountGrowthRepository { accountId, enabled: false, trustLevel: 0, + dailyLimit: null, levelChangedAt: null, consecutiveRemovals: 0, pausedReason: null, @@ -184,26 +193,34 @@ export class AccountGrowthRepository { if (patch.jitterMs !== undefined && (!Number.isInteger(patch.jitterMs) || patch.jitterMs < 0)) { throw new Error('Growth jitter must be a non-negative integer'); } + if (patch.dailyLimit !== undefined && patch.dailyLimit !== null + && (!Number.isInteger(patch.dailyLimit) || patch.dailyLimit < 1 || patch.dailyLimit > 50)) { + throw new Error('Daily comment limit must be a whole number between 1 and 50'); + } if (patch.quietHours) validateQuietHours(patch.quietHours); const current = this.getSettings(accountId, now); const merged: GrowthSettingsRecord = { ...current, ...patch, quietHours: patch.quietHours ?? current.quietHours, + // `null` is a meaningful value here — it hands the allowance back to the + // adaptive ladder — so only an explicitly present key may change it. + dailyLimit: 'dailyLimit' in patch ? patch.dailyLimit ?? null : current.dailyLimit, updatedAt: now, }; this.db.prepare(` INSERT INTO account_growth_settings ( - account_id, enabled, trust_level, level_changed_at, consecutive_removals, + account_id, enabled, trust_level, daily_limit, level_changed_at, consecutive_removals, paused_reason, paused_at, min_delay_ms, jitter_ms, quiet_hours_json, allow_nsfw, updated_at ) VALUES ( - @accountId, @enabled, @trustLevel, @levelChangedAt, @consecutiveRemovals, + @accountId, @enabled, @trustLevel, @dailyLimit, @levelChangedAt, @consecutiveRemovals, @pausedReason, @pausedAt, @minDelayMs, @jitterMs, @quietHoursJson, @allowNsfw, @updatedAt ) ON CONFLICT(account_id) DO UPDATE SET enabled = excluded.enabled, + daily_limit = excluded.daily_limit, min_delay_ms = excluded.min_delay_ms, jitter_ms = excluded.jitter_ms, quiet_hours_json = excluded.quiet_hours_json, @@ -213,6 +230,7 @@ export class AccountGrowthRepository { accountId, enabled: merged.enabled ? 1 : 0, trustLevel: merged.trustLevel, + dailyLimit: merged.dailyLimit, levelChangedAt: merged.levelChangedAt, consecutiveRemovals: merged.consecutiveRemovals, pausedReason: merged.pausedReason, @@ -718,6 +736,7 @@ function mapSettings(row: Record): GrowthSettingsRecord { accountId: row.account_id as string, enabled: row.enabled === 1, trustLevel: row.trust_level as number, + dailyLimit: (row.daily_limit as number | null) ?? null, levelChangedAt: (row.level_changed_at as number | null) ?? null, consecutiveRemovals: row.consecutive_removals as number, pausedReason: (row.paused_reason as string | null) ?? null, diff --git a/src/services/growth-pacer.ts b/src/services/growth-pacer.ts index 607ce63..0ce05ae 100644 --- a/src/services/growth-pacer.ts +++ b/src/services/growth-pacer.ts @@ -32,6 +32,24 @@ export function dailyAllowance(trustLevel: number): number { return TRUST_LEVEL_ALLOWANCES[clamped]; } +/** + * Comments per day this account may actually post. + * + * An operator override is enforced verbatim, in either direction: it can raise + * the allowance above what the account has earned, or hold it below. The ladder + * keeps tracking the account underneath so the earned level stays visible, and + * the removal/shadowban pause is unaffected — that is a safety stop rather than + * a rate control, so no limit setting can switch it off. + */ +export function effectiveDailyAllowance( + settings: Pick, +): number { + if (settings.dailyLimit !== null && Number.isInteger(settings.dailyLimit) && settings.dailyLimit > 0) { + return settings.dailyLimit; + } + return dailyAllowance(settings.trustLevel); +} + export interface TrustAssessment { trustLevel: number; consecutiveRemovals: number; @@ -171,7 +189,7 @@ export function evaluateGrowthWindow(input: GrowthWindowInput): GrowthWindowDeci }; } - const allowance = dailyAllowance(settings.trustLevel); + const allowance = effectiveDailyAllowance(settings); if (input.postedLast24h >= allowance) { return { allowed: false, diff --git a/src/web/growth-contracts.ts b/src/web/growth-contracts.ts index bde2ef6..292d27b 100644 --- a/src/web/growth-contracts.ts +++ b/src/web/growth-contracts.ts @@ -22,6 +22,8 @@ export const GrowthSettingsUpdateRequestSchema = z.strictObject({ enabled: z.boolean().optional(), minDelayMs: z.number().int().min(0).max(DAY_MS).optional(), jitterMs: z.number().int().min(0).max(6 * 60 * 60_000).optional(), + /** Whole comments per day, or null to hand the allowance back to the ladder. */ + dailyLimit: z.number().int().min(1).max(50).nullable().optional(), quietHours: GrowthQuietHoursSchema.optional(), allowNsfw: z.boolean().optional(), }); @@ -45,7 +47,12 @@ export const GrowthSubredditUpdateRequestSchema = z.strictObject({ export const GrowthSettingsSchema = z.strictObject({ enabled: z.boolean(), trustLevel: z.number().int(), + /** Comments per day actually enforced: the override when set, else the earned allowance. */ dailyAllowance: z.number().int(), + /** What the adaptive ladder alone would permit. */ + adaptiveAllowance: z.number().int(), + /** Operator override, or null when the allowance is left adaptive. */ + dailyLimit: z.number().int().nullable(), minDelayMs: z.number().int(), jitterMs: z.number().int(), quietHours: GrowthQuietHoursSchema, diff --git a/src/web/routes/growth.ts b/src/web/routes/growth.ts index 33a1c52..e9f56d1 100644 --- a/src/web/routes/growth.ts +++ b/src/web/routes/growth.ts @@ -7,7 +7,7 @@ import { type GrowthSettingsRecord, type GrowthSubredditRecord, } from '../../database/growth-repository.js'; -import { dailyAllowance } from '../../services/growth-pacer.js'; +import { dailyAllowance, effectiveDailyAllowance } from '../../services/growth-pacer.js'; import { GrowthPauseRequestSchema as PauseSchema, GrowthSettingsUpdateRequestSchema as SettingsUpdateSchema, @@ -215,7 +215,11 @@ function presentSettings(settings: GrowthSettingsRecord) { return { enabled: settings.enabled, trustLevel: settings.trustLevel, - dailyAllowance: dailyAllowance(settings.trustLevel), + /** What is enforced: the override when set, otherwise the earned allowance. */ + dailyAllowance: effectiveDailyAllowance(settings), + /** What the adaptive ladder alone would permit, shown alongside an override. */ + adaptiveAllowance: dailyAllowance(settings.trustLevel), + dailyLimit: settings.dailyLimit, minDelayMs: settings.minDelayMs, jitterMs: settings.jitterMs, quietHours: settings.quietHours, diff --git a/tests/dashboard/growth.test.tsx b/tests/dashboard/growth.test.tsx index 96cb32a..f15cba5 100644 --- a/tests/dashboard/growth.test.tsx +++ b/tests/dashboard/growth.test.tsx @@ -16,6 +16,8 @@ function settings(overrides: Record = {}) { enabled: true, trustLevel: 1, dailyAllowance: 3, + adaptiveAllowance: 3, + dailyLimit: null, minDelayMs: 3_600_000, jitterMs: 900_000, quietHours: { enabled: true, startHour: 23, endHour: 7 }, @@ -197,7 +199,7 @@ describe('GrowthPage', () => { expect(await screen.findByText('1 / 3')).toBeVisible(); expect(screen.getByText('Used in last 24h')).toBeVisible(); - expect(screen.getByText('1 of 4 → 3/day')).toBeVisible(); + expect(screen.getByText('1 of 4 → 3/day earned')).toBeVisible(); // 6 posted less 1 removed. expect(screen.getByText('5')).toBeVisible(); expect(screen.getByText('4')).toBeVisible(); @@ -213,6 +215,74 @@ describe('GrowthPage', () => { expect(screen.getByText(/Two removals in a row/i)).toBeVisible(); }); + describe('daily limit', () => { + it('shows the allowance as automatic until an operator overrides it', async () => { + renderGrowth(); + + expect(await screen.findByText('Automatic — 3/day')).toBeVisible(); + expect(screen.getByText('1 of 4 → 3/day earned')).toBeVisible(); + }); + + it('sets a limit through the API', async () => { + renderGrowth(); + const user = userEvent.setup(); + + const field = await screen.findByLabelText('Daily limit'); + await user.clear(field); + await user.type(field, '9'); + await user.click(screen.getByRole('button', { name: 'Save limit' })); + + await waitFor(() => expect(requests).toHaveLength(1)); + expect(requests[0]).toMatchObject({ method: 'PATCH', body: { dailyLimit: 9 } }); + }); + + it('clearing the field hands the allowance back to the ladder', async () => { + detailPayload = detail({ settings: settings({ dailyLimit: 9, dailyAllowance: 9 }) }); + renderGrowth(); + const user = userEvent.setup(); + + await user.clear(await screen.findByLabelText('Daily limit')); + await user.click(screen.getByRole('button', { name: 'Save limit' })); + + await waitFor(() => expect(requests).toHaveLength(1)); + expect(requests[0]).toMatchObject({ body: { dailyLimit: null } }); + }); + + it('does not submit an out-of-range limit', async () => { + renderGrowth(); + const user = userEvent.setup(); + + const field = await screen.findByLabelText('Daily limit'); + await user.clear(field); + await user.type(field, '99'); + await user.click(screen.getByRole('button', { name: 'Save limit' })); + + expect(requests).toHaveLength(0); + }); + + it('shows an override and warns when it exceeds what was earned', async () => { + detailPayload = detail({ + settings: settings({ trustLevel: 0, dailyLimit: 12, dailyAllowance: 12, adaptiveAllowance: 2 }), + }); + renderGrowth(); + + expect(await screen.findByText('Set to 12/day')).toBeVisible(); + expect(screen.getByText(/above the 2\/day this account has earned/i)).toBeVisible(); + // The safety stop is not something a limit can switch off. + expect(screen.getByText(/pause on two removals in a row.*still applies/i)).toBeVisible(); + }); + + it('does not warn when the override sits within what was earned', async () => { + detailPayload = detail({ + settings: settings({ trustLevel: 4, dailyLimit: 3, dailyAllowance: 3, adaptiveAllowance: 12 }), + }); + renderGrowth(); + + expect(await screen.findByText('Set to 3/day')).toBeVisible(); + expect(screen.queryByText(/this account has earned/i)).not.toBeInTheDocument(); + }); + }); + it('warns when a community cap exceeds what the account allowance permits', async () => { detailPayload = detail({ settings: settings({ trustLevel: 0, dailyAllowance: 2 }), diff --git a/tests/growth-pacer.test.ts b/tests/growth-pacer.test.ts index 843c2d0..786478a 100644 --- a/tests/growth-pacer.test.ts +++ b/tests/growth-pacer.test.ts @@ -5,6 +5,7 @@ import type { } from '../src/database/growth-repository.js'; import { DEMOTION_SCORE_THRESHOLD, + effectiveDailyAllowance, MAX_TRUST_LEVEL, PROMOTION_HOLD_MS, TRUST_LEVEL_ALLOWANCES, @@ -23,6 +24,7 @@ function settings(overrides: Partial = {}): GrowthSettings accountId: '@t2_a:reddit.com', enabled: true, trustLevel: 0, + dailyLimit: null, levelChangedAt: null, consecutiveRemovals: 0, pausedReason: null, @@ -64,6 +66,25 @@ describe('dailyAllowance', () => { }); }); +describe('effectiveDailyAllowance', () => { + it('uses the earned allowance when no override is set', () => { + expect(effectiveDailyAllowance({ trustLevel: 2, dailyLimit: null })).toBe(5); + }); + + it('enforces an override above the earned allowance', () => { + expect(effectiveDailyAllowance({ trustLevel: 0, dailyLimit: 20 })).toBe(20); + }); + + it('enforces an override below the earned allowance', () => { + expect(effectiveDailyAllowance({ trustLevel: 4, dailyLimit: 1 })).toBe(1); + }); + + it('ignores a nonsensical override rather than blocking all commenting', () => { + expect(effectiveDailyAllowance({ trustLevel: 1, dailyLimit: 0 })).toBe(3); + expect(effectiveDailyAllowance({ trustLevel: 1, dailyLimit: -5 })).toBe(3); + }); +}); + describe('assessTrust', () => { it('holds at level 0 with no history', () => { const assessment = assessTrust(settings(), [], NOW); @@ -372,6 +393,38 @@ describe('evaluateGrowthWindow', () => { }); }); + it('enforces an operator override instead of the earned allowance', () => { + const decision = evaluateGrowthWindow({ + ...base, + settings: settings({ trustLevel: 0, dailyLimit: 10 }), + postedLast24h: 4, + }); + + expect(decision).toEqual({ allowed: true, allowance: 10, postedLast24h: 4 }); + }); + + it('stops at an override lower than the earned allowance', () => { + const decision = evaluateGrowthWindow({ + ...base, + settings: settings({ trustLevel: 4, dailyLimit: 2 }), + postedLast24h: 2, + }); + + expect(decision).toMatchObject({ allowed: false }); + if (!decision.allowed) expect(decision.reason).toMatch(/allowance of 2 comments/); + }); + + it('an override never overrides the safety pause', () => { + const decision = evaluateGrowthWindow({ + ...base, + settings: settings({ dailyLimit: 50, pausedReason: 'Two consecutive removals' }), + }); + + expect(decision).toEqual({ + allowed: false, reason: 'Two consecutive removals', retryAt: null, + }); + }); + it('uses the raised allowance at a higher trust level', () => { const decision = evaluateGrowthWindow({ ...base, diff --git a/tests/growth-repository.test.ts b/tests/growth-repository.test.ts index 7d6ee76..1b01989 100644 --- a/tests/growth-repository.test.ts +++ b/tests/growth-repository.test.ts @@ -9,6 +9,8 @@ import type { RedditPost } from '../src/reddit/api.js'; const ACCOUNT = '@t2_grower:reddit.com'; const OTHER_ACCOUNT = '@t2_other:reddit.com'; +/** Migrations that make up account growth, replayed together in the upgrade test. */ +const GROWTH_MIGRATION_COUNT = 2; function thread(id: string, overrides: Partial = {}): RedditPost { return { @@ -76,6 +78,26 @@ describe('AccountGrowthRepository', () => { expect(saved.trustLevel).toBe(3); }); + it('leaves the daily limit automatic until an operator sets one', () => { + expect(repo.getSettings(ACCOUNT).dailyLimit).toBeNull(); + + expect(repo.saveSettings(ACCOUNT, { dailyLimit: 6 }).dailyLimit).toBe(6); + // Null is meaningful: it hands the allowance back to the adaptive ladder. + expect(repo.saveSettings(ACCOUNT, { dailyLimit: null }).dailyLimit).toBeNull(); + }); + + it('keeps a stored daily limit when an unrelated setting is saved', () => { + repo.saveSettings(ACCOUNT, { dailyLimit: 8 }); + + expect(repo.saveSettings(ACCOUNT, { enabled: true }).dailyLimit).toBe(8); + }); + + it('rejects a daily limit outside the allowed range', () => { + for (const dailyLimit of [0, -1, 51, 2.5]) { + expect(() => repo.saveSettings(ACCOUNT, { dailyLimit })).toThrow(/between 1 and 50/); + } + }); + it('rejects invalid quiet hours and negative pacing', () => { expect(() => repo.saveSettings(ACCOUNT, { quietHours: { enabled: true, startHour: 25, endHour: 3 }, @@ -456,26 +478,54 @@ describe('AccountGrowthRepository', () => { }); describe('growth migration', () => { - it('upgrades a database created before growth existed', () => { - const db = new Database(':memory:'); - db.pragma('foreign_keys = ON'); - // Simulate the pre-growth schema by running migrations, then rewinding the - // version so the growth migration is applied on its own. - runMigrations(db); + /** Rewinds past every growth migration so they replay from a pre-growth state. */ + function rewindPastGrowth(db: Database.Database): void { const version = db.pragma('user_version', { simple: true }) as number; db.exec('DROP TABLE account_growth_comments'); db.exec('DROP TABLE account_growth_subreddits'); db.exec('DROP TABLE account_growth_settings'); - db.pragma(`user_version = ${version - 1}`); + db.pragma(`user_version = ${version - GROWTH_MIGRATION_COUNT}`); + } + + it('upgrades a database created before growth existed', () => { + const db = new Database(':memory:'); + db.pragma('foreign_keys = ON'); + runMigrations(db); + rewindPastGrowth(db); runMigrations(db); insertAccount(db, ACCOUNT); const repo = new AccountGrowthRepository(db); expect(repo.getSettings(ACCOUNT).enabled).toBe(false); + expect(repo.getSettings(ACCOUNT).dailyLimit).toBeNull(); expect(repo.enqueueDiscovered(ACCOUNT, [thread('a')], 1_000).discovered).toBe(1); }); + it('adds the daily limit to a database that already had the growth tables', () => { + // The real upgrade path for an instance already running account growth. + const db = new Database(':memory:'); + db.pragma('foreign_keys = ON'); + runMigrations(db); + const version = db.pragma('user_version', { simple: true }) as number; + db.exec('ALTER TABLE account_growth_settings DROP COLUMN daily_limit'); + db.pragma(`user_version = ${version - 1}`); + insertAccount(db, ACCOUNT); + // Seed a row with the old shape so the upgrade runs against real data. + db.prepare(` + INSERT INTO account_growth_settings (account_id, enabled, trust_level, updated_at) + VALUES (?, 1, 3, 100) + `).run(ACCOUNT); + + runMigrations(db); + + const settings = new AccountGrowthRepository(db).getSettings(ACCOUNT); + expect(settings.dailyLimit).toBeNull(); + // The upgrade must not disturb what the account already earned. + expect(settings.trustLevel).toBe(3); + expect(settings.enabled).toBe(true); + }); + it('is idempotent when run twice', () => { const db = new Database(':memory:'); runMigrations(db); diff --git a/tests/web/growth-route.test.ts b/tests/web/growth-route.test.ts index 908d7b6..a7f6a83 100644 --- a/tests/web/growth-route.test.ts +++ b/tests/web/growth-route.test.ts @@ -187,6 +187,43 @@ describe('growth route', () => { }); }); + it('sets and clears the daily limit override', async () => { + const set = await call(app, 'PATCH', `/api/growth/${ENCODED}`, { dailyLimit: 7 }); + + expect(set.status).toBe(200); + GrowthAccountResponseSchema.parse(set.json); + expect(set.json.account.settings).toMatchObject({ + dailyLimit: 7, + dailyAllowance: 7, + adaptiveAllowance: 2, + }); + + const cleared = await call(app, 'PATCH', `/api/growth/${ENCODED}`, { dailyLimit: null }); + + expect(cleared.json.account.settings).toMatchObject({ + dailyLimit: null, + dailyAllowance: 2, + adaptiveAllowance: 2, + }); + }); + + it('reports the override as the enforced allowance even below the earned one', async () => { + repo.setTrustLevel(ACCOUNT, 4); + await call(app, 'PATCH', `/api/growth/${ENCODED}`, { dailyLimit: 1 }); + + const { json } = await call(app, 'GET', `/api/growth/${ENCODED}`); + + expect(json.account.settings).toMatchObject({ + dailyLimit: 1, dailyAllowance: 1, adaptiveAllowance: 12, + }); + }); + + it.each([0, 51, 2.5])('rejects a daily limit of %s', async (dailyLimit) => { + const { status } = await call(app, 'PATCH', `/api/growth/${ENCODED}`, { dailyLimit }); + + expect(status).toBe(400); + }); + it('never resets an earned trust level', async () => { repo.setTrustLevel(ACCOUNT, 2);