Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
172 changes: 172 additions & 0 deletions packages/gittensory-engine/src/governor/reputation-throttle.ts
Original file line number Diff line number Diff line change
@@ -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> = {},
): 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,
},
};
}
17 changes: 11 additions & 6 deletions packages/gittensory-engine/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 {
Expand Down
132 changes: 132 additions & 0 deletions test/unit/reputation-throttle.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});