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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.cursor/rules/030-wave-execution.mdc +17 -1
- package/CHANGELOG.md +185 -412
- package/README.md +12 -9
- package/SECURITY.md +190 -27
- package/agents/AGENTS.md +20 -3
- package/agents/code-implementer.md +6 -6
- package/agents/db-specialist.md +1 -1
- package/agents/qa-strategist.md +31 -6
- package/agents/schemas/qa-strategist.schema.json +27 -0
- package/agents/schemas/test-writer.schema.json +60 -2
- package/agents/security-reviewer.md +1 -1
- package/agents/session-reviewer.md +1 -1
- package/agents/test-writer.md +29 -10
- package/agents/ui-developer.md +1 -1
- package/commands/contract-version-bump.md +28 -0
- package/commands/portfolio.md +1 -1
- package/commands/session.md +6 -2
- package/docs/USER-GUIDE.md +9 -4
- package/docs/ci-setup.md +121 -7
- package/docs/codex-setup.md +1 -1
- package/docs/components.md +6 -6
- package/docs/cursor-setup.md +22 -9
- package/docs/events-schema.md +5 -1
- package/docs/instruction-delivery.md +794 -0
- package/docs/rule-authoring.md +58 -9
- package/docs/session-config-reference.md +245 -50
- package/docs/session-config-template.md +39 -26
- package/hooks/_lib/guard-source-loader.mjs +680 -0
- package/hooks/_lib/lock-bootstrap.mjs +21 -0
- package/hooks/_lib/vcs-create-matcher.mjs +119 -0
- package/hooks/config-protection.mjs +0 -0
- package/hooks/enforce-commands.mjs +226 -19
- package/hooks/enforce-scope.mjs +133 -9
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks-cursor.json +11 -2
- package/hooks/hooks-pi.json +10 -0
- package/hooks/hooks.json +21 -1
- package/hooks/on-session-end.mjs +178 -18
- package/hooks/on-session-start.mjs +30 -4
- package/hooks/post-bash-write-verify.mjs +977 -0
- package/hooks/post-subagent-discovery-validator.mjs +256 -41
- package/hooks/pre-bash-destructive-guard.mjs +616 -164
- package/hooks/pre-bash-issue-budget.mjs +167 -0
- package/hooks/pre-bash-sessions-ledger-guard.mjs +1054 -0
- package/hooks/pre-bash-templates-first.mjs +96 -63
- package/hooks/subagent-telemetry.mjs +527 -37
- package/package.json +6 -3
- package/pi/prompts/contract-version-bump.md +12 -0
- package/rules/README.md +32 -0
- package/scripts/archive-closed-prds.mjs +12 -22
- package/scripts/autopilot-multi.mjs +103 -20
- package/scripts/backfill-abandoned-sessions.mjs +160 -4
- package/scripts/backfill-learnings-from-vault.mjs +967 -0
- package/scripts/check-doc-consistency.sh +17 -1
- package/scripts/emit-session.mjs +3 -40
- package/scripts/eval-session.mjs +50 -9
- package/scripts/fleet-instruction-scan.mjs +141 -0
- package/scripts/lib/autopilot/mr-draft.mjs +31 -1
- package/scripts/lib/autopilot/worktree-pipeline.mjs +113 -5
- package/scripts/lib/backlog-scan.mjs +39 -6
- package/scripts/lib/blocked-commands-policy.mjs +340 -0
- package/scripts/lib/ci-status-banner.mjs +75 -12
- package/scripts/lib/claude-md-budget-lint.mjs +283 -34
- package/scripts/lib/command-blocker.mjs +1273 -58
- package/scripts/lib/config/config-protection.mjs +2 -1
- package/scripts/lib/config/drift-check.mjs +9 -1
- package/scripts/lib/config/gitlab-portfolio.mjs +1 -1
- package/scripts/lib/config/issue-budget.mjs +123 -0
- package/scripts/lib/config/reconcile.mjs +21 -0
- package/scripts/lib/config/section-extractor.mjs +121 -1
- package/scripts/lib/config-schema.mjs +23 -3
- package/scripts/lib/config.mjs +17 -0
- package/scripts/lib/convergence-monitor.mjs +49 -3
- package/scripts/lib/description-surface.mjs +535 -0
- package/scripts/lib/dispatcher/enumerate.mjs +26 -40
- package/scripts/lib/ecosystem-wizard/config-writer.mjs +26 -24
- package/scripts/lib/ecosystem-wizard/wizard-prompt.mjs +1 -1
- package/scripts/lib/eval/engine.mjs +47 -5
- package/scripts/lib/events.mjs +59 -7
- package/scripts/lib/gates/gate-full.mjs +15 -3
- package/scripts/lib/gates/gate-helpers.mjs +132 -6
- package/scripts/lib/gitlab-ops/stale-mr-sweep.mjs +28 -8
- package/scripts/lib/gitlab-portfolio/aggregator.mjs +8 -2
- package/scripts/lib/gitlab-portfolio/cli.mjs +1 -1
- package/scripts/lib/handover-gate.mjs +7 -3
- package/scripts/lib/hardening.mjs +9 -9
- package/scripts/lib/harness-audit/categories/category4.mjs +9 -3
- package/scripts/lib/instruction-budget-guard.mjs +402 -51
- package/scripts/lib/io.mjs +345 -10
- package/scripts/lib/issue-budget.mjs +269 -0
- package/scripts/lib/issue-close-strip-labels.mjs +39 -9
- package/scripts/lib/label-scope.mjs +47 -0
- package/scripts/lib/learnings/affinity.mjs +434 -0
- package/scripts/lib/learnings/candidates.mjs +736 -0
- package/scripts/lib/learnings/expiry-sweep.mjs +408 -53
- package/scripts/lib/learnings/judgment.mjs +782 -0
- package/scripts/lib/learnings/kebab.mjs +128 -0
- package/scripts/lib/learnings/schema.mjs +43 -3
- package/scripts/lib/learnings/select.mjs +550 -0
- package/scripts/lib/lock-reaper.mjs +1 -2
- package/scripts/lib/memory-proposals/schema.mjs +36 -1
- package/scripts/lib/peer-discovery.mjs +645 -0
- package/scripts/lib/pi-hook-bridge.mjs +146 -17
- package/scripts/lib/product-repo-detect.mjs +9 -8
- package/scripts/lib/project-hygiene.mjs +432 -0
- package/scripts/lib/quality-gate.mjs +167 -0
- package/scripts/lib/recommendations-v0.mjs +1 -1
- package/scripts/lib/reconcile/eligibility.mjs +1 -1
- package/scripts/lib/reconcile/emitter.mjs +128 -24
- package/scripts/lib/reconcile/engine.mjs +156 -54
- package/scripts/lib/reconcile/idempotency.mjs +114 -14
- package/scripts/lib/reconcile/renderer.mjs +141 -25
- package/scripts/lib/reconcile/sanitize.mjs +518 -0
- package/scripts/lib/reconcile/writer.mjs +95 -1
- package/scripts/lib/reconcile-nudge-banner.mjs +65 -9
- package/scripts/lib/resource-probe/evaluate.mjs +70 -4
- package/scripts/lib/resource-probe.mjs +19 -0
- package/scripts/lib/rule-loader.mjs +6 -0
- package/scripts/lib/scope-baseline.mjs +564 -0
- package/scripts/lib/scope-gate.mjs +568 -145
- package/scripts/lib/session-close-backfill.mjs +63 -8
- package/scripts/lib/session-end/phase-skip.mjs +1 -0
- package/scripts/lib/session-id.mjs +221 -41
- package/scripts/lib/session-lock.mjs +304 -6
- package/scripts/lib/session-record-repair.mjs +551 -0
- package/scripts/lib/session-schema/constants.mjs +22 -3
- package/scripts/lib/session-schema/serializer.mjs +54 -0
- package/scripts/lib/session-schema/validator.mjs +16 -0
- package/scripts/lib/session-schema.mjs +1 -0
- package/scripts/lib/session-token-rollup.mjs +68 -6
- package/scripts/lib/sessions-integrity-banner.mjs +294 -0
- package/scripts/lib/sessions-staleness-banner.mjs +121 -12
- package/scripts/lib/skill-evolution/idempotency.mjs +135 -16
- package/scripts/lib/skill-evolution/mr-opener.mjs +9 -1
- package/scripts/lib/soul-resolve.mjs +12 -0
- package/scripts/lib/spiral-carryover.mjs +142 -30
- package/scripts/lib/state-md/mission-status.mjs +53 -3
- package/scripts/lib/subagents-schema.mjs +43 -9
- package/scripts/lib/test-runner/issue-reconcile.mjs +53 -13
- package/scripts/lib/tests-src-ratio.mjs +484 -0
- package/scripts/lib/tmux-layout/telemetry.mjs +43 -10
- package/scripts/lib/validate/check-agents.mjs +56 -0
- package/scripts/lib/validate/check-banner-parity.mjs +376 -0
- package/scripts/lib/validate/check-guard-requires-parity.mjs +1148 -0
- package/scripts/lib/validate/check-hooks-symmetry.mjs +244 -10
- package/scripts/lib/validate/check-learning-provenance.mjs +511 -0
- package/scripts/lib/validate/check-owner-leakage.mjs +3 -3
- package/scripts/lib/validate/check-rules.mjs +244 -36
- package/scripts/lib/validate/check-test-value-bans.mjs +782 -0
- package/scripts/lib/validate/check-unicode-safety.mjs +1 -0
- package/scripts/lib/validate/check-unwired-features.mjs +549 -0
- package/scripts/lib/validate-vendored-rules.mjs +10 -2
- package/scripts/lib/vault-archive.mjs +17 -2
- package/scripts/lib/vault-backfill/glab.mjs +8 -0
- package/scripts/lib/vault-mirror/process.mjs +30 -0
- package/scripts/lib/vault-mirror/render-sessions.mjs +293 -36
- package/scripts/lib/vcs-repo-spec.mjs +362 -0
- package/scripts/lib/wave-resource-gate.mjs +115 -11
- package/scripts/lib/worktree/listing.mjs +44 -7
- package/scripts/mcp-server.sh +17 -3
- package/scripts/measure-context-overhead.sh +151 -0
- package/scripts/memory-propose.mjs +72 -9
- package/scripts/print-applicable-rules.mjs +218 -16
- package/scripts/print-learnings-index.mjs +474 -0
- package/scripts/release.mjs +534 -0
- package/scripts/repair-invalid-sessions.mjs +209 -0
- package/scripts/run-quality-gate.mjs +123 -5
- package/scripts/sweep-expired-learnings.mjs +192 -32
- package/scripts/validate-plugin.mjs +21 -0
- package/scripts/validate-wave-scope.mjs +182 -17
- package/scripts/vault-integration-watcher.mjs +32 -10
- package/skills/_shared/config-reading.md +2 -2
- package/skills/bootstrap/fast-template.md +1 -1
- package/skills/brainstorm/soul.md +47 -1
- package/skills/claude-md-drift-check/checker.mjs +145 -28
- package/skills/contract-version-bump/SKILL.md +219 -0
- package/skills/discovery/SKILL.md +4 -4
- package/skills/discovery/issue-templates.md +11 -11
- package/skills/discovery/probes-audit.md +1 -1
- package/skills/discovery/probes-feature.md +1 -1
- package/skills/discovery/probes-session.md +26 -5
- package/skills/ecosystem-health/SKILL.md +1 -1
- package/skills/ecosystem-health/wizard.md +4 -4
- package/skills/evolve/SKILL.md +117 -18
- package/skills/gitlab-ops/SKILL.md +25 -12
- package/skills/gitlab-portfolio/SKILL.md +2 -2
- package/skills/grill/soul.md +44 -1
- package/skills/hook-development/SKILL.md +1 -1
- package/skills/mode-selector/SKILL.md +1 -1
- package/skills/npm-publish/SKILL.md +17 -1
- package/skills/plan/SKILL.md +5 -5
- package/skills/plan/mode-feature.md +4 -4
- package/skills/plan/mode-new.md +10 -10
- package/skills/plan/mode-retro.md +1 -1
- package/skills/plan/soul.md +46 -3
- package/skills/quality-gates/SKILL.md +1 -1
- package/skills/reconcile/SKILL.md +21 -4
- package/skills/session-end/SKILL.md +34 -36
- package/skills/session-end/discovery-scan.md +4 -2
- package/skills/session-end/drift-operations.md +4 -4
- package/skills/session-end/metrics-collection.md +13 -0
- package/skills/session-end/phase-3-2-docs-verification.md +1 -1
- package/skills/session-end/phase-3-6-tail.md +32 -2
- package/skills/session-end/plan-verification.md +6 -7
- package/skills/session-end/session-metrics-write.md +2 -0
- package/skills/session-end/vault-operations.md +1 -1
- package/skills/session-end/verification-checklist.md +1 -1
- package/skills/session-plan/SKILL.md +6 -2
- package/skills/session-plan/wave-template.md +2 -0
- package/skills/session-start/SKILL.md +75 -7
- package/skills/session-start/phase-4-5-resource-health.md +15 -2
- package/skills/session-start/soul.md +41 -1
- package/skills/test-runner/SKILL.md +2 -2
- package/skills/vault-sync/validator.mjs +108 -7
- package/skills/wave-executor/SKILL.md +6 -7
- package/skills/wave-executor/circuit-breaker.md +2 -0
- package/skills/wave-executor/wave-loop.md +198 -80
- 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
|
+
}
|