diff --git a/apps/gittensory-ui/public/openapi.json b/apps/gittensory-ui/public/openapi.json index fe665240bb..7c4a48160e 100644 --- a/apps/gittensory-ui/public/openapi.json +++ b/apps/gittensory-ui/public/openapi.json @@ -4338,6 +4338,12 @@ }, "issueCredibility": { "type": "number" + }, + "validIssueTokenFloor": { + "type": "number" + }, + "validIssueTokenGatePassed": { + "type": "boolean" } }, "required": [ @@ -4352,7 +4358,9 @@ "openIssueCount", "mergedPrFloor", "validSolvedIssuesFloor", - "issueCredibilityFloor" + "issueCredibilityFloor", + "validIssueTokenFloor", + "validIssueTokenGatePassed" ] }, "effectiveEstimatedScore": { @@ -4376,6 +4384,7 @@ "open_issue_threshold", "merged_pr_history_floor", "issue_discovery_validity_floor", + "valid_issue_token_floor", "credibility_floor", "review_penalty", "metadata_only", @@ -4640,6 +4649,12 @@ }, "issueCredibility": { "type": "number" + }, + "validIssueTokenFloor": { + "type": "number" + }, + "validIssueTokenGatePassed": { + "type": "boolean" } }, "required": [ @@ -4654,7 +4669,9 @@ "openIssueCount", "mergedPrFloor", "validSolvedIssuesFloor", - "issueCredibilityFloor" + "issueCredibilityFloor", + "validIssueTokenFloor", + "validIssueTokenGatePassed" ] }, "effectiveEstimatedScore": { @@ -4678,6 +4695,7 @@ "open_issue_threshold", "merged_pr_history_floor", "issue_discovery_validity_floor", + "valid_issue_token_floor", "credibility_floor", "review_penalty", "metadata_only", @@ -4942,6 +4960,12 @@ }, "issueCredibility": { "type": "number" + }, + "validIssueTokenFloor": { + "type": "number" + }, + "validIssueTokenGatePassed": { + "type": "boolean" } }, "required": [ @@ -4956,7 +4980,9 @@ "openIssueCount", "mergedPrFloor", "validSolvedIssuesFloor", - "issueCredibilityFloor" + "issueCredibilityFloor", + "validIssueTokenFloor", + "validIssueTokenGatePassed" ] }, "effectiveEstimatedScore": { @@ -4980,6 +5006,7 @@ "open_issue_threshold", "merged_pr_history_floor", "issue_discovery_validity_floor", + "valid_issue_token_floor", "credibility_floor", "review_penalty", "metadata_only", @@ -5244,6 +5271,12 @@ }, "issueCredibility": { "type": "number" + }, + "validIssueTokenFloor": { + "type": "number" + }, + "validIssueTokenGatePassed": { + "type": "boolean" } }, "required": [ @@ -5258,7 +5291,9 @@ "openIssueCount", "mergedPrFloor", "validSolvedIssuesFloor", - "issueCredibilityFloor" + "issueCredibilityFloor", + "validIssueTokenFloor", + "validIssueTokenGatePassed" ] }, "effectiveEstimatedScore": { @@ -5282,6 +5317,7 @@ "open_issue_threshold", "merged_pr_history_floor", "issue_discovery_validity_floor", + "valid_issue_token_floor", "credibility_floor", "review_penalty", "metadata_only", @@ -5546,6 +5582,12 @@ }, "issueCredibility": { "type": "number" + }, + "validIssueTokenFloor": { + "type": "number" + }, + "validIssueTokenGatePassed": { + "type": "boolean" } }, "required": [ @@ -5560,7 +5602,9 @@ "openIssueCount", "mergedPrFloor", "validSolvedIssuesFloor", - "issueCredibilityFloor" + "issueCredibilityFloor", + "validIssueTokenFloor", + "validIssueTokenGatePassed" ] }, "effectiveEstimatedScore": { @@ -5584,6 +5628,7 @@ "open_issue_threshold", "merged_pr_history_floor", "issue_discovery_validity_floor", + "valid_issue_token_floor", "credibility_floor", "review_penalty", "metadata_only", @@ -5757,6 +5802,7 @@ "open_issue_threshold", "merged_pr_history_floor", "issue_discovery_validity_floor", + "valid_issue_token_floor", "credibility_floor", "review_penalty", "metadata_only", @@ -6504,6 +6550,12 @@ }, "issueCredibility": { "type": "number" + }, + "validIssueTokenFloor": { + "type": "number" + }, + "validIssueTokenGatePassed": { + "type": "boolean" } }, "required": [ @@ -6518,7 +6570,9 @@ "openIssueCount", "mergedPrFloor", "validSolvedIssuesFloor", - "issueCredibilityFloor" + "issueCredibilityFloor", + "validIssueTokenFloor", + "validIssueTokenGatePassed" ] }, "branchEligibility": { @@ -6599,6 +6653,7 @@ "open_issue_threshold", "merged_pr_history_floor", "issue_discovery_validity_floor", + "valid_issue_token_floor", "credibility_floor", "review_penalty", "metadata_only", @@ -6805,6 +6860,12 @@ }, "issueCredibility": { "type": "number" + }, + "validIssueTokenFloor": { + "type": "number" + }, + "validIssueTokenGatePassed": { + "type": "boolean" } }, "required": [ @@ -6819,7 +6880,9 @@ "openIssueCount", "mergedPrFloor", "validSolvedIssuesFloor", - "issueCredibilityFloor" + "issueCredibilityFloor", + "validIssueTokenFloor", + "validIssueTokenGatePassed" ] }, "effectiveEstimatedScore": { @@ -6843,6 +6906,7 @@ "open_issue_threshold", "merged_pr_history_floor", "issue_discovery_validity_floor", + "valid_issue_token_floor", "credibility_floor", "review_penalty", "metadata_only", diff --git a/src/openapi/schemas.ts b/src/openapi/schemas.ts index 356566f9ac..1fa7a0e50b 100644 --- a/src/openapi/schemas.ts +++ b/src/openapi/schemas.ts @@ -1290,6 +1290,8 @@ const ScoreGatesSchema = z.object({ validSolvedIssues: z.number().optional(), issueCredibilityFloor: z.number(), issueCredibility: z.number().optional(), + validIssueTokenFloor: z.number(), + validIssueTokenGatePassed: z.boolean(), }); const BranchEligibilitySchema = z.object({ @@ -1312,6 +1314,7 @@ const ScoreGateBlockerSchema = z.object({ "open_issue_threshold", "merged_pr_history_floor", "issue_discovery_validity_floor", + "valid_issue_token_floor", "credibility_floor", "review_penalty", "metadata_only", diff --git a/src/scenarios/scenario-summary.ts b/src/scenarios/scenario-summary.ts index 2ccdffab69..e1f1cffb08 100644 --- a/src/scenarios/scenario-summary.ts +++ b/src/scenarios/scenario-summary.ts @@ -81,6 +81,7 @@ const PUBLIC_BLOCKER_TEXT: Partial> = { open_issue_threshold: "Too many open issues exist; closing excess issues would help.", merged_pr_history_floor: "Merged PR history on this repo is below the upstream eligibility floor.", issue_discovery_validity_floor: "Valid solved-issue history or issue credibility is below the upstream issue-discovery floor.", + valid_issue_token_floor: "The planned source change may be too small to count as a valid issue-discovery solve upstream.", credibility_floor: "Contributor credibility evidence is below the expected floor.", review_penalty: "Review churn history may reduce the contribution quality signal.", metadata_only: "Only metadata signals are available; detailed analysis requires full context.", diff --git a/src/scoring/preview.ts b/src/scoring/preview.ts index 08db644604..ac75cd818f 100644 --- a/src/scoring/preview.ts +++ b/src/scoring/preview.ts @@ -115,6 +115,7 @@ export type ScoreGateBlocker = { | "open_issue_threshold" | "merged_pr_history_floor" | "issue_discovery_validity_floor" + | "valid_issue_token_floor" | "credibility_floor" | "review_penalty" | "metadata_only" @@ -209,6 +210,9 @@ export type ScorePreviewResult = { issueCredibilityFloor: number; /** Observed issue-discovery credibility when supplied; absent when unknown. */ issueCredibility?: number | undefined; + validIssueTokenFloor: number; + /** True when the planned PR's source tokens meet MIN_TOKEN_SCORE_FOR_VALID_ISSUE (#808). */ + validIssueTokenGatePassed: boolean; }; branchEligibility: BranchEligibilityResult; effectiveEstimatedScore: number; @@ -248,6 +252,9 @@ export function buildScorePreview(args: { ...(current.scoreEstimate.issueDiscoveryHistoryMultiplier === 0 ? ["Build valid solved-issue history and issue credibility before relying on issue-discovery scoring on this repo."] : []), + ...(current.gates.validIssueTokenGatePassed === false + ? ["Increase meaningful source change size so this linked-issue solve would count toward valid issue-discovery history upstream."] + : []), ...(current.scoreEstimate.credibilityMultiplier < 1 ? ["Build or wait for contributor credibility evidence before relying on this preview."] : []), ...(current.scoreEstimate.reviewPenaltyMultiplier < 1 ? ["Reduce review churn with tighter tests and clearer evidence."] : []), ...(current.scoreEstimate.labelMultiplier <= 1 && Object.keys(args.repo?.registryConfig?.labelMultipliers ?? {}).length > 0 @@ -414,6 +421,8 @@ function computeScoreCore( : validSolvedIssuesObserved >= validSolvedIssuesFloor && issueCredibilityObserved >= issueCredibilityFloor ? 1 : 0; + const validIssueTokenFloor = constant(constants, "MIN_TOKEN_SCORE_FOR_VALID_ISSUE"); + const validIssueTokenGatePassed = !issueDiscoveryRelevant || sourceTokenScore >= validIssueTokenFloor; // Upstream time-decay (#703): mirrors upstream's `scored.time_decay_multiplier` applied to a PR's score. // Opt-in + env-gated (default off). A fresh PR (prAgeHours below the grace period) yields 1.0, so a normal // new-PR preview is unchanged even when enabled — only an aged-PR projection decays. @@ -474,6 +483,8 @@ function computeScoreCore( ...(validSolvedIssuesObserved !== undefined ? { validSolvedIssues: validSolvedIssuesObserved } : {}), issueCredibilityFloor, ...(issueCredibilityObserved !== undefined ? { issueCredibility: issueCredibilityObserved } : {}), + validIssueTokenFloor, + validIssueTokenGatePassed, }, }; } @@ -738,6 +749,15 @@ function blockedByFor(input: ScorePreviewInput, repo: RepositoryRecord | null, c }, ] : []), + ...(!core.gates.validIssueTokenGatePassed + ? [ + { + code: "valid_issue_token_floor" as const, + severity: "context" as const, + detail: `Source token score ${roundScore(nonNegative(input.sourceTokenScore))} is below the upstream valid-issue floor ${core.gates.validIssueTokenFloor}; this solve may not count toward valid issue-discovery history.`, + }, + ] + : []), ...(core.gates.credibilityObserved < core.gates.credibilityFloor ? [ { diff --git a/src/services/score-breakdown.ts b/src/services/score-breakdown.ts index 66dfc49d7c..c53adf287c 100644 --- a/src/services/score-breakdown.ts +++ b/src/services/score-breakdown.ts @@ -99,6 +99,64 @@ function openIssueBreakdown(preview: ScorePreviewResult): ScoreMultiplierBreakdo }; } +function mergedHistoryBreakdown(preview: ScorePreviewResult): ScoreMultiplierBreakdown { + const { mergedHistoryMultiplier } = preview.scoreEstimate; + const { mergedPrFloor, mergedPullRequests } = preview.gates; + const band = bandForMultiplier(mergedHistoryMultiplier); + return { + component: "mergedHistoryMultiplier", + band, + summary: + mergedHistoryMultiplier >= 1 + ? mergedPullRequests !== undefined + ? `Merged PR history (${mergedPullRequests}) meets the upstream floor (${mergedPrFloor}).` + : "Merged PR history is unknown; the upstream merged-history floor is not blocking this preview." + : `Merged PR history (${mergedPullRequests ?? 0}) is below the upstream floor (${mergedPrFloor}).`, + lever: + mergedHistoryMultiplier >= 1 + ? "Continue building clean merged PR history on this repo." + : "Land more merged PRs on this repo before relying on full-strength previews.", + leverageScore: mergedHistoryMultiplier >= 1 ? 12 : 90, + }; +} + +function issueDiscoveryHistoryBreakdown(preview: ScorePreviewResult): ScoreMultiplierBreakdown { + const { issueDiscoveryHistoryMultiplier } = preview.scoreEstimate; + const { validSolvedIssues, validSolvedIssuesFloor, issueCredibility, issueCredibilityFloor } = preview.gates; + const band = bandForMultiplier(issueDiscoveryHistoryMultiplier); + return { + component: "issueDiscoveryHistoryMultiplier", + band, + summary: + issueDiscoveryHistoryMultiplier >= 1 + ? validSolvedIssues !== undefined && issueCredibility !== undefined + ? `Issue-discovery history (${validSolvedIssues} valid solved, credibility ${roundBand(issueCredibility)}) meets upstream floors.` + : "Issue-discovery history is unknown; validity floors are not blocking this preview." + : `Issue-discovery history (${validSolvedIssues ?? 0} valid solved, credibility ${roundBand(issueCredibility ?? 0)}) is below upstream floors (${validSolvedIssuesFloor} valid solved, ${issueCredibilityFloor} credibility).`, + lever: + issueDiscoveryHistoryMultiplier >= 1 + ? "Keep building valid solved-issue history with strong issue credibility." + : "Close more valid solved issues and improve issue credibility before relying on issue-discovery scoring.", + leverageScore: issueDiscoveryHistoryMultiplier >= 1 ? 15 : 88, + }; +} + +function validIssueTokenBreakdown(preview: ScorePreviewResult): ScoreMultiplierBreakdown { + const { validIssueTokenGatePassed, validIssueTokenFloor } = preview.gates; + const band: ScoreMultiplierBand = validIssueTokenGatePassed ? "full" : "reduced"; + return { + component: "validIssueTokenGate", + band, + summary: validIssueTokenGatePassed + ? `Source change size meets the upstream valid-issue token floor (${validIssueTokenFloor}).` + : `Source change size is below the upstream valid-issue token floor (${validIssueTokenFloor}), so this linked solve may not count toward valid issue-discovery history.`, + lever: validIssueTokenGatePassed + ? "Keep the linked solve clearly scoped with substantive source changes." + : "Increase meaningful source changes so the linked issue solve can count as valid issue-discovery history upstream.", + leverageScore: validIssueTokenGatePassed ? 10 : 55, + }; +} + function credibilityBreakdown(preview: ScorePreviewResult): ScoreMultiplierBreakdown { const { credibilityMultiplier } = preview.scoreEstimate; const { credibilityObserved, credibilityFloor } = preview.gates; @@ -235,10 +293,13 @@ export function explainScoreBreakdown(preview: ScorePreviewResult): ScoreBreakdo contributionBonusBreakdown(preview), labelMultiplierBreakdown(preview), issueMultiplierBreakdown(preview), + validIssueTokenBreakdown(preview), credibilityBreakdown(preview), reviewPenaltyBreakdown(preview), openPrBreakdown(preview), openIssueBreakdown(preview), + mergedHistoryBreakdown(preview), + issueDiscoveryHistoryBreakdown(preview), ].map((entry) => ({ ...entry, summary: sanitizePublicComment(entry.summary), diff --git a/src/signals/engine.ts b/src/signals/engine.ts index eddf1a29f7..9d87fc6006 100644 --- a/src/signals/engine.ts +++ b/src/signals/engine.ts @@ -26,6 +26,7 @@ import type { GittensorContributorSnapshot } from "../gittensor/api"; import { nowIso } from "../utils/json"; import { sanitizePublicComment } from "../queue-intelligence"; import { projectLinkedIssueMultiplierForPlannedSolve, type LinkedIssueMultiplierStatus } from "../scoring/preview"; +import { DEFAULT_SCORING_CONSTANTS } from "../scoring/model"; import { hasLocalTestEvidence } from "./test-evidence"; import { isDuplicateClusterWinner } from "./duplicate-winner"; import { PREFLIGHT_LIMITS } from "./preflight-limits"; @@ -161,6 +162,7 @@ export type ContributorProfile = { usdPerDay: number; totals: GittensorContributorSnapshot["totals"]; repositories: GittensorContributorSnapshot["repositories"]; + pullRequests: GittensorContributorSnapshot["pullRequests"]; } | undefined; registeredRepoActivity: { pullRequests: number; @@ -633,6 +635,29 @@ export type IssueQualityReport = { export type IssueDiscoveryLifecycleState = "open" | "closed_not_solved" | "solved" | "valid_solved" | "stale" | "duplicate" | "invalid"; +export type IssueDiscoveryLifecycleOptions = { + minValidIssueTokenScore?: number | undefined; + solverTokenScoreByPr?: ReadonlyMap | undefined; +}; + +/** Build a PR-number → upstream tokenScore index from an official Gittensor contributor snapshot. */ +export function solverTokenScoreIndexFromGittensor(profile: Pick): Map { + const index = new Map(); + for (const pr of profile.gittensor?.pullRequests ?? []) { + if (typeof pr.tokenScore === "number" && Number.isFinite(pr.tokenScore)) { + index.set(pr.number, Math.max(0, pr.tokenScore)); + } + } + return index; +} + +function solverMeetsValidIssueTokenFloor(prNumber: number, options?: IssueDiscoveryLifecycleOptions): boolean { + const score = options?.solverTokenScoreByPr?.get(prNumber); + if (score === undefined) return true; + const floor = options?.minValidIssueTokenScore ?? (DEFAULT_SCORING_CONSTANTS.MIN_TOKEN_SCORE_FOR_VALID_ISSUE as number); + return score >= floor; +} + export type IssueLinkageRecord = { status: "raw" | "plausible" | "validated" | "invalid" | "unavailable"; source: "official_mirror" | "github_cache" | "missing"; @@ -1253,6 +1278,7 @@ function buildGittensorContributorProfile( usdPerDay: snapshot.usdPerDay, totals: snapshot.totals, repositories: snapshot.repositories, + pullRequests: snapshot.pullRequests, }, registeredRepoActivity: { pullRequests: snapshot.totals.pullRequests, @@ -1573,7 +1599,12 @@ export function buildRoleContext(args: { // still gets solved credit from merged PR evidence while self-solved issue loops do not // inflate valid issue-discovery credit. (Contributor-wide recent-merged solver PRs are not // loaded here, so detection uses the cached pull_requests set.) -function cachedSolvedIssueCounts(issues: IssueRecord[], pullRequests: PullRequestRecord[], lane: LaneAdvice): { solvedIssues: number; validSolvedIssues: number } { +function cachedSolvedIssueCounts( + issues: IssueRecord[], + pullRequests: PullRequestRecord[], + lane: LaneAdvice, + lifecycleOptions?: IssueDiscoveryLifecycleOptions, +): { solvedIssues: number; validSolvedIssues: number } { let solvedIssues = 0; let validSolvedIssues = 0; for (const issue of issues) { @@ -1581,7 +1612,7 @@ function cachedSolvedIssueCounts(issues: IssueRecord[], pullRequests: PullReques // Issue linkedPrs can be parsed from contributor-controlled issue body text. Cache-derived // outcome counts only trust solver links carried by the merged PR record itself. - const state = classifyIssueDiscoveryLifecycle({ ...issue, linkedPrs: [] }, pullRequests, [], lane).state; + const state = classifyIssueDiscoveryLifecycle({ ...issue, linkedPrs: [] }, pullRequests, [], lane, undefined, lifecycleOptions).state; if (state === "valid_solved") { validSolvedIssues += 1; solvedIssues += 1; @@ -1638,7 +1669,10 @@ export function buildContributorOutcomeHistory(args: { // Like every field above, issue-discovery solved counts fall back to cache (the issue // lifecycle), not a literal 0, when official Gittensor data is absent for this repo. const laneAdvice = buildLaneAdvice(repo, repoFullName); - const cachedDiscovery = cachedSolvedIssueCounts(cachedIssues, cachedPrs, laneAdvice); + const lifecycleOptions: IssueDiscoveryLifecycleOptions = { + solverTokenScoreByPr: solverTokenScoreIndexFromGittensor(args.profile), + }; + const cachedDiscovery = cachedSolvedIssueCounts(cachedIssues, cachedPrs, laneAdvice, lifecycleOptions); const solvedIssues = official?.solvedIssues ?? cachedDiscovery.solvedIssues; const validSolvedIssues = official?.validSolvedIssues ?? cachedDiscovery.validSolvedIssues; const roleContext = buildRoleContext({ login: args.login, repo, repoFullName, pullRequests: args.pullRequests, issues: args.issues, profile: args.profile }); @@ -2957,6 +2991,7 @@ export function buildIssueDiscoveryLifecycleReport( pullRequests: PullRequestRecord[], fullName: string, recentMergedPullRequests: RecentMergedPullRequestRecord[] = [], + lifecycleOptions?: IssueDiscoveryLifecycleOptions, ): IssueDiscoveryLifecycleReport { const lane = buildLaneAdvice(repo, fullName); // One-time PR-by-issue index so each per-issue classification is an O(1) lookup, not a full PR rescan. @@ -2966,7 +3001,7 @@ export function buildIssueDiscoveryLifecycleReport( }; const states = issues .slice(0, 300) - .map((issue) => classifyIssueDiscoveryLifecycle(issue, pullRequests, recentMergedPullRequests, lane, linkedIndex)) + .map((issue) => classifyIssueDiscoveryLifecycle(issue, pullRequests, recentMergedPullRequests, lane, linkedIndex, lifecycleOptions)) .sort((left, right) => lifecycleRank(left.state) - lifecycleRank(right.state) || left.number - right.number); return { repoFullName: fullName, @@ -3328,12 +3363,14 @@ function classifyIssueDiscoveryLifecycle( recentMergedPullRequests: RecentMergedPullRequestRecord[], lane: LaneAdvice, linkedIndex?: { open: Map; merged: Map }, + lifecycleOptions?: IssueDiscoveryLifecycleOptions, ): IssueDiscoveryLifecycleReport["states"][number] { // With a prebuilt index (the per-repo lifecycle report) look up this issue's linked PRs in O(1); ad-hoc // single-issue callers pass no index and fall back to the original filter. Both yield array-order results. const linkedOpenPrs = linkedIndex ? (linkedIndex.open.get(issue.number) ?? []) : pullRequests.filter((pr) => pr.linkedIssues.includes(issue.number)); const linkedMergedPrs = linkedIndex ? (linkedIndex.merged.get(issue.number) ?? []) : recentMergedPullRequests.filter((pr) => pr.linkedIssues.includes(issue.number)); const mergedSolverPrs = [...linkedOpenPrs.filter((pr) => pr.mergedAt || pr.state === "merged"), ...linkedMergedPrs]; + const tokenQualifiedSolverPrs = mergedSolverPrs.filter((pr) => solverMeetsValidIssueTokenFloor(pr.number, lifecycleOptions)); const solvedByPullRequests = [...new Set(mergedSolverPrs.map((pr) => pr.number))].sort((left, right) => left - right); const issueAuthorLogin = issue.authorLogin; const selfSolvedLoop = Boolean(issueAuthorLogin && mergedSolverPrs.length > 0 && mergedSolverPrs.every((pr) => sameLogin(pr.authorLogin, issueAuthorLogin))); @@ -3341,12 +3378,16 @@ function classifyIssueDiscoveryLifecycle( const stale = daysSince(issue.updatedAt ?? issue.createdAt) > 90; const duplicate = labels.some((label) => /duplicate/.test(label)); const invalid = labels.some((label) => /invalid|wontfix|not planned|won't fix/.test(label)); + const tokenFloorDowngrade = + mergedSolverPrs.length > 0 && + tokenQualifiedSolverPrs.length === 0 && + mergedSolverPrs.some((pr) => lifecycleOptions?.solverTokenScoreByPr?.has(pr.number)); const state: IssueDiscoveryLifecycleState = duplicate ? "duplicate" : invalid ? "invalid" - : solvedByPullRequests.length > 0 - ? (lane.lane === "issue_discovery" || lane.lane === "split") && !selfSolvedLoop + : mergedSolverPrs.length > 0 + ? (lane.lane === "issue_discovery" || lane.lane === "split") && !selfSolvedLoop && tokenQualifiedSolverPrs.length > 0 ? "valid_solved" : "solved" : issue.state !== "open" @@ -3358,6 +3399,9 @@ function classifyIssueDiscoveryLifecycle( ...(duplicate ? ["Issue carries duplicate labeling."] : []), ...(invalid ? ["Issue carries invalid or not-planned labeling."] : []), ...(solvedByPullRequests.length > 0 ? [`Linked solver PR(s): ${solvedByPullRequests.map((number) => `#${number}`).join(", ")}.`] : []), + ...(tokenFloorDowngrade + ? ["Linked solver PR token score is below the upstream valid-issue floor; cache treats this as solved but not valid issue-discovery evidence."] + : []), ...(selfSolvedLoop ? ["Linked solver PR author matches the issue reporter; cache treats this as solved but not valid issue-discovery evidence."] : []), ...(issue.state !== "open" && solvedByPullRequests.length === 0 ? ["Issue is closed without cached solver PR evidence."] : []), ...(stale && issue.state === "open" ? ["Issue is stale in cached metadata."] : []), diff --git a/test/unit/score-breakdown.test.ts b/test/unit/score-breakdown.test.ts index 5f82c5740b..3c32973b5d 100644 --- a/test/unit/score-breakdown.test.ts +++ b/test/unit/score-breakdown.test.ts @@ -51,6 +51,20 @@ const repo: RepositoryRecord = { }, }; +const snapshot808: ScoringModelSnapshotRecord = { + ...snapshot, + constants: { + ...snapshot.constants, + OPEN_ISSUE_SPAM_BASE_THRESHOLD: 2, + OPEN_ISSUE_SPAM_TOKEN_SCORE_PER_SLOT: 300, + MAX_OPEN_ISSUE_THRESHOLD: 30, + MIN_VALID_MERGED_PRS: 3, + MIN_VALID_SOLVED_ISSUES: 3, + MIN_ISSUE_CREDIBILITY: 0.8, + MIN_TOKEN_SCORE_FOR_VALID_ISSUE: 5, + }, +}; + describe("explainScoreBreakdown", () => { it("explains each multiplier with a concrete improvement lever", () => { const preview = buildScorePreview({ @@ -79,10 +93,13 @@ describe("explainScoreBreakdown", () => { "contributionBonus", "labelMultiplier", "issueMultiplier", + "validIssueTokenGate", "credibilityMultiplier", "reviewPenaltyMultiplier", "openPrMultiplier", "openIssueMultiplier", + "mergedHistoryMultiplier", + "issueDiscoveryHistoryMultiplier", ]), ); for (const component of breakdown.components) { @@ -303,4 +320,148 @@ describe("explainScoreBreakdown", () => { expect(breakdown.components.find((entry) => entry.component === "credibilityMultiplier")).toMatchObject({ band: "blocked" }); expect(breakdown.components.find((entry) => entry.component === "openPrMultiplier")).toMatchObject({ band: "blocked" }); }); + + it("explains the valid-issue token floor for linked-issue previews (#808)", () => { + const preview = buildScorePreview({ + repo, + snapshot, + input: { + repoFullName: repo.fullName, + sourceTokenScore: 2, + totalTokenScore: 40, + sourceLines: 40, + openPrCount: 0, + credibility: 1, + linkedIssueMode: "standard", + linkedIssueContext: { status: "raw", source: "github_cache", issueNumbers: [12] }, + }, + }); + const breakdown = explainScoreBreakdown(preview); + expect(breakdown.components.find((entry) => entry.component === "validIssueTokenGate")).toMatchObject({ + band: "reduced", + summary: expect.stringMatching(/valid-issue token floor/i), + }); + }); + + it("explains #808 history and spam multiplier branches in score breakdown", () => { + const issueDiscoveryRepo: RepositoryRecord = { + ...repo, + registryConfig: { ...repo.registryConfig!, issueDiscoveryShare: 0.25 }, + }; + const baseInput = { + repoFullName: issueDiscoveryRepo.fullName, + sourceTokenScore: 60, + totalTokenScore: 90, + sourceLines: 50, + openPrCount: 0, + credibility: 1, + linkedIssueMode: "standard" as const, + linkedIssueContext: { status: "raw" as const, source: "github_cache" as const, issueNumbers: [12] }, + }; + + const healthy = buildScorePreview({ + repo: issueDiscoveryRepo, + snapshot: snapshot808, + input: { + ...baseInput, + sourceTokenScore: 8, + openIssueCount: 2, + mergedPullRequests: 4, + validSolvedIssues: 4, + issueCredibility: 0.9, + }, + }); + const healthyBreakdown = explainScoreBreakdown(healthy); + expect(healthyBreakdown.components.find((entry) => entry.component === "validIssueTokenGate")).toMatchObject({ + band: "full", + summary: expect.stringMatching(/meets the upstream valid-issue token floor/i), + lever: expect.stringMatching(/substantive source changes/i), + }); + expect(healthyBreakdown.components.find((entry) => entry.component === "openIssueMultiplier")).toMatchObject({ + band: "full", + summary: expect.stringMatching(/within the current allowance/i), + }); + expect(healthyBreakdown.components.find((entry) => entry.component === "mergedHistoryMultiplier")).toMatchObject({ + band: "full", + summary: expect.stringMatching(/Merged PR history \(4\) meets/i), + }); + expect(healthyBreakdown.components.find((entry) => entry.component === "issueDiscoveryHistoryMultiplier")).toMatchObject({ + band: "full", + summary: expect.stringMatching(/4 valid solved, private context 0.9/i), + }); + + const blocked = buildScorePreview({ + repo: issueDiscoveryRepo, + snapshot: snapshot808, + input: { + ...baseInput, + openIssueCount: 3, + mergedPullRequests: 1, + validSolvedIssues: 1, + issueCredibility: 0.5, + }, + }); + const blockedBreakdown = explainScoreBreakdown(blocked); + expect(blockedBreakdown.components.find((entry) => entry.component === "openIssueMultiplier")).toMatchObject({ + band: "blocked", + summary: expect.stringMatching(/exceeds the current allowance/i), + lever: expect.stringMatching(/close or resolve/i), + }); + expect(blockedBreakdown.components.find((entry) => entry.component === "mergedHistoryMultiplier")).toMatchObject({ + band: "blocked", + summary: expect.stringMatching(/below the upstream floor/i), + lever: expect.stringMatching(/Land more merged PRs/i), + }); + expect(blockedBreakdown.components.find((entry) => entry.component === "issueDiscoveryHistoryMultiplier")).toMatchObject({ + band: "blocked", + summary: "private context", + }); + + const unknownHistory = buildScorePreview({ + repo: issueDiscoveryRepo, + snapshot: snapshot808, + input: { ...baseInput, sourceTokenScore: 8 }, + }); + const unknownBreakdown = explainScoreBreakdown(unknownHistory); + expect(unknownBreakdown.components.find((entry) => entry.component === "mergedHistoryMultiplier")?.summary).toMatch( + /unknown; the upstream merged-history floor is not blocking/i, + ); + expect(unknownBreakdown.components.find((entry) => entry.component === "issueDiscoveryHistoryMultiplier")?.summary).toMatch( + /unknown; validity floors are not blocking/i, + ); + }); + + it("formats blocked history summaries when observed counts are absent (#808)", () => { + const base = buildScorePreview({ + repo, + snapshot: snapshot808, + input: { + repoFullName: repo.fullName, + sourceTokenScore: 40, + totalTokenScore: 60, + sourceLines: 50, + openPrCount: 0, + credibility: 1, + linkedIssueMode: "standard", + linkedIssueContext: { status: "raw", source: "github_cache", issueNumbers: [12] }, + }, + }); + const mergedBlocked = explainScoreBreakdown({ + ...base, + scoreEstimate: { ...base.scoreEstimate, mergedHistoryMultiplier: 0 }, + gates: { ...base.gates, mergedPullRequests: undefined }, + }); + expect(mergedBlocked.components.find((entry) => entry.component === "mergedHistoryMultiplier")?.summary).toMatch( + /Merged PR history \(0\) is below the upstream floor/i, + ); + + const issueDiscoveryBlocked = explainScoreBreakdown({ + ...base, + scoreEstimate: { ...base.scoreEstimate, issueDiscoveryHistoryMultiplier: 0 }, + gates: { ...base.gates, validSolvedIssues: undefined, issueCredibility: undefined }, + }); + expect(issueDiscoveryBlocked.components.find((entry) => entry.component === "issueDiscoveryHistoryMultiplier")?.summary).toBe( + "private context", + ); + }); }); diff --git a/test/unit/scoring.test.ts b/test/unit/scoring.test.ts index bdaded71c8..252b142d8f 100644 --- a/test/unit/scoring.test.ts +++ b/test/unit/scoring.test.ts @@ -1664,6 +1664,68 @@ NOVELTY_BONUS_SCALAR = 3 const unmodeled = findUnmodeledUpstreamConstants(upstreamSource); expect(unmodeled).toEqual([]); }); + + it("valid-issue token floor warns when linked-issue source tokens are below MIN_TOKEN_SCORE_FOR_VALID_ISSUE (#808)", () => { + const issueDiscoveryRepo: RepositoryRecord = { + ...repo, + registryConfig: { ...repo.registryConfig!, issueDiscoveryShare: 0.25 }, + }; + const belowFloor = buildScorePreview({ + repo: issueDiscoveryRepo, + snapshot, + input: { + repoFullName: issueDiscoveryRepo.fullName, + sourceTokenScore: 3, + totalTokenScore: 90, + sourceLines: 50, + openPrCount: 0, + credibility: 1, + mergedPullRequests: 5, + validSolvedIssues: 3, + issueCredibility: 0.9, + linkedIssueMode: "standard", + }, + }); + expect(belowFloor.gates.validIssueTokenFloor).toBe(5); + expect(belowFloor.gates.validIssueTokenGatePassed).toBe(false); + expect(belowFloor.blockedBy.some((b) => b.code === "valid_issue_token_floor")).toBe(true); + expect(belowFloor.blockedBy.find((b) => b.code === "valid_issue_token_floor")?.severity).toBe("context"); + expect(belowFloor.recommendation.actions.some((action) => /valid issue-discovery history/i.test(action))).toBe(true); + + const atFloor = buildScorePreview({ + repo: issueDiscoveryRepo, + snapshot, + input: { + repoFullName: issueDiscoveryRepo.fullName, + sourceTokenScore: 5, + totalTokenScore: 90, + sourceLines: 50, + openPrCount: 0, + credibility: 1, + mergedPullRequests: 5, + validSolvedIssues: 3, + issueCredibility: 0.9, + linkedIssueMode: "standard", + }, + }); + expect(atFloor.gates.validIssueTokenGatePassed).toBe(true); + expect(atFloor.blockedBy.some((b) => b.code === "valid_issue_token_floor")).toBe(false); + + const directPr = buildScorePreview({ + repo, + snapshot, + input: { + repoFullName: repo.fullName, + sourceTokenScore: 1, + totalTokenScore: 90, + sourceLines: 50, + openPrCount: 0, + credibility: 1, + linkedIssueMode: "none", + }, + }); + expect(directPr.gates.validIssueTokenGatePassed).toBe(true); + }); }); describe("upstream time-decay (#703)", () => { diff --git a/test/unit/signals.test.ts b/test/unit/signals.test.ts index a16ed1e508..5a2db1af3f 100644 --- a/test/unit/signals.test.ts +++ b/test/unit/signals.test.ts @@ -30,6 +30,7 @@ import { detectGittensorContributor, isPullRequestInDuplicateCluster, shouldPublishPrIntelligenceComment, + solverTokenScoreIndexFromGittensor, type CollisionReport, } from "../../src/signals/engine"; import { GITTENSOR_HOME_URL } from "../../src/github/footer"; @@ -1320,6 +1321,255 @@ describe("world-class backend signals", () => { expect(changeReport).toMatchObject({ addedRepos: ["owner/added"], removedRepos: ["owner/removed"] }); expect(changeReport.changedRepos[0]?.changes).toEqual(expect.arrayContaining(["label_multipliers changed", "trusted_label_pipeline false -> true"])); }); + + it("downgrades valid_solved to solved when official solver PR token score is below MIN_TOKEN_SCORE_FOR_VALID_ISSUE (#808)", () => { + const issueDiscoveryRepo: RepositoryRecord = { + ...repo, + registryConfig: { ...repo.registryConfig!, issueDiscoveryShare: 1, maintainerCut: 0 }, + }; + const solvedIssue: IssueRecord = { + ...issues[0]!, + number: 44, + title: "Low-token solve", + state: "closed", + labels: ["bug"], + body: "Detailed solved body ".repeat(20), + linkedPrs: [55], + updatedAt: "2026-05-20T00:00:00.000Z", + }; + const solverPr: PullRequestRecord = { + ...pullRequests[0]!, + number: 55, + authorLogin: "solver", + authorAssociation: "NONE", + linkedIssues: [44], + mergedAt: "2026-05-25T00:00:00.000Z", + state: "merged", + }; + const lifecycle = buildIssueDiscoveryLifecycleReport( + issueDiscoveryRepo, + [solvedIssue], + [solverPr], + repo.fullName, + [], + { + minValidIssueTokenScore: 5, + solverTokenScoreByPr: new Map([[55, 2]]), + }, + ); + expect(lifecycle.states[0]).toMatchObject({ number: 44, state: "solved" }); + expect(lifecycle.states[0]?.reasons.join(" ")).toMatch(/valid-issue floor/i); + + const validLifecycle = buildIssueDiscoveryLifecycleReport( + issueDiscoveryRepo, + [solvedIssue], + [solverPr], + repo.fullName, + [], + { + minValidIssueTokenScore: 5, + solverTokenScoreByPr: new Map([[55, 8]]), + }, + ); + expect(validLifecycle.states[0]).toMatchObject({ state: "valid_solved" }); + }); + + it("keeps valid_solved when solver PR token score is absent from the index (#808)", () => { + const issueDiscoveryRepo: RepositoryRecord = { + ...repo, + registryConfig: { ...repo.registryConfig!, issueDiscoveryShare: 1, maintainerCut: 0 }, + }; + const solvedIssue: IssueRecord = { + ...issues[0]!, + number: 45, + title: "Unscored solve", + state: "closed", + labels: ["bug"], + body: "Detailed solved body ".repeat(20), + linkedPrs: [56], + updatedAt: "2026-05-20T00:00:00.000Z", + }; + const solverPr: PullRequestRecord = { + ...pullRequests[0]!, + number: 56, + authorLogin: "solver", + authorAssociation: "NONE", + linkedIssues: [45], + mergedAt: "2026-05-25T00:00:00.000Z", + state: "merged", + }; + const lifecycle = buildIssueDiscoveryLifecycleReport( + issueDiscoveryRepo, + [solvedIssue], + [solverPr], + repo.fullName, + [], + { minValidIssueTokenScore: 5, solverTokenScoreByPr: new Map() }, + ); + expect(lifecycle.states[0]).toMatchObject({ state: "valid_solved" }); + }); + + it("builds a solver token score index from official Gittensor pull request rows (#808)", () => { + const profile = buildContributorProfile( + "miner", + { login: "miner", topLanguages: ["TypeScript"], source: "github" }, + [], + [], + [], + { + source: "gittensor_api", + githubId: "1", + githubUsername: "miner", + isEligible: true, + credibility: 1, + eligibleRepoCount: 1, + issueDiscoveryScore: 0, + issueTokenScore: 0, + issueCredibility: 1, + isIssueEligible: false, + issueEligibleRepoCount: 0, + alphaPerDay: 0, + taoPerDay: 0, + usdPerDay: 0, + totals: { + pullRequests: 1, + mergedPullRequests: 1, + openPullRequests: 0, + closedPullRequests: 0, + openIssues: 0, + closedIssues: 0, + solvedIssues: 1, + validSolvedIssues: 1, + }, + repositories: [], + pullRequests: [{ repoFullName: repo.fullName, number: 9, title: "Fix", state: "merged", score: 10, baseScore: 8, tokenScore: 12 }], + issueLabels: [], + }, + ); + const index = solverTokenScoreIndexFromGittensor(profile); + expect(index.get(9)).toBe(12); + expect(solverTokenScoreIndexFromGittensor({ gittensor: undefined })).toEqual(new Map()); + }); + + it("ignores non-finite token scores and clamps negative values in the solver index (#808)", () => { + const profile = buildContributorProfile( + "miner", + { login: "miner", topLanguages: ["TypeScript"], source: "github" }, + [], + [], + [], + { + source: "gittensor_api", + githubId: "1", + githubUsername: "miner", + isEligible: true, + credibility: 1, + eligibleRepoCount: 1, + issueDiscoveryScore: 0, + issueTokenScore: 0, + issueCredibility: 1, + isIssueEligible: false, + issueEligibleRepoCount: 0, + alphaPerDay: 0, + taoPerDay: 0, + usdPerDay: 0, + totals: { + pullRequests: 3, + mergedPullRequests: 3, + openPullRequests: 0, + closedPullRequests: 0, + openIssues: 0, + closedIssues: 0, + solvedIssues: 0, + validSolvedIssues: 0, + }, + repositories: [], + pullRequests: [ + { repoFullName: repo.fullName, number: 1, title: "Bad", state: "merged", score: 1, baseScore: 1, tokenScore: Number.NaN }, + { repoFullName: repo.fullName, number: 2, title: "Negative", state: "merged", score: 1, baseScore: 1, tokenScore: -4 }, + { repoFullName: repo.fullName, number: 3, title: "Good", state: "merged", score: 1, baseScore: 1, tokenScore: 7 }, + ], + issueLabels: [], + }, + ); + const index = solverTokenScoreIndexFromGittensor(profile); + expect(index.has(1)).toBe(false); + expect(index.get(2)).toBe(0); + expect(index.get(3)).toBe(7); + }); + + it("outcome history counts low-token solver PRs as solved but not valid (#808)", () => { + const issueDiscoveryRepo: RepositoryRecord = { + ...repo, + registryConfig: { ...repo.registryConfig!, issueDiscoveryShare: 1, maintainerCut: 0 }, + }; + const solvedIssue: IssueRecord = { + ...issues[0]!, + number: 44, + title: "Low-token solve", + state: "closed", + authorLogin: "solver", + labels: ["bug"], + body: "Detailed solved body ".repeat(20), + linkedPrs: [], + updatedAt: "2026-05-20T00:00:00.000Z", + }; + const solverPr: PullRequestRecord = { + ...pullRequests[0]!, + number: 55, + authorLogin: "solver", + authorAssociation: "NONE", + linkedIssues: [44], + mergedAt: "2026-05-25T00:00:00.000Z", + state: "merged", + }; + const profile = buildContributorProfile( + "solver", + { login: "solver", topLanguages: ["TypeScript"], source: "github" }, + [solverPr], + [solvedIssue], + [], + { + source: "gittensor_api", + githubId: "2", + githubUsername: "solver", + isEligible: true, + credibility: 1, + eligibleRepoCount: 1, + issueDiscoveryScore: 0, + issueTokenScore: 0, + issueCredibility: 1, + isIssueEligible: false, + issueEligibleRepoCount: 0, + alphaPerDay: 0, + taoPerDay: 0, + usdPerDay: 0, + totals: { + pullRequests: 1, + mergedPullRequests: 1, + openPullRequests: 0, + closedPullRequests: 0, + openIssues: 0, + closedIssues: 1, + solvedIssues: 0, + validSolvedIssues: 0, + }, + repositories: [], + pullRequests: [{ repoFullName: issueDiscoveryRepo.fullName, number: 55, title: "Fix", state: "merged", score: 1, baseScore: 1, tokenScore: 2 }], + issueLabels: [], + }, + ); + const history = buildContributorOutcomeHistory({ + login: "solver", + profile, + repositories: [issueDiscoveryRepo], + pullRequests: [solverPr], + issues: [solvedIssue], + repoStats: [], + }); + const outcome = history.repoOutcomes.find((entry) => entry.repoFullName === issueDiscoveryRepo.fullName); + expect(outcome).toMatchObject({ solvedIssues: 1, validSolvedIssues: 0 }); + }); }); function scoringModelSnapshot(): ScoringModelSnapshotRecord {