@davesheffer/hunch 1.36.0 → 1.37.1

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.
@@ -0,0 +1,195 @@
1
+ /** Offline evaluation of task-record ranking (dec_66925aa0ee, PR B).
2
+ *
3
+ * Leave-one-out over finished task records: for each task T, rank the tasks
4
+ * that finished before it with a query built from T's own record, and ask
5
+ * whether the records T evidently needed appear in the five slots. Compared
6
+ * against the previous behaviour ("latest 3 on the file") with a paired
7
+ * bootstrap confidence interval. Deterministic: seeded resampling, no clock.
8
+ *
9
+ * Ground truth is what the record itself proves T used: an older task that
10
+ * received or saved a record T applied, that ran the same check T ran on an
11
+ * overlapping file, or whose violated rule T received. This is a proxy, not a
12
+ * label; the metric is pre-registered so the ranker cannot be tuned to it and
13
+ * then evaluated on the same window. */
14
+ import { DEFAULT_WEIGHTS, normalizePath, rankTaskRecords, recordIdsOf, selectTaskSlots } from "./taskRanking.js";
15
+ export const TASK_RANK_EVAL_SCHEMA = "hunch.task-rank-eval/1";
16
+ const GENERIC_TITLES = new Set(["Assistant task", "Claude task"]);
17
+ function tokens(text) {
18
+ return new Set(text.toLowerCase().split(/[^a-z0-9_]+/).filter((t) => t.length > 2));
19
+ }
20
+ /** What T evidently used from older tasks. */
21
+ export function groundTruth(task, older) {
22
+ const applied = new Set(task.applied.map((a) => a.record_id));
23
+ const received = new Set(task.lessons.map((l) => l.record_id));
24
+ const labels = new Set(task.checks.map((c) => c.label));
25
+ const files = new Set(task.files.map(normalizePath));
26
+ const truth = new Set();
27
+ for (const o of older) {
28
+ const ids = recordIdsOf(o);
29
+ const overlap = o.files.some((f) => files.has(normalizePath(f)));
30
+ if ([...applied].some((id) => ids.has(id))) {
31
+ truth.add(o.id);
32
+ continue;
33
+ }
34
+ if (overlap && o.checks.some((c) => labels.has(c.label))) {
35
+ truth.add(o.id);
36
+ continue;
37
+ }
38
+ if (o.conformance.some((c) => c.outcome === "violated" && received.has(c.record_id))) {
39
+ truth.add(o.id);
40
+ continue;
41
+ }
42
+ }
43
+ return truth;
44
+ }
45
+ /** T's own record as the query it would have had before editing. */
46
+ export function evalQuery(task) {
47
+ const files = new Set(task.files.map(normalizePath));
48
+ return {
49
+ target: normalizePath(task.files[0] ?? task.title),
50
+ files,
51
+ recordIds: new Set(task.lessons.map((l) => l.record_id)),
52
+ phrase: GENERIC_TITLES.has(task.title) ? null : task.title,
53
+ now: Date.parse(task.finished_at) || 0,
54
+ };
55
+ }
56
+ /** A store-free context: IDF over the older set, lexical by token overlap. */
57
+ export function pureContext(older, query) {
58
+ const df = new Map();
59
+ for (const r of older)
60
+ for (const id of recordIdsOf(r))
61
+ df.set(id, (df.get(id) ?? 0) + 1);
62
+ const n = Math.max(1, older.length);
63
+ const lexical = new Map();
64
+ if (query.phrase) {
65
+ const q = tokens(query.phrase);
66
+ let best = 0;
67
+ const raw = new Map();
68
+ for (const r of older) {
69
+ const doc = tokens(`${r.title} ${r.lessons.map((l) => l.title).join(" ")}`);
70
+ let inter = 0;
71
+ for (const t of q)
72
+ if (doc.has(t))
73
+ inter++;
74
+ const score = q.size ? inter / q.size : 0;
75
+ if (score > 0) {
76
+ raw.set(r.id, score);
77
+ best = Math.max(best, score);
78
+ }
79
+ }
80
+ for (const [id, s] of raw)
81
+ lexical.set(id, s / best);
82
+ }
83
+ return {
84
+ dependents: new Set(), cochange: new Map(), lexical, anchorsAlive: () => 1,
85
+ ruleStats: (id) => { const d = df.get(id) ?? 0; return { df: d, idf: Math.log((n + 1) / (d + 1)) + 1e-6 }; },
86
+ };
87
+ }
88
+ export function buildCases(records) {
89
+ const sorted = [...records].filter((r) => r.state === "completed").sort((a, b) => a.finished_at.localeCompare(b.finished_at) || a.id.localeCompare(b.id));
90
+ const cases = [];
91
+ for (let i = 0; i < sorted.length; i++) {
92
+ const task = sorted[i];
93
+ if (!task.files.length)
94
+ continue;
95
+ const older = sorted.slice(0, i);
96
+ if (!older.length)
97
+ continue;
98
+ const truth = groundTruth(task, older);
99
+ if (!truth.size)
100
+ continue;
101
+ cases.push({ task, file: normalizePath(task.files[0]), older, truth });
102
+ }
103
+ return cases;
104
+ }
105
+ export function rankedRanker(weights = DEFAULT_WEIGHTS) {
106
+ return (c) => {
107
+ const q = evalQuery(c.task);
108
+ const ranked = rankTaskRecords(c.older, q, pureContext(c.older, q), weights);
109
+ return selectTaskSlots(ranked).picks.map((p) => p.ranked.record.id);
110
+ };
111
+ }
112
+ export const latest3Ranker = (c) => c.older.filter((r) => r.files.map(normalizePath).includes(c.file))
113
+ .sort((a, b) => b.finished_at.localeCompare(a.finished_at) || a.id.localeCompare(b.id))
114
+ .slice(0, 3).map((r) => r.id);
115
+ export function scoreRanker(name, ranker, cases) {
116
+ const hits = [];
117
+ let rr = 0;
118
+ for (const c of cases) {
119
+ const picks = ranker(c).slice(0, 5);
120
+ const rank = picks.findIndex((id) => c.truth.has(id));
121
+ hits.push(rank >= 0 ? 1 : 0);
122
+ if (rank >= 0)
123
+ rr += 1 / (rank + 1);
124
+ }
125
+ const n = cases.length || 1;
126
+ return { name, hit5: hits.reduce((s, h) => s + h, 0) / n, mrr: rr / n, hits };
127
+ }
128
+ /** xorshift32: deterministic resampling so two runs agree byte for byte. */
129
+ function prng(seed) {
130
+ let x = seed >>> 0 || 0x9e3779b9;
131
+ return () => { x ^= x << 13; x >>>= 0; x ^= x >>> 17; x ^= x << 5; x >>>= 0; return x / 0x1_0000_0000; };
132
+ }
133
+ export function pairedBootstrap(a, b, resamples = 1000, seed = 42) {
134
+ const n = a.length;
135
+ if (!n)
136
+ return { mean: 0, ci95: [0, 0] };
137
+ const diffs = a.map((x, i) => x - (b[i] ?? 0));
138
+ const mean = diffs.reduce((s, d) => s + d, 0) / n;
139
+ const rnd = prng(seed);
140
+ const means = [];
141
+ for (let r = 0; r < resamples; r++) {
142
+ let s = 0;
143
+ for (let i = 0; i < n; i++)
144
+ s += diffs[Math.floor(rnd() * n)];
145
+ means.push(s / n);
146
+ }
147
+ means.sort((x, y) => x - y);
148
+ const at = (p) => means[Math.min(means.length - 1, Math.max(0, Math.floor(p * (means.length - 1))))];
149
+ return { mean, ci95: [at(0.025), at(0.975)] };
150
+ }
151
+ export function evaluateTaskRanking(records, options = {}) {
152
+ const split = options.split ?? 0.3;
153
+ const minEvaluated = options.minEvaluated ?? 5;
154
+ const all = buildCases(records);
155
+ let cases = all.slice(Math.floor(all.length * (1 - split)));
156
+ let note = null;
157
+ if (cases.length < minEvaluated) {
158
+ cases = all;
159
+ note = `fewer than ${minEvaluated} cases in the newest ${Math.round(split * 100)}%; evaluated every case`;
160
+ }
161
+ const weights = { ...(options.weights ?? DEFAULT_WEIGHTS) };
162
+ const ranked = scoreRanker("ranked", rankedRanker(weights), cases);
163
+ const baseline = scoreRanker("latest3", latest3Ranker, cases);
164
+ const resamples = options.resamples ?? 1000;
165
+ const delta = pairedBootstrap(ranked.hits, baseline.hits, resamples);
166
+ let verdict = "inconclusive";
167
+ if (cases.length < minEvaluated)
168
+ verdict = "insufficient-data";
169
+ else if (delta.ci95[0] > 0)
170
+ verdict = "ranked-better";
171
+ else if (delta.ci95[1] < 0)
172
+ verdict = "baseline-better";
173
+ return {
174
+ schema: TASK_RANK_EVAL_SCHEMA,
175
+ tasks: records.length,
176
+ evaluable: all.length,
177
+ split: { fraction: split, evaluated: cases.length, note },
178
+ rankers: [ranked, baseline].map(({ hits: _h, ...rest }) => rest),
179
+ delta_hit5: { ...delta, resamples },
180
+ verdict,
181
+ weights,
182
+ };
183
+ }
184
+ export function renderRankEval(report) {
185
+ const pct = (x) => `${(x * 100).toFixed(0)}%`;
186
+ const lines = [
187
+ `Task ranking evaluation — ${report.evaluable} evaluable of ${report.tasks} task record(s); evaluated ${report.split.evaluated}${report.split.note ? ` (${report.split.note})` : ` (newest ${Math.round(report.split.fraction * 100)}%)`}`,
188
+ ];
189
+ for (const r of report.rankers)
190
+ lines.push(` ${r.name.padEnd(8)} Hit@5 ${pct(r.hit5).padStart(4)} MRR ${r.mrr.toFixed(2)}`);
191
+ lines.push(` Δ Hit@5 ranked − latest3: ${(report.delta_hit5.mean * 100).toFixed(0)} pts, 95% CI [${(report.delta_hit5.ci95[0] * 100).toFixed(0)}, ${(report.delta_hit5.ci95[1] * 100).toFixed(0)}] (${report.delta_hit5.resamples} resamples)`);
192
+ lines.push(` verdict: ${report.verdict}${report.verdict === "insufficient-data" ? " — keep collecting; the kill rule needs a CI that excludes zero" : ""}`);
193
+ return lines.join("\n");
194
+ }
195
+ //# sourceMappingURL=taskRankEval.js.map
@@ -0,0 +1,118 @@
1
+ /** Ranking of task records for pre-edit delivery (dec_66925aa0ee).
2
+ *
3
+ * Pure functions over plain data: no I/O, no clock, no model. The store gathers
4
+ * candidates and computes the corpus-level inputs (dependents, co-change, IDF,
5
+ * lexical scores, anchor liveness); this module gates, scores, slots and
6
+ * explains. Same inputs always give the same picks, so a selection is
7
+ * replayable and every line carries the reasons that produced it.
8
+ *
9
+ * Design, from the research synthesis: gate before rank (unfiltered injection
10
+ * is what lost in every negative result), structure over similarity, verified
11
+ * outcome as the importance proxy, recency as a decayed term with a floor and
12
+ * never a cutoff, convex weighted sum (not RRF, which degenerates on short
13
+ * lists), IDF-weighted overlap instead of Jaccard on tiny id sets, slots plus
14
+ * MMR instead of a flat top-k, one factual reason per pick. */
15
+ import type { TaskRecord } from "./types.js";
16
+ export interface RankingQuery {
17
+ /** File (posix path) or phrase the agent is working on. */
18
+ target: string;
19
+ /** Files this task has already touched (from its ledger). */
20
+ files: ReadonlySet<string>;
21
+ /** Record ids delivered, applied or saved in this task so far. */
22
+ recordIds: ReadonlySet<string>;
23
+ /** Task title (opt-in) or the hunch_context phrase; null when unknown. */
24
+ phrase: string | null;
25
+ /** Milliseconds since epoch; passed in so ranking is replayable. */
26
+ now: number;
27
+ }
28
+ export interface CochangeStrength {
29
+ /** Commits in which the file and the target changed together. */
30
+ count: number;
31
+ /** count / max(commits touching either), in [0, 1]. */
32
+ strength: number;
33
+ }
34
+ export interface RankingContext {
35
+ /** Files that depend on the target or share its component (posix paths). */
36
+ dependents: ReadonlySet<string>;
37
+ /** Co-change strength per file, for files that changed with the target. */
38
+ cochange: ReadonlyMap<string, CochangeStrength>;
39
+ /** Corpus statistics for a record id across all task records. */
40
+ ruleStats: (recordId: string) => {
41
+ idf: number;
42
+ df: number;
43
+ };
44
+ /** bm25 of the query phrase over task title + lesson titles, normalized to the top hit. */
45
+ lexical: ReadonlyMap<string, number>;
46
+ /** Fraction of a record's files that still exist; 1 when it names no files. */
47
+ anchorsAlive: (record: TaskRecord) => number;
48
+ /** Task ids some later record supersedes; never delivered. */
49
+ superseded?: ReadonlySet<string>;
50
+ /** Last time a task line was delivered (ms since epoch), when receipts know. */
51
+ lastDelivered?: (taskId: string) => number | null;
52
+ }
53
+ export interface RankingWeights {
54
+ file: number;
55
+ rules: number;
56
+ outcome: number;
57
+ lexical: number;
58
+ recency: number;
59
+ workingSet: number;
60
+ }
61
+ /** A prior, not a measurement. Changed only through `hunch task rank-eval`. */
62
+ export declare const DEFAULT_WEIGHTS: Readonly<RankingWeights>;
63
+ export declare const RECENCY_HALF_LIFE_DAYS = 30;
64
+ export declare const RECENCY_FLOOR = 0.1;
65
+ export declare const COCHANGE_MIN_COUNT = 2;
66
+ /** A shared record admits a candidate only when it is informative: present in
67
+ * at most half of all task records (idf ≥ ln 2). A rule every task receives
68
+ * says nothing about relatedness; it still contributes to the score, weakly. */
69
+ export declare const RULE_GATE_MIN_IDF: number;
70
+ export type RankingTerm = keyof RankingWeights;
71
+ export interface RankedTask {
72
+ record: TaskRecord;
73
+ score: number;
74
+ terms: Record<RankingTerm, number>;
75
+ /** Factual reasons, strongest first (weighted term contribution). */
76
+ reasons: string[];
77
+ anchorsAlive: number;
78
+ /** True when the record names the exact target file. */
79
+ exactFile: boolean;
80
+ /** True when a delivered rule was violated or a check failed in the record. */
81
+ problem: boolean;
82
+ }
83
+ export type SlotName = "latest" | "violation" | "relevant";
84
+ export interface SlotPick {
85
+ slot: SlotName;
86
+ ranked: RankedTask;
87
+ }
88
+ export interface TaskSelection {
89
+ picks: SlotPick[];
90
+ /** Ranked candidates not shown. */
91
+ more: number;
92
+ candidates: number;
93
+ /** How the picks were chosen: the ranker, or the "latest 3" fallback the kill rule imposes. */
94
+ mode?: "ranked" | "latest";
95
+ }
96
+ /** The pre-ranking behaviour, kept as the baseline and the fallback: the three
97
+ * most recent records on the exact file, no scoring. */
98
+ export declare function selectLatestTasks(records: readonly TaskRecord[], query: RankingQuery, ctx: RankingContext, limit?: number): TaskSelection;
99
+ export interface SlotOptions {
100
+ limit?: number;
101
+ relevant?: number;
102
+ lambda?: number;
103
+ }
104
+ export declare function normalizePath(path: string): string;
105
+ /** Every record id a task record references: lessons received, applications, saves. */
106
+ export declare function recordIdsOf(record: TaskRecord): Set<string>;
107
+ export declare function recencyTerm(record: TaskRecord, now: number, lastDelivered?: number | null): number;
108
+ export declare function outcomeTerm(record: TaskRecord): {
109
+ value: number;
110
+ reason: string | null;
111
+ };
112
+ /** Gate, score and explain one record. Null when the gate rejects it. */
113
+ export declare function rankTaskRecord(record: TaskRecord, query: RankingQuery, ctx: RankingContext, weights?: Readonly<RankingWeights>): RankedTask | null;
114
+ export declare function rankTaskRecords(records: readonly TaskRecord[], query: RankingQuery, ctx: RankingContext, weights?: Readonly<RankingWeights>): RankedTask[];
115
+ /** Jaccard over files ∪ record ids: the similarity MMR penalizes. */
116
+ export declare function taskSimilarity(a: TaskRecord, b: TaskRecord): number;
117
+ /** Slots: latest on the exact file, most recent problem, then relevant by MMR. */
118
+ export declare function selectTaskSlots(ranked: readonly RankedTask[], options?: SlotOptions): TaskSelection;
@@ -0,0 +1,214 @@
1
+ /** A prior, not a measurement. Changed only through `hunch task rank-eval`. */
2
+ export const DEFAULT_WEIGHTS = Object.freeze({
3
+ file: 0.30, rules: 0.25, outcome: 0.15, lexical: 0.10, recency: 0.10, workingSet: 0.10,
4
+ });
5
+ export const RECENCY_HALF_LIFE_DAYS = 30;
6
+ export const RECENCY_FLOOR = 0.1;
7
+ export const COCHANGE_MIN_COUNT = 2;
8
+ /** A shared record admits a candidate only when it is informative: present in
9
+ * at most half of all task records (idf ≥ ln 2). A rule every task receives
10
+ * says nothing about relatedness; it still contributes to the score, weakly. */
11
+ export const RULE_GATE_MIN_IDF = Math.log(2);
12
+ /** The pre-ranking behaviour, kept as the baseline and the fallback: the three
13
+ * most recent records on the exact file, no scoring. */
14
+ export function selectLatestTasks(records, query, ctx, limit = 3) {
15
+ const target = normalizePath(query.target);
16
+ const ranked = records
17
+ .filter((r) => !ctx.superseded?.has(r.id) && recordFiles(r).includes(target))
18
+ .map((r) => rankTaskRecord(r, query, ctx))
19
+ .filter((r) => r !== null)
20
+ .sort(newestFirst);
21
+ const picks = ranked.slice(0, Math.max(1, limit)).map((r) => ({ slot: "latest", ranked: r }));
22
+ return { picks, more: ranked.length - picks.length, candidates: ranked.length, mode: "latest" };
23
+ }
24
+ const DAY_MS = 86_400_000;
25
+ export function normalizePath(path) {
26
+ return path.trim().replace(/\\/g, "/").replace(/^\.\//, "");
27
+ }
28
+ /** Every record id a task record references: lessons received, applications, saves. */
29
+ export function recordIdsOf(record) {
30
+ const ids = new Set();
31
+ for (const l of record.lessons)
32
+ ids.add(l.record_id);
33
+ for (const a of record.applied)
34
+ ids.add(a.record_id);
35
+ for (const s of record.saved)
36
+ ids.add(s.record_id);
37
+ return ids;
38
+ }
39
+ function recordFiles(record) {
40
+ return record.files.map(normalizePath);
41
+ }
42
+ /** Age from the later of finishing and the last delivery: a record that keeps
43
+ * being delivered stays warm (access-based decay, as in Generative Agents). */
44
+ function ageDays(record, now, lastDelivered = null) {
45
+ const finished = Date.parse(record.finished_at);
46
+ const anchor = Math.max(Number.isFinite(finished) ? finished : Number.NEGATIVE_INFINITY, lastDelivered ?? Number.NEGATIVE_INFINITY);
47
+ if (!Number.isFinite(anchor))
48
+ return Number.POSITIVE_INFINITY;
49
+ return Math.max(0, (now - anchor) / DAY_MS);
50
+ }
51
+ export function recencyTerm(record, now, lastDelivered = null) {
52
+ const days = ageDays(record, now, lastDelivered);
53
+ if (!Number.isFinite(days))
54
+ return RECENCY_FLOOR;
55
+ return Math.max(RECENCY_FLOOR, Math.pow(0.5, days / RECENCY_HALF_LIFE_DAYS));
56
+ }
57
+ export function outcomeTerm(record) {
58
+ if (record.conformance.some((c) => c.outcome === "violated"))
59
+ return { value: 1, reason: "RULE VIOLATED" };
60
+ if (record.checks.some((c) => c.state === "failed" || c.state === "timed out"))
61
+ return { value: 0.8, reason: "check failed" };
62
+ if (record.saved.length)
63
+ return { value: 0.6, reason: `saved ${record.saved[0].record_id}${record.saved.length > 1 ? ` +${record.saved.length - 1}` : ""}` };
64
+ if (record.applied.some((a) => a.supported_by))
65
+ return { value: 0.5, reason: "applied a rule (rule-supported)" };
66
+ if (record.checks.some((c) => c.state === "passed"))
67
+ return { value: 0.3, reason: "check passed" };
68
+ return { value: 0, reason: null };
69
+ }
70
+ function humanAge(days) {
71
+ if (!Number.isFinite(days))
72
+ return "undated";
73
+ if (days < 1)
74
+ return "today";
75
+ if (days < 2)
76
+ return "yesterday";
77
+ return `${Math.floor(days)} days ago`;
78
+ }
79
+ function clip(text, max) {
80
+ return text.length <= max ? text : `${text.slice(0, max - 1).trimEnd()}…`;
81
+ }
82
+ /** Gate, score and explain one record. Null when the gate rejects it. */
83
+ export function rankTaskRecord(record, query, ctx, weights = DEFAULT_WEIGHTS) {
84
+ const target = normalizePath(query.target);
85
+ if (ctx.superseded?.has(record.id))
86
+ return null; // a later task verified over it
87
+ const files = recordFiles(record);
88
+ const alive = files.length ? ctx.anchorsAlive(record) : 1;
89
+ if (files.length && alive === 0)
90
+ return null; // nothing it names still exists
91
+ // --- file: same file 1, dependent / same component 0.5, co-change 0.3 × strength-ish
92
+ const exactFile = files.includes(target);
93
+ const dependent = !exactFile && files.some((f) => ctx.dependents.has(f));
94
+ let cochangeHit = null;
95
+ if (!exactFile && !dependent) {
96
+ for (const f of files) {
97
+ const c = ctx.cochange.get(f);
98
+ if (c && c.count >= COCHANGE_MIN_COUNT && (!cochangeHit || c.count > cochangeHit.count))
99
+ cochangeHit = { file: f, count: c.count };
100
+ }
101
+ }
102
+ const fileValue = exactFile ? 1 : dependent ? 0.5 : cochangeHit ? 0.3 : 0;
103
+ // --- rules: IDF-weighted intersection over the current task's own ids
104
+ const ids = recordIdsOf(record);
105
+ const shared = [];
106
+ let queryMass = 0;
107
+ for (const id of query.recordIds) {
108
+ const s = ctx.ruleStats(id);
109
+ queryMass += s.idf;
110
+ if (ids.has(id))
111
+ shared.push({ id, ...s });
112
+ }
113
+ const rulesValue = queryMass > 0 ? Math.min(1, shared.reduce((sum, s) => sum + s.idf, 0) / queryMass) : 0;
114
+ // --- gate: structure or an informative shared rule; lexical/recency/outcome alone never admit
115
+ const informative = shared.some((s) => s.idf >= RULE_GATE_MIN_IDF);
116
+ if (fileValue === 0 && !informative)
117
+ return null;
118
+ const outcome = outcomeTerm(record);
119
+ const lexicalValue = Math.max(0, Math.min(1, ctx.lexical.get(record.id) ?? 0));
120
+ const lastDelivered = ctx.lastDelivered?.(record.id) ?? null;
121
+ const recencyValue = recencyTerm(record, query.now, lastDelivered);
122
+ const touched = files.filter((f) => query.files.has(f));
123
+ const workingSetValue = query.files.size ? Math.min(1, touched.length / query.files.size) : 0;
124
+ const terms = {
125
+ file: fileValue, rules: rulesValue, outcome: outcome.value, lexical: lexicalValue, recency: recencyValue, workingSet: workingSetValue,
126
+ };
127
+ const score = Object.keys(terms).reduce((sum, k) => sum + weights[k] * terms[k], 0);
128
+ // --- reasons, ordered by weighted contribution
129
+ const reasons = [];
130
+ if (exactFile)
131
+ reasons.push({ weight: weights.file * 1, text: "same file" });
132
+ else if (dependent)
133
+ reasons.push({ weight: weights.file * 0.5, text: `dependent of ${target}` });
134
+ else if (cochangeHit)
135
+ reasons.push({ weight: weights.file * 0.3, text: `co-changed with ${target} in ${cochangeHit.count} commits` });
136
+ if (shared.length) {
137
+ const top = [...shared].sort((a, b) => b.idf - a.idf)[0];
138
+ reasons.push({ weight: weights.rules * rulesValue, text: `shares ${top.id}${top.df ? ` (${top.df === 1 ? "only here" : `${top.df} tasks`})` : ""}${shared.length > 1 ? ` +${shared.length - 1}` : ""}` });
139
+ }
140
+ if (outcome.reason)
141
+ reasons.push({ weight: weights.outcome * outcome.value, text: outcome.reason });
142
+ if (lexicalValue > 0 && query.phrase)
143
+ reasons.push({ weight: weights.lexical * lexicalValue, text: `matches "${clip(query.phrase, 40)}"` });
144
+ if (touched.length)
145
+ reasons.push({ weight: weights.workingSet * workingSetValue, text: `also touched ${touched[0]} this task` });
146
+ const finishedAge = ageDays(record, query.now);
147
+ const effectiveAge = ageDays(record, query.now, lastDelivered);
148
+ reasons.push({ weight: weights.recency * recencyValue, text: effectiveAge < finishedAge ? `delivered ${humanAge(effectiveAge)}` : humanAge(finishedAge) });
149
+ if (alive < 1)
150
+ reasons.push({ weight: 0, text: "files since changed" });
151
+ reasons.sort((a, b) => b.weight - a.weight);
152
+ return {
153
+ record, score, terms, reasons: reasons.map((r) => r.text), anchorsAlive: alive, exactFile,
154
+ problem: outcome.value >= 0.8,
155
+ };
156
+ }
157
+ /** Newest first, then id — the tie order the research insists on. */
158
+ function newestFirst(a, b) {
159
+ return b.record.finished_at.localeCompare(a.record.finished_at) || a.record.id.localeCompare(b.record.id);
160
+ }
161
+ export function rankTaskRecords(records, query, ctx, weights = DEFAULT_WEIGHTS) {
162
+ const ranked = [];
163
+ for (const record of records) {
164
+ const r = rankTaskRecord(record, query, ctx, weights);
165
+ if (r)
166
+ ranked.push(r);
167
+ }
168
+ return ranked.sort((a, b) => (b.score - a.score) || newestFirst(a, b));
169
+ }
170
+ /** Jaccard over files ∪ record ids: the similarity MMR penalizes. */
171
+ export function taskSimilarity(a, b) {
172
+ const sa = new Set([...recordFiles(a), ...recordIdsOf(a)]);
173
+ const sb = new Set([...recordFiles(b), ...recordIdsOf(b)]);
174
+ if (!sa.size && !sb.size)
175
+ return 0;
176
+ let inter = 0;
177
+ for (const x of sa)
178
+ if (sb.has(x))
179
+ inter++;
180
+ return inter / (sa.size + sb.size - inter);
181
+ }
182
+ /** Slots: latest on the exact file, most recent problem, then relevant by MMR. */
183
+ export function selectTaskSlots(ranked, options = {}) {
184
+ const limit = Math.max(1, options.limit ?? 5);
185
+ const relevantMax = Math.max(0, options.relevant ?? 3);
186
+ const lambda = options.lambda ?? 0.7;
187
+ const picks = [];
188
+ const taken = new Set();
189
+ const take = (slot, r) => { picks.push({ slot, ranked: r }); taken.add(r.record.id); };
190
+ const latest = [...ranked].filter((r) => r.exactFile).sort(newestFirst)[0];
191
+ if (latest)
192
+ take("latest", latest);
193
+ const violation = [...ranked].filter((r) => r.problem && !taken.has(r.record.id)).sort(newestFirst)[0];
194
+ if (violation && picks.length < limit)
195
+ take("violation", violation);
196
+ let remaining = ranked.filter((r) => !taken.has(r.record.id));
197
+ while (picks.length < limit && picks.length - (latest ? 1 : 0) - (violation ? 1 : 0) < relevantMax && remaining.length) {
198
+ let best = null, bestValue = -Infinity;
199
+ for (const r of remaining) {
200
+ const redundancy = picks.length ? Math.max(...picks.map((p) => taskSimilarity(p.ranked.record, r.record))) : 0;
201
+ const value = lambda * r.score - (1 - lambda) * redundancy;
202
+ if (value > bestValue || (value === bestValue && best && newestFirst(r, best) < 0)) {
203
+ best = r;
204
+ bestValue = value;
205
+ }
206
+ }
207
+ if (!best)
208
+ break;
209
+ take("relevant", best);
210
+ remaining = remaining.filter((r) => r !== best);
211
+ }
212
+ return { picks, more: ranked.length - picks.length, candidates: ranked.length, mode: "ranked" };
213
+ }
214
+ //# sourceMappingURL=taskRanking.js.map
@@ -0,0 +1,36 @@
1
+ import type { HunchStore } from "../store/hunchStore.js";
2
+ import { type RankEvalReport } from "./taskRankEval.js";
3
+ export declare const RANK_EVAL_CACHE_SCHEMA: "hunch.task-rank-eval-cache/1";
4
+ /** The decision's threshold: the kill rule needs at least this many task records. */
5
+ export declare const KILL_MIN_TASKS = 200;
6
+ export type TaskRankingMode = "ranked" | "latest";
7
+ export interface RankEvalCache {
8
+ schema: typeof RANK_EVAL_CACHE_SCHEMA;
9
+ computed_at: string;
10
+ /** Task-record count the report was computed over; a different count triggers a recompute. */
11
+ records: number;
12
+ /** Content hash of the record ids + report hashes, so an edit without a count change also refreshes. */
13
+ corpus_hash: string;
14
+ report: RankEvalReport;
15
+ }
16
+ export declare function readRankEvalCache(root: string): RankEvalCache | null;
17
+ /** Recompute when the corpus changed; otherwise return the cached report. Never throws. */
18
+ export declare function refreshRankEval(root: string, store: HunchStore, options?: {
19
+ force?: boolean;
20
+ now?: () => string;
21
+ }): RankEvalCache | null;
22
+ /** The rule from the decision, applied to a report. */
23
+ export declare function modeFromReport(report: RankEvalReport | null | undefined): {
24
+ mode: TaskRankingMode;
25
+ reason: string;
26
+ };
27
+ export interface ResolvedRankingMode {
28
+ mode: TaskRankingMode;
29
+ reason: string;
30
+ source: "override" | "auto";
31
+ cache: RankEvalCache | null;
32
+ }
33
+ /** What delivery should do right now for this repository. */
34
+ export declare function resolveTaskRankingMode(root: string, store: HunchStore): ResolvedRankingMode;
35
+ /** One line for `hunch now` and the task stats. */
36
+ export declare function rankingStatusLine(resolved: ResolvedRankingMode): string;