@loopover/engine 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (272) hide show
  1. package/CHANGELOG.md +94 -0
  2. package/LICENSE +661 -0
  3. package/README.md +676 -0
  4. package/dist/advisory/gate-advisory.d.ts +151 -0
  5. package/dist/advisory/gate-advisory.js +512 -0
  6. package/dist/ai-policy-map.d.ts +97 -0
  7. package/dist/ai-policy-map.js +377 -0
  8. package/dist/ams-policy-spec.d.ts +56 -0
  9. package/dist/ams-policy-spec.js +165 -0
  10. package/dist/calibration-dashboard.d.ts +23 -0
  11. package/dist/calibration-dashboard.js +65 -0
  12. package/dist/calibration-trend.d.ts +40 -0
  13. package/dist/calibration-trend.js +60 -0
  14. package/dist/contributor-fit.d.ts +24 -0
  15. package/dist/contributor-fit.js +50 -0
  16. package/dist/discovery-index-contract.d.ts +74 -0
  17. package/dist/discovery-index-contract.js +184 -0
  18. package/dist/discovery-soft-claim.d.ts +35 -0
  19. package/dist/discovery-soft-claim.js +50 -0
  20. package/dist/duplicate-winner.d.ts +57 -0
  21. package/dist/duplicate-winner.js +98 -0
  22. package/dist/feasibility.d.ts +27 -0
  23. package/dist/feasibility.js +86 -0
  24. package/dist/finding-severity-calibration.d.ts +116 -0
  25. package/dist/finding-severity-calibration.js +457 -0
  26. package/dist/fleet-run-manifest.d.ts +41 -0
  27. package/dist/fleet-run-manifest.js +151 -0
  28. package/dist/focus-manifest/guidance.d.ts +21 -0
  29. package/dist/focus-manifest/guidance.js +222 -0
  30. package/dist/focus-manifest.d.ts +826 -0
  31. package/dist/focus-manifest.js +2730 -0
  32. package/dist/gate-verdict-calibration.d.ts +111 -0
  33. package/dist/gate-verdict-calibration.js +411 -0
  34. package/dist/github/constants.d.ts +1 -0
  35. package/dist/github/constants.js +1 -0
  36. package/dist/github/sanitize-public-comment.d.ts +1 -0
  37. package/dist/github/sanitize-public-comment.js +26 -0
  38. package/dist/goal-model.d.ts +7 -0
  39. package/dist/goal-model.js +87 -0
  40. package/dist/governor/action-mode.d.ts +55 -0
  41. package/dist/governor/action-mode.js +74 -0
  42. package/dist/governor/budget-cap.d.ts +49 -0
  43. package/dist/governor/budget-cap.js +34 -0
  44. package/dist/governor/chokepoint.d.ts +58 -0
  45. package/dist/governor/chokepoint.js +269 -0
  46. package/dist/governor/kill-switch.d.ts +38 -0
  47. package/dist/governor/kill-switch.js +62 -0
  48. package/dist/governor/rate-limit.d.ts +40 -0
  49. package/dist/governor/rate-limit.js +63 -0
  50. package/dist/governor/reputation-throttle.d.ts +50 -0
  51. package/dist/governor/reputation-throttle.js +94 -0
  52. package/dist/governor/run-halt.d.ts +39 -0
  53. package/dist/governor/run-halt.js +83 -0
  54. package/dist/governor/self-plagiarism.d.ts +47 -0
  55. package/dist/governor/self-plagiarism.js +200 -0
  56. package/dist/governor/write-rate-limit.d.ts +52 -0
  57. package/dist/governor/write-rate-limit.js +132 -0
  58. package/dist/governor-ledger.d.ts +25 -0
  59. package/dist/governor-ledger.js +91 -0
  60. package/dist/index.d.ts +99 -0
  61. package/dist/index.js +120 -0
  62. package/dist/issue-plan-decomposition.d.ts +18 -0
  63. package/dist/issue-plan-decomposition.js +56 -0
  64. package/dist/issue-rag-query.d.ts +12 -0
  65. package/dist/issue-rag-query.js +35 -0
  66. package/dist/local-scorer.d.ts +34 -0
  67. package/dist/local-scorer.js +36 -0
  68. package/dist/metadata-best-min-score.d.ts +9 -0
  69. package/dist/metadata-best-min-score.js +9 -0
  70. package/dist/metadata-best-pick.d.ts +9 -0
  71. package/dist/metadata-best-pick.js +9 -0
  72. package/dist/metadata-min-score.d.ts +9 -0
  73. package/dist/metadata-min-score.js +13 -0
  74. package/dist/metadata-top-min-score.d.ts +9 -0
  75. package/dist/metadata-top-min-score.js +13 -0
  76. package/dist/metadata-top-pick.d.ts +9 -0
  77. package/dist/metadata-top-pick.js +13 -0
  78. package/dist/miner/acceptance-criteria.d.ts +43 -0
  79. package/dist/miner/acceptance-criteria.js +77 -0
  80. package/dist/miner/agent-sdk-driver.d.ts +39 -0
  81. package/dist/miner/agent-sdk-driver.js +162 -0
  82. package/dist/miner/attempt-log.d.ts +29 -0
  83. package/dist/miner/attempt-log.js +100 -0
  84. package/dist/miner/attempt-metering.d.ts +39 -0
  85. package/dist/miner/attempt-metering.js +67 -0
  86. package/dist/miner/cli-subprocess-driver.d.ts +68 -0
  87. package/dist/miner/cli-subprocess-driver.js +272 -0
  88. package/dist/miner/coding-agent-driver.d.ts +33 -0
  89. package/dist/miner/coding-agent-driver.js +36 -0
  90. package/dist/miner/coding-agent-invoke.d.ts +11 -0
  91. package/dist/miner/coding-agent-invoke.js +80 -0
  92. package/dist/miner/coding-agent-mode.d.ts +25 -0
  93. package/dist/miner/coding-agent-mode.js +43 -0
  94. package/dist/miner/driver-factory.d.ts +82 -0
  95. package/dist/miner/driver-factory.js +169 -0
  96. package/dist/miner/harness-submission-trigger.d.ts +37 -0
  97. package/dist/miner/harness-submission-trigger.js +46 -0
  98. package/dist/miner/iterate-loop.d.ts +97 -0
  99. package/dist/miner/iterate-loop.js +278 -0
  100. package/dist/miner/iterate-policy.d.ts +98 -0
  101. package/dist/miner/iterate-policy.js +98 -0
  102. package/dist/miner/lint-guard.d.ts +50 -0
  103. package/dist/miner/lint-guard.js +68 -0
  104. package/dist/miner/local-write-tools.d.ts +81 -0
  105. package/dist/miner/local-write-tools.js +113 -0
  106. package/dist/miner/loop-reentry-policy.d.ts +36 -0
  107. package/dist/miner/loop-reentry-policy.js +53 -0
  108. package/dist/miner/repo-map.d.ts +51 -0
  109. package/dist/miner/repo-map.js +268 -0
  110. package/dist/miner/self-review-adapter.d.ts +106 -0
  111. package/dist/miner/self-review-adapter.js +83 -0
  112. package/dist/miner/submission-gate.d.ts +40 -0
  113. package/dist/miner/submission-gate.js +89 -0
  114. package/dist/miner/worktree-allocator.d.ts +59 -0
  115. package/dist/miner/worktree-allocator.js +60 -0
  116. package/dist/miner/worktree-pool.d.ts +55 -0
  117. package/dist/miner/worktree-pool.js +65 -0
  118. package/dist/miner-goal-lane-fit.d.ts +18 -0
  119. package/dist/miner-goal-lane-fit.js +76 -0
  120. package/dist/miner-goal-spec.d.ts +139 -0
  121. package/dist/miner-goal-spec.js +281 -0
  122. package/dist/miner-prediction-metrics.d.ts +16 -0
  123. package/dist/miner-prediction-metrics.js +61 -0
  124. package/dist/miner-telemetry.d.ts +32 -0
  125. package/dist/miner-telemetry.js +87 -0
  126. package/dist/objective-anchor.d.ts +125 -0
  127. package/dist/objective-anchor.js +401 -0
  128. package/dist/opportunity-competition.d.ts +6 -0
  129. package/dist/opportunity-competition.js +29 -0
  130. package/dist/opportunity-freshness.d.ts +18 -0
  131. package/dist/opportunity-freshness.js +55 -0
  132. package/dist/opportunity-metadata.d.ts +53 -0
  133. package/dist/opportunity-metadata.js +204 -0
  134. package/dist/opportunity-ranker.d.ts +46 -0
  135. package/dist/opportunity-ranker.js +74 -0
  136. package/dist/pairwise-calibration.d.ts +49 -0
  137. package/dist/pairwise-calibration.js +106 -0
  138. package/dist/phase7-calibration-loop.d.ts +136 -0
  139. package/dist/phase7-calibration-loop.js +437 -0
  140. package/dist/plan-blocked.d.ts +6 -0
  141. package/dist/plan-blocked.js +24 -0
  142. package/dist/plan-completed.d.ts +5 -0
  143. package/dist/plan-completed.js +6 -0
  144. package/dist/plan-completion.d.ts +6 -0
  145. package/dist/plan-completion.js +7 -0
  146. package/dist/plan-empty.d.ts +5 -0
  147. package/dist/plan-empty.js +6 -0
  148. package/dist/plan-export.d.ts +27 -0
  149. package/dist/plan-export.js +86 -0
  150. package/dist/plan-failure.d.ts +5 -0
  151. package/dist/plan-failure.js +6 -0
  152. package/dist/plan-overall-status.d.ts +6 -0
  153. package/dist/plan-overall-status.js +25 -0
  154. package/dist/plan-pending.d.ts +5 -0
  155. package/dist/plan-pending.js +6 -0
  156. package/dist/plan-progress-complete.d.ts +7 -0
  157. package/dist/plan-progress-complete.js +10 -0
  158. package/dist/plan-ready.d.ts +6 -0
  159. package/dist/plan-ready.js +12 -0
  160. package/dist/plan-running.d.ts +5 -0
  161. package/dist/plan-running.js +6 -0
  162. package/dist/plan-skipped.d.ts +5 -0
  163. package/dist/plan-skipped.js +6 -0
  164. package/dist/plan-step-count.d.ts +5 -0
  165. package/dist/plan-step-count.js +6 -0
  166. package/dist/plan-step-stats.d.ts +5 -0
  167. package/dist/plan-step-stats.js +6 -0
  168. package/dist/plan-templates.d.ts +25 -0
  169. package/dist/plan-templates.js +107 -0
  170. package/dist/plan-terminated.d.ts +6 -0
  171. package/dist/plan-terminated.js +9 -0
  172. package/dist/portfolio/non-convergence.d.ts +37 -0
  173. package/dist/portfolio/non-convergence.js +57 -0
  174. package/dist/portfolio/queue.d.ts +30 -0
  175. package/dist/portfolio/queue.js +150 -0
  176. package/dist/predicted-gate.d.ts +103 -0
  177. package/dist/predicted-gate.js +218 -0
  178. package/dist/prompt-packet.d.ts +11 -0
  179. package/dist/prompt-packet.js +34 -0
  180. package/dist/ranked-opportunity-best-min-score.d.ts +8 -0
  181. package/dist/ranked-opportunity-best-min-score.js +9 -0
  182. package/dist/ranked-opportunity-best-pick.d.ts +8 -0
  183. package/dist/ranked-opportunity-best-pick.js +9 -0
  184. package/dist/ranked-opportunity-min-score.d.ts +8 -0
  185. package/dist/ranked-opportunity-min-score.js +13 -0
  186. package/dist/ranked-opportunity-top-min-score.d.ts +8 -0
  187. package/dist/ranked-opportunity-top-min-score.js +13 -0
  188. package/dist/review/advisory-ai-routing-config.d.ts +8 -0
  189. package/dist/review/advisory-ai-routing-config.js +40 -0
  190. package/dist/review/check-names.d.ts +1 -0
  191. package/dist/review/check-names.js +1 -0
  192. package/dist/review/cla-check.d.ts +49 -0
  193. package/dist/review/cla-check.js +71 -0
  194. package/dist/review/diff-file-priority.d.ts +1 -0
  195. package/dist/review/diff-file-priority.js +12 -0
  196. package/dist/review/enrichment-analyzer-names.d.ts +3 -0
  197. package/dist/review/enrichment-analyzer-names.js +63 -0
  198. package/dist/review/guardrail-config.d.ts +10 -0
  199. package/dist/review/guardrail-config.js +62 -0
  200. package/dist/review/linked-issue-hard-rules-config.d.ts +4 -0
  201. package/dist/review/linked-issue-hard-rules-config.js +79 -0
  202. package/dist/review/linked-issue-label-propagation.d.ts +9 -0
  203. package/dist/review/linked-issue-label-propagation.js +114 -0
  204. package/dist/review/pre-merge-checks.d.ts +28 -0
  205. package/dist/review/pre-merge-checks.js +66 -0
  206. package/dist/review/review-thread-findings.d.ts +1 -0
  207. package/dist/review/review-thread-findings.js +1 -0
  208. package/dist/review/safe-url.d.ts +5 -0
  209. package/dist/review/safe-url.js +137 -0
  210. package/dist/review/screenshot-table-gate.d.ts +87 -0
  211. package/dist/review/screenshot-table-gate.js +340 -0
  212. package/dist/review/unlinked-issue-guardrail-config.d.ts +9 -0
  213. package/dist/review/unlinked-issue-guardrail-config.js +44 -0
  214. package/dist/reviewer-consensus-calibration.d.ts +113 -0
  215. package/dist/reviewer-consensus-calibration.js +493 -0
  216. package/dist/reward-risk.d.ts +223 -0
  217. package/dist/reward-risk.js +618 -0
  218. package/dist/scoring/label-match.d.ts +4 -0
  219. package/dist/scoring/label-match.js +145 -0
  220. package/dist/scoring/model.d.ts +31 -0
  221. package/dist/scoring/model.js +157 -0
  222. package/dist/scoring/pending-pr-scenarios.d.ts +44 -0
  223. package/dist/scoring/pending-pr-scenarios.js +145 -0
  224. package/dist/scoring/preview.d.ts +246 -0
  225. package/dist/scoring/preview.js +1108 -0
  226. package/dist/scoring/types.d.ts +131 -0
  227. package/dist/scoring/types.js +7 -0
  228. package/dist/settings/auto-close-exempt.d.ts +10 -0
  229. package/dist/settings/auto-close-exempt.js +58 -0
  230. package/dist/settings/autonomy.d.ts +33 -0
  231. package/dist/settings/autonomy.js +80 -0
  232. package/dist/settings/command-authorization.d.ts +49 -0
  233. package/dist/settings/command-authorization.js +222 -0
  234. package/dist/settings/contributor-blacklist.d.ts +17 -0
  235. package/dist/settings/contributor-blacklist.js +86 -0
  236. package/dist/settings/moderation-rules.d.ts +51 -0
  237. package/dist/settings/moderation-rules.js +104 -0
  238. package/dist/settings/pr-type-label.d.ts +70 -0
  239. package/dist/settings/pr-type-label.js +149 -0
  240. package/dist/signals/change-guardrail.d.ts +49 -0
  241. package/dist/signals/change-guardrail.js +164 -0
  242. package/dist/signals/check-summary.d.ts +9 -0
  243. package/dist/signals/check-summary.js +12 -0
  244. package/dist/signals/contributor-calibration.d.ts +23 -0
  245. package/dist/signals/contributor-calibration.js +47 -0
  246. package/dist/signals/duplicate-winner.d.ts +9 -0
  247. package/dist/signals/duplicate-winner.js +9 -0
  248. package/dist/signals/path-matchers.d.ts +36 -0
  249. package/dist/signals/path-matchers.js +371 -0
  250. package/dist/signals/predicted-gate-engine.d.ts +62 -0
  251. package/dist/signals/predicted-gate-engine.js +895 -0
  252. package/dist/signals/preflight-limits.d.ts +14 -0
  253. package/dist/signals/preflight-limits.js +14 -0
  254. package/dist/signals/slop.d.ts +56 -0
  255. package/dist/signals/slop.js +324 -0
  256. package/dist/signals/test-evidence.d.ts +25 -0
  257. package/dist/signals/test-evidence.js +108 -0
  258. package/dist/subprocess-env.d.ts +23 -0
  259. package/dist/subprocess-env.js +74 -0
  260. package/dist/track-record-summary.d.ts +92 -0
  261. package/dist/track-record-summary.js +301 -0
  262. package/dist/types/manifest-deps-types.d.ts +514 -0
  263. package/dist/types/manifest-deps-types.js +4 -0
  264. package/dist/types/predicted-gate-types.d.ts +338 -0
  265. package/dist/types/predicted-gate-types.js +3 -0
  266. package/dist/types/reward-risk-types.d.ts +319 -0
  267. package/dist/types/reward-risk-types.js +11 -0
  268. package/dist/utils/json.d.ts +1 -0
  269. package/dist/utils/json.js +3 -0
  270. package/dist/version.d.ts +2 -0
  271. package/dist/version.js +3 -0
  272. package/package.json +80 -0
@@ -0,0 +1,1108 @@
1
+ import { DEFAULT_SCORING_CONSTANTS } from "./model.js";
2
+ import { hasUnsafeWildcardCount } from "../signals/change-guardrail.js";
3
+ function nowIso() {
4
+ return new Date().toISOString();
5
+ }
6
+ const PROJECTED_SOLVED_BY_PULL_REQUEST_VALIDATION = Symbol("projectedSolvedByPullRequestValidation");
7
+ export function buildScorePreview(args) {
8
+ const branchEligibility = normalizeBranchEligibility(args.input);
9
+ const current = computeScoreCore(args.input, args.repo, args.snapshot, args.contributorEvidence);
10
+ const scenarioPreviews = buildScenarioPreviews(args.input, args.repo, args.snapshot, args.contributorEvidence, current);
11
+ const blockedBy = blockedByFor(args.input, args.repo, current, branchEligibility);
12
+ const gateDeltas = buildGateDeltas(current, scenarioPreviews);
13
+ const effectiveEstimatedScore = current.scoreEstimate.estimatedMergedScore;
14
+ const underlyingPotentialScore = current.scoreEstimate.pendingSaturationScore;
15
+ const scoreabilityStatus = statusFor(args.repo, blockedBy, effectiveEstimatedScore, scenarioPreviews);
16
+ const warnings = [...args.snapshot.warnings, ...warningsFor(args.input, args.repo, current, branchEligibility)];
17
+ const actions = [
18
+ ...(!current.gates.baseTokenGatePassed ? ["Increase meaningful source change size or scope clarity before relying on this preview."] : []),
19
+ ...(current.scoreEstimate.openPrMultiplier === 0 ? ["Land or close existing open PRs before opening more concurrent work."] : []),
20
+ ...(current.scoreEstimate.openIssueMultiplier === 0 ? ["Close excess open issues to stay within the open-issue spam threshold."] : []),
21
+ ...(current.scoreEstimate.mergedHistoryMultiplier === 0 ? ["Build merged PR history on this repo before relying on this preview; upstream requires a minimum merged count."] : []),
22
+ ...(current.scoreEstimate.issueDiscoveryHistoryMultiplier === 0
23
+ ? ["Build valid solved-issue history and issue credibility before relying on issue-discovery scoring on this repo."]
24
+ : []),
25
+ ...(current.scoreEstimate.credibilityMultiplier < 1 ? ["Build or wait for contributor credibility evidence before relying on this preview."] : []),
26
+ ...(current.scoreEstimate.reviewPenaltyMultiplier < 1 ? ["Reduce review churn with tighter tests and clearer evidence."] : []),
27
+ ...(current.scoreEstimate.labelMultiplier <= 1 && Object.keys(args.repo?.registryConfig?.labelMultipliers ?? {}).length > 0
28
+ ? ["Check whether the change legitimately matches one of the repo's configured trusted labels."]
29
+ : []),
30
+ ...(branchEligibility.required && branchEligibility.status === "ineligible" ? ["Use an eligible branch or remove linked-issue assumptions before relying on this preview."] : []),
31
+ ...(branchEligibility.required && (branchEligibility.evidence === "missing" || branchEligibility.stale)
32
+ ? ["Refresh branch/base eligibility metadata before relying on linked-issue assumptions."]
33
+ : []),
34
+ ...(current.linkedIssueMultiplier.mode === "standard" && !current.linkedIssueMultiplier.eligible
35
+ ? ["Validate linked issue context with solved-by-PR evidence before relying on the standard issue multiplier."]
36
+ : []),
37
+ ];
38
+ return {
39
+ repoFullName: args.input.repoFullName,
40
+ generatedAt: nowIso(),
41
+ scoringModelSnapshotId: args.snapshot.id,
42
+ activeModel: args.snapshot.activeModel,
43
+ privateOnly: true,
44
+ laneMath: current.laneMath,
45
+ scoreEstimate: current.scoreEstimate,
46
+ linkedIssueMultiplier: current.linkedIssueMultiplier,
47
+ gates: current.gates,
48
+ branchEligibility,
49
+ effectiveEstimatedScore,
50
+ underlyingPotentialScore,
51
+ blockedBy,
52
+ gateDeltas,
53
+ scenarioPreviews,
54
+ scoreabilityStatus,
55
+ warnings,
56
+ assumptions: [
57
+ "Advisory preview only; tied to the recorded scoring model snapshot and cached Gittensory data.",
58
+ "No future outcome or exact payout is guaranteed.",
59
+ "Private API/MCP output only; public comments intentionally omit these details.",
60
+ `Linked issue multiplier status: ${current.linkedIssueMultiplier.status}; ${current.linkedIssueMultiplier.reason}`,
61
+ ...branchEligibility.warnings,
62
+ ...(args.input.scenarioNotes ?? []).map((note) => `User scenario note: ${note}`),
63
+ ],
64
+ recommendation: {
65
+ level: scoreabilityStatus === "hold" || warnings.some((warning) => /not registered|no active|exceeds/i.test(warning))
66
+ ? "hold"
67
+ : effectiveEstimatedScore >= 30 && warnings.length === 0
68
+ ? "strong_fit"
69
+ : effectiveEstimatedScore >= 15
70
+ ? "reasonable_fit"
71
+ : "needs_work",
72
+ actions: actions.length > 0 ? actions : ["Keep the PR focused, linked, tested, and easy for maintainers to review."],
73
+ },
74
+ };
75
+ }
76
+ export function makeScorePreviewRecord(input, snapshot, result) {
77
+ return {
78
+ id: crypto.randomUUID(),
79
+ scoringModelSnapshotId: snapshot.id,
80
+ repoFullName: input.repoFullName,
81
+ targetType: input.targetType ?? "planned_pr",
82
+ targetKey: input.targetKey ?? `${input.repoFullName}:${input.targetType ?? "planned_pr"}:${Date.now()}`,
83
+ contributorLogin: input.contributorLogin,
84
+ input: input,
85
+ result: result,
86
+ generatedAt: result.generatedAt,
87
+ };
88
+ }
89
+ function computeScoreCore(input, repo, snapshot, contributorEvidence) {
90
+ const constants = { ...snapshot.constants };
91
+ const config = repo?.registryConfig;
92
+ const emissionShare = clamp(config?.emissionShare ?? 0, 0, 1);
93
+ const issueDiscoveryShare = clamp(config?.issueDiscoveryShare ?? 0, 0, 1);
94
+ const ossEmissionShare = constant(constants, "OSS_EMISSION_SHARE");
95
+ const repoSlice = emissionShare * ossEmissionShare;
96
+ const directPrSlice = repoSlice * (1 - issueDiscoveryShare);
97
+ const issueDiscoverySlice = repoSlice * issueDiscoveryShare;
98
+ const sourceTokenScore = nonNegative(input.sourceTokenScore);
99
+ // TEST_FILE_CONTRIBUTION_WEIGHT (#808): upstream weights test-file tokens at 0.05× relative to source tokens.
100
+ // Applied only when totalTokenScore is not explicitly provided — an explicit caller total is honoured as-is.
101
+ const testFileWeight = constant(constants, "TEST_FILE_CONTRIBUTION_WEIGHT");
102
+ const cappedNonCodeTokenScore = applyNonCodeLineCap(input, constants);
103
+ const derivedTotalTokenScore = sourceTokenScore + testFileWeight * nonNegative(input.testTokenScore) + cappedNonCodeTokenScore;
104
+ const totalTokenScore = input.totalTokenScore === undefined ? nonNegative(derivedTotalTokenScore) : applyNonCodeCapToTotal(input.totalTokenScore, input, cappedNonCodeTokenScore);
105
+ const sourceLines = Math.max(1, nonNegative(input.sourceLines ?? sourceTokenScore));
106
+ const fixedBaseScore = clampFixedBaseScore(input.fixedBaseScore ?? config?.fixedBaseScore);
107
+ const rawDensity = sourceTokenScore / sourceLines;
108
+ // Density branch (#812): upstream is on the saturation model, but `current_density_model` is still a
109
+ // supported `activeModel` (types.ts union, the public OpenAPI schema, the DB parser, ~20 test fixtures, and
110
+ // src/services/score-breakdown.ts which keys off densityMultiplier). The issue's "if density is dead,
111
+ // remove the branch" condition is therefore FALSE — the branch is retained as the supported alternate /
112
+ // fetch-failure-`unknown` fallback model. Its fallback constants are now single-sourced from
113
+ // DEFAULT_SCORING_CONSTANTS (model.ts) instead of silent duplicated literals, closing the drift surface.
114
+ const densityMultiplier = clamp(rawDensity || 0, 0, constant(constants, "MAX_CODE_DENSITY_MULTIPLIER"));
115
+ const densityTokenGatePassed = sourceTokenScore >= constant(constants, "MIN_TOKEN_SCORE_FOR_BASE_SCORE");
116
+ const baseTokenGatePassed = snapshot.activeModel === "pending_saturation_model" ? sourceTokenScore > 0 : densityTokenGatePassed;
117
+ const densityContributionBonus = contributionBonusRamp(totalTokenScore, constants);
118
+ const saturationContributionBonusValue = saturationContributionBonus(totalTokenScore, constants);
119
+ const saturationBaseScore = saturationScore(sourceTokenScore, totalTokenScore, constants);
120
+ const densityBaseScore = (densityTokenGatePassed ? constant(constants, "MERGED_PR_BASE_SCORE") * densityMultiplier : 0) + densityContributionBonus;
121
+ const baseScore = fixedBaseScore !== undefined
122
+ ? fixedBaseScore
123
+ : snapshot.activeModel === "pending_saturation_model"
124
+ ? saturationBaseScore
125
+ : densityBaseScore;
126
+ const baseScoreCap = fixedBaseScore !== undefined
127
+ ? undefined
128
+ : snapshot.activeModel === "pending_saturation_model"
129
+ ? constant(constants, "MERGED_PR_BASE_SCORE") + constant(constants, "MAX_CONTRIBUTION_BONUS")
130
+ : constant(constants, "MERGED_PR_BASE_SCORE") * constant(constants, "MAX_CODE_DENSITY_MULTIPLIER") + constant(constants, "MAX_CONTRIBUTION_BONUS");
131
+ const activeContributionBonus = snapshot.activeModel === "pending_saturation_model" ? saturationContributionBonusValue : densityContributionBonus;
132
+ const labelMultiplier = selectLabelMultiplier(input.labels ?? [], config?.labelMultipliers ?? {}, config?.defaultLabelMultiplier ?? 1);
133
+ const branchEligibility = normalizeBranchEligibility(input);
134
+ const linkedIssueMultiplier = decideLinkedIssueMultiplier(input.linkedIssueMode ?? "none", input.linkedIssueContext, constants, branchEligibility);
135
+ const issueMultiplier = linkedIssueMultiplier.appliedMultiplier;
136
+ const credibilityObserved = clamp(input.credibility ?? inferCredibility(contributorEvidence), 0, 1);
137
+ const credibilityFloor = constant(constants, "MIN_CREDIBILITY");
138
+ const credibilityMultiplier = credibilityObserved >= credibilityFloor ? 1 : credibilityObserved / credibilityFloor;
139
+ const changesRequestedCount = nonNegative(input.changesRequestedCount);
140
+ const reviewPenaltyRate = constant(constants, "REVIEW_PENALTY_RATE");
141
+ const reviewPenaltyMultiplier = clamp(1 - changesRequestedCount * reviewPenaltyRate, 0, 1);
142
+ const reviewCollateralMultiplier = Math.min(constant(constants, "MAX_OPEN_PR_REVIEW_COLLATERAL_MULTIPLIER"), 1 + changesRequestedCount * reviewPenaltyRate);
143
+ const openPrCollateralPercent = constant(constants, "OPEN_PR_COLLATERAL_PERCENT");
144
+ const openPrCount = nonNegative(input.openPrCount);
145
+ // The concurrency allowance is earned from the contributor's established merged-history token
146
+ // score; the planned PR's own tokens (totalTokenScore) must not inflate its own open-PR threshold.
147
+ const openPrThreshold = Math.min(constant(constants, "MAX_OPEN_PR_THRESHOLD"), constant(constants, "EXCESSIVE_PR_PENALTY_BASE_THRESHOLD") +
148
+ Math.floor(nonNegative(input.existingContributorTokenScore) / constant(constants, "OPEN_PR_THRESHOLD_TOKEN_SCORE")));
149
+ const openPrMultiplier = openPrCount <= openPrThreshold ? 1 : 0;
150
+ // Open-issue spam gate (#808): mirrors the open-PR gate for the issue-discovery channel.
151
+ // A contributor earns extra open-issue slots from their existing merged-history token score.
152
+ const openIssueCount = nonNegative(input.openIssueCount);
153
+ const openIssueThreshold = Math.min(constant(constants, "MAX_OPEN_ISSUE_THRESHOLD"), constant(constants, "OPEN_ISSUE_SPAM_BASE_THRESHOLD") +
154
+ Math.floor(nonNegative(input.existingContributorTokenScore) / constant(constants, "OPEN_ISSUE_SPAM_TOKEN_SCORE_PER_SLOT")));
155
+ const openIssueMultiplier = openIssueCount <= openIssueThreshold ? 1 : 0;
156
+ const mergedPrFloor = constant(constants, "MIN_VALID_MERGED_PRS");
157
+ const mergedPullRequestsObserved = resolveMergedPullRequests(input, contributorEvidence);
158
+ const mergedHistoryMultiplier = mergedPullRequestsObserved === undefined ? 1 : mergedPullRequestsObserved >= mergedPrFloor ? 1 : 0;
159
+ const validSolvedIssuesFloor = constant(constants, "MIN_VALID_SOLVED_ISSUES");
160
+ const issueCredibilityFloor = constant(constants, "MIN_ISSUE_CREDIBILITY");
161
+ const validSolvedIssuesObserved = input.validSolvedIssues !== undefined ? nonNegative(input.validSolvedIssues) : undefined;
162
+ const issueCredibilityObserved = input.issueCredibility !== undefined ? clamp(input.issueCredibility, 0, 1) : undefined;
163
+ // Issue-discovery validity mirrors upstream's separate issue lane, which is the `standard` linked-issue
164
+ // lane only. The `maintainer` lane explicitly does not require solved-by-PR issue linkage (see
165
+ // decideLinkedIssueMultiplier), so it must not be gated by the solved-issue history floor — otherwise a
166
+ // maintainer preview with sparse issue history is wrongly zeroed and flagged issue_discovery_validity_floor.
167
+ const issueDiscoveryRelevant = (input.linkedIssueMode ?? "none") === "standard";
168
+ const issueDiscoveryHistoryKnown = validSolvedIssuesObserved !== undefined && issueCredibilityObserved !== undefined;
169
+ const issueDiscoveryHistoryMultiplier = !issueDiscoveryRelevant || !issueDiscoveryHistoryKnown
170
+ ? 1
171
+ : validSolvedIssuesObserved >= validSolvedIssuesFloor && issueCredibilityObserved >= issueCredibilityFloor
172
+ ? 1
173
+ : 0;
174
+ // Upstream time-decay (#703): mirrors upstream's `scored.time_decay_multiplier` applied to a PR's score.
175
+ // Opt-in + env-gated (default off). A fresh PR (prAgeHours below the grace period) yields 1.0, so a normal
176
+ // new-PR preview is unchanged even when enabled — only an aged-PR projection decays.
177
+ // Per-repo curve (#703): the repo's registry `scoring.time_decay` overrides overlay the snapshot defaults.
178
+ const timeDecayMultiplier = input.applyTimeDecay ? calculateTimeDecay(nonNegative(input.prAgeHours), constants, config?.timeDecay) : 1;
179
+ const estimatedMergedScore = roundScore(baseScore *
180
+ labelMultiplier *
181
+ issueMultiplier *
182
+ credibilityMultiplier *
183
+ reviewPenaltyMultiplier *
184
+ openPrMultiplier *
185
+ openIssueMultiplier *
186
+ mergedHistoryMultiplier *
187
+ issueDiscoveryHistoryMultiplier *
188
+ timeDecayMultiplier);
189
+ const pendingSaturationScore = roundScore(saturationBaseScore);
190
+ return {
191
+ laneMath: {
192
+ repoEmissionShare: emissionShare,
193
+ ossEmissionShare,
194
+ repoSlice: roundScore(repoSlice),
195
+ directPrSlice: roundScore(directPrSlice),
196
+ issueDiscoverySlice: roundScore(issueDiscoverySlice),
197
+ issueDiscoveryShare,
198
+ },
199
+ scoreEstimate: {
200
+ baseScore: roundScore(baseScore),
201
+ ...(baseScoreCap !== undefined ? { baseScoreCap: roundScore(baseScoreCap) } : {}),
202
+ densityMultiplier: roundScore(densityMultiplier),
203
+ contributionBonus: roundScore(activeContributionBonus),
204
+ labelMultiplier,
205
+ issueMultiplier,
206
+ credibilityMultiplier: roundScore(credibilityMultiplier),
207
+ reviewPenaltyMultiplier: roundScore(reviewPenaltyMultiplier),
208
+ openPrMultiplier,
209
+ openIssueMultiplier,
210
+ mergedHistoryMultiplier,
211
+ issueDiscoveryHistoryMultiplier,
212
+ timeDecayMultiplier: roundScore(timeDecayMultiplier),
213
+ estimatedMergedScore,
214
+ pendingSaturationScore,
215
+ },
216
+ linkedIssueMultiplier,
217
+ gates: {
218
+ baseTokenGatePassed,
219
+ openPrThreshold,
220
+ openPrCount,
221
+ reviewCollateralMultiplier: roundScore(reviewCollateralMultiplier),
222
+ collateralFraction: roundScore(openPrCollateralPercent * reviewCollateralMultiplier),
223
+ credibilityFloor,
224
+ credibilityObserved,
225
+ openIssueThreshold,
226
+ openIssueCount,
227
+ mergedPrFloor,
228
+ ...(mergedPullRequestsObserved !== undefined ? { mergedPullRequests: mergedPullRequestsObserved } : {}),
229
+ validSolvedIssuesFloor,
230
+ ...(validSolvedIssuesObserved !== undefined ? { validSolvedIssues: validSolvedIssuesObserved } : {}),
231
+ issueCredibilityFloor,
232
+ ...(issueCredibilityObserved !== undefined ? { issueCredibility: issueCredibilityObserved } : {}),
233
+ nonCodeLineCap: constant(constants, "MAX_LINES_SCORED_FOR_NON_CODE_EXT"),
234
+ ...(input.nonCodeLines !== undefined ? { nonCodeLinesObserved: nonNegative(input.nonCodeLines) } : {}),
235
+ },
236
+ };
237
+ }
238
+ function buildScenarioPreviews(input, repo, snapshot, contributorEvidence, current) {
239
+ // Count each pending open PR at most once. `approvedPrCount` and
240
+ // `pendingMergedPrCount` are the same merge-ready set — detectPendingPrScenario
241
+ // sets both to `mergeReady.length` — so they must be folded with `max`, not
242
+ // added, or the merge-ready PRs get double-subtracted from the open-PR
243
+ // projection. Closed/likely-close PRs are a disjoint set and add on top. This
244
+ // mirrors the canonical reduction in pending-pr-scenarios.ts
245
+ // (currentOpen - pendingMergedPrCount - pendingClosedPrCount).
246
+ const mergeReadyPending = Math.max(nonNegative(input.pendingMergedPrCount), nonNegative(input.approvedPrCount));
247
+ const userPendingCount = mergeReadyPending + nonNegative(input.pendingClosedPrCount);
248
+ const observedApprovedCount = nonNegative(input.observedApprovedPrCount);
249
+ const observedStaleCloseCount = nonNegative(input.observedStalePrCount);
250
+ const observedClosedCount = nonNegative(input.observedClosedPrCount);
251
+ const combinedPendingCount = userPendingCount + observedApprovedCount + observedStaleCloseCount;
252
+ const expectedOpenPrCountAfterMerge = input.expectedOpenPrCountAfterMerge !== undefined ? nonNegative(input.expectedOpenPrCountAfterMerge) : Math.max(0, current.gates.openPrCount - userPendingCount);
253
+ const projectedCredibility = input.projectedCredibility !== undefined
254
+ ? clamp(input.projectedCredibility, 0, 1)
255
+ : userPendingCount > 0
256
+ ? Math.max(current.gates.credibilityObserved, current.gates.credibilityFloor)
257
+ : current.gates.credibilityObserved;
258
+ const observedApprovalCredibility = observedApprovedCount > 0 ? Math.max(current.gates.credibilityObserved, current.gates.credibilityFloor) : current.gates.credibilityObserved;
259
+ const afterApprovedInput = {
260
+ ...input,
261
+ openPrCount: Math.max(0, current.gates.openPrCount - observedApprovedCount),
262
+ credibility: observedApprovalCredibility,
263
+ };
264
+ const afterStaleInput = {
265
+ ...input,
266
+ openPrCount: Math.max(0, current.gates.openPrCount - observedStaleCloseCount),
267
+ credibility: current.gates.credibilityObserved,
268
+ };
269
+ const cleanGatesInput = {
270
+ ...input,
271
+ openPrCount: Math.min(current.gates.openPrCount, current.gates.openPrThreshold),
272
+ openIssueCount: Math.min(current.gates.openIssueCount, current.gates.openIssueThreshold),
273
+ credibility: Math.max(current.gates.credibilityObserved, current.gates.credibilityFloor),
274
+ ...(current.gates.mergedPullRequests !== undefined
275
+ ? { mergedPullRequests: Math.max(current.gates.mergedPullRequests, current.gates.mergedPrFloor) }
276
+ : {}),
277
+ ...(current.gates.validSolvedIssues !== undefined
278
+ ? { validSolvedIssues: Math.max(current.gates.validSolvedIssues, current.gates.validSolvedIssuesFloor) }
279
+ : {}),
280
+ ...(current.gates.issueCredibility !== undefined
281
+ ? { issueCredibility: Math.max(current.gates.issueCredibility, current.gates.issueCredibilityFloor) }
282
+ : {}),
283
+ };
284
+ const afterPendingInput = {
285
+ ...input,
286
+ openPrCount: expectedOpenPrCountAfterMerge,
287
+ credibility: projectedCredibility,
288
+ ...(current.gates.mergedPullRequests !== undefined
289
+ ? { mergedPullRequests: nonNegative(current.gates.mergedPullRequests) + mergeReadyPending }
290
+ : {}),
291
+ };
292
+ const linkedIssueInput = withValidatedLinkedIssueScenario(input);
293
+ const bestReasonableInput = {
294
+ ...linkedIssueInput,
295
+ openPrCount: Math.min(input.expectedOpenPrCountAfterMerge !== undefined ? expectedOpenPrCountAfterMerge : Math.max(0, current.gates.openPrCount - combinedPendingCount), current.gates.openPrThreshold),
296
+ // Project open-issue spam cleanup (#808): mirror the open-PR projection so the
297
+ // "best reasonable case" can clear the open-issue gate just like it clears open-PR pressure.
298
+ openIssueCount: Math.min(current.gates.openIssueCount, current.gates.openIssueThreshold),
299
+ credibility: Math.max(projectedCredibility, observedApprovalCredibility, current.gates.credibilityFloor),
300
+ ...(current.gates.mergedPullRequests !== undefined
301
+ ? {
302
+ mergedPullRequests: Math.max(nonNegative(current.gates.mergedPullRequests) + mergeReadyPending, current.gates.mergedPrFloor),
303
+ }
304
+ : {}),
305
+ ...(current.gates.validSolvedIssues !== undefined
306
+ ? { validSolvedIssues: Math.max(current.gates.validSolvedIssues, current.gates.validSolvedIssuesFloor) }
307
+ : {}),
308
+ ...(current.gates.issueCredibility !== undefined
309
+ ? { issueCredibility: Math.max(current.gates.issueCredibility, current.gates.issueCredibilityFloor) }
310
+ : {}),
311
+ };
312
+ return [
313
+ scenario("current", "current_data", input, current, ["Current cached/account state and supplied local diff metadata."], repo),
314
+ scenario("cleanGates", "gittensory_projection", cleanGatesInput, computeScoreCore(cleanGatesInput, repo, snapshot, contributorEvidence), [
315
+ "Open PR, open-issue, credibility, and contributor-history gates are projected as cleared; branch metadata is otherwise unchanged.",
316
+ ], repo),
317
+ scenario("afterPendingMerges", input.pendingScenarioObserved
318
+ ? "github_observed"
319
+ : userPendingCount > 0 || input.expectedOpenPrCountAfterMerge !== undefined || input.projectedCredibility !== undefined
320
+ ? "user_supplied"
321
+ : "gittensory_projection", afterPendingInput, computeScoreCore(afterPendingInput, repo, snapshot, contributorEvidence), [
322
+ userPendingCount > 0
323
+ ? `${userPendingCount} pending merged/closed PR(s) are treated as no longer open for this scenario${input.pendingScenarioObserved ? "" : " (caller-supplied)"}.`
324
+ : "No pending merge/close count was supplied; this scenario preserves current open PR pressure.",
325
+ ...(input.projectedCredibility !== undefined
326
+ ? [`Projected credibility is user-supplied as ${roundScore(projectedCredibility)}.`]
327
+ : userPendingCount > 0
328
+ ? [`Projected credibility is raised to the current floor ${current.gates.credibilityFloor} because pending merges are expected to land.`]
329
+ : []),
330
+ ...(input.scenarioNotes ?? []),
331
+ ], repo),
332
+ scenario("afterApprovedPrsMerge", "github_observed", afterApprovedInput, computeScoreCore(afterApprovedInput, repo, snapshot, contributorEvidence), [
333
+ observedApprovedCount > 0
334
+ ? `${observedApprovedCount} GitHub-observed approved or mergeable open PR(s) are treated as no longer open if they merge.`
335
+ : "No GitHub-observed approved or mergeable open PRs were available for this scenario.",
336
+ ...(observedApprovedCount > 0 ? [`Projected credibility is raised to the current floor ${current.gates.credibilityFloor} after observed mergeable work lands.`] : []),
337
+ ...observedScenarioNotes(input),
338
+ ], repo),
339
+ scenario("afterStalePrsClose", "github_observed", afterStaleInput, computeScoreCore(afterStaleInput, repo, snapshot, contributorEvidence), [
340
+ observedStaleCloseCount > 0
341
+ ? `${observedStaleCloseCount} GitHub-observed stale open PR(s) are treated as no longer open if they close or withdraw.`
342
+ : "No GitHub-observed stale open PRs were available for this scenario.",
343
+ ...(observedClosedCount > 0 ? [`${observedClosedCount} GitHub-observed already-closed PR(s) are excluded because they no longer contribute to open PR pressure.`] : []),
344
+ "Credibility is not increased in this scenario because stale cleanup is not the same as merged work.",
345
+ ...observedScenarioNotes(input),
346
+ ], repo),
347
+ scenario("linkedIssueFixed", "gittensory_projection", linkedIssueInput, computeScoreCore(linkedIssueInput, repo, snapshot, contributorEvidence), [
348
+ input.linkedIssueMode === "none" || !input.linkedIssueMode
349
+ ? "A standard linked-issue/no-issue rationale multiplier is projected as solved-by-PR validated."
350
+ : "Linked issue mode was already supplied; this scenario projects solved-by-PR validation where needed.",
351
+ ], repo),
352
+ scenario("bestReasonableCase", "gittensory_projection", bestReasonableInput, computeScoreCore(bestReasonableInput, repo, snapshot, contributorEvidence), [
353
+ "Combines plausible near-term gate cleanup: open PR pressure at threshold or below, open-issue spam pressure at threshold or below, credibility at floor or above, contributor merged-history and issue-discovery validity at floor or above, and linked-issue context where applicable.",
354
+ ...(input.scenarioNotes ?? []),
355
+ ...observedScenarioNotes(input),
356
+ ], repo),
357
+ ];
358
+ }
359
+ function observedScenarioNotes(input) {
360
+ return [
361
+ ...(nonNegative(input.observedDraftPrCount) > 0 ? [`${nonNegative(input.observedDraftPrCount)} draft PR(s) were excluded from likely-to-land projections.`] : []),
362
+ ...(nonNegative(input.observedBlockedPrCount) > 0 ? [`${nonNegative(input.observedBlockedPrCount)} blocked PR(s) were excluded from likely-to-land projections.`] : []),
363
+ ...(nonNegative(input.observedMaintainerPrCount) > 0 ? [`${nonNegative(input.observedMaintainerPrCount)} maintainer-lane PR(s) were kept out of outside-contributor projections.`] : []),
364
+ ...(input.observedScenarioNotes ?? []),
365
+ ];
366
+ }
367
+ function scenario(name, source, input, core, assumptions, repo) {
368
+ const blockedBy = blockedByFor(input, repo, core);
369
+ return {
370
+ name,
371
+ source,
372
+ assumptions,
373
+ scoreEstimate: core.scoreEstimate,
374
+ linkedIssueMultiplier: core.linkedIssueMultiplier,
375
+ gates: core.gates,
376
+ effectiveEstimatedScore: core.scoreEstimate.estimatedMergedScore,
377
+ underlyingPotentialScore: core.scoreEstimate.pendingSaturationScore,
378
+ blockedBy,
379
+ deltaExplanation: deltaExplanationFor(core, blockedBy),
380
+ };
381
+ }
382
+ function blockedByFor(input, repo, core, branchEligibility = normalizeBranchEligibility(input)) {
383
+ return [
384
+ ...(!repo?.isRegistered
385
+ ? [{ code: "repo_not_registered", severity: "blocker", detail: "Repository is not registered in the local Gittensory cache." }]
386
+ : []),
387
+ ...(core.laneMath.repoEmissionShare <= 0
388
+ ? [{ code: "inactive_allocation", severity: "blocker", detail: "Repository has no active allocation in the current registry snapshot." }]
389
+ : []),
390
+ ...(input.metadataOnly
391
+ ? [{ code: "metadata_only", severity: "context", detail: "Preview used metadata-only inputs, so token and density estimates are rough." }]
392
+ : []),
393
+ ...(branchEligibility.required && branchEligibility.status === "ineligible"
394
+ ? [
395
+ {
396
+ code: "branch_ineligible",
397
+ severity: "reducer",
398
+ detail: "Branch eligibility is confirmed ineligible; linked-issue multiplier assumptions are disabled.",
399
+ },
400
+ ]
401
+ : []),
402
+ ...(branchEligibility.required && branchEligibility.status === "unknown"
403
+ ? [
404
+ {
405
+ code: "branch_eligibility_missing",
406
+ severity: "context",
407
+ detail: branchEligibility.evidence === "missing"
408
+ ? "Branch eligibility evidence is missing; refresh branch/base metadata before relying on linked-issue assumptions."
409
+ : "Branch eligibility is unknown; refresh branch/base metadata before relying on linked-issue assumptions.",
410
+ },
411
+ ]
412
+ : []),
413
+ ...(!core.gates.baseTokenGatePassed
414
+ ? [{ code: "base_token_gate", severity: "blocker", detail: "Source token score does not pass the current base-score token gate." }]
415
+ : []),
416
+ ...(core.scoreEstimate.openPrMultiplier === 0
417
+ ? [
418
+ {
419
+ code: "open_pr_threshold",
420
+ severity: "blocker",
421
+ detail: `Open PR count ${core.gates.openPrCount} exceeds threshold ${core.gates.openPrThreshold}.`,
422
+ },
423
+ ]
424
+ : []),
425
+ ...(core.scoreEstimate.openIssueMultiplier === 0
426
+ ? [
427
+ {
428
+ code: "open_issue_threshold",
429
+ severity: "blocker",
430
+ detail: `Open issue count ${core.gates.openIssueCount} exceeds spam threshold ${core.gates.openIssueThreshold}.`,
431
+ },
432
+ ]
433
+ : []),
434
+ ...(core.scoreEstimate.mergedHistoryMultiplier === 0
435
+ ? [
436
+ {
437
+ code: "merged_pr_history_floor",
438
+ severity: "blocker",
439
+ detail: `Merged PR count ${core.gates.mergedPullRequests} is below upstream floor ${core.gates.mergedPrFloor}.`,
440
+ },
441
+ ]
442
+ : []),
443
+ ...(core.scoreEstimate.issueDiscoveryHistoryMultiplier === 0
444
+ ? [
445
+ {
446
+ code: "issue_discovery_validity_floor",
447
+ severity: "blocker",
448
+ detail: `Issue-discovery history (${core.gates.validSolvedIssues} valid solved, credibility ${roundScore(core.gates.issueCredibility)}) is below upstream floors (${core.gates.validSolvedIssuesFloor} valid solved, ${core.gates.issueCredibilityFloor} credibility).`,
449
+ },
450
+ ]
451
+ : []),
452
+ ...(core.gates.credibilityObserved < core.gates.credibilityFloor
453
+ ? [
454
+ {
455
+ code: "credibility_floor",
456
+ severity: "reducer",
457
+ detail: `Credibility ${roundScore(core.gates.credibilityObserved)} is below floor ${core.gates.credibilityFloor}.`,
458
+ },
459
+ ]
460
+ : []),
461
+ ...(core.scoreEstimate.reviewPenaltyMultiplier < 1
462
+ ? [{ code: "review_penalty", severity: "reducer", detail: "Change-request history reduces the estimate." }]
463
+ : []),
464
+ ...(core.linkedIssueMultiplier.mode === "standard" && core.linkedIssueMultiplier.status === "invalid"
465
+ ? [
466
+ {
467
+ code: "linked_issue_invalid",
468
+ severity: "reducer",
469
+ detail: core.linkedIssueMultiplier.reason,
470
+ },
471
+ ]
472
+ : []),
473
+ ...(core.linkedIssueMultiplier.mode === "standard" && ["raw", "plausible", "unavailable"].includes(core.linkedIssueMultiplier.status)
474
+ ? [
475
+ {
476
+ code: "linked_issue_unvalidated",
477
+ severity: "context",
478
+ detail: core.linkedIssueMultiplier.reason,
479
+ },
480
+ ]
481
+ : []),
482
+ ...(nonNegative(input.observedStalePrCount) > 0
483
+ ? [
484
+ {
485
+ code: "stale_work",
486
+ severity: "reducer",
487
+ detail: `${nonNegative(input.observedStalePrCount)} stale open PR(s) detected; consider closing stale work before opening new contributions.`,
488
+ },
489
+ ]
490
+ : []),
491
+ ...(nonNegative(input.duplicateRiskCount) > 0
492
+ ? [
493
+ {
494
+ code: "duplicate_risk",
495
+ severity: "reducer",
496
+ detail: `${nonNegative(input.duplicateRiskCount)} duplicate-risk issue(s) or PR(s) detected; verify there is no conflicting work before proceeding.`,
497
+ },
498
+ ]
499
+ : []),
500
+ ];
501
+ }
502
+ function buildGateDeltas(current, scenarios) {
503
+ const currentScenario = scenarios[0];
504
+ /* v8 ignore next -- buildScenarioPreviews always emits a current scenario; this protects malformed adapters. */
505
+ if (!currentScenario)
506
+ return [];
507
+ const bestMatch = scenarios.find((scenarioPreview) => scenarioPreview.name === "bestReasonableCase");
508
+ /* v8 ignore next -- buildScenarioPreviews always emits bestReasonableCase; current is the defensive fallback. */
509
+ const best = bestMatch ?? currentScenario;
510
+ const linkedMatch = scenarios.find((scenarioPreview) => scenarioPreview.name === "linkedIssueFixed");
511
+ /* v8 ignore next -- buildScenarioPreviews always emits linkedIssueFixed; best is the defensive fallback. */
512
+ const linked = linkedMatch ?? best;
513
+ return [
514
+ ...(current.scoreEstimate.openPrMultiplier !== best.scoreEstimate.openPrMultiplier || current.gates.openPrCount !== best.gates.openPrCount
515
+ ? [
516
+ {
517
+ gate: "open_pr_threshold",
518
+ current: `${current.gates.openPrCount}/${current.gates.openPrThreshold} open PRs, multiplier ${current.scoreEstimate.openPrMultiplier}`,
519
+ projected: `${best.gates.openPrCount}/${best.gates.openPrThreshold} open PRs, multiplier ${best.scoreEstimate.openPrMultiplier}`,
520
+ explanation: `Open PR pressure changes estimated score ${current.scoreEstimate.estimatedMergedScore} -> ${best.scoreEstimate.estimatedMergedScore}.`,
521
+ },
522
+ ]
523
+ : []),
524
+ ...(current.scoreEstimate.openIssueMultiplier !== best.scoreEstimate.openIssueMultiplier || current.gates.openIssueCount !== best.gates.openIssueCount
525
+ ? [
526
+ {
527
+ gate: "open_issue_threshold",
528
+ current: `${current.gates.openIssueCount}/${current.gates.openIssueThreshold} open issues, multiplier ${current.scoreEstimate.openIssueMultiplier}`,
529
+ projected: `${best.gates.openIssueCount}/${best.gates.openIssueThreshold} open issues, multiplier ${best.scoreEstimate.openIssueMultiplier}`,
530
+ explanation: `Open issue spam pressure changes estimated score ${current.scoreEstimate.estimatedMergedScore} -> ${best.scoreEstimate.estimatedMergedScore}.`,
531
+ },
532
+ ]
533
+ : []),
534
+ ...(current.scoreEstimate.mergedHistoryMultiplier !== best.scoreEstimate.mergedHistoryMultiplier
535
+ ? [
536
+ {
537
+ gate: "merged_pr_history_floor",
538
+ current: `${current.gates.mergedPullRequests}/${current.gates.mergedPrFloor} merged PRs, multiplier ${current.scoreEstimate.mergedHistoryMultiplier}`,
539
+ projected: `${best.gates.mergedPullRequests}/${best.gates.mergedPrFloor} merged PRs, multiplier ${best.scoreEstimate.mergedHistoryMultiplier}`,
540
+ explanation: `Merged PR history changes estimated score ${current.scoreEstimate.estimatedMergedScore} -> ${best.scoreEstimate.estimatedMergedScore}.`,
541
+ },
542
+ ]
543
+ : []),
544
+ ...(current.scoreEstimate.issueDiscoveryHistoryMultiplier !== best.scoreEstimate.issueDiscoveryHistoryMultiplier
545
+ ? [
546
+ {
547
+ gate: "issue_discovery_validity_floor",
548
+ current: `${current.gates.validSolvedIssues} valid solved / ${roundScore(current.gates.issueCredibility)} credibility, multiplier ${current.scoreEstimate.issueDiscoveryHistoryMultiplier}`,
549
+ projected: `${best.gates.validSolvedIssues} valid solved / ${roundScore(best.gates.issueCredibility)} credibility, multiplier ${best.scoreEstimate.issueDiscoveryHistoryMultiplier}`,
550
+ explanation: `Issue-discovery validity changes estimated score ${current.scoreEstimate.estimatedMergedScore} -> ${best.scoreEstimate.estimatedMergedScore}.`,
551
+ },
552
+ ]
553
+ : []),
554
+ ...(current.gates.credibilityObserved !== best.gates.credibilityObserved || current.scoreEstimate.credibilityMultiplier !== best.scoreEstimate.credibilityMultiplier
555
+ ? [
556
+ {
557
+ gate: "credibility_floor",
558
+ current: `${roundScore(current.gates.credibilityObserved)} observed, multiplier ${current.scoreEstimate.credibilityMultiplier}`,
559
+ projected: `${roundScore(best.gates.credibilityObserved)} projected, multiplier ${best.scoreEstimate.credibilityMultiplier}`,
560
+ explanation: `Credibility changes estimated score ${current.scoreEstimate.estimatedMergedScore} -> ${best.scoreEstimate.estimatedMergedScore}.`,
561
+ },
562
+ ]
563
+ : []),
564
+ ...(current.scoreEstimate.issueMultiplier !== linked.scoreEstimate.issueMultiplier
565
+ ? [
566
+ {
567
+ gate: "linked_issue_multiplier",
568
+ current: `${current.scoreEstimate.issueMultiplier}`,
569
+ projected: `${linked.scoreEstimate.issueMultiplier}`,
570
+ explanation: `Linked issue/no-issue context changes estimated score ${current.scoreEstimate.estimatedMergedScore} -> ${linked.scoreEstimate.estimatedMergedScore}.`,
571
+ },
572
+ ]
573
+ : []),
574
+ ];
575
+ }
576
+ function warningsFor(input, repo, core, branchEligibility = normalizeBranchEligibility(input)) {
577
+ return [...new Set([...blockedByFor(input, repo, core, branchEligibility).map((blocker) => blocker.detail), ...core.linkedIssueMultiplier.warnings])];
578
+ }
579
+ function statusFor(repo, blockedBy, effectiveEstimatedScore, scenarios) {
580
+ if (!repo?.isRegistered || blockedBy.some((blocker) => blocker.code === "inactive_allocation"))
581
+ return "hold";
582
+ if (effectiveEstimatedScore > 0 && !blockedBy.some((blocker) => blocker.severity === "blocker"))
583
+ return "scoreable";
584
+ if (scenarios.some((scenarioPreview) => scenarioPreview.name !== "current" && scenarioPreview.effectiveEstimatedScore > effectiveEstimatedScore)) {
585
+ return "conditionally_scoreable";
586
+ }
587
+ return "blocked";
588
+ }
589
+ function deltaExplanationFor(core, blockedBy) {
590
+ if (blockedBy.length === 0)
591
+ return `Currently scoreable at ${core.scoreEstimate.estimatedMergedScore}; underlying potential ${core.scoreEstimate.pendingSaturationScore}.`;
592
+ return `Effective score ${core.scoreEstimate.estimatedMergedScore}; underlying potential ${core.scoreEstimate.pendingSaturationScore}; blocked or reduced by ${blockedBy.map((blocker) => blocker.code).join(", ")}.`;
593
+ }
594
+ // A label multiplier must be a positive, finite number (mirrors the validity rule signals/engine.ts's own
595
+ // config-quality check already documents and enforces for its ADVISORY health score: "0, negative, NaN, or
596
+ // Infinity are config errors that would silently misweight scoring"). That check only ever adjusted a
597
+ // repo's informational config-quality score -- it never stopped an invalid value from reaching the REAL
598
+ // scoring formula here, where `labelMultiplier` multiplies directly into `estimatedMergedScore`. A
599
+ // registry-sourced label multiplier of 0 or a negative number is valid JSON and passed neither `numberValue`
600
+ // (only applied to the scalar overrides, not this map) nor any check in this function, so it would zero out
601
+ // or invert any PR/issue score for a matching label. Filtering here closes the gap where it actually matters.
602
+ function isValidLabelMultiplier(value) {
603
+ return typeof value === "number" && Number.isFinite(value) && value > 0;
604
+ }
605
+ function selectLabelMultiplier(labels, multipliers, fallback) {
606
+ const normalized = labels.map((label) => label.toLowerCase());
607
+ const matched = Object.entries(multipliers).flatMap(([pattern, multiplier]) => {
608
+ if (!isValidLabelMultiplier(multiplier))
609
+ return [];
610
+ const matcher = labelPatternToRegExp(pattern.toLowerCase());
611
+ return normalized.some((label) => matcher.test(label)) ? [multiplier] : [];
612
+ });
613
+ return matched.length > 0 ? Math.max(...matched) : isValidLabelMultiplier(fallback) ? fallback : 1;
614
+ }
615
+ /** True when `label` matches the configured multiplier `pattern` under the SAME case-insensitive fnmatch glob
616
+ * semantics scoring uses to resolve label multipliers (see {@link labelPatternToRegExp}). Exported so the
617
+ * signals surfaces that audit configured label keys (config-quality, label-audit) match them as the GLOBS they
618
+ * are: a `type:*` key must count `type:bug-fix` as observed/configured, not silently report it missing because
619
+ * the literal pattern never appears verbatim on a real issue/PR. A literal key (no glob metacharacter) still
620
+ * matches only its exact label, so existing configs behave identically. */
621
+ export function labelMatchesPattern(label, pattern) {
622
+ return labelPatternToRegExp(pattern.toLowerCase()).test(label.toLowerCase());
623
+ }
624
+ // Compiled fnmatch→RegExp matchers are memoized by pattern. The same small,
625
+ // config-derived set of label keys is matched on every scored PR/issue, so the
626
+ // per-call recompile inside the nested label loops in engine.ts is pure waste.
627
+ // Keys come from a repo's registryConfig.labelMultipliers, sourced from the externally-fetched gittensor
628
+ // registry (registry/sync.ts + registry/normalize.ts, not a value this repo's own maintainer directly controls
629
+ // via .gittensory.yml) — so the pattern SET is small per repo, but individual pattern CONTENT is untrusted, not
630
+ // literally attacker-supplied-per-request the way GitHub PR content is. The wildcard-count cap below (#2456)
631
+ // bounds a single pattern's compile cost; this cache is additionally bounded to a fixed max entry count and
632
+ // evicted LRU, so a long-running isolate that observes many distinct registry snapshots over its life still
633
+ // can't grow the cache unboundedly. The compiled RegExp carries only the "i" flag (no global/sticky `lastIndex`
634
+ // state), so sharing one instance across calls is safe and byte-identical to recompiling on every call.
635
+ export const LABEL_PATTERN_REGEXP_CACHE_MAX_ENTRIES = 256;
636
+ const labelPatternRegExpCache = new Map();
637
+ // A RegExp that never matches any input — mirrors change-guardrail.ts's identical NEVER_MATCHES fallback for an
638
+ // over-complex pattern, so a pathological registry entry degrades to "this label multiplier never applies"
639
+ // instead of hanging the scoring path that evaluates it.
640
+ const LABEL_PATTERN_NEVER_MATCHES = /^(?!)$/;
641
+ // Upstream resolves label multipliers by matching each configured key as a Python `fnmatch` GLOB, not a
642
+ // literal string: `fnmatch(label.lower(), pattern.lower())` in
643
+ // gittensor/validator/oss_contributions/label_resolution.py, so a repo can configure `type:*`, `kind/*`, or
644
+ // `priority:?` and have it match `type:bug-fix`, `kind/bug`, `priority:1` (#1244-class scoring parity). The
645
+ // preview previously did exact equality, so it silently scored every wildcard-configured trusted label at the
646
+ // neutral default — under-/over-estimating the score for any repo using glob keys. Translate one fnmatch
647
+ // pattern to an anchored, case-insensitive RegExp. fnmatch semantics differ from the path-glob in
648
+ // change-guardrail.ts (there `*` stops at `/` and `?` is literal): labels are flat strings, so `*` matches any
649
+ // run, `?` any single character, and `[seq]`/`[!seq]` a character class. Literal keys are unaffected — for a
650
+ // pattern with no glob metacharacter the RegExp is an exact match, so existing configs score identically.
651
+ function labelPatternToRegExp(pattern) {
652
+ const cached = labelPatternRegExpCache.get(pattern);
653
+ if (cached !== undefined) {
654
+ // Refresh recency on hit so the cache behaves as an LRU: the most-recently-matched patterns
655
+ // survive eviction, not just the most-recently-inserted ones.
656
+ labelPatternRegExpCache.delete(pattern);
657
+ labelPatternRegExpCache.set(pattern, cached);
658
+ return cached;
659
+ }
660
+ // Reuses change-guardrail.ts's wildcard-GROUP counting (a `*` here matches the same "any run of chars"
661
+ // semantics as that glob compiler's `*`, so the same catastrophic-backtracking risk and the same empirically-
662
+ // safe threshold apply) — an over-complex registry-sourced label_multipliers key degrades to a safe never-match
663
+ // instead of hanging RegExp.test() on an adversarial near-miss label (#2456). Reachable via the public
664
+ // score-preview API, the MCP tool, and the per-PR label-audit signal, so one bad registry entry could otherwise
665
+ // hang scoring for every PR on that repo.
666
+ if (hasUnsafeWildcardCount(pattern)) {
667
+ setLabelPatternRegExpCacheEntry(pattern, LABEL_PATTERN_NEVER_MATCHES);
668
+ return LABEL_PATTERN_NEVER_MATCHES;
669
+ }
670
+ let regex = "";
671
+ let i = 0;
672
+ while (i < pattern.length) {
673
+ const char = pattern.charAt(i);
674
+ i += 1;
675
+ if (char === "*") {
676
+ regex += ".*";
677
+ }
678
+ else if (char === "?") {
679
+ regex += ".";
680
+ }
681
+ else if (char === "[") {
682
+ const close = pattern.indexOf("]", i);
683
+ if (close === -1) {
684
+ // No closing bracket: fnmatch treats the `[` as a literal character.
685
+ regex += "\\[";
686
+ }
687
+ else {
688
+ const rawBody = pattern.slice(i, close);
689
+ if (rawBody === "" || rawBody === "!") {
690
+ // Empty classes and bare `[!]` stay literal in Python fnmatch instead of compiling as classes.
691
+ regex += `\\[${escapeRegExpLiteral(rawBody)}\\]`;
692
+ }
693
+ else if (hasDescendingCharacterRange(rawBody)) {
694
+ // Python fnmatch treats invalid ranges like `[z-a]` as a never-match pattern; RegExp throws.
695
+ regex += "(?!)";
696
+ }
697
+ else {
698
+ let body = rawBody.replace(/\\/g, "\\\\");
699
+ // `[!seq]` is fnmatch's negated class; RegExp spells negation as `[^seq]`.
700
+ if (body.startsWith("!"))
701
+ body = `^${body.slice(1)}`;
702
+ else if (body.startsWith("^"))
703
+ body = `\\${body}`;
704
+ regex += `[${body}]`;
705
+ }
706
+ i = close + 1;
707
+ }
708
+ }
709
+ else if (/[.+^${}()|\]\\]/.test(char)) {
710
+ regex += `\\${char}`;
711
+ }
712
+ else {
713
+ regex += char;
714
+ }
715
+ }
716
+ const compiled = new RegExp(`^${regex}$`, "i");
717
+ setLabelPatternRegExpCacheEntry(pattern, compiled);
718
+ return compiled;
719
+ }
720
+ // Inserts a new (never-before-cached) entry, evicting the least-recently-used entry first if the
721
+ // cache is already at its bound. Callers must only use this for keys not already present — refreshing
722
+ // an existing key's recency on a cache hit is handled inline above via delete+set.
723
+ function setLabelPatternRegExpCacheEntry(pattern, compiled) {
724
+ if (labelPatternRegExpCache.size >= LABEL_PATTERN_REGEXP_CACHE_MAX_ENTRIES) {
725
+ // Map iteration order is insertion order, so the first key is always the least-recently-used
726
+ // one (recency is refreshed via delete+set on every hit/insert). The map is non-empty here
727
+ // because size >= LABEL_PATTERN_REGEXP_CACHE_MAX_ENTRIES (a positive constant), so the loop body
728
+ // always runs exactly once.
729
+ for (const oldestPattern of labelPatternRegExpCache.keys()) {
730
+ labelPatternRegExpCache.delete(oldestPattern);
731
+ break;
732
+ }
733
+ }
734
+ labelPatternRegExpCache.set(pattern, compiled);
735
+ }
736
+ export function clearLabelPatternRegExpCacheForTest() {
737
+ labelPatternRegExpCache.clear();
738
+ }
739
+ export function labelPatternRegExpCacheKeysForTest() {
740
+ return [...labelPatternRegExpCache.keys()];
741
+ }
742
+ function escapeRegExpLiteral(value) {
743
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
744
+ }
745
+ function hasDescendingCharacterRange(body) {
746
+ const start = body.startsWith("!") ? 1 : 0;
747
+ // Walk the class left-to-right, consuming each `X-Y` range as a unit so a range endpoint can't be
748
+ // misread as the start of a spurious second range. Only a genuinely inverted range like `[z-a]` — the
749
+ // case JS `RegExp` actually throws on — must degrade the class to never-match; a literal `-` that
750
+ // follows a completed range (as in `[a-z-9]`, a valid class) must NOT be suppressed. The prior scan
751
+ // flagged any `-` whose left neighbor outranked its right neighbor, so it wrongly killed `[a-z-9]`.
752
+ let i = start;
753
+ while (i < body.length) {
754
+ if (i + 2 < body.length && body.charAt(i + 1) === "-") {
755
+ if (body.charCodeAt(i) > body.charCodeAt(i + 2))
756
+ return true;
757
+ i += 3;
758
+ }
759
+ else {
760
+ i += 1;
761
+ }
762
+ }
763
+ return false;
764
+ }
765
+ function decideLinkedIssueMultiplier(mode, context, constants, branchEligibility) {
766
+ const baseMultiplier = selectIssueMultiplier(mode, constants);
767
+ const issueNumbers = uniquePositiveInts(context?.issueNumbers ?? []);
768
+ const solvedByPullRequests = uniquePositiveInts(context?.solvedByPullRequests ?? []);
769
+ if (mode === "none") {
770
+ return {
771
+ mode,
772
+ status: "not_required",
773
+ source: "none",
774
+ eligible: false,
775
+ issueNumbers,
776
+ solvedByPullRequests,
777
+ baseMultiplier,
778
+ appliedMultiplier: 1,
779
+ reason: "No linked-issue multiplier mode was requested.",
780
+ warnings: [],
781
+ };
782
+ }
783
+ if (mode === "maintainer") {
784
+ return {
785
+ mode,
786
+ status: "not_required",
787
+ source: context?.source ?? "none",
788
+ eligible: true,
789
+ issueNumbers,
790
+ solvedByPullRequests,
791
+ baseMultiplier,
792
+ appliedMultiplier: baseMultiplier,
793
+ reason: "Maintainer-lane multiplier does not require solved-by-PR issue linkage.",
794
+ warnings: context?.warnings ?? [],
795
+ };
796
+ }
797
+ const projectedSolvedByPullRequestValidation = context?.[PROJECTED_SOLVED_BY_PULL_REQUEST_VALIDATION] === true;
798
+ const requestedStatus = context?.status ?? (solvedByPullRequests.length > 0 ? "validated" : issueNumbers.length > 0 ? "raw" : "unavailable");
799
+ const hasSolvedByPullRequestEvidence = solvedByPullRequests.length > 0 || projectedSolvedByPullRequestValidation;
800
+ const status = requestedStatus === "validated" && !hasSolvedByPullRequestEvidence ? (issueNumbers.length > 0 ? "raw" : "unavailable") : requestedStatus;
801
+ const source = context?.source ?? (status === "unavailable" ? "missing" : "user_supplied");
802
+ const branchEligible = isConfirmedBranchEligible(branchEligibility);
803
+ const eligible = status === "validated" && hasSolvedByPullRequestEvidence && branchEligible;
804
+ const reason = branchEligible || status !== "validated"
805
+ ? status === requestedStatus
806
+ ? context?.reason ?? linkedIssueReason(status, source, issueNumbers, solvedByPullRequests)
807
+ : linkedIssueReason(status, source, issueNumbers, solvedByPullRequests)
808
+ : branchEligibilityFailureReason(branchEligibility);
809
+ return {
810
+ mode,
811
+ status,
812
+ source,
813
+ eligible,
814
+ issueNumbers,
815
+ solvedByPullRequests,
816
+ baseMultiplier,
817
+ appliedMultiplier: eligible ? baseMultiplier : 1,
818
+ reason,
819
+ warnings: [...new Set([...linkedIssueWarnings(status), ...branchEligibility.warnings, ...(context?.warnings ?? [])])],
820
+ };
821
+ }
822
+ function isConfirmedBranchEligible(branchEligibility) {
823
+ return !branchEligibility.required || (branchEligibility.status === "eligible" && branchEligibility.evidence === "provided" && !branchEligibility.stale);
824
+ }
825
+ function branchEligibilityFailureReason(branchEligibility) {
826
+ if (branchEligibility.status === "ineligible")
827
+ return "Branch eligibility is confirmed ineligible; standard issue multiplier is not applied.";
828
+ if (branchEligibility.evidence === "missing")
829
+ return "Branch eligibility evidence is missing; standard issue multiplier is not applied.";
830
+ if (branchEligibility.status === "unknown")
831
+ return "Branch eligibility is unknown; standard issue multiplier is not applied.";
832
+ if (branchEligibility.stale)
833
+ return "Branch eligibility evidence is stale; standard issue multiplier is not applied.";
834
+ if (branchEligibility.source === "user_supplied")
835
+ return "Branch eligibility evidence is user-supplied; standard issue multiplier is not applied until verified metadata is available.";
836
+ return "Branch eligibility is not confirmed; standard issue multiplier is not applied.";
837
+ }
838
+ function withValidatedLinkedIssueScenario(input) {
839
+ const mode = input.linkedIssueMode ?? "none";
840
+ if (mode === "maintainer")
841
+ return input;
842
+ const issueNumbers = uniquePositiveInts(input.linkedIssueContext?.issueNumbers ?? []);
843
+ const solvedByPullRequests = uniquePositiveInts(input.linkedIssueContext?.solvedByPullRequests ?? []);
844
+ const linkedIssueContext = {
845
+ ...input.linkedIssueContext,
846
+ status: "validated",
847
+ source: input.linkedIssueContext?.source ?? "user_supplied",
848
+ issueNumbers,
849
+ solvedByPullRequests,
850
+ warnings: [],
851
+ [PROJECTED_SOLVED_BY_PULL_REQUEST_VALIDATION]: true,
852
+ };
853
+ return {
854
+ ...input,
855
+ linkedIssueMode: "standard",
856
+ linkedIssueContext,
857
+ };
858
+ }
859
+ /**
860
+ * Project the standard linked-issue multiplier decision under the assumption that a planned PR
861
+ * becomes the merged solver of the given issue(s). Reuses {@link decideLinkedIssueMultiplier} — the
862
+ * same eligibility rule used by buildScorePreview — so standalone validators stay consistent with
863
+ * the scoring engine. The numeric multiplier on the returned decision is private; callers that are
864
+ * public-safe should surface only `eligible`/`status`/`reason`.
865
+ */
866
+ export function projectLinkedIssueMultiplierForPlannedSolve(issueNumbers) {
867
+ const branchEligibility = {
868
+ required: true,
869
+ status: "eligible",
870
+ evidence: "provided",
871
+ source: "user_supplied",
872
+ stale: false,
873
+ warnings: [],
874
+ };
875
+ const context = {
876
+ status: "validated",
877
+ source: "user_supplied",
878
+ issueNumbers: uniquePositiveInts(issueNumbers),
879
+ solvedByPullRequests: [],
880
+ warnings: [],
881
+ [PROJECTED_SOLVED_BY_PULL_REQUEST_VALIDATION]: true,
882
+ };
883
+ return decideLinkedIssueMultiplier("standard", context, {}, branchEligibility);
884
+ }
885
+ function linkedIssueReason(status, source, issueNumbers, solvedByPullRequests) {
886
+ const issues = issueNumbers.length > 0 ? ` for issue(s) ${issueNumbers.map((number) => `#${number}`).join(", ")}` : "";
887
+ if (status === "validated") {
888
+ const solvers = solvedByPullRequests.length > 0 ? ` via solved-by-PR ${solvedByPullRequests.map((number) => `#${number}`).join(", ")}` : "";
889
+ return `Linked issue context is solved-by-PR validated${issues}${solvers}.`;
890
+ }
891
+ if (status === "invalid")
892
+ return `Linked issue context is invalid${issues}; standard issue multiplier is not applied.`;
893
+ if (status === "plausible")
894
+ return `Linked issue context is plausible${issues}, but solved-by-PR validation is not available yet.`;
895
+ if (status === "unavailable")
896
+ return `Linked issue mirror/cache data is unavailable${issues}; standard issue multiplier is not applied until validation is available.`;
897
+ return `Raw linked issue reference${issues} has no solved-by-PR validation from ${source}.`;
898
+ }
899
+ function linkedIssueWarnings(status) {
900
+ if (status === "validated")
901
+ return [];
902
+ if (status === "invalid")
903
+ return ["Linked issue context is invalid; standard issue multiplier is not applied."];
904
+ if (status === "unavailable")
905
+ return ["Linked issue mirror/cache data is unavailable; standard issue multiplier is not applied until validation is available."];
906
+ if (status === "plausible")
907
+ return ["Linked issue context is plausible but not solved-by-PR validated; standard issue multiplier is not applied."];
908
+ return ["Raw linked issue reference has no solved-by-PR evidence; standard issue multiplier is not applied."];
909
+ }
910
+ function uniquePositiveInts(values) {
911
+ return [...new Set(values.filter((value) => Number.isInteger(value) && value > 0))].sort((left, right) => left - right);
912
+ }
913
+ function selectIssueMultiplier(mode, constants) {
914
+ if (mode === "maintainer")
915
+ return constant(constants, "MAINTAINER_ISSUE_MULTIPLIER");
916
+ if (mode === "standard")
917
+ return constant(constants, "STANDARD_ISSUE_MULTIPLIER");
918
+ return 1;
919
+ }
920
+ function normalizeBranchEligibility(input) {
921
+ const required = input.linkedIssueMode === "standard";
922
+ const supplied = input.branchEligibility;
923
+ if (!required) {
924
+ return {
925
+ required: false,
926
+ status: "not_required",
927
+ evidence: supplied ? "provided" : "missing",
928
+ source: supplied ? supplied.source ?? "user_supplied" : "missing",
929
+ reason: supplied?.reason,
930
+ checkedAt: supplied?.checkedAt,
931
+ stale: Boolean(supplied?.stale),
932
+ warnings: [],
933
+ };
934
+ }
935
+ if (!supplied) {
936
+ return {
937
+ required: true,
938
+ status: "unknown",
939
+ evidence: "missing",
940
+ source: "missing",
941
+ stale: false,
942
+ warnings: ["Branch eligibility evidence is missing; refresh branch/base metadata before relying on linked-issue assumptions."],
943
+ };
944
+ }
945
+ const status = supplied.status ?? "unknown";
946
+ const stale = Boolean(supplied.stale);
947
+ const warnings = [
948
+ ...(status === "ineligible" ? ["Branch eligibility is confirmed ineligible; linked-issue multiplier assumptions are disabled."] : []),
949
+ ...(status === "unknown" ? ["Branch eligibility is unknown; refresh branch/base metadata before relying on linked-issue assumptions."] : []),
950
+ ...(stale ? ["Branch eligibility evidence is stale; refresh branch/base metadata before relying on linked-issue assumptions."] : []),
951
+ ];
952
+ return {
953
+ required: true,
954
+ status,
955
+ evidence: "provided",
956
+ source: supplied.source ?? "user_supplied",
957
+ reason: supplied.reason,
958
+ checkedAt: supplied.checkedAt,
959
+ stale,
960
+ warnings,
961
+ };
962
+ }
963
+ function resolveMergedPullRequests(input, contributorEvidence) {
964
+ if (input.mergedPullRequests !== undefined)
965
+ return nonNegative(input.mergedPullRequests);
966
+ const fromEvidence = Number(contributorEvidence?.payload?.mergedPullRequests);
967
+ return Number.isFinite(fromEvidence) ? nonNegative(fromEvidence) : undefined;
968
+ }
969
+ function inferCredibility(evidence) {
970
+ const payload = evidence?.payload;
971
+ const merged = Number(payload?.mergedPullRequests ?? 0);
972
+ const stale = Number(payload?.stalePullRequests ?? 0);
973
+ const unlinked = Number(payload?.unlinkedPullRequests ?? 0);
974
+ // The payload is a loosely-typed cache (`Record<string, JsonValue>`), so any count can arrive as a
975
+ // non-numeric value. A non-finite `stale`/`unlinked` propagates NaN through the arithmetic below —
976
+ // and `clamp` cannot rescue NaN — poisoning the whole credibility multiplier and score, so guard all
977
+ // three counts, not just `merged`, falling back to the neutral credibility.
978
+ if (!Number.isFinite(merged) || !Number.isFinite(stale) || !Number.isFinite(unlinked))
979
+ return 0.8;
980
+ return clamp(0.75 + merged * 0.04 - stale * 0.03 - unlinked * 0.02, 0.25, 1);
981
+ }
982
+ function applyNonCodeLineCap(input, constants) {
983
+ const score = nonNegative(input.nonCodeTokenScore);
984
+ const lines = nonNegative(input.nonCodeLines);
985
+ if (score <= 0 || lines <= 0)
986
+ return score;
987
+ const maxLines = constant(constants, "MAX_LINES_SCORED_FOR_NON_CODE_EXT");
988
+ return lines <= maxLines ? score : score * (maxLines / lines);
989
+ }
990
+ function applyNonCodeCapToTotal(totalTokenScore, input, cappedNonCodeTokenScore) {
991
+ const total = nonNegative(totalTokenScore);
992
+ const nonCodeTokenScore = nonNegative(input.nonCodeTokenScore);
993
+ if (nonCodeTokenScore <= 0 || cappedNonCodeTokenScore >= nonCodeTokenScore)
994
+ return total;
995
+ return Math.max(0, total - (nonCodeTokenScore - cappedNonCodeTokenScore));
996
+ }
997
+ // Single source of truth (#812): the fallback for any constant is ALWAYS DEFAULT_SCORING_CONSTANTS — never
998
+ // a duplicated literal at the call site. The live `constants` (snapshot.constants, which already merges
999
+ // DEFAULT_SCORING_CONSTANTS with parsed upstream values) wins when present; otherwise the declared default
1000
+ // is used. This removes the duplicate-source-of-truth drift surface without changing any value (every
1001
+ // former call-site literal already matched its DEFAULT_SCORING_CONSTANTS entry).
1002
+ function constant(constants, key) {
1003
+ const value = constants[key];
1004
+ if (typeof value === "number" && Number.isFinite(value))
1005
+ return value;
1006
+ const fallback = DEFAULT_SCORING_CONSTANTS[key];
1007
+ /* v8 ignore next -- defensive: every recognized key is in DEFAULT_SCORING_CONSTANTS; this guards typos/forward-compat. */
1008
+ return typeof fallback === "number" && Number.isFinite(fallback) ? fallback : 0;
1009
+ }
1010
+ /**
1011
+ * Resolve a repo's time-decay curve: each parameter is the repo's per-repo override (from the registry's
1012
+ * `scoring.time_decay`) when present, else the global default constant from the live scoring snapshot.
1013
+ * Mirrors upstream's `resolve_time_decay` (RepoTimeDecayConfig overlaid on the module constants).
1014
+ *
1015
+ * Parity note (#1320): upstream coerces ONLY `grace_period_hours` to an integer
1016
+ * (`grace_period_hours=int(pick(...))`) while the three curve params stay floats. A maintainer may legally
1017
+ * configure a fractional grace (upstream validates `0 <= grace_period_hours <= 168`), so the resolved grace
1018
+ * must be truncated toward zero to match the validator — otherwise a PR aged between `trunc(grace)` and
1019
+ * `grace` is treated as fresh in the preview but already decaying upstream.
1020
+ */
1021
+ export function resolveTimeDecay(constants, overrides) {
1022
+ return {
1023
+ gracePeriodHours: clampGracePeriodHours(Math.trunc(pickOverride(overrides?.gracePeriodHours, constant(constants, "TIME_DECAY_GRACE_PERIOD_HOURS")))),
1024
+ sigmoidMidpointDays: pickOverride(overrides?.sigmoidMidpointDays, constant(constants, "TIME_DECAY_SIGMOID_MIDPOINT")),
1025
+ sigmoidSteepness: pickOverride(overrides?.sigmoidSteepness, constant(constants, "TIME_DECAY_SIGMOID_STEEPNESS_SCALAR")),
1026
+ minMultiplier: clampMinMultiplier(pickOverride(overrides?.minMultiplier, constant(constants, "TIME_DECAY_MIN_MULTIPLIER"))),
1027
+ };
1028
+ }
1029
+ // Documented bound (see the parity note above): upstream validates 0 <= grace_period_hours <= 168. The
1030
+ // override reaches this resolver via a bare Number.isFinite check (registry/normalize.ts's parseTimeDecayOverrides),
1031
+ // so an out-of-band value (negative, or beyond a week) would otherwise apply verbatim.
1032
+ const GRACE_PERIOD_HOURS_MIN = 0;
1033
+ const GRACE_PERIOD_HOURS_MAX = 168;
1034
+ function clampGracePeriodHours(value) {
1035
+ return clamp(value, GRACE_PERIOD_HOURS_MIN, GRACE_PERIOD_HOURS_MAX);
1036
+ }
1037
+ // minMultiplier is the sigmoid's floor (calculateTimeDecay: Math.max(sigmoid, minMultiplier)), and the
1038
+ // sigmoid itself is always in (0, 1). A per-repo override above 1 would floor every aged PR ABOVE a fresh
1039
+ // PR's multiplier -- inverting time decay into an age bonus -- and a negative override applied verbatim
1040
+ // today has no floor semantics at all. Bound to the sigmoid's own range so the floor invariant always holds.
1041
+ const MIN_MULTIPLIER_MIN = 0;
1042
+ const MIN_MULTIPLIER_MAX = 1;
1043
+ function clampMinMultiplier(value) {
1044
+ return clamp(value, MIN_MULTIPLIER_MIN, MIN_MULTIPLIER_MAX);
1045
+ }
1046
+ function pickOverride(value, fallback) {
1047
+ return typeof value === "number" && Number.isFinite(value) ? value : fallback;
1048
+ }
1049
+ /**
1050
+ * Upstream gittensor's sigmoid time-decay multiplier (#703), ported verbatim from the validator's
1051
+ * `calculate_time_decay` (gittensor/validator/utils/datetime_utils.py): for the first grace-period hours the
1052
+ * multiplier is exactly 1.0 (hard cutoff); after that it follows a logistic on days-since-merge centred at
1053
+ * the sigmoid midpoint (50% at that point), floored at the minimum multiplier. The curve params are
1054
+ * resolved PER-REPO (overrides ?? snapshot defaults), so each maintainer's registry hyperparameters apply.
1055
+ * Pure + deterministic.
1056
+ */
1057
+ export function calculateTimeDecay(prAgeHours, constants, overrides) {
1058
+ const { gracePeriodHours, sigmoidMidpointDays, sigmoidSteepness, minMultiplier } = resolveTimeDecay(constants, overrides);
1059
+ if (!Number.isFinite(prAgeHours) || prAgeHours < gracePeriodHours)
1060
+ return 1;
1061
+ const days = prAgeHours / 24;
1062
+ const sigmoid = 1 / (1 + Math.exp(sigmoidSteepness * (days - sigmoidMidpointDays)));
1063
+ return Math.max(sigmoid, minMultiplier);
1064
+ }
1065
+ function saturationScore(sourceTokenScore, totalTokenScore, constants) {
1066
+ // SRC_TOK_SATURATION_SCALE is per-repo overridable only within [10, 500] upstream; a snapshot value outside
1067
+ // that band (a bad override, a parse glitch) would otherwise distort the saturation curve — at scale 1 the
1068
+ // component saturates almost immediately, well above the documented floor. Clamp to the documented range so
1069
+ // the curve stays within upstream bounds (the prior Math.max(...,1) only guarded the divide-by-zero edge).
1070
+ const scale = clampSaturationScale(constant(constants, "SRC_TOK_SATURATION_SCALE"));
1071
+ return (constant(constants, "MERGED_PR_BASE_SCORE") * (1 - Math.exp(-sourceTokenScore / scale)) +
1072
+ saturationContributionBonus(totalTokenScore, constants));
1073
+ }
1074
+ function saturationContributionBonus(totalTokenScore, constants) {
1075
+ return contributionBonusRamp(totalTokenScore, constants);
1076
+ }
1077
+ // Shared contribution-bonus ramp used by both scoring models so the saturation
1078
+ // and density bonuses cannot drift: clamp(totalTokenScore / FULL_BONUS, 0, 1)
1079
+ // scaled by MAX_CONTRIBUTION_BONUS (upstream default 5; single-sourced in model.ts, see #807/#812).
1080
+ function contributionBonusRamp(totalTokenScore, constants) {
1081
+ return (clamp(totalTokenScore / constant(constants, "CONTRIBUTION_SCORE_FOR_FULL_BONUS"), 0, 1) *
1082
+ constant(constants, "MAX_CONTRIBUTION_BONUS"));
1083
+ }
1084
+ function nonNegative(value) {
1085
+ /* v8 ignore next -- API schemas and local scorers normalize numeric preview inputs before this defensive fallback. */
1086
+ return Number.isFinite(value) ? Math.max(0, value ?? 0) : 0;
1087
+ }
1088
+ function clamp(value, min, max) {
1089
+ return Math.min(max, Math.max(min, value));
1090
+ }
1091
+ // Bounds documented by upstream for the two repo-configurable scoring inputs the preview consumes directly.
1092
+ const FIXED_BASE_SCORE_MIN = 0;
1093
+ const FIXED_BASE_SCORE_MAX = 100;
1094
+ const SRC_TOK_SATURATION_SCALE_MIN = 10;
1095
+ const SRC_TOK_SATURATION_SCALE_MAX = 500;
1096
+ // A repo's fixed_base_score override forces base_score to a constant within [0, 100]. The value reaches the
1097
+ // preview unbounded above — the API schema only enforces `.min(0)` and registry normalization accepts any
1098
+ // finite number — so a misconfigured 150 would otherwise mint a base score above the model ceiling. Clamp to
1099
+ // the documented range; a non-finite/absent value falls through to the token-derived base score.
1100
+ function clampFixedBaseScore(value) {
1101
+ return Number.isFinite(value) ? clamp(value, FIXED_BASE_SCORE_MIN, FIXED_BASE_SCORE_MAX) : undefined;
1102
+ }
1103
+ function clampSaturationScale(value) {
1104
+ return clamp(value, SRC_TOK_SATURATION_SCALE_MIN, SRC_TOK_SATURATION_SCALE_MAX);
1105
+ }
1106
+ function roundScore(value) {
1107
+ return Math.round(value * 10000) / 10000;
1108
+ }