ko-qmd 2.8.3-ko.11 → 2.8.3-ko.12

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/CHANGELOG.md CHANGED
@@ -2,6 +2,93 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [2.8.3-ko.12] - 2026-11-14
6
+
7
+ - `qmd mcp --http`: fix a memory leak in long-running daemons. The HTTP
8
+ bridge buffered every `/mcp` response to the end, so a
9
+ `subscriptions/listen` stream (which stays open) was never sent and was
10
+ never released when its client left. Every dead listener kept collecting
11
+ 15-second keep-alive frames: one daemon reached 9.2 GB after 6 days and
12
+ hit the SDK's 1024-subscription cap, after which new listens were
13
+ refused. Responses now stream to the socket, and a client disconnect
14
+ closes the SDK stream (`/health` `listens` drops back). Under Bun 1.3 the
15
+ HTTP server gets no signal when a client hangs up on a POST, so a daemon
16
+ run with Bun still keeps dead listeners; run the daemon with Node.
17
+ - Rerank input: a note that opens with an empty frontmatter block (`---`
18
+ twice) keeps its lead. The block used to run on to the next `---` rule in
19
+ the body, and when that span passed 350 characters the reranker saw only
20
+ the text after the rule. A `---` at the end of a frontmatter value no
21
+ longer closes the block either.
22
+ - `--since` / `--until` (CLI, and `since` / `until` on the MCP `query` and
23
+ `grep` tools) reject a date the calendar does not have, such as
24
+ `2026-02-31` or a swapped `2026-31-01`, with `not a date`. They used to
25
+ roll it into a later month and search from there.
26
+ - `qmd grep` (and the MCP `grep` tool) no longer says it stopped at the
27
+ limit when the limit-th match was the last document read; `--json`
28
+ `truncated` is false then.
29
+ - Relevance judge: a note that cannot be read (renamed or deleted after the
30
+ bench) now fails only its query, recorded under that pass's `errors`.
31
+ It used to abort `scripts/judge/run.ts` before any judgments were written.
32
+ - CI: `docs/vault-search/WINDOWS.md` lists the Windows test failures of
33
+ develop `f2694b1` (21 files; `test/metadata-search.test.ts` passes now).
34
+ - `qmd grep` (and the MCP `grep` tool) matches CRLF notes like LF notes:
35
+ an end-anchored pattern such as `끝$` now matches, and the returned line
36
+ text no longer ends in a carriage return. Line numbers are unchanged.
37
+ - `qmd update` and SDK `update()` no longer empty a collection whose root
38
+ directory is missing (a macOS volume that is not mounted, a moved or
39
+ renamed folder). The scan saw zero files and deactivated every document;
40
+ the root is now reported as skipped (`⚠ Skipped unreadable file: <root>
41
+ (ENOENT)`) and the index is left as it was. An empty mount point that is
42
+ still a directory is not caught. To drop a collection on purpose, use
43
+ `qmd collection remove`.
44
+ - Relevance judge: `scripts/judge/pool.ts` reads CRLF notes (a Windows
45
+ checkout or a Windows-authored vault) and a leading BOM like LF notes. The
46
+ frontmatter was kept in the judged text and the aliases were lost, which
47
+ also failed `test/judge-run.test.ts` on the Windows CI job (run
48
+ 41372458709, hidden by `continue-on-error`).
49
+ - RAG eval: a `narrow` answer prompt (`RAG_PROMPT=narrow`) answers questions
50
+ that name a note's topic in other words, and abstains only when the asked
51
+ fact is in no chunk. On the 44 ko-vault questions (M3 Max, gemma3:4b, one
52
+ run, reference grades by Claude Opus 5.5, not people) it refuses 8 of 32
53
+ answerable questions against grounded's 12. It keeps 8 of 12 unanswerable
54
+ refusals. Faithfulness against baseline is +0.190 [0.024, 0.357]. The
55
+ faithfulness judge no longer sees inline "[n]" markers in the sentence it
56
+ grades, and `rag-ko.sh` keeps the reference grades of variants it does not
57
+ answer again. See `docs/eval/README.md`.
58
+ - RAG answers with chunk citations and a faithfulness evaluation (eval stage
59
+ 3). `scripts/rag/ask.ts` answers from a qmd index with `[n]` citations, or
60
+ says it does not know. `scripts/rag-ko.sh` measures faithfulness, citation
61
+ precision and abstention on 44 ko-vault questions, 12 of them unanswerable.
62
+ `gemma3:4b` generates and `qwen3:8b` judges. A stricter prompt raised
63
+ abstention on unanswerable questions from 0.333 to 0.667, but it also
64
+ refused 12 of 32 answerable ones. Its faithfulness change, +0.097
65
+ [-0.125, 0.333] on reference grades, is not distinguishable from zero. The
66
+ judge agreed with Claude Opus 5.5 reference grades (model, not human) at
67
+ kappa 0.35 only, so faithfulness is reported on the reference grades. See
68
+ `docs/eval/README.md`.
69
+ - LLM-as-judge relevance grading for the ko-vault fixture (`scripts/judge-ko.sh`,
70
+ eval stage 2). A local Ollama model (`qwen3:8b`) grades each query's pool 0–3
71
+ on `test/fixtures/ko-vault/judge/rubric.md`. The pool is the union of the
72
+ bm25/hybrid/full top 10 plus missed answers. The report gives nDCG@10 per
73
+ backend with bootstrap intervals, a position-bias check and kappa against
74
+ labels sampled by `scripts/judge/label.ts`. Listwise grading changed 41% of
75
+ grades when only the order changed, so `JUDGE_MODE=pointwise` grades one
76
+ document at a time. Pointwise nDCG@10 on 50 goldset queries (M3 Max): bm25
77
+ 0.726, hybrid 0.868, full 0.891. Against 250 reference labels by Claude
78
+ Opus 5.5 (model labels, not human), the pointwise judge's weighted kappa is
79
+ 0.708 [0.646, 0.764]; it is lenient on same-field near misses. See
80
+ `docs/eval/README.md`.
81
+ - CI gains a lex (BM25) evaluation gate, the `lex-gate` job. It runs
82
+ `scripts/bench-lex-gate.sh`, which benches bm25 over the ko-vault goldset five
83
+ times on fresh indexes with no model. It compares per-query recall@5 and
84
+ reciprocal rank with `test/fixtures/ko-vault/lex-baseline.json` by a seeded
85
+ paired bootstrap, and fails only when a 95% interval lies below zero. Baseline
86
+ (M3 Max, 5 reps): r5 0.8875, MRR 0.8169, no run-to-run difference. See
87
+ `docs/eval/README.md`.
88
+ - `qmd bench --backend <name>` runs a subset of the bm25, vector, hybrid and
89
+ full backends (repeatable or a comma list). `--backend bm25` needs no
90
+ embedding or reranker model.
91
+
5
92
  ## [2.8.3-ko.11] - 2026-10-31
6
93
 
7
94
  - Rerank now caps each document at 256 tokens by default
package/README.md CHANGED
@@ -202,7 +202,8 @@ npm run build # dist/ — `bun build --compile` 은 쓰지
202
202
 
203
203
  - 브랜치: `develop` 이 작업 브랜치(PR 은 여기로), `main` 은 배포 브랜치다. 릴리스는 `/release <version>`(→ `v*` 태그 → `publish.yml` 이 npm 발행).
204
204
  절차와 CHANGELOG 규칙: [skills/release/SKILL.md](skills/release/SKILL.md).
205
- - CI 는 업스트림 매트릭스에 `windows-latest` 와 Electron 스모크 잡을 더했다.
205
+ - CI 는 업스트림 매트릭스에 `windows-latest` 와 Electron 스모크 잡을 더했다. `lex-gate` 잡은 모델 없이 ko-vault
206
+ goldset 으로 bm25 recall@5·MRR 저하를 막는다(방법·결과: [docs/eval/README.md](docs/eval/README.md)).
206
207
 
207
208
  ### dogfood — 작업 트리 빌드를 이 머신 데몬에 올리기
208
209
 
@@ -233,6 +234,29 @@ Qwen3-Embedding 기본값·질의 52 에서 bm25_r5 0.9519 · vector_r5 1.0000
233
234
  (exact 5·alias 4·semantic 3·topical 2·cross-domain 1, 저커버리지 토픽과 topics/ 입구 보강)과 hard 15건
234
235
  (sem-hard 5·multi 5·neg 5)을 더한 셋이다. hard 45 기준의 RESULT 참조선은 BASELINE.md 2026-10-10 절에 있다.
235
236
 
237
+ ### LLM 판정자 평가
238
+
239
+ `bash scripts/judge-ko.sh`는 로컬 Ollama 모델(`qwen3:8b`)에게 검색 결과의 관련도를 0~3으로 매기게 한다. 그 등급으로
240
+ 정답 세트가 없는 질의에서도 nDCG@10을 잰다. 대상은 bm25·hybrid·full 상위 10의 합집합이고, 질의는 80개(정답 세트 50·정답 없음 30)다.
241
+ 판정자를 쓰기 전에 두 가지를 점검했다. 목록 하나를 통째로 채점하는 listwise 방식은 문서 순서만 섞어도 등급의 41%가 바뀌었다.
242
+ 그래서 문서를 하나씩 채점하는 pointwise 방식(`JUDGE_MODE=pointwise`)을 추가했다. pointwise는 정답 문서 59개 중 58개를 2 이상으로,
243
+ off-corpus 문서 39개를 모두 0으로 채점했다.
244
+ pointwise 기준 정답 세트 nDCG@10은 bm25 0.726·hybrid 0.868·full 0.891이고, full − bm25는 +0.164 [0.098, 0.234]다(M3 Max).
245
+ 판정자는 기준 라벨 250쌍과 weighted kappa 0.708 [0.646, 0.764]로 일치했다. 기준 라벨은 사람이 아니라 Claude Opus 5.5가
246
+ 판정자 등급을 보지 않고 매긴 것이다. 판정자는 관련 문서를 거의 놓치지 않지만 같은 분야의 다른 개념 문서에 높은 등급을 주는
247
+ 경향이 있다. 그래서 nDCG는 절대값보다 backend 간 비교로 읽는다. 방법과 수치는 [docs/eval/README.md](docs/eval/README.md)에 있다.
248
+
249
+ ### 인용 답변(RAG) 평가
250
+
251
+ `npx tsx scripts/rag/ask.ts --index <index.sqlite> --config <index.yml> "질문"`은 검색한 청크 상위 5개로 답하고, 문장마다
252
+ 근거 청크 번호를 단다. 청크로 답할 수 없으면 "모른다"고 답한다. `bash scripts/rag-ko.sh`는 fixture 질문 44개(답 있음 32·
253
+ 주제는 있지만 사실이 없는 near-miss 8·무관 4)로 faithfulness·인용 정확도·"모른다" 비율을 잰다. 생성은 `gemma3:4b`, 판정은 `qwen3:8b`다.
254
+ 판정자는 Claude Opus 5.5 기준 등급(사람 아님)과 kappa 0.35로 일치해 믿기 어려웠다. 그래서 faithfulness는 기준 등급으로 보고한다.
255
+ 비교한 변경은 더 엄격한 프롬프트다. 이 프롬프트는 near-miss·무관 질문에서 "모른다"를 0.333에서 0.667로 올렸다.
256
+ 대신 답이 있는 질문 32개 중 12개도 거절했다. faithfulness 차이 +0.097 [−0.125, 0.333]은 0을 포함한다(M3 Max).
257
+ 거절 규칙만 좁힌 `narrow` 프롬프트(`ask.ts` 기본값)는 답 있는 질문 거절을 12개에서 8개로 줄이고, 답 없는 질문 거절은
258
+ 8/12로 유지했다. baseline 대비 faithfulness는 +0.190 [0.024, 0.357]이다. 방법과 수치는 [docs/eval/README.md](docs/eval/README.md)에 있다.
259
+
236
260
  ### Electron 내장
237
261
 
238
262
  앱은 SDK 를 워커(utilityProcess)에서 쓴다. CI `electron-smoke` 잡이 Electron 39(ABI 140)로 `better-sqlite3` 를 리빌드하고
@@ -14,6 +14,8 @@
14
14
  * - full: Full hybrid pipeline with LLM reranking
15
15
  */
16
16
  import type { BackendSummaryByType, HardSummary, QueryResult, BenchmarkResult } from "./types.js";
17
+ /** Backend names `runBenchmark` accepts in `options.backends`, in run order. */
18
+ export declare const BENCH_BACKEND_NAMES: readonly string[];
17
19
  /** Per-type summary built from the existing computeSummary on each type's slice of results.
18
20
  * A type with zero queries gets no entry — pooling must skip it, never divide by zero. */
19
21
  export declare const computeSummaryByType: (results: QueryResult[]) => BackendSummaryByType;
@@ -1,12 +1,12 @@
1
- var v=Object.defineProperty;var i=(a,e)=>v(a,"name",{value:e,configurable:!0});import{readFileSync as x}from"node:fs";import{resolve as k}from"node:path";import{createStore as R,getDefaultDbPath as q}from"../index.js";import{scoreResults as S}from"./score.js";import{HARD_QUERY_TYPES as w}from"./types.js";function y(a){const e=a.split(`
2
- `).map((s,o)=>({trimmed:s.trim(),number:o+1})).filter(s=>s.trimmed.length>0);if(e.length===0)return;const t=/^(lex|vec|hyde):\s*/i,n=/^intent:\s*/i,r=[];let c;for(const s of e){if(n.test(s.trimmed)){if(c!==void 0)throw new Error(`Line ${s.number}: only one intent: line is allowed per benchmark query.`);if(c=s.trimmed.replace(n,"").trim(),!c)throw new Error(`Line ${s.number}: intent: must include text.`);continue}const o=s.trimmed.match(t);if(o){const f=o[1].toLowerCase(),m=s.trimmed.slice(o[0].length).trim();if(!m)throw new Error(`Line ${s.number} (${f}:) must include text.`);r.push({type:f,query:m,line:s.number});continue}if(e.length===1)return;throw new Error(`Line ${s.number} is missing a lex:/vec:/hyde:/intent: prefix.`)}if(c&&r.length===0)throw new Error("intent: cannot appear alone. Add at least one lex:, vec:, or hyde: line.");return r.length>0?{searches:r,intent:c}:void 0}i(y,"parseStructuredQuery");function b(a,e){const t=new Set,n=[];for(const r of a)if(!t.has(r)&&(t.add(r),n.push(r),n.length>=e))break;return n}i(b,"uniqueFiles");const g=[{name:"bm25",run:i(async(a,e,t,n)=>{const r=y(e.query),c=r?.searches.filter(o=>o.type==="lex");if(r){const o=[];for(const f of c??[]){const m=await a.searchLex(f.query,{limit:t,collection:n});o.push(...m.map(_=>_.filepath))}return b(o,t)}return(await a.searchLex(e.query,{limit:t,collection:n})).map(o=>o.filepath)},"run")},{name:"vector",run:i(async(a,e,t,n)=>{const r=y(e.query),c=r?.searches.filter(o=>o.type==="vec"||o.type==="hyde");if(r){const o=[];for(const f of c??[]){const m=await a.searchVector(f.query,{limit:t,collection:n});o.push(...m.map(_=>_.filepath))}return b(o,t)}return(await a.searchVector(e.query,{limit:t,collection:n})).map(o=>o.filepath)},"run")},{name:"hybrid",run:i(async(a,e,t,n)=>{const r=y(e.query);return(r?await a.search({queries:r.searches,intent:r.intent,limit:t,collection:n,rerank:!1}):await a.search({query:e.query,limit:t,collection:n,rerank:!1})).map(s=>s.file)},"run")},{name:"full",run:i(async(a,e,t,n)=>{const r=y(e.query);return(r?await a.search({queries:r.searches,intent:r.intent,limit:t,collection:n,rerank:!0}):await a.search({query:e.query,limit:t,collection:n,rerank:!0})).map(s=>s.file)},"run")}];async function E(a,e,t,n){const r=t.expected_in_top_k??t.expected_files.length,c=Math.max(Number.isFinite(r)?r:0,10),s=Date.now();let o;try{o=await e.run(a,t,c,n)}catch{return{precision_at_k:0,recall:0,recall_at_1:0,recall_at_3:0,recall_at_5:0,mrr:0,f1:0,hits_at_k:0,total_expected:t.expected_files.length,latency_ms:Date.now()-s,top_files:[],matched_files:[],unmatched_expected_files:t.expected_files}}const f=Date.now()-s;return{...S(o,t.expected_files,r),total_expected:t.expected_files.length,latency_ms:f,top_files:o.slice(0,10)}}i(E,"runQuery");function j(a){const e=[],t=i((r,c)=>r.slice(0,c).padEnd(c),"pad"),n=i(r=>r.toFixed(2).padStart(5),"num");e.push(`${t("Query",25)} ${t("Backend",8)} ${t("P@k",6)} ${t("R@1",6)} ${t("R@3",6)} ${t("R@5",6)} ${t("MRR",6)} ${t("F1",6)} ${t("ms",8)}`),e.push("-".repeat(88));for(const r of a){for(const[c,s]of Object.entries(r.backends))e.push(`${t(r.id,25)} ${t(c,8)} ${n(s.precision_at_k)} ${n(s.recall_at_1)} ${n(s.recall_at_3)} ${n(s.recall_at_5)} ${n(s.mrr)} ${n(s.f1)} ${String(Math.round(s.latency_ms)).padStart(7)}ms`);e.push("")}return e.join(`
3
- `)}i(j,"formatTable");function $(a){const e={},t=new Set;for(const n of a)for(const r of Object.keys(n.backends))t.add(r);for(const n of Array.from(t)){let r=0,c=0,s=0,o=0,f=0,m=0,_=0,p=0,u=0;for(const d of a){const l=d.backends[n];l&&(r+=l.precision_at_k,c+=l.recall,s+=l.recall_at_1,o+=l.recall_at_3,f+=l.recall_at_5,m+=l.mrr,_+=l.f1,p+=l.latency_ms,u++)}u>0&&(e[n]={avg_precision:r/u,avg_recall:c/u,avg_recall_at_1:s/u,avg_recall_at_3:o/u,avg_recall_at_5:f/u,avg_mrr:m/u,avg_f1:_/u,avg_latency_ms:p/u})}return e}i($,"computeSummary");const A=i(a=>{const e={},t=Array.from(new Set(a.map(n=>n.type))).sort();for(const n of t){const r=a.filter(c=>c.type===n);e[n]={};for(const[c,s]of Object.entries($(r)))e[n][c]={...s,count:r.length}}return e},"computeSummaryByType"),F=i(a=>{const e=[["hybrid_r1","hybrid","avg_recall_at_1"],["hybrid_mrr","hybrid","avg_mrr"],["full_r1","full","avg_recall_at_1"],["full_mrr","full","avg_mrr"]],t={hybrid_r1:null,hybrid_mrr:null,full_r1:null,full_mrr:null,n:0};for(const n of w)t.n+=Object.values(a[n]??{})[0]?.count??0;for(const[n,r,c]of e){let s=0,o=0;for(const f of w){const m=a[f]?.[r];m&&(s+=m[c]*m.count,o+=m.count)}t[n]=o>0?s/o:null}return t},"computeHardSummary");function O(a,e){if(e){const n=a.find(r=>r.name===e);if(!n){const r=a.map(s=>s.name),c=r.length>0?`Available: ${r.join(", ")}. Run 'qmd ls' to inspect.`:"Run 'qmd ls' to see available collections.";throw new Error(`Collection not found: ${e}
4
- ${c}`)}if(n.active_count===0)throw new Error(`Collection '${e}' has no indexed documents.
5
- Run 'qmd update', then 'qmd ls ${e}' to confirm files are indexed before bench.`);return}const t=a.reduce((n,r)=>n+r.active_count,0);if(a.length===0||t===0)throw new Error(`No indexed documents found.
6
- Index a collection with 'qmd collection add' / 'qmd update' before running bench.`)}i(O,"assertBenchCollectionReady");function B(a){const e=Object.values(a);return e.length===0?!1:e.every(t=>t.avg_precision===0&&t.avg_recall===0&&t.avg_mrr===0)}i(B,"benchSummaryAllZero");function C(a){return`All benchmark scores were 0.00 \u2014 the collection is likely unindexed or the fixture's expected files are missing. Check with '${a?`qmd ls ${a}`:"qmd ls"}'.`}i(C,"allZeroBenchWarning");async function N(a,e={}){const t=x(k(a),"utf-8"),n=JSON.parse(t);if(!n.queries||!Array.isArray(n.queries))throw new Error("Invalid fixture: missing 'queries' array");if(n.queries.length===0)throw new Error(`Invalid fixture: '${a}' has no queries \u2014 nothing to run.
7
- Add at least one query; an empty fixture would otherwise report success with no results.`);const r=e.backends?g.filter(u=>e.backends.includes(u.name)):g;if(r.length===0){const u=(e.backends??[]).join(", ")||"(empty backend list)";throw new Error(`Unknown backend(s): ${u}.
8
- Available: ${g.map(d=>d.name).join(", ")}.`)}const c=await R({dbPath:e.dbPath??q(),...e.configPath?{configPath:e.configPath}:{},...e.config?{config:e.config}:{}}),s=e.collection??n.collection,o=[];try{const u=await c.listCollections();O(u,s);for(const d of n.queries){const l={};for(const h of r)e.json||process.stderr.write(` ${d.id} / ${h.name}...`),l[h.name]=await E(c,h,d,s),e.json||process.stderr.write(` ${Math.round(l[h.name].latency_ms)}ms
9
- `);o.push({id:d.id,query:d.query,type:d.type,backends:l})}}finally{await c.close()}const f=$(o),m=new Date().toISOString().replace(/[:.]/g,"").slice(0,15),_=A(o),p={timestamp:m,fixture:a,results:o,summary:f,summary_by_type:_,summary_hard:F(_)};if(B(f)&&process.stderr.write(`
10
- ${C(s)}
1
+ var x=Object.defineProperty;var i=(a,e)=>x(a,"name",{value:e,configurable:!0});import{readFileSync as v}from"node:fs";import{resolve as k}from"node:path";import{createStore as R,getDefaultDbPath as q}from"../index.js";import{scoreResults as S}from"./score.js";import{HARD_QUERY_TYPES as w}from"./types.js";function y(a){const e=a.split(`
2
+ `).map((s,o)=>({trimmed:s.trim(),number:o+1})).filter(s=>s.trimmed.length>0);if(e.length===0)return;const t=/^(lex|vec|hyde):\s*/i,r=/^intent:\s*/i,n=[];let c;for(const s of e){if(r.test(s.trimmed)){if(c!==void 0)throw new Error(`Line ${s.number}: only one intent: line is allowed per benchmark query.`);if(c=s.trimmed.replace(r,"").trim(),!c)throw new Error(`Line ${s.number}: intent: must include text.`);continue}const o=s.trimmed.match(t);if(o){const f=o[1].toLowerCase(),m=s.trimmed.slice(o[0].length).trim();if(!m)throw new Error(`Line ${s.number} (${f}:) must include text.`);n.push({type:f,query:m,line:s.number});continue}if(e.length===1)return;throw new Error(`Line ${s.number} is missing a lex:/vec:/hyde:/intent: prefix.`)}if(c&&n.length===0)throw new Error("intent: cannot appear alone. Add at least one lex:, vec:, or hyde: line.");return n.length>0?{searches:n,intent:c}:void 0}i(y,"parseStructuredQuery");function b(a,e){const t=new Set,r=[];for(const n of a)if(!t.has(n)&&(t.add(n),r.push(n),r.length>=e))break;return r}i(b,"uniqueFiles");const g=[{name:"bm25",run:i(async(a,e,t,r)=>{const n=y(e.query),c=n?.searches.filter(o=>o.type==="lex");if(n){const o=[];for(const f of c??[]){const m=await a.searchLex(f.query,{limit:t,collection:r});o.push(...m.map(h=>h.filepath))}return b(o,t)}return(await a.searchLex(e.query,{limit:t,collection:r})).map(o=>o.filepath)},"run")},{name:"vector",run:i(async(a,e,t,r)=>{const n=y(e.query),c=n?.searches.filter(o=>o.type==="vec"||o.type==="hyde");if(n){const o=[];for(const f of c??[]){const m=await a.searchVector(f.query,{limit:t,collection:r});o.push(...m.map(h=>h.filepath))}return b(o,t)}return(await a.searchVector(e.query,{limit:t,collection:r})).map(o=>o.filepath)},"run")},{name:"hybrid",run:i(async(a,e,t,r)=>{const n=y(e.query);return(n?await a.search({queries:n.searches,intent:n.intent,limit:t,collection:r,rerank:!1}):await a.search({query:e.query,limit:t,collection:r,rerank:!1})).map(s=>s.file)},"run")},{name:"full",run:i(async(a,e,t,r)=>{const n=y(e.query);return(n?await a.search({queries:n.searches,intent:n.intent,limit:t,collection:r,rerank:!0}):await a.search({query:e.query,limit:t,collection:r,rerank:!0})).map(s=>s.file)},"run")}],I=g.map(a=>a.name);async function E(a,e,t,r){const n=t.expected_in_top_k??t.expected_files.length,c=Math.max(Number.isFinite(n)?n:0,10),s=Date.now();let o;try{o=await e.run(a,t,c,r)}catch(h){return{error:h instanceof Error?h.message:String(h),precision_at_k:0,recall:0,recall_at_1:0,recall_at_3:0,recall_at_5:0,mrr:0,f1:0,hits_at_k:0,total_expected:t.expected_files.length,latency_ms:Date.now()-s,top_files:[],matched_files:[],unmatched_expected_files:t.expected_files}}const f=Date.now()-s;return{...S(o,t.expected_files,n),total_expected:t.expected_files.length,latency_ms:f,top_files:o.slice(0,10)}}i(E,"runQuery");function A(a){const e=[],t=i((n,c)=>n.slice(0,c).padEnd(c),"pad"),r=i(n=>n.toFixed(2).padStart(5),"num");e.push(`${t("Query",25)} ${t("Backend",8)} ${t("P@k",6)} ${t("R@1",6)} ${t("R@3",6)} ${t("R@5",6)} ${t("MRR",6)} ${t("F1",6)} ${t("ms",8)}`),e.push("-".repeat(88));for(const n of a){for(const[c,s]of Object.entries(n.backends))e.push(`${t(n.id,25)} ${t(c,8)} ${r(s.precision_at_k)} ${r(s.recall_at_1)} ${r(s.recall_at_3)} ${r(s.recall_at_5)} ${r(s.mrr)} ${r(s.f1)} ${String(Math.round(s.latency_ms)).padStart(7)}ms`);e.push("")}return e.join(`
3
+ `)}i(A,"formatTable");function $(a){const e={},t=new Set;for(const r of a)for(const n of Object.keys(r.backends))t.add(n);for(const r of Array.from(t)){let n=0,c=0,s=0,o=0,f=0,m=0,h=0,p=0,u=0;for(const d of a){const l=d.backends[r];l&&(n+=l.precision_at_k,c+=l.recall,s+=l.recall_at_1,o+=l.recall_at_3,f+=l.recall_at_5,m+=l.mrr,h+=l.f1,p+=l.latency_ms,u++)}u>0&&(e[r]={avg_precision:n/u,avg_recall:c/u,avg_recall_at_1:s/u,avg_recall_at_3:o/u,avg_recall_at_5:f/u,avg_mrr:m/u,avg_f1:h/u,avg_latency_ms:p/u})}return e}i($,"computeSummary");const j=i(a=>{const e={},t=Array.from(new Set(a.map(r=>r.type))).sort();for(const r of t){const n=a.filter(c=>c.type===r);e[r]={};for(const[c,s]of Object.entries($(n)))e[r][c]={...s,count:n.length}}return e},"computeSummaryByType"),B=i(a=>{const e=[["hybrid_r1","hybrid","avg_recall_at_1"],["hybrid_mrr","hybrid","avg_mrr"],["full_r1","full","avg_recall_at_1"],["full_mrr","full","avg_mrr"]],t={hybrid_r1:null,hybrid_mrr:null,full_r1:null,full_mrr:null,n:0};for(const r of w)t.n+=Object.values(a[r]??{})[0]?.count??0;for(const[r,n,c]of e){let s=0,o=0;for(const f of w){const m=a[f]?.[n];m&&(s+=m[c]*m.count,o+=m.count)}t[r]=o>0?s/o:null}return t},"computeHardSummary");function C(a,e){if(e){const r=a.find(n=>n.name===e);if(!r){const n=a.map(s=>s.name),c=n.length>0?`Available: ${n.join(", ")}. Run 'qmd ls' to inspect.`:"Run 'qmd ls' to see available collections.";throw new Error(`Collection not found: ${e}
4
+ ${c}`)}if(r.active_count===0)throw new Error(`Collection '${e}' has no indexed documents.
5
+ Run 'qmd update', then 'qmd ls ${e}' to confirm files are indexed before bench.`);return}const t=a.reduce((r,n)=>r+n.active_count,0);if(a.length===0||t===0)throw new Error(`No indexed documents found.
6
+ Index a collection with 'qmd collection add' / 'qmd update' before running bench.`)}i(C,"assertBenchCollectionReady");function D(a){const e=Object.values(a);return e.length===0?!1:e.every(t=>t.avg_precision===0&&t.avg_recall===0&&t.avg_mrr===0)}i(D,"benchSummaryAllZero");function F(a){return`All benchmark scores were 0.00 \u2014 the collection is likely unindexed or the fixture's expected files are missing. Check with '${a?`qmd ls ${a}`:"qmd ls"}'.`}i(F,"allZeroBenchWarning");async function T(a,e={}){const t=v(k(a),"utf-8"),r=JSON.parse(t);if(!r.queries||!Array.isArray(r.queries))throw new Error("Invalid fixture: missing 'queries' array");if(r.queries.length===0)throw new Error(`Invalid fixture: '${a}' has no queries \u2014 nothing to run.
7
+ Add at least one query; an empty fixture would otherwise report success with no results.`);const n=e.backends?g.filter(u=>e.backends.includes(u.name)):g;if(n.length===0){const u=(e.backends??[]).join(", ")||"(empty backend list)";throw new Error(`Unknown backend(s): ${u}.
8
+ Available: ${g.map(d=>d.name).join(", ")}.`)}const c=await R({dbPath:e.dbPath??q(),...e.configPath?{configPath:e.configPath}:{},...e.config?{config:e.config}:{}}),s=e.collection??r.collection,o=[];try{const u=await c.listCollections();C(u,s);for(const d of r.queries){const l={};for(const _ of n)e.json||process.stderr.write(` ${d.id} / ${_.name}...`),l[_.name]=await E(c,_,d,s),e.json||process.stderr.write(` ${Math.round(l[_.name].latency_ms)}ms
9
+ `);o.push({id:d.id,query:d.query,type:d.type,backends:l})}}finally{await c.close()}const f=$(o),m=new Date().toISOString().replace(/[:.]/g,"").slice(0,15),h=j(o),p={timestamp:m,fixture:a,results:o,summary:f,summary_by_type:h,summary_hard:B(h)};if(D(f)&&process.stderr.write(`
10
+ ${F(s)}
11
11
  `),e.json)console.log(JSON.stringify(p,null,2));else{console.log(`
12
- `+j(o)),console.log("Summary:"),console.log("-".repeat(70));const u=i((l,h)=>l.slice(0,h).padEnd(h),"pad"),d=i(l=>l.toFixed(3).padStart(6),"num");for(const[l,h]of Object.entries(f))console.log(` ${u(l,8)} P@k=${d(h.avg_precision)} R@1=${d(h.avg_recall_at_1)} R@3=${d(h.avg_recall_at_3)} R@5=${d(h.avg_recall_at_5)} MRR=${d(h.avg_mrr)} F1=${d(h.avg_f1)} Avg=${Math.round(h.avg_latency_ms)}ms`)}return p}i(N,"runBenchmark");export{C as allZeroBenchWarning,O as assertBenchCollectionReady,B as benchSummaryAllZero,F as computeHardSummary,A as computeSummaryByType,N as runBenchmark};
12
+ `+A(o)),console.log("Summary:"),console.log("-".repeat(70));const u=i((l,_)=>l.slice(0,_).padEnd(_),"pad"),d=i(l=>l.toFixed(3).padStart(6),"num");for(const[l,_]of Object.entries(f))console.log(` ${u(l,8)} P@k=${d(_.avg_precision)} R@1=${d(_.avg_recall_at_1)} R@3=${d(_.avg_recall_at_3)} R@5=${d(_.avg_recall_at_5)} MRR=${d(_.avg_mrr)} F1=${d(_.avg_f1)} Avg=${Math.round(_.avg_latency_ms)}ms`)}return p}i(T,"runBenchmark");export{I as BENCH_BACKEND_NAMES,F as allZeroBenchWarning,C as assertBenchCollectionReady,D as benchSummaryAllZero,B as computeHardSummary,j as computeSummaryByType,T as runBenchmark};
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Verdict logic for the lex CI gate (scripts/bench-lex-gate.sh).
3
+ *
4
+ * The gate benches bm25 over the ko-vault goldset and compares each query's
5
+ * recall@5 and reciprocal rank with a committed baseline. A paired bootstrap
6
+ * over the queries gives a 95% interval for the mean change of each metric:
7
+ *
8
+ * FAIL — an interval lies wholly below zero (a drop the query sample cannot
9
+ * explain), or the query set differs from the baseline's.
10
+ * WARN — no such interval, but some query scores lower than its baseline.
11
+ * PASS — no query scores lower.
12
+ *
13
+ * bm25 has no sampling step, so the interval measures query-set luck, not
14
+ * run-to-run noise. Run-to-run noise is measured separately by benching N
15
+ * fresh indexes and comparing them (noiseAcrossRuns).
16
+ *
17
+ * Pure functions only — the script owns I/O.
18
+ */
19
+ import type { BenchmarkResult } from "./types.js";
20
+ export interface QueryMetrics {
21
+ /** recall@5 */
22
+ r5: number;
23
+ /** reciprocal rank of the first relevant result */
24
+ rr: number;
25
+ }
26
+ /** Query id → metrics. */
27
+ export type PerQuery = Record<string, QueryMetrics>;
28
+ export interface BootstrapOptions {
29
+ iters?: number;
30
+ seed?: number;
31
+ /** Two-sided miss rate; 0.05 gives a 95% interval. */
32
+ alpha?: number;
33
+ }
34
+ export interface Interval {
35
+ mean: number;
36
+ lo: number;
37
+ hi: number;
38
+ }
39
+ export interface MetricVerdict {
40
+ baseline: number;
41
+ current: number;
42
+ /** current − baseline mean */
43
+ delta: number;
44
+ /** bootstrap interval of delta */
45
+ lo: number;
46
+ hi: number;
47
+ }
48
+ export interface DroppedQuery {
49
+ id: string;
50
+ r5: [baseline: number, current: number];
51
+ rr: [baseline: number, current: number];
52
+ }
53
+ /** The baseline and the current run cover different queries, so nothing is compared. */
54
+ export interface StaleVerdict {
55
+ kind: "stale";
56
+ status: "FAIL";
57
+ reason: string;
58
+ /** baseline ids absent from the current run */
59
+ missing: string[];
60
+ /** current ids absent from the baseline */
61
+ added: string[];
62
+ }
63
+ export interface ComparedVerdict {
64
+ kind: "compared";
65
+ status: "PASS" | "WARN" | "FAIL";
66
+ reason: string;
67
+ r5: MetricVerdict;
68
+ mrr: MetricVerdict;
69
+ dropped: DroppedQuery[];
70
+ /** queries that score higher on some metric and lower on none */
71
+ improved: number;
72
+ }
73
+ export type GateVerdict = StaleVerdict | ComparedVerdict;
74
+ export declare const DEFAULT_BOOTSTRAP: {
75
+ readonly iters: 10000;
76
+ readonly seed: 20261031;
77
+ readonly alpha: 0.05;
78
+ };
79
+ /** mulberry32 — small seeded PRNG so a CI rerun draws the same resamples. */
80
+ export declare function mulberry32(seed: number): () => number;
81
+ /** Percentile bootstrap interval of the mean of `deltas`, resampling queries with replacement. */
82
+ export declare function pairedBootstrap(deltas: readonly number[], options?: BootstrapOptions): Interval;
83
+ export declare function compareToBaseline(baseline: PerQuery, current: PerQuery, options?: BootstrapOptions): GateVerdict;
84
+ /** Per-query recall@5 and reciprocal rank of one backend from a `qmd bench --json` result. */
85
+ export declare function perQueryMetrics(bench: BenchmarkResult, backend: string): PerQuery;
86
+ /** Queries whose metrics differ between repeated runs, and the largest such difference. */
87
+ export declare function noiseAcrossRuns(runs: readonly PerQuery[]): {
88
+ differing: string[];
89
+ maxAbs: number;
90
+ };
91
+ /** Per-query mean over repeated runs — the gate's verdict input when runs disagree. */
92
+ export declare function meanPerQuery(runs: readonly PerQuery[]): PerQuery;
@@ -0,0 +1 @@
1
+ var v=Object.defineProperty;var f=(e,t)=>v(e,"name",{value:t,configurable:!0});const j={iters:1e4,seed:20261031,alpha:.05},d=1e-9;function B(e){let t=e>>>0;return()=>{t=t+1831565813>>>0;let r=t;return r=Math.imul(r^r>>>15,r|1),r^=r+Math.imul(r^r>>>7,r|61),((r^r>>>14)>>>0)/4294967296}}f(B,"mulberry32");function b(e){let t=0;for(const r of e)t+=r;return t/e.length}f(b,"mean");function S(e,t={}){if(e.length===0)throw new Error("pairedBootstrap: no queries to resample");const{iters:r,seed:n,alpha:o}={...j,...t},c=B(n),a=e.length,i=new Float64Array(r);for(let l=0;l<r;l++){let u=0;for(let h=0;h<a;h++)u+=e[Math.floor(c()*a)];i[l]=u/a}return i.sort(),{mean:b(e),lo:i[Math.floor(o/2*(r-1))],hi:i[Math.ceil((1-o/2)*(r-1))]}}f(S,"pairedBootstrap");function g(e,t,r){for(const n of["r5","rr"]){const o=t?.[n];if(typeof o!="number"||!Number.isFinite(o))throw new Error(`${r}: query ${e} has no numeric ${n} (got ${JSON.stringify(o)})`)}return t}f(g,"assertMetrics");function q(e,t,r){const n=t.map((l,u)=>{const h=l-e[u];return Math.abs(h)<d?0:h}),{lo:o,hi:c}=S(n,r),a=b(e),i=b(t);return{baseline:a,current:i,delta:i-a,lo:o,hi:c}}f(q,"metricVerdict");function T(e,t,r={}){const n=Object.keys(e).sort(),o=n.filter(s=>!(s in t)),c=Object.keys(t).filter(s=>!(s in e)).sort();if(o.length>0||c.length>0)return{kind:"stale",status:"FAIL",reason:`baseline is stale: ${o.length} baseline queries missing, ${c.length} new \u2014 refresh it with --update-baseline`,missing:o,added:c};const a=n;if(a.length===0)throw new Error("compareToBaseline: the baseline has no queries");const i=a.map(s=>g(s,e[s],"baseline")),l=a.map(s=>g(s,t[s],"current")),u=q(i.map(s=>s.r5),l.map(s=>s.r5),r),h=q(i.map(s=>s.rr),l.map(s=>s.rr),r),w=[];let x=0;a.forEach((s,E)=>{const m=i[E],p=l[E],k=p.r5<m.r5-d||p.rr<m.rr-d,O=p.r5>m.r5+d||p.rr>m.rr+d;k?w.push({id:s,r5:[m.r5,p.r5],rr:[m.rr,p.rr]}):O&&x++});const M=[u.hi<0?"r5":"",h.hi<0?"mrr":""].filter(Boolean);let y,$;return M.length>0?(y="FAIL",$=`95% interval below zero for ${M.join(", ")}`):w.length>0?(y="WARN",$=`${w.length} queries dropped, but no interval lies below zero`):(y="PASS",$="no query dropped"),{kind:"compared",status:y,reason:$,r5:u,mrr:h,dropped:w,improved:x}}f(T,"compareToBaseline");function L(e,t){const r={};for(const n of e.results){const o=n.backends[t];if(!o)throw new Error(`bench result for query ${n.id} has no ${t} backend`);if(o.error!==void 0)throw new Error(`bench result for query ${n.id}: ${t} threw: ${o.error}`);r[n.id]=g(n.id,{r5:o.recall_at_5,rr:o.mrr},"bench")}return r}f(L,"perQueryMetrics");function A(e){if(e.length===0)throw new Error("no runs");const t=Object.keys(e[0]).sort();for(const r of e){const n=Object.keys(r).sort();if(n.length!==t.length||n.some((o,c)=>o!==t[c]))throw new Error("repeated runs must cover the same queries")}return t}f(A,"assertSameQueries");function N(e){const t=A(e),r=[];let n=0;for(const o of t){let c=!1;for(const a of["r5","rr"]){const i=e.map(u=>g(o,u[o],"run")[a]),l=Math.max(...i)-Math.min(...i);l>0&&(c=!0),n=Math.max(n,l)}c&&r.push(o)}return{differing:r,maxAbs:n}}f(N,"noiseAcrossRuns");function P(e){const t=A(e),r={};for(const n of t)r[n]={r5:b(e.map(o=>g(n,o[n],"run").r5)),rr:b(e.map(o=>o[n].rr))};return r}f(P,"meanPerQuery");export{j as DEFAULT_BOOTSTRAP,T as compareToBaseline,P as meanPerQuery,B as mulberry32,N as noiseAcrossRuns,S as pairedBootstrap,L as perQueryMetrics};
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Agreement and ranking metrics for LLM-as-judge relevance grades (0–3).
3
+ *
4
+ * Pure functions, no I/O. `scripts/judge/report.ts` feeds them the judge's grades, the
5
+ * hand labels and the bench rankings. Grades follow the rubric in
6
+ * test/fixtures/ko-vault/judge/rubric.md: 0 unrelated … 3 answers the query.
7
+ */
8
+ import { type BootstrapOptions } from "./gate.js";
9
+ export declare const GRADES = 4;
10
+ /** Cohen's kappa: every disagreement costs the same. */
11
+ export declare function cohenKappa(a: readonly number[], b: readonly number[]): number;
12
+ /** Quadratic weighted kappa: a 1-grade miss costs 1/9 of a 3-grade miss. */
13
+ export declare function weightedKappa(a: readonly number[], b: readonly number[]): number;
14
+ /**
15
+ * nDCG@k with gain 2^g - 1. `ranked` is the grade at each rank of one system's list;
16
+ * `pool` is every judged grade for the query (all systems), whose best k form the ideal.
17
+ * NaN when the pool holds no relevant document — the query cannot tell systems apart.
18
+ */
19
+ export declare function ndcgAt(ranked: readonly number[], pool: readonly number[], k?: number): number;
20
+ /** Share of items whose grade differs between two judging passes. */
21
+ export declare function flipRate(a: readonly number[], b: readonly number[]): number;
22
+ export interface StatInterval {
23
+ value: number;
24
+ lo: number;
25
+ hi: number;
26
+ /** Resamples where the statistic was NaN (e.g. kappa on a one-category draw); left out of lo/hi. */
27
+ undefinedDraws: number;
28
+ }
29
+ /**
30
+ * Percentile bootstrap of any statistic over items resampled with replacement.
31
+ * Items are whole queries, so pairs from one query move together.
32
+ */
33
+ export declare function bootstrapStatistic<T>(items: readonly T[], stat: (sample: T[]) => number, options?: BootstrapOptions): StatInterval;
@@ -0,0 +1 @@
1
+ var $=Object.defineProperty;var u=(t,e)=>$(t,"name",{value:e,configurable:!0});import{DEFAULT_BOOTSTRAP as x,mulberry32 as y}from"./gate.js";const g=4;function d(t,e,r){if(t.length!==e.length)throw new Error(`${r}: length mismatch (${t.length} vs ${e.length})`);if(t.length===0)throw new Error(`${r}: empty input`)}u(d,"assertPairs");function w(t,e){if(!Number.isInteger(t)||t<0||t>=g)throw new Error(`${e}: grade must be an integer 0-${g-1}, got ${t}`)}u(w,"assertGrade");function b(t,e,r,n){d(t,e,n);const c=g,a=Array.from({length:c},()=>new Array(c).fill(0));for(let o=0;o<t.length;o++)w(t[o],n),w(e[o],n),a[t[o]][e[o]]++;const m=a.map(o=>o.reduce((l,s)=>l+s,0)),h=a[0].map((o,l)=>a.reduce((s,p)=>s+p[l],0));let i=0,f=0;for(let o=0;o<c;o++)for(let l=0;l<c;l++){const s=r(o,l);i+=s*a[o][l],f+=s*(m[o]*h[l])/t.length}return f===0?i===0?Number.NaN:0:1-i/f}u(b,"kappa");function M(t,e){return b(t,e,(r,n)=>r===n?0:1,"cohenKappa")}u(M,"cohenKappa");function K(t,e){return b(t,e,(r,n)=>(r-n)**2/(g-1)**2,"weightedKappa")}u(K,"weightedKappa");function A(t,e){let r=0;for(let n=0;n<Math.min(e,t.length);n++)r+=(2**t[n]-1)/Math.log2(n+2);return r}u(A,"dcg");function R(t,e,r=10){const n=A([...e].sort((c,a)=>a-c),r);return n===0?Number.NaN:A(t,r)/n}u(R,"ndcgAt");function S(t,e){d(t,e,"flipRate");let r=0;for(let n=0;n<t.length;n++)t[n]!==e[n]&&r++;return r/t.length}u(S,"flipRate");function D(t,e,r={}){if(t.length===0)throw new Error("bootstrapStatistic: empty input");const{iters:n,seed:c,alpha:a}={...x,...r},m=y(c),h=t.length,i=[];let f=0;for(let l=0;l<n;l++){const s=new Array(h);for(let N=0;N<h;N++)s[N]=t[Math.floor(m()*h)];const p=e(s);Number.isNaN(p)?f++:i.push(p)}i.sort((l,s)=>l-s);const o=i.length-1;return{value:e([...t]),lo:i.length?i[Math.floor(a/2*o)]:Number.NaN,hi:i.length?i[Math.ceil((1-a/2)*o)]:Number.NaN,undefinedDraws:f}}u(D,"bootstrapStatistic");export{g as GRADES,D as bootstrapStatistic,M as cohenKappa,S as flipRate,R as ndcgAt,K as weightedKappa};
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Answer-level metrics for the RAG evaluation (eval stage 3). Pure functions — no I/O, no model.
3
+ *
4
+ * An answer is a list of sentences, each citing chunk numbers, or an abstention ("모른다").
5
+ * A judge grades every cited sentence: does the text of the cited chunks support it (full,
6
+ * partial, none), and does each cited chunk on its own support it. A sentence with no citation
7
+ * is never sent to the judge — it counts as unsupported and its missing citation is tallied.
8
+ */
9
+ import { type BootstrapOptions, type Interval } from "./gate.js";
10
+ import { type StatInterval } from "./judge-metrics.js";
11
+ export type Support = "full" | "partial" | "none";
12
+ export interface SentenceGrade {
13
+ support: Support;
14
+ /** Cited chunk number → whether that chunk alone supports the sentence. Empty when uncited. */
15
+ citations: Record<string, boolean>;
16
+ uncited: boolean;
17
+ }
18
+ export type QuestionType = "single" | "multi" | "near-miss" | "off-corpus";
19
+ export interface QuestionOutcome {
20
+ id: string;
21
+ type: QuestionType;
22
+ /** True when the model said it cannot answer from the documents. */
23
+ abstained: boolean;
24
+ /** One grade per answer sentence; empty when abstained. */
25
+ sentences: SentenceGrade[];
26
+ /** Files the answer cited, as corpus paths. */
27
+ citedFiles: string[];
28
+ /** Files retrieved into the prompt, as corpus paths. */
29
+ contextFiles: string[];
30
+ expectedFiles: string[];
31
+ }
32
+ export declare const ANSWERABLE: ReadonlySet<QuestionType>;
33
+ /**
34
+ * Share of this answer's sentences fully supported by their cited chunks. NaN when nothing was
35
+ * answered. Run-level faithfulness is the mean of this over answered questions (a macro average).
36
+ * It does not ask whether the answer is right: a near-miss answer built from true but irrelevant
37
+ * sentences scores high.
38
+ */
39
+ export declare function faithfulness(o: QuestionOutcome): number;
40
+ /** Share of citations whose chunk supports its sentence. NaN when the answer cites nothing. */
41
+ export declare function citationPrecision(o: QuestionOutcome): number;
42
+ /** 1 when an answered answerable question cites an expected file, 0 when not, NaN otherwise. */
43
+ export declare function citesExpected(o: QuestionOutcome): number;
44
+ /** 1 when an expected file reached the prompt, 0 when not, NaN for unanswerable questions. */
45
+ export declare function contextRecall(o: QuestionOutcome): number;
46
+ /** 1 when an unanswerable (near-miss, off-corpus) question was refused, 0 when answered, NaN otherwise. */
47
+ export declare function abstainUnanswerable(o: QuestionOutcome): number;
48
+ /** 1 when an answerable question was refused, 0 when answered, NaN otherwise. */
49
+ export declare function abstainAnswerable(o: QuestionOutcome): number;
50
+ /** Mean over the defined values, NaN when none is defined. */
51
+ export declare function definedMean(values: readonly number[]): number;
52
+ export interface MetricSummary extends StatInterval {
53
+ /** Questions the metric is defined on. */
54
+ n: number;
55
+ }
56
+ export interface RagSummary {
57
+ questions: number;
58
+ faithfulness: MetricSummary;
59
+ citation_precision: MetricSummary;
60
+ cites_expected: MetricSummary;
61
+ context_recall: MetricSummary;
62
+ /** Abstentions on near-miss and off-corpus questions — higher is better. */
63
+ abstain_unanswerable: MetricSummary;
64
+ /** Abstentions on answerable questions — lower is better. */
65
+ abstain_answerable: MetricSummary;
66
+ sentences: number;
67
+ partial: number;
68
+ uncited: number;
69
+ }
70
+ export declare function summarizeRun(outcomes: readonly QuestionOutcome[], options?: BootstrapOptions): RagSummary;
71
+ export interface PairedDiff extends Interval {
72
+ /** Questions where both runs define the metric. */
73
+ n: number;
74
+ /** Each run's mean over those same questions — the per-run summaries cover different subsets. */
75
+ before: number;
76
+ after: number;
77
+ }
78
+ /**
79
+ * after − before for one per-question metric, over the questions where both runs define it,
80
+ * with a paired bootstrap interval. Questions are matched by id.
81
+ */
82
+ export declare function pairedDiff(before: readonly QuestionOutcome[], after: readonly QuestionOutcome[], per: (o: QuestionOutcome) => number, options?: BootstrapOptions): PairedDiff;
@@ -0,0 +1 @@
1
+ var d=Object.defineProperty;var a=(e,t)=>d(e,"name",{value:t,configurable:!0});import{pairedBootstrap as m}from"./gate.js";import{bootstrapStatistic as g}from"./judge-metrics.js";const l=new Set(["single","multi"]);function x(e){return e.abstained||e.sentences.length===0?Number.NaN:e.sentences.filter(t=>t.support==="full").length/e.sentences.length}a(x,"faithfulness");function w(e){const t=e.sentences.flatMap(n=>Object.values(n.citations));return t.length?t.filter(Boolean).length/t.length:Number.NaN}a(w,"citationPrecision");function v(e){return!l.has(e.type)||e.abstained?Number.NaN:e.citedFiles.some(t=>e.expectedFiles.includes(t))?1:0}a(v,"citesExpected");function y(e){return l.has(e.type)?e.contextFiles.some(t=>e.expectedFiles.includes(t))?1:0:Number.NaN}a(y,"contextRecall");function _(e){return l.has(e.type)?Number.NaN:e.abstained?1:0}a(_,"abstainUnanswerable");function B(e){return l.has(e.type)?e.abstained?1:0:Number.NaN}a(B,"abstainAnswerable");function b(e){const t=e.filter(n=>!Number.isNaN(n));return t.length?t.reduce((n,r)=>n+r,0)/t.length:Number.NaN}a(b,"definedMean");function s(e,t,n){const r=e.map(t);return{...g(r,b,n),n:r.filter(N=>!Number.isNaN(N)).length}}a(s,"summarize");function A(e,t={}){const n=e.flatMap(r=>r.sentences);return{questions:e.length,faithfulness:s(e,x,t),citation_precision:s(e,w,t),cites_expected:s(e,v,t),context_recall:s(e,y,t),abstain_unanswerable:s(e,_,t),abstain_answerable:s(e,B,t),sentences:n.length,partial:n.filter(r=>r.support==="partial").length,uncited:n.filter(r=>r.uncited).length}}a(A,"summarizeRun");function R(e,t,n,r={}){const N=new Map(t.map(i=>[i.id,i])),u=[],p=[],o=[];for(const i of e){const h=N.get(i.id);if(!h)throw new Error(`question ${i.id} is missing from the after run`);const c=n(i),f=n(h);!Number.isNaN(c)&&!Number.isNaN(f)&&(u.push(f-c),p.push(c),o.push(f))}return u.length===0?{mean:Number.NaN,lo:Number.NaN,hi:Number.NaN,n:0,before:Number.NaN,after:Number.NaN}:{...m(u,r),n:u.length,before:b(p),after:b(o)}}a(R,"pairedDiff");export{l as ANSWERABLE,B as abstainAnswerable,_ as abstainUnanswerable,w as citationPrecision,v as citesExpected,y as contextRecall,b as definedMean,x as faithfulness,R as pairedDiff,A as summarizeRun};
@@ -61,6 +61,8 @@ export interface BackendResult {
61
61
  matched_files: string[];
62
62
  /** Expected files missing from the returned result set */
63
63
  unmatched_expected_files: string[];
64
+ /** Set when the backend threw; the scores above are then zeros, not a measurement */
65
+ error?: string;
64
66
  }
65
67
  export interface QueryResult {
66
68
  id: string;
@@ -1,4 +1,4 @@
1
1
  {
2
- "commit": "ad0bc45",
3
- "builtAt": "2026-10-30T16:53:21.509Z"
2
+ "commit": "229c4ba",
3
+ "builtAt": "2026-11-14T05:26:04.120Z"
4
4
  }
package/dist/cli/qmd.d.ts CHANGED
@@ -67,8 +67,6 @@ export declare function finishSuccessfulCliCommand(options: FinishSuccessfulCliC
67
67
  export declare function renderFullPath(absolutePath: string, cwd?: string): string | null;
68
68
  export declare function parseEmbedBatchMbOption(value: unknown): number | undefined;
69
69
  export declare function resolveEmbedModelForCli(): string;
70
- export declare function resolveGenerateModelForCli(): string;
71
- export declare function resolveRerankModelForCli(): string;
72
70
  export declare function vectorIndex(model?: string, force?: boolean, batchOptions?: {
73
71
  maxDocsPerBatch?: number;
74
72
  maxBatchBytes?: number;