diff --git a/packages/gittensory-miner/lib/replay-task-generation.d.ts b/packages/gittensory-miner/lib/replay-task-generation.d.ts index ced38e3253..02cb3941b4 100644 --- a/packages/gittensory-miner/lib/replay-task-generation.d.ts +++ b/packages/gittensory-miner/lib/replay-task-generation.d.ts @@ -72,10 +72,12 @@ export type ReplayTask = { commitT: string | null; contextTexts: string[]; }; - revealed: { - commitCount: number; - groundTruth: unknown; - }; +}; + +export type ReplayScoringKey = { + eligible: true; + commitCount: number; + groundTruth: unknown; }; export function detectForwardReferences( @@ -108,3 +110,8 @@ export function generateReplayTask( context: ForwardRefContext | null | undefined, options: ReplayTaskOptions | null | undefined, ): ReplayTask | ReplayTaskRejected; + +export function generateReplayScoringKey( + candidate: FreezePointCandidate | null | undefined, + options: ReplayTaskOptions | null | undefined, +): ReplayScoringKey | ReplayTaskRejected; diff --git a/packages/gittensory-miner/lib/replay-task-generation.js b/packages/gittensory-miner/lib/replay-task-generation.js index a861c3855f..f121b6b6e1 100644 --- a/packages/gittensory-miner/lib/replay-task-generation.js +++ b/packages/gittensory-miner/lib/replay-task-generation.js @@ -4,9 +4,10 @@ // history on both sides to be worth scoring, and (b) nothing in the frozen context lets a replay run infer // the future by pattern-matching text rather than reasoning. This module selects calibration-worthy freeze // points, scrubs forward references out of the frozen context, tags each point's recency pool, and returns -// the frozen snapshot and the revealed post-T ground truth as *separate* bundles so the replay pipeline never -// holds both at once. Every function here is pure and deterministic — no clock, no randomness, no IO — so a -// given (candidate, context) always yields an identical task. +// the frozen replay task without the revealed post-T ground truth. Scoring data is exposed through a separate +// function so replay execution never has to hold both sides at once. Every function here is pure and +// deterministic — no clock, no randomness, no IO — so a given (candidate, context) always yields an +// identical task. // What a scrubbed-away forward reference is replaced with. A fixed, self-delimiting token so the scrubbed // text stays readable and the substitution is itself deterministic. @@ -168,9 +169,9 @@ export function classifyRecencyPool(candidate, options) { return lastActivityAt >= modelCutoffIso ? "recent" : "older"; } -// One-shot generator. Applies selection, then scrubs and lints the frozen context, then returns the frozen -// snapshot and the revealed post-T ground truth as SEPARATE bundles — never merged — so a caller persists and -// scopes them independently. An ineligible or un-scrubbable candidate is rejected without producing a task. +// One-shot replay generator. Applies selection, then scrubs and lints the frozen context, then returns only +// the frozen replay task. Revealed post-T ground truth is intentionally available only through +// generateReplayScoringKey, so a replay worker/serializer/logger/model call never receives both sides. export function generateReplayTask(candidate, context, options) { const selection = selectFreezePoint(candidate, options?.thresholds); if (!selection.eligible) { @@ -194,9 +195,29 @@ export function generateReplayTask(candidate, context, options) { commitT: typeof candidate?.commitT === "string" ? candidate.commitT : null, contextTexts: scrubbedTexts, }, - revealed: { - commitCount: selection.revealedCommitCount, - groundTruth: candidate?.revealedGroundTruth ?? null, - }, + }; +} + +// Scoring-only accessor. Call this from the isolated scorer path after replay execution has finished; do not +// pass its result to replay workers. It deliberately shares only selection eligibility with generateReplayTask +// and never carries frozen context. +// +// IMPORTANT: `eligible: true` here means only that selectFreezePoint accepted the candidate -- it does NOT +// mean generateReplayTask would also produce a usable frozen task for it. generateReplayTask can still reject +// a selection-eligible candidate afterward (`rejected: "unscrubbable_forward_reference"`, from +// lintFrozenContext), because scrub/lint eligibility is about the FROZEN CONTEXT TEXT, which this function +// never touches -- it only reveals commitCount/groundTruth, so lint/scrub has nothing to check here. A caller +// must not assume a scoring key implies a replay task was ever generated for the same candidate; check +// generateReplayTask's own result independently before treating the two as a matched pair. +export function generateReplayScoringKey(candidate, options) { + const selection = selectFreezePoint(candidate, options?.thresholds); + if (!selection.eligible) { + return { eligible: false, rejected: "selection", reasons: selection.reasons }; + } + + return { + eligible: true, + commitCount: selection.revealedCommitCount, + groundTruth: candidate?.revealedGroundTruth ?? null, }; } diff --git a/test/unit/miner-replay-task-generation.test.ts b/test/unit/miner-replay-task-generation.test.ts index 8fe77a2e50..4d2a8c72d6 100644 --- a/test/unit/miner-replay-task-generation.test.ts +++ b/test/unit/miner-replay-task-generation.test.ts @@ -4,6 +4,7 @@ import { RECENCY_POOLS, classifyRecencyPool, detectForwardReferences, + generateReplayScoringKey, generateReplayTask, lintFrozenContext, scrubForwardReferences, @@ -137,7 +138,7 @@ describe("gittensory-miner leakage-safe replay task generation (#3011)", () => { modelCutoffIso: "2026-01-01T00:00:00Z", }; - it("produces a scrubbed frozen bundle and a SEPARATE revealed bundle for an eligible clean point", () => { + it("produces only a scrubbed frozen bundle for an eligible clean point", () => { const task = generateReplayTask( { ...eligible, frozenContextTexts: ["intro references #300 and old #12"] }, CONTEXT, @@ -150,8 +151,8 @@ describe("gittensory-miner leakage-safe replay task generation (#3011)", () => { commitT: "abc1234def", contextTexts: [`intro references ${FORWARD_REF_PLACEHOLDER} and old #12`], }); - // Ground truth lives only on the revealed side — never merged into the frozen bundle. - expect(task.revealed).toEqual({ commitCount: 10, groundTruth: { merged: true, approach: "refactor" } }); + expect(task).not.toHaveProperty("revealed"); + expect(JSON.stringify(task)).not.toContain("refactor"); expect(task.frozen).not.toHaveProperty("groundTruth"); }); @@ -187,5 +188,39 @@ describe("gittensory-miner leakage-safe replay task generation (#3011)", () => { generateReplayTask(input, CONTEXT, options), ); }); + + it("exposes revealed ground truth only through the scoring-only accessor", () => { + const replayTask = generateReplayTask( + { ...eligible, frozenContextTexts: ["intro references #300"] }, + CONTEXT, + options, + ); + const scoringKey = generateReplayScoringKey( + { ...eligible, frozenContextTexts: ["intro references #300"] }, + options, + ); + + if (!replayTask.eligible) { + throw new Error(`expected eligible replay task, got ${JSON.stringify(replayTask)}`); + } + if (!scoringKey.eligible) { + throw new Error(`expected eligible scoring key, got ${JSON.stringify(scoringKey)}`); + } + expect(replayTask).not.toHaveProperty("revealed"); + expect(scoringKey).toEqual({ + commitCount: 10, + eligible: true, + groundTruth: { merged: true, approach: "refactor" }, + }); + expect(scoringKey).not.toHaveProperty("frozen"); + }); + + it("rejects a scoring key for a candidate that fails selection", () => { + expect(generateReplayScoringKey({ priorCommitCount: 2, revealedCommitCount: 1 }, options)).toEqual({ + eligible: false, + rejected: "selection", + reasons: ["insufficient_prior_history", "insufficient_revealed_history"], + }); + }); }); });