session-orchestrator 3.17.0 → 3.20.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 (221) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +1 -1
  4. package/.cursor/rules/030-wave-execution.mdc +17 -1
  5. package/CHANGELOG.md +185 -412
  6. package/README.md +12 -9
  7. package/SECURITY.md +190 -27
  8. package/agents/AGENTS.md +20 -3
  9. package/agents/code-implementer.md +6 -6
  10. package/agents/db-specialist.md +1 -1
  11. package/agents/qa-strategist.md +31 -6
  12. package/agents/schemas/qa-strategist.schema.json +27 -0
  13. package/agents/schemas/test-writer.schema.json +60 -2
  14. package/agents/security-reviewer.md +1 -1
  15. package/agents/session-reviewer.md +1 -1
  16. package/agents/test-writer.md +29 -10
  17. package/agents/ui-developer.md +1 -1
  18. package/commands/contract-version-bump.md +28 -0
  19. package/commands/portfolio.md +1 -1
  20. package/commands/session.md +6 -2
  21. package/docs/USER-GUIDE.md +9 -4
  22. package/docs/ci-setup.md +121 -7
  23. package/docs/codex-setup.md +1 -1
  24. package/docs/components.md +6 -6
  25. package/docs/cursor-setup.md +22 -9
  26. package/docs/events-schema.md +5 -1
  27. package/docs/instruction-delivery.md +794 -0
  28. package/docs/rule-authoring.md +58 -9
  29. package/docs/session-config-reference.md +245 -50
  30. package/docs/session-config-template.md +39 -26
  31. package/hooks/_lib/guard-source-loader.mjs +680 -0
  32. package/hooks/_lib/lock-bootstrap.mjs +21 -0
  33. package/hooks/_lib/vcs-create-matcher.mjs +119 -0
  34. package/hooks/config-protection.mjs +0 -0
  35. package/hooks/enforce-commands.mjs +226 -19
  36. package/hooks/enforce-scope.mjs +133 -9
  37. package/hooks/hooks-codex.json +1 -1
  38. package/hooks/hooks-cursor.json +11 -2
  39. package/hooks/hooks-pi.json +10 -0
  40. package/hooks/hooks.json +21 -1
  41. package/hooks/on-session-end.mjs +178 -18
  42. package/hooks/on-session-start.mjs +30 -4
  43. package/hooks/post-bash-write-verify.mjs +977 -0
  44. package/hooks/post-subagent-discovery-validator.mjs +256 -41
  45. package/hooks/pre-bash-destructive-guard.mjs +616 -164
  46. package/hooks/pre-bash-issue-budget.mjs +167 -0
  47. package/hooks/pre-bash-sessions-ledger-guard.mjs +1054 -0
  48. package/hooks/pre-bash-templates-first.mjs +96 -63
  49. package/hooks/subagent-telemetry.mjs +527 -37
  50. package/package.json +6 -3
  51. package/pi/prompts/contract-version-bump.md +12 -0
  52. package/rules/README.md +32 -0
  53. package/scripts/archive-closed-prds.mjs +12 -22
  54. package/scripts/autopilot-multi.mjs +103 -20
  55. package/scripts/backfill-abandoned-sessions.mjs +160 -4
  56. package/scripts/backfill-learnings-from-vault.mjs +967 -0
  57. package/scripts/check-doc-consistency.sh +17 -1
  58. package/scripts/emit-session.mjs +3 -40
  59. package/scripts/eval-session.mjs +50 -9
  60. package/scripts/fleet-instruction-scan.mjs +141 -0
  61. package/scripts/lib/autopilot/mr-draft.mjs +31 -1
  62. package/scripts/lib/autopilot/worktree-pipeline.mjs +113 -5
  63. package/scripts/lib/backlog-scan.mjs +39 -6
  64. package/scripts/lib/blocked-commands-policy.mjs +340 -0
  65. package/scripts/lib/ci-status-banner.mjs +75 -12
  66. package/scripts/lib/claude-md-budget-lint.mjs +283 -34
  67. package/scripts/lib/command-blocker.mjs +1273 -58
  68. package/scripts/lib/config/config-protection.mjs +2 -1
  69. package/scripts/lib/config/drift-check.mjs +9 -1
  70. package/scripts/lib/config/gitlab-portfolio.mjs +1 -1
  71. package/scripts/lib/config/issue-budget.mjs +123 -0
  72. package/scripts/lib/config/reconcile.mjs +21 -0
  73. package/scripts/lib/config/section-extractor.mjs +121 -1
  74. package/scripts/lib/config-schema.mjs +23 -3
  75. package/scripts/lib/config.mjs +17 -0
  76. package/scripts/lib/convergence-monitor.mjs +49 -3
  77. package/scripts/lib/description-surface.mjs +535 -0
  78. package/scripts/lib/dispatcher/enumerate.mjs +26 -40
  79. package/scripts/lib/ecosystem-wizard/config-writer.mjs +26 -24
  80. package/scripts/lib/ecosystem-wizard/wizard-prompt.mjs +1 -1
  81. package/scripts/lib/eval/engine.mjs +47 -5
  82. package/scripts/lib/events.mjs +59 -7
  83. package/scripts/lib/gates/gate-full.mjs +15 -3
  84. package/scripts/lib/gates/gate-helpers.mjs +132 -6
  85. package/scripts/lib/gitlab-ops/stale-mr-sweep.mjs +28 -8
  86. package/scripts/lib/gitlab-portfolio/aggregator.mjs +8 -2
  87. package/scripts/lib/gitlab-portfolio/cli.mjs +1 -1
  88. package/scripts/lib/handover-gate.mjs +7 -3
  89. package/scripts/lib/hardening.mjs +9 -9
  90. package/scripts/lib/harness-audit/categories/category4.mjs +9 -3
  91. package/scripts/lib/instruction-budget-guard.mjs +402 -51
  92. package/scripts/lib/io.mjs +345 -10
  93. package/scripts/lib/issue-budget.mjs +269 -0
  94. package/scripts/lib/issue-close-strip-labels.mjs +39 -9
  95. package/scripts/lib/label-scope.mjs +47 -0
  96. package/scripts/lib/learnings/affinity.mjs +434 -0
  97. package/scripts/lib/learnings/candidates.mjs +736 -0
  98. package/scripts/lib/learnings/expiry-sweep.mjs +408 -53
  99. package/scripts/lib/learnings/judgment.mjs +782 -0
  100. package/scripts/lib/learnings/kebab.mjs +128 -0
  101. package/scripts/lib/learnings/schema.mjs +43 -3
  102. package/scripts/lib/learnings/select.mjs +550 -0
  103. package/scripts/lib/lock-reaper.mjs +1 -2
  104. package/scripts/lib/memory-proposals/schema.mjs +36 -1
  105. package/scripts/lib/peer-discovery.mjs +645 -0
  106. package/scripts/lib/pi-hook-bridge.mjs +146 -17
  107. package/scripts/lib/product-repo-detect.mjs +9 -8
  108. package/scripts/lib/project-hygiene.mjs +432 -0
  109. package/scripts/lib/quality-gate.mjs +167 -0
  110. package/scripts/lib/recommendations-v0.mjs +1 -1
  111. package/scripts/lib/reconcile/eligibility.mjs +1 -1
  112. package/scripts/lib/reconcile/emitter.mjs +128 -24
  113. package/scripts/lib/reconcile/engine.mjs +156 -54
  114. package/scripts/lib/reconcile/idempotency.mjs +114 -14
  115. package/scripts/lib/reconcile/renderer.mjs +141 -25
  116. package/scripts/lib/reconcile/sanitize.mjs +518 -0
  117. package/scripts/lib/reconcile/writer.mjs +95 -1
  118. package/scripts/lib/reconcile-nudge-banner.mjs +65 -9
  119. package/scripts/lib/resource-probe/evaluate.mjs +70 -4
  120. package/scripts/lib/resource-probe.mjs +19 -0
  121. package/scripts/lib/rule-loader.mjs +6 -0
  122. package/scripts/lib/scope-baseline.mjs +564 -0
  123. package/scripts/lib/scope-gate.mjs +568 -145
  124. package/scripts/lib/session-close-backfill.mjs +63 -8
  125. package/scripts/lib/session-end/phase-skip.mjs +1 -0
  126. package/scripts/lib/session-id.mjs +221 -41
  127. package/scripts/lib/session-lock.mjs +304 -6
  128. package/scripts/lib/session-record-repair.mjs +551 -0
  129. package/scripts/lib/session-schema/constants.mjs +22 -3
  130. package/scripts/lib/session-schema/serializer.mjs +54 -0
  131. package/scripts/lib/session-schema/validator.mjs +16 -0
  132. package/scripts/lib/session-schema.mjs +1 -0
  133. package/scripts/lib/session-token-rollup.mjs +68 -6
  134. package/scripts/lib/sessions-integrity-banner.mjs +294 -0
  135. package/scripts/lib/sessions-staleness-banner.mjs +121 -12
  136. package/scripts/lib/skill-evolution/idempotency.mjs +135 -16
  137. package/scripts/lib/skill-evolution/mr-opener.mjs +9 -1
  138. package/scripts/lib/soul-resolve.mjs +12 -0
  139. package/scripts/lib/spiral-carryover.mjs +142 -30
  140. package/scripts/lib/state-md/mission-status.mjs +53 -3
  141. package/scripts/lib/subagents-schema.mjs +43 -9
  142. package/scripts/lib/test-runner/issue-reconcile.mjs +53 -13
  143. package/scripts/lib/tests-src-ratio.mjs +484 -0
  144. package/scripts/lib/tmux-layout/telemetry.mjs +43 -10
  145. package/scripts/lib/validate/check-agents.mjs +56 -0
  146. package/scripts/lib/validate/check-banner-parity.mjs +376 -0
  147. package/scripts/lib/validate/check-guard-requires-parity.mjs +1148 -0
  148. package/scripts/lib/validate/check-hooks-symmetry.mjs +244 -10
  149. package/scripts/lib/validate/check-learning-provenance.mjs +511 -0
  150. package/scripts/lib/validate/check-owner-leakage.mjs +3 -3
  151. package/scripts/lib/validate/check-rules.mjs +244 -36
  152. package/scripts/lib/validate/check-test-value-bans.mjs +782 -0
  153. package/scripts/lib/validate/check-unicode-safety.mjs +1 -0
  154. package/scripts/lib/validate/check-unwired-features.mjs +549 -0
  155. package/scripts/lib/validate-vendored-rules.mjs +10 -2
  156. package/scripts/lib/vault-archive.mjs +17 -2
  157. package/scripts/lib/vault-backfill/glab.mjs +8 -0
  158. package/scripts/lib/vault-mirror/process.mjs +30 -0
  159. package/scripts/lib/vault-mirror/render-sessions.mjs +293 -36
  160. package/scripts/lib/vcs-repo-spec.mjs +362 -0
  161. package/scripts/lib/wave-resource-gate.mjs +115 -11
  162. package/scripts/lib/worktree/listing.mjs +44 -7
  163. package/scripts/mcp-server.sh +17 -3
  164. package/scripts/measure-context-overhead.sh +151 -0
  165. package/scripts/memory-propose.mjs +72 -9
  166. package/scripts/print-applicable-rules.mjs +218 -16
  167. package/scripts/print-learnings-index.mjs +474 -0
  168. package/scripts/release.mjs +534 -0
  169. package/scripts/repair-invalid-sessions.mjs +209 -0
  170. package/scripts/run-quality-gate.mjs +123 -5
  171. package/scripts/sweep-expired-learnings.mjs +192 -32
  172. package/scripts/validate-plugin.mjs +21 -0
  173. package/scripts/validate-wave-scope.mjs +182 -17
  174. package/scripts/vault-integration-watcher.mjs +32 -10
  175. package/skills/_shared/config-reading.md +2 -2
  176. package/skills/bootstrap/fast-template.md +1 -1
  177. package/skills/brainstorm/soul.md +47 -1
  178. package/skills/claude-md-drift-check/checker.mjs +145 -28
  179. package/skills/contract-version-bump/SKILL.md +219 -0
  180. package/skills/discovery/SKILL.md +4 -4
  181. package/skills/discovery/issue-templates.md +11 -11
  182. package/skills/discovery/probes-audit.md +1 -1
  183. package/skills/discovery/probes-feature.md +1 -1
  184. package/skills/discovery/probes-session.md +26 -5
  185. package/skills/ecosystem-health/SKILL.md +1 -1
  186. package/skills/ecosystem-health/wizard.md +4 -4
  187. package/skills/evolve/SKILL.md +117 -18
  188. package/skills/gitlab-ops/SKILL.md +25 -12
  189. package/skills/gitlab-portfolio/SKILL.md +2 -2
  190. package/skills/grill/soul.md +44 -1
  191. package/skills/hook-development/SKILL.md +1 -1
  192. package/skills/mode-selector/SKILL.md +1 -1
  193. package/skills/npm-publish/SKILL.md +17 -1
  194. package/skills/plan/SKILL.md +5 -5
  195. package/skills/plan/mode-feature.md +4 -4
  196. package/skills/plan/mode-new.md +10 -10
  197. package/skills/plan/mode-retro.md +1 -1
  198. package/skills/plan/soul.md +46 -3
  199. package/skills/quality-gates/SKILL.md +1 -1
  200. package/skills/reconcile/SKILL.md +21 -4
  201. package/skills/session-end/SKILL.md +34 -36
  202. package/skills/session-end/discovery-scan.md +4 -2
  203. package/skills/session-end/drift-operations.md +4 -4
  204. package/skills/session-end/metrics-collection.md +13 -0
  205. package/skills/session-end/phase-3-2-docs-verification.md +1 -1
  206. package/skills/session-end/phase-3-6-tail.md +32 -2
  207. package/skills/session-end/plan-verification.md +6 -7
  208. package/skills/session-end/session-metrics-write.md +2 -0
  209. package/skills/session-end/vault-operations.md +1 -1
  210. package/skills/session-end/verification-checklist.md +1 -1
  211. package/skills/session-plan/SKILL.md +6 -2
  212. package/skills/session-plan/wave-template.md +2 -0
  213. package/skills/session-start/SKILL.md +75 -7
  214. package/skills/session-start/phase-4-5-resource-health.md +15 -2
  215. package/skills/session-start/soul.md +41 -1
  216. package/skills/test-runner/SKILL.md +2 -2
  217. package/skills/vault-sync/validator.mjs +108 -7
  218. package/skills/wave-executor/SKILL.md +6 -7
  219. package/skills/wave-executor/circuit-breaker.md +2 -0
  220. package/skills/wave-executor/wave-loop.md +198 -80
  221. package/templates/_shared/loop.md +4 -4
@@ -0,0 +1,736 @@
1
+ /**
2
+ * learnings/candidates.mjs — build the per-seed CANDIDATE POOL for
3
+ * learning→learning duplicate/contradiction judgment (#1016).
4
+ *
5
+ * ## What this module is
6
+ *
7
+ * The cheap mechanical half of "which learnings might duplicate or contradict
8
+ * this one?". It narrows N² pairs down to a small, bounded, per-seed shortlist.
9
+ * A sibling module makes the actual duplicate/contradiction JUDGMENT on those
10
+ * shortlists; `/evolve` wires the two together.
11
+ *
12
+ * ## What this module is NOT (deliberate boundary)
13
+ *
14
+ * - **Not a verdict.** A pool member is a *maybe*, never a duplicate. Nothing
15
+ * here decides, merges, rewrites, or deletes a learning.
16
+ * - **Not a clustering pass.** No transitive closure, no union-find, no
17
+ * connected components — see accepted failure mode 2 below. Measured: even
18
+ * at K=3 the pool graph collapses into an 81-of-94 (86%) giant component,
19
+ * so "cluster the corpus" returns *the corpus*.
20
+ * - **Not a ranker of quality.** `confidence` is carried through as metadata
21
+ * and never scored on. Recency decay lives in `surface.mjs::effectiveScore`;
22
+ * that time axis stays out of here (the ONLY clock reading is the expiry
23
+ * gate, and it is injectable).
24
+ * - **Not a renderer.** No char budgets, no lines, no prompt text —
25
+ * `select.mjs` owns that shape for the other consumer.
26
+ *
27
+ * ## Import graph (acyclic by construction)
28
+ *
29
+ * `candidates.mjs → {affinity, kebab, io, schema}.mjs`, all of which are leaves
30
+ * or import only `schema.mjs`. Deliberately NOT re-exported from the
31
+ * `scripts/lib/learnings.mjs` barrel — `surface.mjs`, `affinity.mjs` and
32
+ * `kebab.mjs` set that precedent: consumers import the leaf directly.
33
+ *
34
+ * ## The algorithm (measured, not preferred)
35
+ *
36
+ * A wave-1 discovery pass fixed every threshold below against a hand-labelled
37
+ * ground-truth set. They are measured knees. Do not re-tune them from taste;
38
+ * re-measure or leave them alone.
39
+ *
40
+ * **Measurement provenance (PSA-006).** Every corpus figure below is a SNAPSHOT,
41
+ * not a standing fact — the corpus grows each session and records expire out of
42
+ * it, which moves counts, IDF weights and therefore individual pair scores. All
43
+ * figures marked `[m]` were re-measured **2026-08-13 @5d59e62** against
44
+ * `.orchestrator/metrics/learnings.jsonl`: **100 records, 6 expired, 94 in the
45
+ * scored pool, 4,371 pairs.** Figures marked `[gt]` derive from the wave-1
46
+ * ground-truth LABELS, which were never persisted to the repo — they are the
47
+ * original measurement, are NOT reproducible from the tree, and must be
48
+ * re-derived (not re-cited) if a threshold is ever revisited.
49
+ *
50
+ * **Stage 0 — corpus prep (once per run).**
51
+ * 1. Read through the existing funnel (`io.mjs::readLearnings` →
52
+ * `schema.mjs::normalizeLearning`) so producer dialects (`files` →
53
+ * `file_paths`) and type aliases (`gotcha` → `anti-pattern`) are already
54
+ * canonical. The in-memory entry point re-applies {@link normalizeDialects}
55
+ * so both entry points score the same shape.
56
+ * 2. Drop expired records — they may neither seed a pool nor join one.
57
+ * 3. Derive `learning_key` = `` `${type}/${kebab(title || subject)}` `` using
58
+ * the SHARED {@link kebab}. A divergent kebab does not produce an ugly
59
+ * slug, it forks the key space (see `kebab.mjs`).
60
+ * 4. Exact-key pass FIRST: identical `learning_key`s are duplicates under the
61
+ * existing contract and are resolved before any scoring. This fires on
62
+ * nothing in today's corpus — kept because it is free and it is the rule
63
+ * that already governs this key space.
64
+ *
65
+ * **Stage 1 — tokenisation.** `subject ∪ insight`, lowercased, split on every
66
+ * non-`[a-z0-9]` run, length > 2, minus {@link STOPWORDS}.
67
+ *
68
+ * - `evidence` is deliberately EXCLUDED: 64.4% of the vocabulary is already
69
+ * hapax `[m]` and `evidence` is dense with dates and one-off identifiers.
70
+ * (It may also legally be an array — `schema.mjs` documents this and does
71
+ * not coerce it.)
72
+ * - The **German half of the stoplist is mandatory, not optional.** 7 of 100
73
+ * records are German `[m]`, and the highest-scoring pair in the entire
74
+ * corpus is a German↔German pair whose score is inflated by shared function
75
+ * words: 0.3523 with the German stoplist DISABLED vs 0.2584 with it enabled
76
+ * `[m]` — a 27% reduction attributable to function words alone. Without the
77
+ * stoplist the score measures *language*, not content.
78
+ *
79
+ * Scope note (re-measurement 2026-08-13): the stoplist SUPPRESSES that
80
+ * inflation but no longer DEMOTES the pair — it ranks first either way. The
81
+ * original text read "driven purely by shared function words"; at 0.2584
82
+ * post-stoplist, "purely" overstates what is measurable today. The
83
+ * mandatory-stoplist conclusion is unaffected.
84
+ *
85
+ * **Stage 2 — IDF-weighted Dice over all unordered pairs.**
86
+ * ```
87
+ * idf(w) = log((N+1) / (df(w) + 0.5)) // df over the FILTERED corpus
88
+ * base(i,j) = 2·Σ_{w∈Ti∩Tj} idf(w) / (Σ_{w∈Ti} idf(w) + Σ_{w∈Tj} idf(w))
89
+ * score = base + pathBoost(i,j)
90
+ * ```
91
+ * IDF-Dice rather than plain Jaccard because plain Jaccard has no usable tail
92
+ * here: 74 pairs ≥0.10 vs 9 `[m]`, and with 47.4-token mean sets and 64.4%
93
+ * hapax its p99 is 0.0805 `[m]`.
94
+ *
95
+ * `type` is **neither a filter nor a boost** — the counter-intuitive measured
96
+ * result. Both strongest ground-truth links are CROSS-type (0.2127 and 0.1735
97
+ * `[gt]`), and a type-equality gate drops ground-truth connectivity from 6/6 to
98
+ * 4/6 `[gt]` while still retaining 27.9% of all pairs `[m]`. It rides along as
99
+ * metadata only.
100
+ *
101
+ * **Stage 3 — per-seed pool.** `top-K by score, j ≠ i, score ≥ FLOOR`, with
102
+ * K = {@link CANDIDATE_TOP_K} and FLOOR = {@link CANDIDATE_FLOOR}.
103
+ *
104
+ * ## Why each number
105
+ *
106
+ * - **FLOOR 0.085** — chosen as the last threshold at which the ground-truth
107
+ * arc stays connected: at 0.090 the `EXIT0~TOCONTAIN` bridge (0.0882 `[gt]`)
108
+ * snaps and the arc splits. Retains 152/4,371 = 3.48% of pairs `[m]`
109
+ * (originally 172/4,950 = 3.47% — the RATIO is stable, both terms are not).
110
+ *
111
+ * ⚠ **This justification did not reproduce on 2026-08-13 @5d59e62.** The
112
+ * 0.0882 bridge score is not recoverable from today's corpus, and the pair
113
+ * that best matches the `EXIT0~TOCONTAIN` label by subject now scores
114
+ * 0.0849 — *below* the 0.085 floor it is cited to justify. Expected in
115
+ * direction (11 recovered + 6 expired records moved every IDF weight), but
116
+ * it means the floor currently rests on an unverifiable premise. The
117
+ * constant is deliberately left UNCHANGED: re-deriving it needs the
118
+ * ground-truth labels, which were never persisted. Do not re-tune from this
119
+ * note — re-label, then re-measure.
120
+ * - **K 8** — every ground-truth pair that matters ranks ≤4 for at least one
121
+ * endpoint `[gt]`; 8 is 2× headroom.
122
+ * - **path boost 0.050** — strictly BELOW the floor, so a zero-token pair can
123
+ * never enter a pool on paths alone. Hard invariant, pinned by a test.
124
+ * Arithmetic, not measured: it cannot go stale.
125
+ * - **dir boost 0.025** — a 2-level directory prefix is an order of magnitude
126
+ * less selective than an exact path overlap (13.0×: 65 dir-only vs 5 exact
127
+ * pairs `[m]`; 11.5× when first measured), so it gets strictly less weight.
128
+ * The ordering is what the constant encodes; the exact multiple drifts with
129
+ * `file_paths` coverage.
130
+ *
131
+ * ## Accepted failure modes — each with its ceiling and revisit trigger
132
+ *
133
+ * 1. **~82% of the scored pool carries no usable `file_paths`** (81.9% of the
134
+ * 94 active records; 83.0% of all 100 `[m]`), so the boost is identically 0
135
+ * for them. CEILING: the boost may only ever RAISE a score, never gate one
136
+ * — `PATH_BOOST_EXACT < CANDIDATE_FLOOR` is the mechanical form of that
137
+ * ceiling. REVISIT when `file_paths` coverage crosses 50%.
138
+ * 2. **Non-transitivity is by design** — the ground-truth arc is recoverable
139
+ * only by chaining pools. CEILING: a consumer may walk at most
140
+ * {@link MAX_POOL_HOPS} hops from a seed; beyond that the reachable set
141
+ * approaches the giant component. REVISIT only with a fresh component-size
142
+ * measurement, never on intuition.
143
+ * 3. **Clique recall is 33% (5 of 15) `[gt]` and will not improve by lowering
144
+ * the floor.** CEILING: never set `floor` below 0.060 — at 0.060 you retain
145
+ * 11.85% of pairs `[m]` to gain exactly ONE more ground-truth pair `[gt]`,
146
+ * with connectivity unchanged. REVISIT if recall itself becomes the
147
+ * acceptance criterion, in which case the fix is a better signal, not a
148
+ * lower floor.
149
+ * 4. **Two languages only (en/de).** A third language enters unstoplisted and
150
+ * the function-word collision of failure mode 3's German case returns.
151
+ * REVISIT when a non-en/de record lands in the corpus.
152
+ *
153
+ * ## Cost
154
+ *
155
+ * Single-digit milliseconds for the full pairwise pass at the current corpus
156
+ * size — 7.7 ms / 1.77 µs per pair at N=94 `[m]`, against 13.1 ms / 2.65 µs at
157
+ * N=100 when first measured. Wall-clock is HARDWARE-dependent and the two runs
158
+ * are not comparable across machines; the order of magnitude is the durable
159
+ * claim, not the figure. O(N²) is the correct choice — the viability boundary is
160
+ * ~N=2000, which at the observed ~2.3 learnings per learning-producing session
161
+ * (100 records over 43 distinct `source_session` values `[m]`) is years away and
162
+ * is additionally capped by the per-type TTL policy (`LEARNING_TTL_DAYS`).
163
+ * CEILING: do NOT build an inverted index below that boundary; REVISIT at
164
+ * N≈2000.
165
+ *
166
+ * ## Contract
167
+ *
168
+ * 1. **Never throws.** Hostile input yields {@link emptyPools}. This feeds an
169
+ * `/evolve` housekeeping path; a malformed corpus line must degrade to
170
+ * "no pools", never abort the run. (Same posture as `affinity()`,
171
+ * `surfaceTopN()` and `selectLearnings()`.)
172
+ * 2. **Deterministic and clock-injectable.** Same records + same `now` → the
173
+ * same pools in the same order. Ties break by score DESC → `created_at`
174
+ * DESC → `id` ASC, mirroring `select.mjs`.
175
+ * 3. **Symmetric scoring, asymmetric pools.** `score(i,j) === score(j,i)`, but
176
+ * `j ∈ pool(i)` does NOT imply `i ∈ pool(j)` — top-K is per seed.
177
+ * 4. Every emitted `score` is finite and in `[0, 1 + PATH_BOOST_EXACT]`.
178
+ * 5. Expired records never seed and never join a pool.
179
+ * 6. Fields read: `subject`, `insight` (tokens), `file_paths[]` (+ legacy
180
+ * `files`, via the dialect normalizer), `type` + `title` (key + metadata),
181
+ * `expires_at` (the one gate), `created_at`/`id` (tie-breaks only).
182
+ * Deliberately NOT read: `confidence`, `evidence`, `scope`, `host_class`,
183
+ * `source_session`.
184
+ */
185
+
186
+ import { tokenize } from './affinity.mjs';
187
+ import { readLearnings } from './io.mjs';
188
+ import { kebab } from './kebab.mjs';
189
+ import { normalizeDialects } from './schema.mjs';
190
+
191
+ // ---------------------------------------------------------------------------
192
+ // Constants — measured knees (see module header). Exported so a consumer can
193
+ // cite them rather than restate them; overriding them is a re-measurement, not
194
+ // a preference.
195
+ // ---------------------------------------------------------------------------
196
+
197
+ /**
198
+ * Minimum pair score for pool membership. Chosen as the last threshold at which
199
+ * the ground-truth arc stays connected (the 0.0882 `EXIT0~TOCONTAIN` bridge sat
200
+ * just above it). Retains 3.48% of all pairs (152/4,371, measured 2026-08-13
201
+ * @5d59e62). See the module header § Why each number for the ⚠ note: the 0.0882
202
+ * bridge no longer reproduces, so this threshold's original justification is
203
+ * currently unverifiable. Left unchanged deliberately.
204
+ */
205
+ export const CANDIDATE_FLOOR = 0.085;
206
+
207
+ /** Per-seed pool cap. Every ground-truth pair ranks ≤4 for one endpoint; 2× headroom. */
208
+ export const CANDIDATE_TOP_K = 8;
209
+
210
+ /**
211
+ * Boost for an exact `file_paths` overlap. STRICTLY BELOW {@link CANDIDATE_FLOOR}
212
+ * on purpose: a pair with zero shared tokens can never enter a pool on paths
213
+ * alone. This inequality is a hard invariant, not a coincidence — a test pins it.
214
+ */
215
+ export const PATH_BOOST_EXACT = 0.05;
216
+
217
+ /** Boost for a shared 2-level directory prefix — an order of magnitude less selective (13.0× at 2026-08-13 @5d59e62), so half the weight. */
218
+ export const PATH_BOOST_DIR = 0.025;
219
+
220
+ /** How many leading directory segments define the directory-prefix boost. */
221
+ export const DIR_PREFIX_SEGMENTS = 2;
222
+
223
+ /**
224
+ * Documented ceiling for consumers (accepted failure mode 2): a pool graph walk
225
+ * may chain at most this many hops from a seed. Beyond 2 the reachable set
226
+ * approaches the giant component (81 of 94 scored records at K=3, measured
227
+ * 2026-08-13 @5d59e62), i.e. "the corpus".
228
+ */
229
+ export const MAX_POOL_HOPS = 2;
230
+
231
+ /** Token length floor — tokens shorter than this are dropped before stoplisting. */
232
+ export const MIN_TOKEN_LENGTH = 3;
233
+
234
+ /** Cap on the reported `sharedTokens` — a diagnostic for the judge, not a payload. */
235
+ const SHARED_TOKEN_CAP = 8;
236
+
237
+ /**
238
+ * English + German function words, dropped after tokenisation.
239
+ *
240
+ * Two notes that look like omissions but are not:
241
+ *
242
+ * - Words of 1–2 characters (`is`, `to`, `of`, `an`, …) are absent because
243
+ * {@link MIN_TOKEN_LENGTH} already removes them; listing them would be dead
244
+ * weight.
245
+ * - Umlaut forms (`für`, `über`) are listed for completeness but are already
246
+ * shredded by the ASCII tokenizer (`ü` is a separator, leaving `f`/`r` — both
247
+ * under the length floor). They earn their place only if the tokenizer ever
248
+ * gains transliteration.
249
+ *
250
+ * The German half is load-bearing — see the module header. Adding a THIRD
251
+ * language requires extending this set in the same commit (failure mode 4).
252
+ *
253
+ * Deliberately NOT `Object.freeze`d: freezing a Set seals its properties, not
254
+ * its contents — `.add()` still works — so a freeze here would advertise a
255
+ * guarantee it cannot keep. Treat it as read-only by convention.
256
+ */
257
+ export const STOPWORDS = /** @type {ReadonlySet<string>} */ (
258
+ new Set([
259
+ // English (standard function-word list, ≥3 chars)
260
+ 'about', 'above', 'after', 'again', 'against', 'all', 'and', 'any', 'are',
261
+ 'because', 'been', 'before', 'being', 'below', 'between', 'both', 'but',
262
+ 'can', 'did', 'does', 'doing', 'down', 'during', 'each', 'few', 'for',
263
+ 'from', 'further', 'had', 'has', 'have', 'having', 'her', 'here', 'hers',
264
+ 'herself', 'him', 'himself', 'his', 'how', 'into', 'its', 'itself', 'just',
265
+ 'more', 'most', 'nor', 'not', 'off', 'once', 'only', 'other', 'our', 'ours',
266
+ 'ourselves', 'out', 'over', 'own', 'same', 'she', 'should', 'some', 'such',
267
+ 'than', 'that', 'the', 'their', 'theirs', 'them', 'themselves', 'then',
268
+ 'there', 'these', 'they', 'this', 'those', 'through', 'too', 'under',
269
+ 'until', 'very', 'was', 'were', 'what', 'when', 'where', 'which', 'while',
270
+ 'who', 'whom', 'why', 'will', 'with', 'you', 'your', 'yours', 'yourself',
271
+ 'yourselves',
272
+ // German — mandatory half (see module header)
273
+ 'der', 'die', 'das', 'und', 'nicht', 'ist', 'ein', 'eine', 'einen', 'auf',
274
+ 'mit', 'von', 'dem', 'den', 'für', 'fuer', 'wird', 'werden', 'sich', 'aber',
275
+ 'nur', 'noch', 'schon', 'beim', 'durch',
276
+ ])
277
+ );
278
+
279
+ /**
280
+ * @typedef {{record: object, key: string|null, score: number, base: number,
281
+ * boost: number, type: string|null, sharedTokens: string[]}} PoolCandidate
282
+ *
283
+ * @typedef {{seed: object, key: string|null, type: string|null,
284
+ * candidates: PoolCandidate[]}} CandidatePool
285
+ *
286
+ * @typedef {{key: string, kept: object, dropped: object[]}} DuplicateGroup
287
+ *
288
+ * @typedef {{pools: CandidatePool[], duplicates: DuplicateGroup[],
289
+ * stats: {input: number, expired: number, unkeyable: number,
290
+ * duplicateGroups: number, duplicatesDropped: number,
291
+ * scored: number, pairs: number, retained: number,
292
+ * seedsWithPool: number}}} CandidatePools
293
+ */
294
+
295
+ // ---------------------------------------------------------------------------
296
+ // Internals
297
+ // ---------------------------------------------------------------------------
298
+
299
+ /** True for a plain-ish object we may read properties off (not null, not array). */
300
+ function _isRecord(v) {
301
+ return v !== null && typeof v === 'object' && !Array.isArray(v);
302
+ }
303
+
304
+ /**
305
+ * Canonicalize a repo-relative path: trim, strip leading `./`, strip trailing
306
+ * `/`. Case-SENSITIVE — Linux CI is the authority.
307
+ *
308
+ * Re-stated rather than imported: `affinity.mjs` keeps its equivalent private
309
+ * and that module is frozen. The two must agree on canonical form; they are six
310
+ * lines each and the shape is pinned by a test here.
311
+ */
312
+ function _normalizePath(p) {
313
+ if (typeof p !== 'string') return '';
314
+ let s = p.trim();
315
+ while (s.startsWith('./')) s = s.slice(2);
316
+ while (s.length > 1 && s.endsWith('/')) s = s.slice(0, -1);
317
+ return s;
318
+ }
319
+
320
+ /** Normalized, deduped, non-empty file paths of a record. Never throws. */
321
+ function _recordPaths(record) {
322
+ const raw = Array.isArray(record?.file_paths)
323
+ ? record.file_paths
324
+ : Array.isArray(record?.files)
325
+ ? record.files
326
+ : [];
327
+ const out = [];
328
+ const seen = new Set();
329
+ for (const p of raw) {
330
+ const norm = _normalizePath(p);
331
+ if (norm.length === 0 || seen.has(norm)) continue;
332
+ seen.add(norm);
333
+ out.push(norm);
334
+ }
335
+ return out;
336
+ }
337
+
338
+ /**
339
+ * The directory key of a path: up to {@link DIR_PREFIX_SEGMENTS} leading
340
+ * DIRECTORY segments (the last segment is the filename and is dropped).
341
+ *
342
+ * `scripts/lib/learnings/candidates.mjs` → `scripts/lib`
343
+ * `hooks/emit.mjs` → `hooks` (only one dir level exists)
344
+ * `README.md` → `''` (no directory at all)
345
+ *
346
+ * A top-level file therefore has NO directory key and can never earn the
347
+ * directory boost — right, because "both live at the repo root" is not evidence
348
+ * of relatedness.
349
+ */
350
+ function _dirKey(pathNorm) {
351
+ const segs = pathNorm.split('/').filter(Boolean);
352
+ if (segs.length < 2) return '';
353
+ return segs.slice(0, Math.min(DIR_PREFIX_SEGMENTS, segs.length - 1)).join('/');
354
+ }
355
+
356
+ /** Date.parse or 0 — tie-break only, never a filter. */
357
+ function _createdMs(record) {
358
+ const v = record?.created_at;
359
+ if (typeof v !== 'string') return 0;
360
+ const ms = Date.parse(v);
361
+ return Number.isFinite(ms) ? ms : 0;
362
+ }
363
+
364
+ /** Epoch ms from a Date | number | undefined clock option. */
365
+ function _resolveNowMs(now) {
366
+ if (now instanceof Date) return now.getTime();
367
+ if (typeof now === 'number' && Number.isFinite(now)) return now;
368
+ return Date.now();
369
+ }
370
+
371
+ /**
372
+ * The expiry gate. Matches the repo's existing active-filter convention exactly
373
+ * (`surface.mjs::surfaceTopN`, `select.mjs::_isActive`): a record whose
374
+ * `expires_at` parses and is `<= now` is expired. An unparseable or absent
375
+ * `expires_at` reads as not-expired.
376
+ *
377
+ * Deliberately NO confidence floor here — the other two consumers filter on
378
+ * confidence because they INJECT into a prompt. This one only proposes pairs
379
+ * for judgment, and a low-confidence record is exactly the kind that a
380
+ * duplicate/contradiction pass should be allowed to look at.
381
+ */
382
+ function _isExpired(record, nowMs) {
383
+ if (typeof record.expires_at !== 'string') return false;
384
+ const ms = Date.parse(record.expires_at);
385
+ return Number.isFinite(ms) && ms <= nowMs;
386
+ }
387
+
388
+ /** Total order over pool candidates: score DESC → created_at DESC → id ASC. */
389
+ function _compareCandidates(a, b) {
390
+ if (b.score !== a.score) return b.score - a.score;
391
+ const timeDiff = _createdMs(b.record) - _createdMs(a.record);
392
+ if (timeDiff !== 0) return timeDiff;
393
+ const aId = typeof a.record.id === 'string' ? a.record.id : '';
394
+ const bId = typeof b.record.id === 'string' ? b.record.id : '';
395
+ return aId < bId ? -1 : aId > bId ? 1 : 0;
396
+ }
397
+
398
+ /** Representative pick inside an exact-key group: created_at DESC → id ASC. */
399
+ function _compareRepresentatives(a, b) {
400
+ const timeDiff = _createdMs(b) - _createdMs(a);
401
+ if (timeDiff !== 0) return timeDiff;
402
+ const aId = typeof a.id === 'string' ? a.id : '';
403
+ const bId = typeof b.id === 'string' ? b.id : '';
404
+ return aId < bId ? -1 : aId > bId ? 1 : 0;
405
+ }
406
+
407
+ /** Dialect-normalize one record, falling back to the raw shape on any quirk. */
408
+ function _canonical(record) {
409
+ try {
410
+ const out = normalizeDialects(record, { reserializeTimestamps: false });
411
+ return _isRecord(out) ? out : record;
412
+ } catch {
413
+ // A dialect quirk must never abort a pooling run.
414
+ return record;
415
+ }
416
+ }
417
+
418
+ // ---------------------------------------------------------------------------
419
+ // Public surface
420
+ // ---------------------------------------------------------------------------
421
+
422
+ /** The zero-value result. Built fresh per call — no shared singleton to mutate. */
423
+ export function emptyPools() {
424
+ return {
425
+ pools: [],
426
+ duplicates: [],
427
+ stats: {
428
+ input: 0,
429
+ expired: 0,
430
+ unkeyable: 0,
431
+ duplicateGroups: 0,
432
+ duplicatesDropped: 0,
433
+ scored: 0,
434
+ pairs: 0,
435
+ retained: 0,
436
+ seedsWithPool: 0,
437
+ },
438
+ };
439
+ }
440
+
441
+ /**
442
+ * Logical identity of a learning: `` `${type}/${kebab(title || subject)}` ``.
443
+ *
444
+ * Returns `null` when either half is unusable — mirroring
445
+ * `validate/check-learning-provenance.mjs::learningKeyOf`, so an unkeyable
446
+ * record simply does not participate in the exact-key pass. It still seeds and
447
+ * joins pools: a missing title is not a reason to hide a learning from dedupe.
448
+ *
449
+ * @param {unknown} record
450
+ * @returns {string|null}
451
+ */
452
+ export function learningKey(record) {
453
+ if (!_isRecord(record)) return null;
454
+ const type = typeof record.type === 'string' ? record.type.trim() : '';
455
+ const titleOrSubject =
456
+ (typeof record.title === 'string' && record.title.trim() !== '' ? record.title : '') ||
457
+ (typeof record.subject === 'string' && record.subject.trim() !== '' ? record.subject : '');
458
+ if (type === '' || titleOrSubject === '') return null;
459
+ const slug = kebab(titleOrSubject);
460
+ // `kebab` may legally return '' (all-symbol input) — an empty slug is not an
461
+ // identity, so such a record is unkeyable rather than colliding with every
462
+ // other empty-slug record of the same type.
463
+ if (slug === '') return null;
464
+ return `${type}/${slug}`;
465
+ }
466
+
467
+ /**
468
+ * Content tokens of one record: `subject ∪ insight`, minus {@link STOPWORDS}.
469
+ *
470
+ * Reuses `affinity.mjs::tokenize` for the lowercase/split/length/dedupe half —
471
+ * a second tokenizer would be a second definition of "same word". The stoplist
472
+ * and the field selection are the parts this module owns; `affinity()` reads
473
+ * FIVE text fields including `evidence`, which is exactly what must not happen
474
+ * here (module header, Stage 1).
475
+ *
476
+ * @param {unknown} record
477
+ * @returns {string[]} deduped tokens in first-appearance order
478
+ */
479
+ export function candidateTokens(record) {
480
+ if (!_isRecord(record)) return [];
481
+ try {
482
+ const out = [];
483
+ const seen = new Set();
484
+ for (const field of ['subject', 'insight']) {
485
+ for (const t of tokenize(record[field], { minTokenLength: MIN_TOKEN_LENGTH })) {
486
+ if (STOPWORDS.has(t) || seen.has(t)) continue;
487
+ seen.add(t);
488
+ out.push(t);
489
+ }
490
+ }
491
+ return out;
492
+ } catch {
493
+ return [];
494
+ }
495
+ }
496
+
497
+ /**
498
+ * File-path boost for one pair: exact overlap > shared 2-level directory prefix
499
+ * > nothing. Returns 0 when either side declares no usable path.
500
+ *
501
+ * The returned value is strictly less than {@link CANDIDATE_FLOOR} by
502
+ * construction, so it can only ever RAISE a score above the floor that token
503
+ * overlap already carried — never create a link on its own (accepted failure
504
+ * mode 1's ceiling).
505
+ *
506
+ * @param {unknown} aPaths — normalized or raw path list
507
+ * @param {unknown} bPaths
508
+ * @returns {number} 0 | PATH_BOOST_DIR | PATH_BOOST_EXACT
509
+ */
510
+ export function pathBoost(aPaths, bPaths) {
511
+ const a = Array.isArray(aPaths) ? aPaths.map(_normalizePath).filter(Boolean) : [];
512
+ const b = Array.isArray(bPaths) ? bPaths.map(_normalizePath).filter(Boolean) : [];
513
+ if (a.length === 0 || b.length === 0) return 0;
514
+
515
+ const exactB = new Set(b);
516
+ for (const p of a) if (exactB.has(p)) return PATH_BOOST_EXACT;
517
+
518
+ const dirsB = new Set();
519
+ for (const p of b) {
520
+ const d = _dirKey(p);
521
+ if (d) dirsB.add(d);
522
+ }
523
+ for (const p of a) {
524
+ const d = _dirKey(p);
525
+ if (d && dirsB.has(d)) return PATH_BOOST_DIR;
526
+ }
527
+ return 0;
528
+ }
529
+
530
+ /**
531
+ * Build the per-seed candidate pools for a set of learnings.
532
+ *
533
+ * @param {unknown} records — learning records (already read from disk)
534
+ * @param {object} [opts]
535
+ * @param {Date|number} [opts.now] — injectable clock for the expiry gate
536
+ * @param {number} [opts.topK=CANDIDATE_TOP_K] — per-seed cap
537
+ * @param {number} [opts.floor=CANDIDATE_FLOOR] — minimum pair score
538
+ * @returns {CandidatePools} pools (non-empty ones only, in input order), the
539
+ * exact-key duplicate groups, and the counters the thresholds are judged by.
540
+ *
541
+ * The `seed` / `record` values handed back are the DIALECT-NORMALIZED shape
542
+ * (legacy `files` read as `file_paths`, aliased types canonicalized), not the
543
+ * caller's object identity — match them by `id`/`key`, never by `===`.
544
+ * A seed with no candidates emits no pool; absence IS the "nothing related"
545
+ * signal, so no empty-pool placeholders are returned.
546
+ */
547
+ export function buildCandidatePools(records, opts = {}) {
548
+ try {
549
+ if (!Array.isArray(records) || records.length === 0) return emptyPools();
550
+
551
+ const o = _isRecord(opts) ? opts : {};
552
+ const nowMs = _resolveNowMs(o.now);
553
+ const topK = Number.isInteger(o.topK) && o.topK >= 0 ? o.topK : CANDIDATE_TOP_K;
554
+ const floor =
555
+ typeof o.floor === 'number' && Number.isFinite(o.floor) ? o.floor : CANDIDATE_FLOOR;
556
+
557
+ const result = emptyPools();
558
+ const stats = result.stats;
559
+ stats.input = records.length;
560
+
561
+ // --- Stage 0.1/0.2: canonicalize + expiry gate ---------------------------
562
+ /** @type {object[]} */
563
+ const active = [];
564
+ // Identity-dedupe: the same object reference passed twice would otherwise
565
+ // become a pair with itself (score 1.0) and pollute every counter.
566
+ const seenRefs = new Set();
567
+ for (const raw of records) {
568
+ if (!_isRecord(raw)) continue;
569
+ if (seenRefs.has(raw)) continue;
570
+ seenRefs.add(raw);
571
+ const record = _canonical(raw);
572
+ if (!_isRecord(record)) continue;
573
+ if (_isExpired(record, nowMs)) {
574
+ stats.expired++;
575
+ continue;
576
+ }
577
+ active.push(record);
578
+ }
579
+
580
+ // --- Stage 0.3/0.4: exact learning_key pass, BEFORE any scoring ----------
581
+ /** @type {Map<string, object[]>} */
582
+ const byKey = new Map();
583
+ /** @type {object[]} */
584
+ const unkeyed = [];
585
+ const keyOf = new Map();
586
+ for (const record of active) {
587
+ const key = learningKey(record);
588
+ keyOf.set(record, key);
589
+ if (key === null) {
590
+ stats.unkeyable++;
591
+ unkeyed.push(record);
592
+ continue;
593
+ }
594
+ const group = byKey.get(key);
595
+ if (group) group.push(record);
596
+ else byKey.set(key, [record]);
597
+ }
598
+
599
+ /** Survivors of the exact-key pass, in input order. */
600
+ const kept = new Set(unkeyed);
601
+ for (const [key, group] of byKey) {
602
+ if (group.length === 1) {
603
+ kept.add(group[0]);
604
+ continue;
605
+ }
606
+ const ranked = [...group].sort(_compareRepresentatives);
607
+ kept.add(ranked[0]);
608
+ stats.duplicateGroups++;
609
+ stats.duplicatesDropped += ranked.length - 1;
610
+ result.duplicates.push({ key, kept: ranked[0], dropped: ranked.slice(1) });
611
+ }
612
+ const pool = active.filter((r) => kept.has(r));
613
+ stats.scored = pool.length;
614
+ if (pool.length < 2) return result;
615
+
616
+ // --- Stage 1: tokenise ---------------------------------------------------
617
+ const tokenSets = pool.map((r) => new Set(candidateTokens(r)));
618
+ const paths = pool.map((r) => _recordPaths(r));
619
+
620
+ // --- Stage 2: IDF over the FILTERED corpus -------------------------------
621
+ const n = pool.length;
622
+ /** @type {Map<string, number>} */
623
+ const df = new Map();
624
+ for (const set of tokenSets) {
625
+ for (const t of set) df.set(t, (df.get(t) ?? 0) + 1);
626
+ }
627
+ /** @type {Map<string, number>} */
628
+ const idf = new Map();
629
+ for (const [t, count] of df) idf.set(t, Math.log((n + 1) / (count + 0.5)));
630
+
631
+ const idfSums = tokenSets.map((set) => {
632
+ let sum = 0;
633
+ for (const t of set) sum += idf.get(t) ?? 0;
634
+ return sum;
635
+ });
636
+
637
+ // --- Stage 2/3: one pass over all unordered pairs ------------------------
638
+ // O(N²) by design — 2.65 µs/pair measured, viable to ~N=2000. No inverted
639
+ // index below that boundary (module header § Cost).
640
+ /** @type {PoolCandidate[][]} */
641
+ const perSeed = Array.from({ length: n }, () => []);
642
+
643
+ for (let i = 0; i < n; i++) {
644
+ const setI = tokenSets[i];
645
+ for (let j = i + 1; j < n; j++) {
646
+ stats.pairs++;
647
+ const setJ = tokenSets[j];
648
+
649
+ // Intersect over the smaller set — same result, half the work.
650
+ const [small, large] = setI.size <= setJ.size ? [setI, setJ] : [setJ, setI];
651
+ let interIdf = 0;
652
+ const shared = [];
653
+ for (const t of small) {
654
+ if (!large.has(t)) continue;
655
+ interIdf += idf.get(t) ?? 0;
656
+ shared.push(t);
657
+ }
658
+
659
+ const denom = idfSums[i] + idfSums[j];
660
+ const base = denom > 0 ? (2 * interIdf) / denom : 0;
661
+ const boost = pathBoost(paths[i], paths[j]);
662
+ const score = base + boost;
663
+ if (!Number.isFinite(score) || score < floor) continue;
664
+ stats.retained++;
665
+
666
+ // Diagnostic only — the strongest shared terms, so a downstream judge
667
+ // can see WHY the pair surfaced. Deterministic: idf DESC, then alpha.
668
+ shared.sort((a, b) => {
669
+ const d = (idf.get(b) ?? 0) - (idf.get(a) ?? 0);
670
+ return d !== 0 ? d : a < b ? -1 : a > b ? 1 : 0;
671
+ });
672
+ const sharedTokens = shared.slice(0, SHARED_TOKEN_CAP);
673
+
674
+ perSeed[i].push({
675
+ record: pool[j],
676
+ key: keyOf.get(pool[j]) ?? null,
677
+ score,
678
+ base,
679
+ boost,
680
+ type: typeof pool[j].type === 'string' ? pool[j].type : null,
681
+ sharedTokens,
682
+ });
683
+ perSeed[j].push({
684
+ record: pool[i],
685
+ key: keyOf.get(pool[i]) ?? null,
686
+ score,
687
+ base,
688
+ boost,
689
+ type: typeof pool[i].type === 'string' ? pool[i].type : null,
690
+ sharedTokens,
691
+ });
692
+ }
693
+ }
694
+
695
+ // --- Stage 3: top-K per seed. No union, no closure (failure mode 2). -----
696
+ for (let i = 0; i < n; i++) {
697
+ if (perSeed[i].length === 0) continue;
698
+ perSeed[i].sort(_compareCandidates);
699
+ const candidates = perSeed[i].slice(0, topK);
700
+ if (candidates.length === 0) continue;
701
+ stats.seedsWithPool++;
702
+ result.pools.push({
703
+ seed: pool[i],
704
+ key: keyOf.get(pool[i]) ?? null,
705
+ type: typeof pool[i].type === 'string' ? pool[i].type : null,
706
+ candidates,
707
+ });
708
+ }
709
+
710
+ return result;
711
+ } catch {
712
+ // Contract point 1 — every reachable path above is already total; this is
713
+ // the last-resort net for an exotic input shape (throwing getter, Proxy).
714
+ return emptyPools();
715
+ }
716
+ }
717
+
718
+ /**
719
+ * File entry-point: read through the existing funnel (`readLearnings` →
720
+ * `normalizeLearning`, so dialects and type aliases are canonical), then pool.
721
+ *
722
+ * There is no second reader here on purpose — a private read path would be a
723
+ * second place for the dialect normalization to drift out of.
724
+ *
725
+ * @param {string} filePath — absolute path to learnings.jsonl
726
+ * @param {object} [opts] — everything {@link buildCandidatePools} accepts
727
+ * @returns {Promise<CandidatePools>} {@link emptyPools} on a missing/unreadable file
728
+ */
729
+ export async function buildCandidatePoolsFromFile(filePath, opts = {}) {
730
+ try {
731
+ const { entries } = await readLearnings(filePath);
732
+ return buildCandidatePools(entries, opts);
733
+ } catch {
734
+ return emptyPools();
735
+ }
736
+ }