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
4 changes: 4 additions & 0 deletions packages/gittensory-engine/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -498,10 +498,14 @@ export {
buildPredictedGateVerdict,
predictedGateNote,
publicSafeFinding,
applyContributorCalibration,
MIN_CALIBRATION_SAMPLES,
MAX_READINESS_ADJUSTMENT,
type GateCheckConclusion,
type GatePolicyPack,
type PredictedGateInput,
type PredictedGateVerdict,
type ContributorCalibrationSignal,
} from "./predicted-gate.js";
// Focus-manifest parse/compile core (#2280): shared by the maintainer review stack and the miner's
// `.gittensory-miner.yml` goal-spec parser (see miner-goal-spec.ts for the parallel surface).
Expand Down
15 changes: 14 additions & 1 deletion packages/gittensory-engine/src/predicted-gate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,14 @@ import {
import { buildFocusManifestGuidance, type FocusManifest } from "./focus-manifest/guidance.js";
import { guardrailPathMatches, isGuardrailHit } from "./signals/change-guardrail.js";
import { resolveHardGuardrailGlobs } from "./review/guardrail-config.js";
import { applyContributorCalibration, type ContributorCalibrationSignal } from "./signals/contributor-calibration.js";
import { sanitizePublicComment } from "./github/sanitize-public-comment.js";
import { GITTENSOR_HOME_URL } from "./github/constants.js";
import type { BountyRecord, GatePolicyPack, IssueRecord, PullRequestRecord, RepositoryRecord } from "./types/predicted-gate-types.js";

export type { GatePolicyPack } from "./types/predicted-gate-types.js";
export type { GateCheckConclusion } from "./advisory/gate-advisory.js";
export { applyContributorCalibration, MIN_CALIBRATION_SAMPLES, MAX_READINESS_ADJUSTMENT, type ContributorCalibrationSignal } from "./signals/contributor-calibration.js";

// Opt-in funnel (#694): a non-Gittensor adopter running the `oss-anti-slop` pack learns that Gittensor pays
// contributors for OSS work like this. Public-safe "earn" wording only (never reward/payout/score).
Expand Down Expand Up @@ -136,6 +138,15 @@ export function buildPredictedGateVerdict(args: {
* focus-manifest path policy and the path-gated pre-merge checks. Absent ⇒ only path-independent pre-merge
* checks are predicted and the note discloses the gap (#11-13/#18). */
changedPaths?: string[] | undefined;
/** #2349: this login's own historical predict-vs-real agreement (predicted_gate_calibration_ledger),
* pre-aggregated by the caller -- this engine package never touches D1. Adjusts ONLY the returned
* readinessScore, strictly AFTER blockers/conclusion/warnings are finalized below, so personalization can
* never flip a hard blocker off or add/remove a finding; it can only narrow/widen the advisory confidence
* number within a fixed clamp (see applyContributorCalibration). `undefined`, `null`, or below the
* cold-start sample threshold ⇒ unweighted baseline, byte-identical to before this field existed. The raw
* calibration numbers are never echoed back in the returned verdict -- only their clamped, already-public
* downstream effect on readinessScore is. */
contributorCalibration?: ContributorCalibrationSignal | null | undefined;
}): PredictedGateVerdict {
const { input, manifest, repo, issues, pullRequests } = args;
const gate = manifest.gate;
Expand Down Expand Up @@ -313,7 +324,9 @@ export function buildPredictedGateVerdict(args: {
conclusion: evaluation.conclusion,
title: sanitizePublicComment(evaluation.title),
summary: sanitizePublicComment(evaluation.summary),
readinessScore: readiness.total,
// #2349: applied strictly downstream of `evaluation` (already finalized above) -- personalization can
// only nudge this number, never the blockers/conclusion/warnings that were just computed.
readinessScore: applyContributorCalibration(readiness.total, args.contributorCalibration),
confirmedContributor: effectiveConfirmedContributor,
blockers: evaluation.blockers.map((finding) => publicSafeFinding(finding)),
warnings: evaluation.warnings.map((finding) => publicSafeFinding(finding)),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
// #2349: personalizes buildPredictedGateVerdict's readiness/confidence output using a contributor/miner's OWN
// historical predict-vs-real agreement (predicted_gate_calibration_ledger, written by
// src/review/predicted-gate-calibration-ledger.ts). Pure and D1-free by design -- this package never touches a
// database; the caller (src/mcp/server.ts, src/api/routes.ts) reads and aggregates the ledger for one login,
// then hands this module a plain, already-computed signal.
//
// SAFETY BOUNDARY (mirrors the design note in both src/review/contributor-calibration.ts and
// src/review/predicted-gate-calibration-ledger.ts): buildPredictedGateVerdict calls applyContributorCalibration
// strictly AFTER evaluateGateCheck has already finalized conclusion/blockers/warnings, and threads through
// ONLY the numeric readinessScore. This function never receives blockers or conclusion, so it is structurally
// incapable of flipping a hard blocker off -- not just clamped by convention, but by construction.
//
// PRIVACY: the calibration signal (sampleSize, agreementRate) is consumed here and never echoed back in
// PredictedGateVerdict -- only its clamped, bounded DOWNSTREAM EFFECT (a shifted readinessScore, itself
// already a public-facing concept) is returned. src/signals/redaction.ts's "no raw per-actor trust signal,
// ever public" boundary is preserved because the raw numbers never reach the output at all.

function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value));
}

/** Below this many historical (prediction, real-decision) pairings, a login's track record is treated as
* cold-start (unweighted baseline) -- too few samples to distinguish signal from noise. */
export const MIN_CALIBRATION_SAMPLES = 5;

/** Maximum points (out of the 0-100 readinessScore scale) personalization may add or subtract. Deliberately
* small relative to the 100-point scale: this is a confidence nudge, not a re-scoring. */
export const MAX_READINESS_ADJUSTMENT = 10;

/** Agreement rate that maps to a zero adjustment -- a coin-flip predict-vs-real track record earns neither a
* bonus nor a penalty. */
const NEUTRAL_AGREEMENT_RATE = 0.5;

export type ContributorCalibrationSignal = {
/** How many (prediction, real-decision) pairings this login has in predicted_gate_calibration_ledger. */
sampleSize: number;
/** Fraction of those pairings where the predicted action matched the real decision. Clamped to [0, 1]
* before use, so a malformed upstream aggregate can never push the adjustment past its own clamp. */
agreementRate: number;
};

/**
* Adjusts a baseline readinessScore by a login's own predict-vs-real calibration history, clamped to
* +/-{@link MAX_READINESS_ADJUSTMENT} points and to the score's own [0, 100] range.
*
* Cold start -- no calibration signal, or fewer than {@link MIN_CALIBRATION_SAMPLES} pairings -- returns the
* baseline completely UNCHANGED: a never-seen (or barely-seen) actor gets no penalty and no bonus. A `null`
* baseline (no readiness score to begin with) stays `null` -- personalization never manufactures a score out
* of nothing.
*/
export function applyContributorCalibration(
baselineReadinessScore: number | null,
calibration: ContributorCalibrationSignal | null | undefined,
): number | null {
if (baselineReadinessScore === null) return null;
if (!calibration || calibration.sampleSize < MIN_CALIBRATION_SAMPLES) return baselineReadinessScore;
const agreementRate = clamp(calibration.agreementRate, 0, 1);
const rawAdjustment = (agreementRate - NEUTRAL_AGREEMENT_RATE) * 2 * MAX_READINESS_ADJUSTMENT;
const adjustment = clamp(rawAdjustment, -MAX_READINESS_ADJUSTMENT, MAX_READINESS_ADJUSTMENT);
return clamp(baselineReadinessScore + adjustment, 0, 100);
}
5 changes: 5 additions & 0 deletions src/api/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,7 @@ import { buildPullRequestReviewability, type PullRequestReviewability } from "..
import { buildLocalBranchAnalysis, findCurrentBranchPullRequest } from "../signals/local-branch";
import { buildSlopAssessment, buildIssueSlopAssessment, SLOP_RUBRIC_MARKDOWN, ISSUE_SLOP_RUBRIC_MARKDOWN } from "../signals/slop";
import { buildPredictedGateVerdict } from "../rules/predicted-gate";
import { computeContributorCalibration } from "../review/predicted-gate-calibration-ledger";
import { buildFocusManifestValidation } from "../services/focus-manifest-validation";
import { buildMaintainerActivationPreview, recommendedAdvisoryActivationSettings } from "../services/maintainer-activation";
import { buildRepoOutcomeCalibration } from "../services/outcome-calibration";
Expand Down Expand Up @@ -3056,6 +3057,9 @@ export function createApp() {
// Pre-submission gate prediction: the SAME advisory + evaluateGateCheck the maintainer PR pipeline
// runs, over a synthetic PR from this local branch, using ONLY the repo's PUBLIC .gittensory.yml gate
// policy (never the maintainer's private DB settings). Self-scoped (requireContributorAccess above).
// #2349: this login's own predict-vs-real track record, personalizing ONLY the returned readinessScore
// (see buildPredictedGateVerdict's contributorCalibration doc comment for the safety boundary).
const contributorCalibration = await computeContributorCalibration(c.env, parsed.data.login);
const predictedGate = buildPredictedGateVerdict({
input: {
repoFullName: parsed.data.repoFullName,
Expand All @@ -3075,6 +3079,7 @@ export function createApp() {
// #11-13/#18: thread the local branch's changed PATHS (already in the request) so the predictor also
// evaluates the focus-manifest path policy + path-gated pre-merge checks, matching the live gate.
...(parsed.data.changedFiles ? { changedPaths: parsed.data.changedFiles.map((file) => file.path) } : {}),
contributorCalibration,
});
const response = { ...analysis, predictedGate, dataQuality: await loadRepoDataQuality(c.env, parsed.data.repoFullName) };
await persistSignal(c.env, "local-branch-analysis", `${parsed.data.login}:${parsed.data.repoFullName}:${parsed.data.branchName ?? parsed.data.headRef ?? "local"}`, parsed.data.repoFullName, response as unknown as Record<string, JsonValue>, analysis.generatedAt);
Expand Down
5 changes: 5 additions & 0 deletions src/mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,7 @@ import { simulateOpenPrPressure, type OpenPrPressureInput } from "../services/op
import { buildFindingTaxonomyDocument, FINDING_TAXONOMY_URI } from "../review/finding-taxonomy";
import { buildEnrichmentAnalyzersTaxonomyDocument, ENRICHMENT_ANALYZERS_URI } from "../review/enrichment-analyzers-taxonomy";
import { recordPredictedGateCall } from "../review/predicted-gate-calls";
import { computeContributorCalibration } from "../review/predicted-gate-calibration-ledger";

type AppContext = Context<{ Bindings: Env }>;
type ToolPayload = {
Expand Down Expand Up @@ -3032,6 +3033,9 @@ export class GittensoryMcp {
// all, so skip the lookup there (keeps the prediction account-free for non-Gittensor adopters).
const pack = manifest.gate.pack ?? "gittensor";
const confirmedContributor = pack === "oss-anti-slop" ? undefined : (await fetchGittensorContributorSnapshot(input.login)) !== null;
// #2349: this login's own predict-vs-real track record, personalizing ONLY the returned readinessScore
// (see buildPredictedGateVerdict's contributorCalibration doc comment for the safety boundary).
const contributorCalibration = await computeContributorCalibration(this.env, input.login);
const verdict = buildPredictedGateVerdict({
input: {
repoFullName,
Expand All @@ -3049,6 +3053,7 @@ export class GittensoryMcp {
issueQuality: issueQuality?.report,
confirmedContributor,
...(input.changedPaths === undefined ? {} : { changedPaths: input.changedPaths }),
contributorCalibration,
});
// #predicted-live-gate-agreement: record this call so a later real gate decision for the same
// (repo, login) can be paired against it (src/review/predicted-gate-agreement.ts). Shared by BOTH
Expand Down
43 changes: 39 additions & 4 deletions src/review/predicted-gate-calibration-ledger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,14 +24,19 @@
// recordContributorGateDecision's own per-commit REPLACE semantics, deliberately: once this ledger records a
// prediction-vs-outcome pairing, that pairing must never change underneath a future calibration reader.
//
// THIS PR ONLY WRITES THE LEDGER. Nothing reads predicted_gate_calibration_ledger yet -- mirrors
// contributor_gate_history's (migrations/0126) own "write-only, nothing reads yet" precedent; the eventual
// #2349 consumer is explicit future work, deliberately deferred so a personalization-adjustment reader gets
// its own focused review.
// READ SIDE (#2349): computeContributorCalibration aggregates ONE login's full history into a plain
// {sampleSize, agreementRate} signal for buildPredictedGateVerdict's personalization input
// (packages/gittensory-engine/src/signals/contributor-calibration.ts). It is intentionally NOT gated by
// isSelfHostedReviewRuntime/isParityAuditEnabled the way the writer above is: the write-side flag controls
// whether this telemetry class is collected at all, but the read is a plain "use whatever rows already
// exist" query -- gating it too would make historical calibration data silently stop being read the moment
// the flag is toggled off, which is a surprising extra restriction nothing here asks for. When the flag was
// never on, the table is simply empty and the read naturally degrades to cold-start.

import { isParityAuditEnabled } from "./parity-wire";
import { isSelfHostedReviewRuntime } from "../selfhost/review-runtime";
import { errorMessage, nowIso } from "../utils/json";
import type { ContributorCalibrationSignal } from "../../packages/gittensory-engine/src/signals/contributor-calibration";

/** The minimal env shape the recorder needs -- mirrors parity-wire.ts's ParityRecorderEnv / contributor-
* calibration.ts's ContributorCalibrationEnv exactly (same gate-accuracy telemetry family, same flag). */
Expand Down Expand Up @@ -124,3 +129,33 @@ export async function recordPredictedGateCalibration(
console.warn(JSON.stringify({ event: "predicted_gate_calibration_write_error", project, message: errorMessage(error).slice(0, 200) }));
}
}

/**
* Aggregate one login's full predicted_gate_calibration_ledger history into the plain signal
* {@link ContributorCalibrationSignal} that buildPredictedGateVerdict's `contributorCalibration` argument
* expects (#2349). A missing/blank login or a read failure both resolve to `null` -- the caller threads that
* straight into buildPredictedGateVerdict, whose cold-start handling treats `null` exactly like "never seen
* this actor": no penalty, no bonus. Best-effort and fail-safe: a read error is swallowed and logged, never
* thrown -- a calibration lookup must never break gate prediction.
*/
export async function computeContributorCalibration(env: PredictedGateCalibrationEnv, login: string | null | undefined): Promise<ContributorCalibrationSignal | null> {
const trimmed = login?.trim();
if (!trimmed) return null;
try {
const row = await env.DB.prepare(
`SELECT COUNT(*) AS sampleSize, COALESCE(AVG(agreed), 0) AS agreementRate
FROM predicted_gate_calibration_ledger
WHERE login = ?`,
)
.bind(trimmed)
.first<{ sampleSize: number; agreementRate: number }>();
// COUNT(*)/AVG(...) with no GROUP BY always returns exactly one row, even over zero matches (COUNT: 0,
// AVG: NULL -> COALESCE: 0) -- .first()'s nullable return type is a TypeScript-level formality here, not
// a reachable runtime case for this query shape.
/* v8 ignore next */
return row ? { sampleSize: row.sampleSize, agreementRate: row.agreementRate } : { sampleSize: 0, agreementRate: 0 };
} catch (error) {
console.warn(JSON.stringify({ event: "contributor_calibration_read_error", message: errorMessage(error).slice(0, 200) }));
return null;
}
}
52 changes: 52 additions & 0 deletions test/unit/mcp-predict-gate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -274,4 +274,56 @@ testExpectations:
expect(await rawAll(env, "SELECT * FROM predicted_gate_calls")).toHaveLength(0);
});
});

describe("personalized calibration wiring (#2349)", () => {
async function seedLedgerRow(env: Env, opts: { login: string; agreed: boolean; pullNumber: number }) {
const now = new Date().toISOString();
await env.DB.prepare(
`INSERT INTO predicted_gate_calibration_ledger (id, login, project, target_id, predicted_action, real_decision, agreed, predicted_at, decided_at, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
)
.bind(crypto.randomUUID(), opts.login, "acme/widgets", `acme/widgets#${opts.pullNumber}`, "merge", opts.agreed ? "merge" : "hold", opts.agreed ? 1 : 0, now, now, now)
.run();
}

it("a login with a weak predict-vs-real track record gets a LOWER readinessScore than an identical fresh login", async () => {
const env = createTestEnv();
await upsertRepositoryFromGitHub(env, { name: "widgets", full_name: "acme/widgets" });
await upsertRepoFocusManifest(env, "acme/widgets", { gate: { pack: "oss-anti-slop", duplicates: "block", linkedIssue: "advisory" } }, "repo_file");
// 6 rows, mostly disagreements -- well above MIN_CALIBRATION_SAMPLES (5), well below neutral (50%).
for (let i = 0; i < 6; i++) await seedLedgerRow(env, { login: "shaky-miner", pullNumber: i, agreed: i === 0 });
const client = await connect(env);

const fresh = await client.callTool({
name: "gittensory_predict_gate",
arguments: { login: "fresh-miner", owner: "acme", repo: "widgets", title: "Add retry to upload client" },
});
const weak = await client.callTool({
name: "gittensory_predict_gate",
arguments: { login: "shaky-miner", owner: "acme", repo: "widgets", title: "Add retry to upload client" },
});

const freshData = fresh.structuredContent as { readinessScore: number };
const weakData = weak.structuredContent as { readinessScore: number };
expect(typeof freshData.readinessScore).toBe("number");
expect(weakData.readinessScore).toBeLessThan(freshData.readinessScore);
});

it("never echoes the raw calibration numbers (sampleSize, agreementRate) back to the caller", async () => {
const env = createTestEnv();
await upsertRepositoryFromGitHub(env, { name: "widgets", full_name: "acme/widgets" });
await upsertRepoFocusManifest(env, "acme/widgets", { gate: { pack: "oss-anti-slop", duplicates: "block", linkedIssue: "advisory" } }, "repo_file");
for (let i = 0; i < 6; i++) await seedLedgerRow(env, { login: "shaky-miner", pullNumber: i, agreed: false });
const client = await connect(env);

const result = await client.callTool({
name: "gittensory_predict_gate",
arguments: { login: "shaky-miner", owner: "acme", repo: "widgets", title: "Add retry to upload client" },
});

const serialized = JSON.stringify(result.structuredContent);
expect(serialized).not.toContain("agreementRate");
expect(serialized).not.toContain("sampleSize");
});
});
});
Loading