diff --git a/packages/gittensory-engine/src/governor/reputation-throttle.ts b/packages/gittensory-engine/src/governor/reputation-throttle.ts new file mode 100644 index 0000000000..17e04eb731 --- /dev/null +++ b/packages/gittensory-engine/src/governor/reputation-throttle.ts @@ -0,0 +1,172 @@ +// Governor self-reputation throttle (#2346, pure). +// Deterministic, side-effect-free cadence math for the local Governor. Given the miner's OWN recent terminal +// outcomes on one repo (merged vs. human-closed/gate-rejected) and a threshold config, it decides how much to +// slow that repo's submission cadence: a clean track record runs at full cadence, a rising unfavorable ratio +// degrades cadence toward a floor, and a recovering ratio restores it — never a hard permanent ban. It reads +// only the miner's own local history (never shared/cross-fleet data), computes numbers only, and does NOT store +// state or gate any write; that enforcement wiring is a separate, maintainer-owned chokepoint. The +// outcome-history-driven shape mirrors src/signals/reward-risk.ts, adapted to the miner's own local-only view. +import type { GovernorLedgerEvent } from "../governor-ledger.js"; + +export type SelfReputationThresholds = { + /** Terminal outcomes required on a repo before throttling engages; below this it fails OPEN (full cadence). */ + minSampleSize: number; + /** Unfavorable ratio (unfavorable / decided) at which cadence starts degrading below full. */ + throttleAtRatio: number; + /** Unfavorable ratio at (or above) which cadence is pinned to its floor. */ + floorAtRatio: number; + /** Cadence multiplier at/above `floorAtRatio` — the slowest permitted fraction of normal cadence (never 0). */ + minCadenceFactor: number; +}; + +/** Conservative built-in defaults; a `.gittensory-miner.yml` override is merged over these. */ +export const DEFAULT_SELF_REPUTATION_THRESHOLDS: SelfReputationThresholds = + Object.freeze({ + minSampleSize: 5, + throttleAtRatio: 0.5, + floorAtRatio: 0.9, + minCadenceFactor: 0.1, + }); + +/** The miner's own terminal outcomes on one repo over its recent-history window. */ +export type RepoOutcomeHistory = { + /** Submissions with a terminal outcome (merged + closed + rejected). */ + decided: number; + /** Terminal outcomes that went against the miner (human-closed or gate-rejected). */ + unfavorable: number; +}; + +export type SelfReputationThrottleReason = + | "insufficient_history" + | "clean" + | "throttled" + | "floored"; + +export type SelfReputationThrottleDecision = { + /** Multiplier on normal submission cadence, in [minCadenceFactor, 1]. 1 = unthrottled. */ + cadenceFactor: number; + throttled: boolean; + /** The unfavorable ratio that drove the decision; null when below the sample floor (fail-open). */ + unfavorableRatio: number | null; + reason: SelfReputationThrottleReason; +}; + +function finiteNonNegativeInt(value: number): number { + return Number.isFinite(value) ? Math.max(0, Math.floor(value)) : 0; +} + +function clampFraction(value: number, fallback: number): number { + return Number.isFinite(value) ? Math.min(1, Math.max(0, value)) : fallback; +} + +function round3(value: number): number { + return Number(value.toFixed(3)); +} + +/** + * Merge a partial (e.g. `.gittensory-miner.yml`-sourced) threshold config over the conservative defaults, + * normalizing every field so a malformed value can never produce a NaN/negative/out-of-range decision. The + * throttle band is kept well-formed: `floorAtRatio` is pulled to at least `throttleAtRatio` so the interpolation + * span is never negative. + */ +export function resolveSelfReputationThresholds( + overrides: Partial = {}, +): SelfReputationThresholds { + const d = DEFAULT_SELF_REPUTATION_THRESHOLDS; + const minSampleSize = Math.max( + 1, + finiteNonNegativeInt(overrides.minSampleSize ?? d.minSampleSize), + ); + const throttleAtRatio = clampFraction( + overrides.throttleAtRatio ?? d.throttleAtRatio, + d.throttleAtRatio, + ); + const floorAtRatio = Math.max( + throttleAtRatio, + clampFraction(overrides.floorAtRatio ?? d.floorAtRatio, d.floorAtRatio), + ); + const minCadenceFactor = clampFraction( + overrides.minCadenceFactor ?? d.minCadenceFactor, + d.minCadenceFactor, + ); + return { minSampleSize, throttleAtRatio, floorAtRatio, minCadenceFactor }; +} + +/** + * Decide the cadence throttle for one repo from the miner's own recent outcome history. Pure: reads the history + * and thresholds and returns a decision without mutating anything. Fails OPEN on genuinely insufficient history + * (fewer than `minSampleSize` decided outcomes) — a brand-new miner or a new repo is never falsely throttled. + * Between `throttleAtRatio` and `floorAtRatio` the cadence factor interpolates linearly from 1 down to + * `minCadenceFactor`, so an improving ratio measurably restores cadence and a worsening one measurably cuts it. + */ +export function selfReputationThrottle( + history: RepoOutcomeHistory, + thresholds: SelfReputationThresholds = DEFAULT_SELF_REPUTATION_THRESHOLDS, +): SelfReputationThrottleDecision { + const decided = finiteNonNegativeInt(history.decided); + const unfavorable = Math.min( + decided, + finiteNonNegativeInt(history.unfavorable), + ); + + if (decided < thresholds.minSampleSize) { + return { + cadenceFactor: 1, + throttled: false, + unfavorableRatio: null, + reason: "insufficient_history", + }; + } + + const ratio = unfavorable / decided; + if (ratio < thresholds.throttleAtRatio) { + return { + cadenceFactor: 1, + throttled: false, + unfavorableRatio: round3(ratio), + reason: "clean", + }; + } + if (ratio >= thresholds.floorAtRatio) { + return { + cadenceFactor: thresholds.minCadenceFactor, + throttled: true, + unfavorableRatio: round3(ratio), + reason: "floored", + }; + } + // throttleAtRatio <= ratio < floorAtRatio ⇒ floorAtRatio > throttleAtRatio, so the span is strictly positive. + const t = + (ratio - thresholds.throttleAtRatio) / + (thresholds.floorAtRatio - thresholds.throttleAtRatio); + const cadenceFactor = round3(1 - t * (1 - thresholds.minCadenceFactor)); + return { + cadenceFactor, + throttled: true, + unfavorableRatio: round3(ratio), + reason: "throttled", + }; +} + +/** + * Shape a throttle decision as a governor-ledger event so the chokepoint can record WHY a submission cadence was + * scaled, with the outcome ratio that triggered it. An unthrottled decision is an `allowed` event; a throttled + * one is a `throttled` event. + */ +export function selfReputationThrottleLedgerEvent( + repoFullName: string, + actionClass: string, + decision: SelfReputationThrottleDecision, +): GovernorLedgerEvent { + return { + eventType: decision.throttled ? "throttled" : "allowed", + repoFullName, + actionClass, + decision: decision.throttled ? "throttle" : "allow", + reason: decision.reason, + payload: { + cadenceFactor: decision.cadenceFactor, + unfavorableRatio: decision.unfavorableRatio, + }, + }; +} diff --git a/packages/gittensory-engine/src/index.ts b/packages/gittensory-engine/src/index.ts index b6fb4a36f6..ff790fdb80 100644 --- a/packages/gittensory-engine/src/index.ts +++ b/packages/gittensory-engine/src/index.ts @@ -146,6 +146,7 @@ export { export * from "./governor/rate-limit.js"; export * from "./governor/budget-cap.js"; export * from "./governor/self-plagiarism.js"; +export * from "./governor/reputation-throttle.js"; export { GOVERNOR_LEDGER_EVENT_TYPES, normalizeGovernorLedgerEvent, @@ -283,7 +284,10 @@ export { hasPlanSkippedSteps } from "./plan-skipped.js"; export { hasPlanCompletedSteps } from "./plan-completed.js"; export { isPlanBlocked } from "./plan-blocked.js"; export { isPlanProgressComplete } from "./plan-progress-complete.js"; -export { resolvePlanOverallStatus, type PlanOverallStatus } from "./plan-overall-status.js"; +export { + resolvePlanOverallStatus, + type PlanOverallStatus, +} from "./plan-overall-status.js"; export { hasPlanReadySteps } from "./plan-ready.js"; export { isPlanTerminated } from "./plan-terminated.js"; export * from "./plan-templates.js"; @@ -376,10 +380,7 @@ export { type FreshnessIssue, } from "./opportunity-freshness.js"; export { computeOpportunityCompetition } from "./opportunity-competition.js"; -export { - computeLaneFit, - type GoalModelInput, -} from "./goal-model.js"; +export { computeLaneFit, type GoalModelInput } from "./goal-model.js"; export { classifyContributorFit, type ContributorFit, @@ -423,7 +424,11 @@ export { } from "./duplicate-winner.js"; // Issue-centric RAG query composition (#2320, extracted in #4254): the pure query builder + the shared // minimum-query floor; the Vectorize/D1 retrieval backend intentionally stays in the backend. -export { MIN_QUERY_CHARS, buildIssueRagQuery, type IssueRagQueryInput } from "./issue-rag-query.js"; +export { + MIN_QUERY_CHARS, + buildIssueRagQuery, + type IssueRagQueryInput, +} from "./issue-rag-query.js"; // #782 deterministic local scorer (extracted in #4253): pure token-scoring from changed-file metadata, // shared by the published CLIs and the hosted Worker. The Node-coupled local-branch.ts stays in the backend. export { diff --git a/test/unit/reputation-throttle.test.ts b/test/unit/reputation-throttle.test.ts new file mode 100644 index 0000000000..29667d7ad6 --- /dev/null +++ b/test/unit/reputation-throttle.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, it } from "vitest"; +import { + DEFAULT_SELF_REPUTATION_THRESHOLDS, + resolveSelfReputationThresholds, + selfReputationThrottle, + selfReputationThrottleLedgerEvent, +} from "../../packages/gittensory-engine/src/index"; + +describe("resolveSelfReputationThresholds (#2346)", () => { + it("returns the conservative defaults when nothing is overridden", () => { + expect(resolveSelfReputationThresholds()).toEqual( + DEFAULT_SELF_REPUTATION_THRESHOLDS, + ); + }); + + it("applies a well-formed override verbatim", () => { + expect( + resolveSelfReputationThresholds({ + minSampleSize: 8, + throttleAtRatio: 0.4, + floorAtRatio: 0.8, + minCadenceFactor: 0.2, + }), + ).toEqual({ + minSampleSize: 8, + throttleAtRatio: 0.4, + floorAtRatio: 0.8, + minCadenceFactor: 0.2, + }); + }); + + it("normalizes malformed overrides (clamps ranges, floors sample size, keeps the band well-formed)", () => { + expect( + resolveSelfReputationThresholds({ + minSampleSize: 0, // → floored to 1 + throttleAtRatio: 2, // → clamped to 1 + floorAtRatio: 0.1, // → pulled up to throttleAtRatio (1) + minCadenceFactor: -1, // → clamped to 0 + }), + ).toEqual({ + minSampleSize: 1, + throttleAtRatio: 1, + floorAtRatio: 1, + minCadenceFactor: 0, + }); + }); + + it("falls back to a default for a non-finite override value", () => { + expect( + resolveSelfReputationThresholds({ throttleAtRatio: Number.NaN }) + .throttleAtRatio, + ).toBe(DEFAULT_SELF_REPUTATION_THRESHOLDS.throttleAtRatio); + }); +}); + +describe("selfReputationThrottle (#2346)", () => { + it("fails open (full cadence) on insufficient history", () => { + expect(selfReputationThrottle({ decided: 3, unfavorable: 3 })).toEqual({ + cadenceFactor: 1, + throttled: false, + unfavorableRatio: null, + reason: "insufficient_history", + }); + }); + + it("treats a non-finite decided count as no history", () => { + expect( + selfReputationThrottle({ decided: Number.NaN, unfavorable: 5 }).reason, + ).toBe("insufficient_history"); + }); + + it("runs at full cadence for a clean track record", () => { + expect(selfReputationThrottle({ decided: 10, unfavorable: 3 })).toEqual({ + cadenceFactor: 1, + throttled: false, + unfavorableRatio: 0.3, + reason: "clean", + }); + }); + + it("degrades cadence linearly across the throttle band", () => { + // ratio 0.7 in [0.5, 0.9): t = 0.5 → cadence 1 - 0.5*(1-0.1) = 0.55 + expect(selfReputationThrottle({ decided: 10, unfavorable: 7 })).toEqual({ + cadenceFactor: 0.55, + throttled: true, + unfavorableRatio: 0.7, + reason: "throttled", + }); + }); + + it("pins cadence to the floor once the unfavorable ratio hits floorAtRatio", () => { + expect(selfReputationThrottle({ decided: 10, unfavorable: 9 })).toEqual({ + cadenceFactor: 0.1, + throttled: true, + unfavorableRatio: 0.9, + reason: "floored", + }); + }); + + it("clamps unfavorable to decided so a bad feed cannot exceed a 100% ratio", () => { + const decision = selfReputationThrottle({ decided: 10, unfavorable: 20 }); + expect(decision.unfavorableRatio).toBe(1); + expect(decision.reason).toBe("floored"); + }); +}); + +describe("selfReputationThrottleLedgerEvent (#2346)", () => { + it("records a throttled decision as a throttled ledger event", () => { + const decision = selfReputationThrottle({ decided: 10, unfavorable: 9 }); + expect( + selfReputationThrottleLedgerEvent("acme/widgets", "open_pr", decision), + ).toEqual({ + eventType: "throttled", + repoFullName: "acme/widgets", + actionClass: "open_pr", + decision: "throttle", + reason: "floored", + payload: { cadenceFactor: 0.1, unfavorableRatio: 0.9 }, + }); + }); + + it("records an unthrottled decision as an allowed ledger event", () => { + const decision = selfReputationThrottle({ decided: 10, unfavorable: 1 }); + const event = selfReputationThrottleLedgerEvent( + "acme/widgets", + "file_issue", + decision, + ); + expect(event.eventType).toBe("allowed"); + expect(event.decision).toBe("allow"); + }); +});