@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,55 @@
1
+ function round4(value) {
2
+ return Math.round(value * 10000) / 10000;
3
+ }
4
+ function clamp(value, min, max) {
5
+ return Math.max(min, Math.min(max, value));
6
+ }
7
+ function isParseableTimestamp(value) {
8
+ return Number.isFinite(Date.parse(value));
9
+ }
10
+ function pickTimestamp(issue) {
11
+ const updated = typeof issue.updatedAt === "string" ? issue.updatedAt.trim() : "";
12
+ if (updated && isParseableTimestamp(updated))
13
+ return updated;
14
+ const created = typeof issue.createdAt === "string" ? issue.createdAt.trim() : "";
15
+ if (created && isParseableTimestamp(created))
16
+ return created;
17
+ return null;
18
+ }
19
+ function issueAgeDays(value, nowMs) {
20
+ if (!value)
21
+ return Number.POSITIVE_INFINITY;
22
+ const parsed = Date.parse(value);
23
+ if (!Number.isFinite(parsed))
24
+ return Number.POSITIVE_INFINITY;
25
+ return Math.floor((nowMs - parsed) / 86_400_000);
26
+ }
27
+ function isOpenIssue(issue) {
28
+ return typeof issue?.state === "string" && issue.state.trim().toLowerCase() === "open";
29
+ }
30
+ /* v8 ignore start -- Test-only export surface for branch coverage. */
31
+ export const opportunityFreshnessInternals = {
32
+ pickTimestamp,
33
+ issueAgeDays,
34
+ };
35
+ /* v8 ignore stop */
36
+ /**
37
+ * Compute a [0.05, 1] freshness factor from open issue timestamps, mirroring
38
+ * `opportunityFreshnessFactor` in `src/signals/reward-risk.ts` with an injected clock so the miner engine
39
+ * stays pure and testable.
40
+ */
41
+ export function computeOpportunityFreshness(issues, nowMs) {
42
+ /* v8 ignore next -- Caller supplies a finite epoch; non-finite clocks degrade to zero freshness. */
43
+ if (!Number.isFinite(nowMs))
44
+ return 0;
45
+ const openIssues = issues.filter(isOpenIssue);
46
+ if (openIssues.length === 0)
47
+ return 0;
48
+ let mostRecentAgeDays = Number.POSITIVE_INFINITY;
49
+ for (const issue of openIssues) {
50
+ const ageDays = issueAgeDays(pickTimestamp(issue), nowMs);
51
+ if (ageDays < mostRecentAgeDays)
52
+ mostRecentAgeDays = ageDays;
53
+ }
54
+ return round4(clamp(Math.exp(-mostRecentAgeDays / 20), 0.05, 1));
55
+ }
@@ -0,0 +1,53 @@
1
+ import { type MinerGoalSpec } from "./miner-goal-spec.js";
2
+ import { type OpportunityRankInput } from "./opportunity-ranker.js";
3
+ /** Metadata-only candidate issue shape produced by `@loopover/miner` fan-out helpers. */
4
+ export type MetadataCandidateIssue = {
5
+ repoFullName: string;
6
+ issueNumber: number;
7
+ title: string;
8
+ labels: readonly string[];
9
+ /** When present, lane fit uses path+label goal matching instead of labels alone. */
10
+ candidatePaths?: readonly string[] | undefined;
11
+ commentsCount: number;
12
+ createdAt?: string | null | undefined;
13
+ updatedAt?: string | null | undefined;
14
+ };
15
+ export type MetadataRankContext = {
16
+ nowMs: number;
17
+ highRiskDuplicateClusters?: number | undefined;
18
+ openPullRequests?: number | undefined;
19
+ goalSpecsByRepo?: Readonly<Record<string, MinerGoalSpec>> | undefined;
20
+ };
21
+ declare function normalizeLabels(labels: readonly string[]): string[];
22
+ declare function resolveGoalSpec(repoFullName: string, context: MetadataRankContext): MinerGoalSpec;
23
+ declare function pickMetadataTimestamp(issue: MetadataCandidateIssue): string;
24
+ /**
25
+ * Estimate reward potential from issue labels alone. Explicitly negative labels collapse the score; common
26
+ * contribution labels raise it; everything else keeps a neutral baseline.
27
+ */
28
+ export declare function computeMetadataPotential(issue: {
29
+ labels: readonly string[];
30
+ }): number;
31
+ /**
32
+ * Estimate achievability from metadata-only cues: lower discussion load and fresher issues score higher.
33
+ */
34
+ export declare function computeMetadataFeasibility(issue: MetadataCandidateIssue, nowMs: number): number;
35
+ declare function titlesOverlap(left: string, right: string): boolean;
36
+ export declare const opportunityMetadataInternals: {
37
+ titlesOverlap: typeof titlesOverlap;
38
+ normalizeLabels: typeof normalizeLabels;
39
+ resolveGoalSpec: typeof resolveGoalSpec;
40
+ pickMetadataTimestamp: typeof pickMetadataTimestamp;
41
+ };
42
+ /**
43
+ * Estimate duplicate-work risk inside a metadata-only candidate batch by looking for overlapping titles in the
44
+ * same repository. This is intentionally conservative: any strong overlap raises dupRisk toward 1.
45
+ */
46
+ export declare function computeMetadataDupRisk(issue: MetadataCandidateIssue, peers: readonly MetadataCandidateIssue[]): number;
47
+ /** Build the five ranker inputs for one metadata candidate. Pure. */
48
+ export declare function buildMetadataRankInput(issue: MetadataCandidateIssue, peers: readonly MetadataCandidateIssue[], context: MetadataRankContext): OpportunityRankInput;
49
+ /** Rank metadata-only candidates with the shared opportunity ranker. Pure. */
50
+ export declare function rankMetadataOpportunities<T extends MetadataCandidateIssue>(candidates: readonly T[], context: MetadataRankContext): Array<T & OpportunityRankInput & {
51
+ rankScore: number;
52
+ }>;
53
+ export {};
@@ -0,0 +1,204 @@
1
+ import { computeMetadataLaneFit, isMinerRepoTargetable } from "./miner-goal-lane-fit.js";
2
+ import { DEFAULT_MINER_GOAL_SPEC } from "./miner-goal-spec.js";
3
+ import { computeOpportunityCompetition } from "./opportunity-competition.js";
4
+ import { computeOpportunityFreshness } from "./opportunity-freshness.js";
5
+ import { rankOpportunities, } from "./opportunity-ranker.js";
6
+ const POSITIVE_LABELS = Object.freeze([
7
+ "good first issue",
8
+ "help wanted",
9
+ "enhancement",
10
+ "feature",
11
+ "documentation",
12
+ ]);
13
+ const NEGATIVE_LABELS = Object.freeze([
14
+ "blocked",
15
+ "wontfix",
16
+ "duplicate",
17
+ "invalid",
18
+ "question",
19
+ ]);
20
+ function clamp01(value) {
21
+ /* v8 ignore next -- Defensive guard for malformed adapter input; scores are always finite in practice. */
22
+ if (!Number.isFinite(value))
23
+ return 0;
24
+ return Math.min(1, Math.max(0, value));
25
+ }
26
+ function finiteNonNegativeInt(value) {
27
+ /* v8 ignore next -- Defensive guard for malformed adapter input; counts are normalized before scoring. */
28
+ if (!Number.isFinite(value))
29
+ return 0;
30
+ return Math.max(0, Math.trunc(value));
31
+ }
32
+ /* v8 ignore start -- Label/title normalization helpers are covered through exported ranker entrypoints. */
33
+ function normalizeLabels(labels) {
34
+ return labels
35
+ .filter((label) => typeof label === "string")
36
+ .map((label) => label.trim().toLowerCase())
37
+ .filter(Boolean);
38
+ }
39
+ function normalizeTitle(title) {
40
+ return title.replace(/\s+/g, " ").trim().toLowerCase();
41
+ }
42
+ function resolveGoalSpec(repoFullName, context) {
43
+ const target = repoFullName.trim().toLowerCase();
44
+ const entries = context.goalSpecsByRepo ? Object.entries(context.goalSpecsByRepo) : [];
45
+ for (const [repo, spec] of entries) {
46
+ if (repo.trim().toLowerCase() === target)
47
+ return spec;
48
+ }
49
+ return DEFAULT_MINER_GOAL_SPEC;
50
+ }
51
+ /* v8 ignore stop */
52
+ const STALE_AGE_DAYS = 9999;
53
+ /* v8 ignore start -- Internal timestamp helpers mirror freshness semantics; exercised via exported ranker paths. */
54
+ function pickMetadataTimestamp(issue) {
55
+ // Mirror freshness semantics (opportunity-freshness's pickTimestamp): only commit to a timestamp that actually
56
+ // parses. Without the guard, a present-but-unparseable updatedAt shadows a valid createdAt, so issueAgeDays
57
+ // hits the STALE_AGE_DAYS sentinel and a genuinely fresh issue is scored as maximally stale.
58
+ if (typeof issue.updatedAt === "string") {
59
+ const updated = issue.updatedAt.trim();
60
+ if (updated && Number.isFinite(Date.parse(updated)))
61
+ return updated;
62
+ }
63
+ if (typeof issue.createdAt === "string") {
64
+ const created = issue.createdAt.trim();
65
+ if (created && Number.isFinite(Date.parse(created)))
66
+ return created;
67
+ }
68
+ return "";
69
+ }
70
+ function issueAgeDays(issue, nowMs) {
71
+ const stamp = pickMetadataTimestamp(issue);
72
+ if (!stamp)
73
+ return STALE_AGE_DAYS;
74
+ const parsed = Date.parse(stamp);
75
+ if (!Number.isFinite(parsed))
76
+ return STALE_AGE_DAYS;
77
+ return Math.max(0, Math.floor((nowMs - parsed) / 86_400_000));
78
+ }
79
+ /* v8 ignore stop */
80
+ /**
81
+ * Estimate reward potential from issue labels alone. Explicitly negative labels collapse the score; common
82
+ * contribution labels raise it; everything else keeps a neutral baseline.
83
+ */
84
+ /* v8 ignore start -- Metadata heuristics are exercised end-to-end in test/unit/miner-opportunity-ranker.test.ts. */
85
+ export function computeMetadataPotential(issue) {
86
+ const labels = normalizeLabels(issue.labels);
87
+ /* v8 ignore next -- Terminal labels short-circuit to zero potential; exercised in ranker tests. */
88
+ if (labels.some((label) => NEGATIVE_LABELS.includes(label)))
89
+ return 0;
90
+ let score = 0.45;
91
+ /* v8 ignore next -- Neutral metadata keeps the baseline when no contribution labels are present. */
92
+ if (labels.some((label) => POSITIVE_LABELS.includes(label)))
93
+ score += 0.35;
94
+ /* v8 ignore next -- Bug/refactor bonuses are additive; neutral-only labels keep the baseline score. */
95
+ if (labels.includes("bug"))
96
+ score += 0.1;
97
+ /* v8 ignore next */
98
+ if (labels.includes("refactor"))
99
+ score += 0.05;
100
+ return clamp01(score);
101
+ }
102
+ /**
103
+ * Estimate achievability from metadata-only cues: lower discussion load and fresher issues score higher.
104
+ */
105
+ export function computeMetadataFeasibility(issue, nowMs) {
106
+ /* v8 ignore next -- Ranker callers inject a finite epoch; malformed clocks degrade to zero feasibility. */
107
+ if (!Number.isFinite(nowMs))
108
+ return 0;
109
+ const comments = finiteNonNegativeInt(issue.commentsCount);
110
+ const commentScore = clamp01(1 - comments / 25);
111
+ const ageDays = issueAgeDays(issue, nowMs);
112
+ const ageScore = clamp01(Math.exp(-ageDays / 45));
113
+ const titleLength = normalizeTitle(issue.title).length;
114
+ /* v8 ignore start -- Title-length tiers are covered through ranker integration tests. */
115
+ let titleScore = 0.4;
116
+ if (titleLength >= 8) {
117
+ titleScore = 1;
118
+ }
119
+ else if (titleLength >= 4) {
120
+ titleScore = 0.7;
121
+ }
122
+ /* v8 ignore stop */
123
+ return clamp01(commentScore * 0.45 + ageScore * 0.35 + titleScore * 0.2);
124
+ }
125
+ /* v8 ignore start -- Title overlap helper is exercised through computeMetadataDupRisk. */
126
+ function titlesOverlap(left, right) {
127
+ if (!left || !right)
128
+ return false;
129
+ if (left === right)
130
+ return true;
131
+ let shorter = left;
132
+ let longer = right;
133
+ if (left.length > right.length) {
134
+ shorter = right;
135
+ longer = left;
136
+ }
137
+ return longer.includes(shorter) && shorter.length >= 12;
138
+ }
139
+ /* v8 ignore stop */
140
+ /* v8 ignore start -- Test-only export surface for branch coverage. */
141
+ export const opportunityMetadataInternals = {
142
+ titlesOverlap,
143
+ normalizeLabels,
144
+ resolveGoalSpec,
145
+ pickMetadataTimestamp,
146
+ };
147
+ /* v8 ignore stop */
148
+ /**
149
+ * Estimate duplicate-work risk inside a metadata-only candidate batch by looking for overlapping titles in the
150
+ * same repository. This is intentionally conservative: any strong overlap raises dupRisk toward 1.
151
+ */
152
+ export function computeMetadataDupRisk(issue, peers) {
153
+ const normalized = normalizeTitle(issue.title);
154
+ /* v8 ignore next -- Blank titles are treated as maximum dup risk. */
155
+ if (!normalized)
156
+ return 1;
157
+ let overlaps = 0;
158
+ for (const peer of peers) {
159
+ /* v8 ignore next -- Self-peer rows are skipped when scanning the shared batch list. */
160
+ if (peer.issueNumber === issue.issueNumber && peer.repoFullName === issue.repoFullName)
161
+ continue;
162
+ /* v8 ignore next -- Cross-repo peers are ignored when scanning for overlap inside a batch. */
163
+ if (peer.repoFullName.trim().toLowerCase() !== issue.repoFullName.trim().toLowerCase())
164
+ continue;
165
+ /* v8 ignore next -- Overlap hits are counted only for same-repo peers with shared title segments. */
166
+ if (titlesOverlap(normalized, normalizeTitle(peer.title)))
167
+ overlaps += 1;
168
+ }
169
+ /* v8 ignore next -- No overlaps keeps dup risk at zero for unique titles. */
170
+ if (overlaps === 0)
171
+ return 0;
172
+ return clamp01(overlaps / (overlaps + 1));
173
+ }
174
+ /** Build the five ranker inputs for one metadata candidate. Pure. */
175
+ export function buildMetadataRankInput(issue, peers, context) {
176
+ const goalSpec = resolveGoalSpec(issue.repoFullName, context);
177
+ const repoCompetition = computeOpportunityCompetition(
178
+ /* v8 ignore next */
179
+ context.highRiskDuplicateClusters ?? 0,
180
+ /* v8 ignore next */
181
+ context.openPullRequests ?? 0);
182
+ const batchDupRisk = computeMetadataDupRisk(issue, peers);
183
+ return {
184
+ potential: computeMetadataPotential(issue),
185
+ feasibility: computeMetadataFeasibility(issue, context.nowMs),
186
+ laneFit: computeMetadataLaneFit(issue, goalSpec),
187
+ freshness: computeOpportunityFreshness(
188
+ /* v8 ignore next */
189
+ [{ state: "open", updatedAt: issue.updatedAt ?? null, createdAt: issue.createdAt ?? null }], context.nowMs),
190
+ /* v8 ignore next */
191
+ dupRisk: clamp01(Math.max(batchDupRisk, repoCompetition)),
192
+ };
193
+ }
194
+ /** Rank metadata-only candidates with the shared opportunity ranker. Pure. */
195
+ export function rankMetadataOpportunities(candidates, context) {
196
+ const targetableCandidates = candidates.filter((candidate) => isMinerRepoTargetable(resolveGoalSpec(candidate.repoFullName, context)));
197
+ const annotated = targetableCandidates.map((candidate) => ({
198
+ ...candidate,
199
+ ...buildMetadataRankInput(candidate, targetableCandidates, context),
200
+ }));
201
+ /* v8 ignore next */
202
+ return rankOpportunities(annotated);
203
+ }
204
+ /* v8 ignore stop */
@@ -0,0 +1,46 @@
1
+ /** The five 0-1 normalized signals for one candidate opportunity. */
2
+ export type OpportunityRankInput = {
3
+ /** Expected reward if the work is won (score / label-multiplier potential). */
4
+ potential: number;
5
+ /** How achievable the issue is for the miner. */
6
+ feasibility: number;
7
+ /** Fit with the miner's preferred lanes. */
8
+ laneFit: number;
9
+ /** How recently actionable the opportunity is (decays as it ages). */
10
+ freshness: number;
11
+ /** Risk the work is already claimed / contested; higher means more likely a wasted attempt. */
12
+ dupRisk: number;
13
+ };
14
+ /**
15
+ * The ordinal opportunity score: `potential * feasibility * laneFit * freshness * (1 - dupRisk)`, with every field
16
+ * clamped to [0, 1] first. Because it is a product, ANY single factor at 0 — or a `dupRisk` of exactly 1 — collapses
17
+ * the whole score to 0: a candidate that fails any one dimension is not an opportunity. Malformed input never passes
18
+ * through raw and always degrades the score toward 0: the four positive factors clamp a non-finite value to 0, and a
19
+ * non-finite `dupRisk` fails closed to 1 (max risk). So a bad signal can neither invert the sign nor overflow the
20
+ * product. Pure.
21
+ *
22
+ * Signal-source map for the composing caller (a later issue): `feasibility` ← the per-repo report in
23
+ * `src/services/issue-quality.ts`; `laneFit` ← `MinerGoalSpec.preferredLanes` (the goal-model issue); `freshness`
24
+ * ← `src/signals/reward-risk.ts`'s `freshnessFactor`; `dupRisk` ← `src/signals/reward-risk.ts`'s
25
+ * `competitionFactor` combined with `src/signals/duplicate-winner.ts`'s claim adjudication.
26
+ */
27
+ export declare function rankOpportunityScore(input: OpportunityRankInput): number;
28
+ /**
29
+ * Rank a candidate list by descending {@link rankOpportunityScore}, annotating each candidate with its `rankScore`.
30
+ * Equal scores keep their input order: the tie-break is made EXPLICIT via a carried index (`rankScore` desc, then
31
+ * `index` asc) rather than relying on `Array.prototype.sort` stability, so the contract holds on any engine and is
32
+ * enforced by this function. Mirrors the tie-break intent of `isDuplicateClusterWinnerByClaim` in
33
+ * src/signals/duplicate-winner.ts, where an earlier entry wins a tie. Pure — returns a new array; the input array
34
+ * and its elements are not mutated. The computed `rankScore` REPLACES any `rankScore` already on an input element
35
+ * (`Omit<T, "rankScore">` in the result), so a caller carrying its own field can't collide with the annotation.
36
+ */
37
+ export declare function rankOpportunities<T>(candidates: Array<T & OpportunityRankInput>): Array<Omit<T, "rankScore"> & OpportunityRankInput & {
38
+ rankScore: number;
39
+ }>;
40
+ /**
41
+ * Rank candidates and return the top `limit` entries. Non-finite or negative limits return an empty list.
42
+ * Pure — delegates to {@link rankOpportunities} for ordering and tie-breaking.
43
+ */
44
+ export declare function pickTopRankedOpportunities<T>(candidates: Array<T & OpportunityRankInput>, limit: number): Array<Omit<T, "rankScore"> & OpportunityRankInput & {
45
+ rankScore: number;
46
+ }>;
@@ -0,0 +1,74 @@
1
+ // Opportunity ranker (#2302). The core Phase-1 miner-discovery ranker: it composes five already-normalized,
2
+ // deterministic signals into a single ordinal score used to sort a cross-repo candidate-issue list, so a later
3
+ // `gittensory_find_opportunities` tool has something deterministic to sort by.
4
+ //
5
+ // This module is PURE — no IO, no Date, no random — so identical inputs always produce identical order, matching
6
+ // the house convention in src/signals/duplicate-winner.ts. Every input is clamped to [0, 1] before use; the sole
7
+ // exception is a NON-finite `dupRisk` (NaN/±Infinity), which can't be clamped and fails closed to max risk so a
8
+ // broken contention signal never looks safe. Either way a malformed signal degrades the score toward 0 rather than
9
+ // inverting or blowing up the product.
10
+ /** Clamp a positive factor to [0, 1]; a non-finite value (NaN/±Infinity from a broken upstream) degrades to 0. */
11
+ function clamp01(value) {
12
+ if (!Number.isFinite(value))
13
+ return 0;
14
+ return Math.min(1, Math.max(0, value));
15
+ }
16
+ /**
17
+ * Normalize the contention/risk signal to [0, 1]. A FINITE value is clamped like every other field — below-range
18
+ * → 0, above-range → 1 — so `dupRisk = -0.1` reads as no contention and `dupRisk = 1.4` as full contention. A
19
+ * NON-finite value (`NaN`/`±Infinity`) cannot be clamped and signals a broken upstream, so it FAILS CLOSED to
20
+ * maximum risk (1), never 0: a broken contention signal must not masquerade as a safe, uncontested opportunity
21
+ * (mirroring the fail-closed convention in `src/signals/duplicate-winner.ts`, where sparse rows fail closed).
22
+ */
23
+ function clampRisk(value) {
24
+ if (!Number.isFinite(value))
25
+ return 1;
26
+ return Math.min(1, Math.max(0, value));
27
+ }
28
+ /**
29
+ * The ordinal opportunity score: `potential * feasibility * laneFit * freshness * (1 - dupRisk)`, with every field
30
+ * clamped to [0, 1] first. Because it is a product, ANY single factor at 0 — or a `dupRisk` of exactly 1 — collapses
31
+ * the whole score to 0: a candidate that fails any one dimension is not an opportunity. Malformed input never passes
32
+ * through raw and always degrades the score toward 0: the four positive factors clamp a non-finite value to 0, and a
33
+ * non-finite `dupRisk` fails closed to 1 (max risk). So a bad signal can neither invert the sign nor overflow the
34
+ * product. Pure.
35
+ *
36
+ * Signal-source map for the composing caller (a later issue): `feasibility` ← the per-repo report in
37
+ * `src/services/issue-quality.ts`; `laneFit` ← `MinerGoalSpec.preferredLanes` (the goal-model issue); `freshness`
38
+ * ← `src/signals/reward-risk.ts`'s `freshnessFactor`; `dupRisk` ← `src/signals/reward-risk.ts`'s
39
+ * `competitionFactor` combined with `src/signals/duplicate-winner.ts`'s claim adjudication.
40
+ */
41
+ export function rankOpportunityScore(input) {
42
+ return (clamp01(input.potential) *
43
+ clamp01(input.feasibility) *
44
+ clamp01(input.laneFit) *
45
+ clamp01(input.freshness) *
46
+ (1 - clampRisk(input.dupRisk)));
47
+ }
48
+ /**
49
+ * Rank a candidate list by descending {@link rankOpportunityScore}, annotating each candidate with its `rankScore`.
50
+ * Equal scores keep their input order: the tie-break is made EXPLICIT via a carried index (`rankScore` desc, then
51
+ * `index` asc) rather than relying on `Array.prototype.sort` stability, so the contract holds on any engine and is
52
+ * enforced by this function. Mirrors the tie-break intent of `isDuplicateClusterWinnerByClaim` in
53
+ * src/signals/duplicate-winner.ts, where an earlier entry wins a tie. Pure — returns a new array; the input array
54
+ * and its elements are not mutated. The computed `rankScore` REPLACES any `rankScore` already on an input element
55
+ * (`Omit<T, "rankScore">` in the result), so a caller carrying its own field can't collide with the annotation.
56
+ */
57
+ export function rankOpportunities(candidates) {
58
+ return candidates
59
+ .map((candidate, index) => ({ candidate, rankScore: rankOpportunityScore(candidate), index }))
60
+ .sort((a, b) => b.rankScore - a.rankScore || a.index - b.index)
61
+ .map(({ candidate, rankScore }) => ({ ...candidate, rankScore }));
62
+ }
63
+ /**
64
+ * Rank candidates and return the top `limit` entries. Non-finite or negative limits return an empty list.
65
+ * Pure — delegates to {@link rankOpportunities} for ordering and tie-breaking.
66
+ */
67
+ export function pickTopRankedOpportunities(candidates, limit) {
68
+ if (!Number.isFinite(limit))
69
+ return [];
70
+ const safeLimit = Math.max(0, Math.trunc(limit));
71
+ if (safeLimit === 0 || candidates.length === 0)
72
+ return [];
73
+ return rankOpportunities(candidates).slice(0, safeLimit);
74
+ }
@@ -0,0 +1,49 @@
1
+ import type { ObjectiveAnchorScore } from "./objective-anchor.js";
2
+ export type PairwiseCalibrationVerdict = "replay_better" | "revealed_better" | "tie" | "incomparable";
3
+ export type PairwiseCalibrationAttempt = {
4
+ /** Judge result when replayed output is shown first and revealed history second. */
5
+ replayFirst: PairwiseCalibrationVerdict;
6
+ /** Judge result when revealed history is shown first and replayed output second. */
7
+ revealedFirst: PairwiseCalibrationVerdict;
8
+ };
9
+ export type PairwiseCalibrationWeights = {
10
+ objectiveAnchor?: number | undefined;
11
+ pairwiseJudge?: number | undefined;
12
+ };
13
+ export type PairwiseCalibrationResolvedSample = {
14
+ stable: boolean;
15
+ exhausted: boolean;
16
+ attemptsUsed: number;
17
+ maxAttempts: number;
18
+ verdict: PairwiseCalibrationVerdict | "unstable";
19
+ pairwiseScore: number | null;
20
+ };
21
+ export type PairwiseCalibrationScore = {
22
+ compositeScore: number;
23
+ objectiveAnchorScore: number;
24
+ pairwiseJudgeScore: number | null;
25
+ weights: {
26
+ objectiveAnchor: number;
27
+ pairwiseJudge: number;
28
+ };
29
+ samples: PairwiseCalibrationResolvedSample[];
30
+ metrics: {
31
+ totalSamples: number;
32
+ stableSamples: number;
33
+ unstableSamples: number;
34
+ exhaustedSamples: number;
35
+ orderInstabilityRate: number;
36
+ };
37
+ };
38
+ export declare function resolvePairwiseCalibrationSample(input: {
39
+ attempts: readonly PairwiseCalibrationAttempt[];
40
+ maxAttempts?: number | undefined;
41
+ }): PairwiseCalibrationResolvedSample;
42
+ export declare function computePairwiseCalibrationScore(input: {
43
+ objectiveAnchor: number | ObjectiveAnchorScore;
44
+ samples: readonly {
45
+ attempts: readonly PairwiseCalibrationAttempt[];
46
+ maxAttempts?: number | undefined;
47
+ }[];
48
+ weights?: PairwiseCalibrationWeights | undefined;
49
+ }): PairwiseCalibrationScore;
@@ -0,0 +1,106 @@
1
+ // Deterministic pairwise-judge calibration combiner (#3013).
2
+ //
3
+ // The model invocation itself belongs to the miner runtime. This engine module owns the pure part: interpret the
4
+ // two order-swapped judge outputs, discard unstable pairs, cap retries, expose instability metrics, and combine the
5
+ // surviving judge score with the objective-anchor score.
6
+ const DEFAULT_PAIRWISE_WEIGHTS = {
7
+ objectiveAnchor: 0.5,
8
+ pairwiseJudge: 0.5,
9
+ };
10
+ function finiteNonNegative(value, fallback) {
11
+ if (value === undefined)
12
+ return fallback;
13
+ if (!Number.isFinite(value) || value < 0)
14
+ return 0;
15
+ return value;
16
+ }
17
+ function normalizePairwiseWeights(weights) {
18
+ const raw = {
19
+ objectiveAnchor: finiteNonNegative(weights?.objectiveAnchor, DEFAULT_PAIRWISE_WEIGHTS.objectiveAnchor),
20
+ pairwiseJudge: finiteNonNegative(weights?.pairwiseJudge, DEFAULT_PAIRWISE_WEIGHTS.pairwiseJudge),
21
+ };
22
+ const total = raw.objectiveAnchor + raw.pairwiseJudge;
23
+ if (total <= 0)
24
+ return DEFAULT_PAIRWISE_WEIGHTS;
25
+ return {
26
+ objectiveAnchor: raw.objectiveAnchor / total,
27
+ pairwiseJudge: raw.pairwiseJudge / total,
28
+ };
29
+ }
30
+ function invertedVerdict(verdict) {
31
+ if (verdict === "replay_better")
32
+ return "revealed_better";
33
+ if (verdict === "revealed_better")
34
+ return "replay_better";
35
+ return verdict;
36
+ }
37
+ function verdictScore(verdict) {
38
+ if (verdict === "replay_better")
39
+ return 1;
40
+ if (verdict === "tie")
41
+ return 0.5;
42
+ if (verdict === "revealed_better")
43
+ return 0;
44
+ return null;
45
+ }
46
+ function roundScore(value) {
47
+ return Math.round(Math.min(1, Math.max(0, value)) * 1_000_000) / 1_000_000;
48
+ }
49
+ export function resolvePairwiseCalibrationSample(input) {
50
+ const requestedMaxAttempts = input.maxAttempts ?? input.attempts.length;
51
+ const maxAttempts = Math.max(1, Math.floor(requestedMaxAttempts || 1));
52
+ const attempts = input.attempts.slice(0, maxAttempts);
53
+ for (let index = 0; index < attempts.length; index += 1) {
54
+ const attempt = attempts[index];
55
+ const stable = attempt.replayFirst === invertedVerdict(attempt.revealedFirst);
56
+ if (stable) {
57
+ const score = verdictScore(attempt.replayFirst);
58
+ if (score !== null) {
59
+ return {
60
+ stable: true,
61
+ exhausted: false,
62
+ attemptsUsed: index + 1,
63
+ maxAttempts,
64
+ verdict: attempt.replayFirst,
65
+ pairwiseScore: score,
66
+ };
67
+ }
68
+ }
69
+ }
70
+ return {
71
+ stable: false,
72
+ exhausted: attempts.length >= maxAttempts,
73
+ attemptsUsed: attempts.length,
74
+ maxAttempts,
75
+ verdict: "unstable",
76
+ pairwiseScore: null,
77
+ };
78
+ }
79
+ export function computePairwiseCalibrationScore(input) {
80
+ const objectiveAnchorScore = typeof input.objectiveAnchor === "number" ? roundScore(input.objectiveAnchor) : input.objectiveAnchor.score;
81
+ const samples = input.samples.map(resolvePairwiseCalibrationSample);
82
+ const stableScores = samples
83
+ .map((sample) => sample.pairwiseScore)
84
+ .filter((score) => score !== null);
85
+ const pairwiseJudgeScore = stableScores.length === 0 ? null : roundScore(stableScores.reduce((sum, score) => sum + score, 0) / stableScores.length);
86
+ const weights = normalizePairwiseWeights(input.weights);
87
+ const compositeScore = pairwiseJudgeScore === null
88
+ ? objectiveAnchorScore
89
+ : roundScore(objectiveAnchorScore * weights.objectiveAnchor + pairwiseJudgeScore * weights.pairwiseJudge);
90
+ const unstableSamples = samples.filter((sample) => !sample.stable).length;
91
+ const exhaustedSamples = samples.filter((sample) => sample.exhausted).length;
92
+ return {
93
+ compositeScore,
94
+ objectiveAnchorScore,
95
+ pairwiseJudgeScore,
96
+ weights,
97
+ samples,
98
+ metrics: {
99
+ totalSamples: samples.length,
100
+ stableSamples: stableScores.length,
101
+ unstableSamples,
102
+ exhaustedSamples,
103
+ orderInstabilityRate: samples.length === 0 ? 0 : roundScore(unstableSamples / samples.length),
104
+ },
105
+ };
106
+ }