ko-qmd 2.8.3-ko.10 → 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 +105 -0
- package/README.md +29 -4
- package/dist/bench/bench.d.ts +2 -0
- package/dist/bench/bench.js +11 -11
- package/dist/bench/gate.d.ts +92 -0
- package/dist/bench/gate.js +1 -0
- package/dist/bench/judge-metrics.d.ts +33 -0
- package/dist/bench/judge-metrics.js +1 -0
- package/dist/bench/rag-metrics.d.ts +82 -0
- package/dist/bench/rag-metrics.js +1 -0
- package/dist/bench/types.d.ts +2 -0
- package/dist/cli/build-info.json +2 -2
- package/dist/cli/qmd.d.ts +0 -2
- package/dist/cli/qmd.js +83 -82
- package/dist/filters.js +1 -1
- package/dist/grep.js +3 -4
- package/dist/llm.d.ts +7 -0
- package/dist/llm.js +13 -13
- package/dist/mcp/server.js +19 -19
- package/dist/rerank-input.js +1 -1
- package/dist/store.d.ts +0 -1
- package/dist/store.js +56 -56
- package/package.json +5 -5
- package/skills/release/SKILL.md +10 -1
- package/skills/release/scripts/install-hooks.sh +6 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,111 @@
|
|
|
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
|
+
|
|
92
|
+
## [2.8.3-ko.11] - 2026-10-31
|
|
93
|
+
|
|
94
|
+
- Rerank now caps each document at 256 tokens by default
|
|
95
|
+
(`DEFAULT_RERANK_MAX_DOC_TOKENS` in `src/llm.ts`); `QMD_RERANK_MAX_DOC_TOKENS=0`
|
|
96
|
+
restores whole chunks, and an unset or invalid value takes the default. Replaying
|
|
97
|
+
29 rerank queries from the daemon query log with the rerank cache cleared
|
|
98
|
+
(M3 Max): query p50 5323 → 1682 ms, p90 8508 → 2668 ms, rerank stage p50
|
|
99
|
+
4968 → 1343 ms. ko-vault seam bench: `full_mrr` 0.9440 → 0.9451, hard line
|
|
100
|
+
unchanged (`full_r1`=0.6630). The cap is part of the rerank cache key, so
|
|
101
|
+
uncapped cache rows miss once after upgrading.
|
|
102
|
+
- Release: the publish workflow runs the package smoke test before
|
|
103
|
+
`npm publish`, so a tarball that fails to install or start never ships.
|
|
104
|
+
- CI: the Windows git-install job installs the pushed ref instead of `main`;
|
|
105
|
+
`docs/vault-search/WINDOWS.md` records the accepted Windows test failures.
|
|
106
|
+
- Deps: zod 4.6.7, oxlint 1.88.0, actions/checkout and setup-node v7.
|
|
107
|
+
- Docs: REFERENCE documents the memory MCP tools and `grep`, and corrects the
|
|
108
|
+
`multi_get` `maxBytes` default to 65536.
|
|
109
|
+
|
|
5
110
|
## [2.8.3-ko.10] - 2026-10-15
|
|
6
111
|
|
|
7
112
|
ko-qmd 2.8.3-ko.10 adds a shared working memory for agents: three MCP tools
|
package/README.md
CHANGED
|
@@ -79,8 +79,9 @@ flowchart LR
|
|
|
79
79
|
그 경로의 `score` 는 1/순위라 임계값을 걸 수 없기 때문이다.
|
|
80
80
|
- **모델 상주** — `QMD_LLM_IDLE_TIMEOUT_MS=0` 이면 모델을 내리지 않는다. 기본은 5분 idle 뒤 내린다.
|
|
81
81
|
- **리랭크 입력 cap** — 리랭커에 보내는 문서당 토큰을 자른다. 데몬 전체는 `QMD_RERANK_MAX_DOC_TOKENS`,
|
|
82
|
-
요청 하나는 REST `rerankMaxDocTokens` 로 건다(요청 값이 우선).
|
|
83
|
-
|
|
82
|
+
요청 하나는 REST `rerankMaxDocTokens` 로 건다(요청 값이 우선). 기본은 256토큰이고 `QMD_RERANK_MAX_DOC_TOKENS=0` 이면
|
|
83
|
+
자르지 않는다. 요청 값은 양의 정수만 받으므로 요청 하나에서 cap 을 풀려면 큰 값(예: 100000)을 보낸다. 데몬 쿼리 29건 재생에서 256 cap 은 쿼리 p50 을 5.3s → 1.7s 로 줄였고 ko-vault bench 지표는 같거나
|
|
84
|
+
올랐다(Apple M3 Max, 리랭크 캐시 비움). KB 골드셋에서는 128토큰도 품질을 지켰다(콜드 p90 3.6s → 0.9s, 후보 15).
|
|
84
85
|
|
|
85
86
|
## 설치
|
|
86
87
|
|
|
@@ -171,7 +172,7 @@ API 전체: [docs/REFERENCE.md § SDK / Library Usage](docs/REFERENCE.md#sdk--li
|
|
|
171
172
|
| `QMD_LLM_IDLE_TIMEOUT_MS` | 모델을 내리기까지 idle ms (`0` = 내리지 않음) |
|
|
172
173
|
| `QMD_LLM_LOAD_TIMEOUT_MS` | 모델 로드 대기의 상한 ms (기본 120000, `0` = 무한 대기). 넘으면 그 단계 없이 답하고 로드는 백그라운드에서 계속된다 |
|
|
173
174
|
| `QMD_RERANK_CASCADE` | `0`/`false`/`off`/`no` 면 리랭크 cascade를 끄고 후보 전체를 한 번에 채점한다 |
|
|
174
|
-
| `QMD_RERANK_MAX_DOC_TOKENS` | 리랭커에 보내는 문서당 토큰 cap (요청의 `rerankMaxDocTokens` 가 우선) |
|
|
175
|
+
| `QMD_RERANK_MAX_DOC_TOKENS` | 리랭커에 보내는 문서당 토큰 cap (기본 256, `0` = 자르지 않음, 요청의 `rerankMaxDocTokens` 가 우선) |
|
|
175
176
|
| `QMD_RERANK_CONTEXT_SIZE` · `QMD_EMBED_CONTEXT_SIZE` · `QMD_EXPAND_CONTEXT_SIZE` | 역할별 context 크기 |
|
|
176
177
|
| `QMD_EMBED_PARALLELISM` | 병렬 context 수 (높으면 RAM/VRAM 고갈) |
|
|
177
178
|
| `QMD_LLAMA_GPU` · `QMD_FORCE_CPU` | GPU 백엔드 선택·끄기 |
|
|
@@ -201,7 +202,8 @@ npm run build # dist/ — `bun build --compile` 은 쓰지
|
|
|
201
202
|
|
|
202
203
|
- 브랜치: `develop` 이 작업 브랜치(PR 은 여기로), `main` 은 배포 브랜치다. 릴리스는 `/release <version>`(→ `v*` 태그 → `publish.yml` 이 npm 발행).
|
|
203
204
|
절차와 CHANGELOG 규칙: [skills/release/SKILL.md](skills/release/SKILL.md).
|
|
204
|
-
- 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)).
|
|
205
207
|
|
|
206
208
|
### dogfood — 작업 트리 빌드를 이 머신 데몬에 올리기
|
|
207
209
|
|
|
@@ -232,6 +234,29 @@ Qwen3-Embedding 기본값·질의 52 에서 bm25_r5 0.9519 · vector_r5 1.0000
|
|
|
232
234
|
(exact 5·alias 4·semantic 3·topical 2·cross-domain 1, 저커버리지 토픽과 topics/ 입구 보강)과 hard 15건
|
|
233
235
|
(sem-hard 5·multi 5·neg 5)을 더한 셋이다. hard 45 기준의 RESULT 참조선은 BASELINE.md 2026-10-10 절에 있다.
|
|
234
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
|
+
|
|
235
260
|
### Electron 내장
|
|
236
261
|
|
|
237
262
|
앱은 SDK 를 워커(utilityProcess)에서 쓴다. CI `electron-smoke` 잡이 Electron 39(ABI 140)로 `better-sqlite3` 를 리빌드하고
|
package/dist/bench/bench.d.ts
CHANGED
|
@@ -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;
|
package/dist/bench/bench.js
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
|
-
var
|
|
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,
|
|
3
|
-
`)}i(
|
|
4
|
-
${c}`)}if(
|
|
5
|
-
Run 'qmd update', then 'qmd ls ${e}' to confirm files are indexed before bench.`);return}const t=a.reduce((n
|
|
6
|
-
Index a collection with 'qmd collection add' / 'qmd update' before running bench.`)}i(
|
|
7
|
-
Add at least one query; an empty fixture would otherwise report success with no results.`);const
|
|
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??
|
|
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),
|
|
10
|
-
${
|
|
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
|
-
`+
|
|
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};
|
package/dist/bench/types.d.ts
CHANGED
|
@@ -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;
|
package/dist/cli/build-info.json
CHANGED
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;
|