@gmickel/gno 1.14.0 → 1.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -94,7 +94,7 @@ gno daemon --detach # headless continuous indexing (background; --status / --st
94
94
 
95
95
  <!-- public-truth:current-version -->
96
96
 
97
- > Current release: **v1.14.0** — see [CHANGELOG.md](./CHANGELOG.md)
97
+ > Current release: **v1.16.0** — see [CHANGELOG.md](./CHANGELOG.md)
98
98
 
99
99
  <!-- /public-truth -->
100
100
 
@@ -1036,9 +1036,30 @@ official production GGUF was validated for Nemotron.
1036
1036
  These small fixture results support keeping Qwen as the built-in default; they
1037
1037
  do not establish general language superiority. Query-language classification
1038
1038
  supports a broader set than the indexed-document detector (`en`, `de`, `fr`,
1039
- `it`, `zh`, `ja`, `ko`), and the committed fixture covers only five languages.
1040
- The BM25-only multilingual eval remains a legacy sanity lane; dedicated lexical
1041
- CJK benchmarking is pending.
1039
+ `it`, `zh`, `ja`, `ko`), and the committed semantic fixture covers only five
1040
+ languages.
1041
+
1042
+ <!-- public-truth:cjk-lexical-benchmark -->
1043
+
1044
+ Lexical fallback has separate evidence. The immutable
1045
+ [July 22, 2026 CJK result](./evals/fixtures/cjk-lexical-benchmark/2026-07-22.md)
1046
+ uses 21 synthetic documents and 25 same-language queries across Chinese,
1047
+ Japanese, and Korean. Production BM25 lexical results and frozen floors:
1048
+
1049
+ - Chinese: baseline Recall@10 `0.2222`, nDCG@10 `0.1481`, zero-result `0.7778`; promotion Recall@10 `0.4722`, nDCG@10 `0.3981`, maximum zero-result `0.5278`
1050
+ - Japanese: baseline Recall@10 `0.125`, nDCG@10 `0.125`, zero-result `0.875`; promotion Recall@10 `0.375`, nDCG@10 `0.375`, maximum zero-result `0.625`
1051
+ - Korean: baseline Recall@10 `0.5`, nDCG@10 `0.5`, zero-result `0.5`; promotion Recall@10 `0.75`, nDCG@10 `0.75`, maximum zero-result `0.25`
1052
+
1053
+ The
1054
+ [promotion-gates.md](./evals/fixtures/cjk-lexical-benchmark/promotion-gates.md)
1055
+ also bind MRR, non-regression, and cost requirements. This lexical result does
1056
+ not reduce or replace the semantic evidence above. All positive qrels use
1057
+ relevance `3`, so
1058
+ nDCG measures placement but not distinctions among positive gain grades.
1059
+ Production tokenization is unchanged; improvements remain gated work for
1060
+ `fn-109`.
1061
+
1062
+ <!-- /public-truth -->
1042
1063
 
1043
1064
  ---
1044
1065
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gmickel/gno",
3
- "version": "1.14.0",
3
+ "version": "1.16.0",
4
4
  "description": "Local semantic search for your documents. Index Markdown, PDF, and Office files with hybrid BM25 + vector search.",
5
5
  "keywords": [
6
6
  "embeddings",
@@ -78,8 +78,10 @@
78
78
  "bench:general-embeddings:write": "bun scripts/general-embedding-benchmark.ts --write",
79
79
  "bench:cpu-embeddings": "bun scripts/cpu-embed-autoresearch.ts",
80
80
  "bench:cpu-embeddings:native-batch-probe": "bun scripts/native-embedding-batch-probe.ts",
81
+ "bench:cjk-lexical": "bun scripts/cjk-lexical-benchmark.ts",
81
82
  "eval:retrieval-candidates": "bun scripts/retrieval-candidate-benchmark.ts",
82
83
  "eval:retrieval-candidates:write": "bun scripts/retrieval-candidate-benchmark.ts --write",
84
+ "eval:agentic": "bun evals/agentic/cli.ts",
83
85
  "eval:watch": "bun --bun evalite watch",
84
86
  "research:finetune:bootstrap": "bun research/finetune/scripts/bootstrap-promotion-fixtures.ts",
85
87
  "research:finetune:autonomous:noop": "bun research/finetune/autonomous/scripts/noop-run.ts",
@@ -0,0 +1,42 @@
1
+ const canonicalize = (value: unknown): unknown => {
2
+ if (Array.isArray(value)) return value.map(canonicalize);
3
+ if (value !== null && typeof value === "object") {
4
+ return Object.fromEntries(
5
+ Object.entries(value as Record<string, unknown>)
6
+ .sort(([left], [right]) => left.localeCompare(right))
7
+ .map(([key, child]) => [key, canonicalize(child)])
8
+ );
9
+ }
10
+ return value;
11
+ };
12
+
13
+ export const stableJson = (value: unknown): string =>
14
+ JSON.stringify(canonicalize(value));
15
+
16
+ export const fingerprint = (value: unknown): string => {
17
+ const hasher = new Bun.CryptoHasher("sha256");
18
+ hasher.update(stableJson(value));
19
+ return hasher.digest("hex");
20
+ };
21
+
22
+ const withoutVolatileFields = (value: unknown, parentKey?: string): unknown => {
23
+ if (Array.isArray(value)) {
24
+ return value.map((child) => withoutVolatileFields(child, parentKey));
25
+ }
26
+ if (value !== null && typeof value === "object") {
27
+ return Object.fromEntries(
28
+ Object.entries(value as Record<string, unknown>)
29
+ .filter(
30
+ ([key]) =>
31
+ key !== "generatedAt" &&
32
+ !key.endsWith("Ms") &&
33
+ !(parentKey === "fingerprints" && key === "result")
34
+ )
35
+ .map(([key, child]) => [key, withoutVolatileFields(child, key)])
36
+ );
37
+ }
38
+ return value;
39
+ };
40
+
41
+ export const fingerprintStableResult = (value: unknown): string =>
42
+ fingerprint(withoutVolatileFields(value));
@@ -0,0 +1,110 @@
1
+ import type {
2
+ CjkBenchCaseResult,
3
+ CjkBenchFailure,
4
+ CjkBenchLanguage,
5
+ CjkBenchLanguageResult,
6
+ CjkBenchMetrics,
7
+ } from "./types";
8
+
9
+ import { computeMrr, computeNdcg, computeRecall } from "./metrics";
10
+
11
+ const round = (value: number, places = 4): number =>
12
+ Number(value.toFixed(places));
13
+
14
+ const average = (values: number[]): number =>
15
+ values.length === 0
16
+ ? 0
17
+ : values.reduce((sum, value) => sum + value, 0) / values.length;
18
+
19
+ export const summarizeLatency = (
20
+ values: number[]
21
+ ): { p50Ms: number; p95Ms: number; meanMs: number } => {
22
+ if (values.length === 0) {
23
+ return { p50Ms: 0, p95Ms: 0, meanMs: 0 };
24
+ }
25
+ const sorted = [...values].sort((left, right) => left - right);
26
+ const percentile = (percent: number): number => {
27
+ const index = Math.ceil((percent / 100) * sorted.length) - 1;
28
+ return sorted[Math.max(0, Math.min(sorted.length - 1, index))] ?? 0;
29
+ };
30
+ return {
31
+ p50Ms: round(percentile(50), 2),
32
+ p95Ms: round(percentile(95), 2),
33
+ meanMs: round(average(values), 2),
34
+ };
35
+ };
36
+
37
+ export const buildCjkCaseResult = (input: {
38
+ queryId: string;
39
+ language: CjkBenchLanguage;
40
+ category: CjkBenchCaseResult["category"];
41
+ query: string;
42
+ expected: string[];
43
+ judgments: Array<{ docid: string; relevance: number }>;
44
+ topDocs: string[];
45
+ warmLatencyMs: number;
46
+ error?: string;
47
+ }): CjkBenchCaseResult => ({
48
+ queryId: input.queryId,
49
+ language: input.language,
50
+ category: input.category,
51
+ query: input.query,
52
+ expected: input.expected,
53
+ topDocs: input.topDocs.slice(0, 10),
54
+ metrics: {
55
+ recallAt5: round(computeRecall(input.topDocs, input.expected, 5)),
56
+ recallAt10: round(computeRecall(input.topDocs, input.expected, 10)),
57
+ mrr: round(computeMrr(input.topDocs, input.expected)),
58
+ ndcgAt10: round(computeNdcg(input.topDocs, input.judgments, 10)),
59
+ },
60
+ zeroResult: input.topDocs.length === 0,
61
+ warmLatencyMs: round(input.warmLatencyMs, 2),
62
+ ...(input.error ? { error: input.error } : {}),
63
+ });
64
+
65
+ export const summarizeCjkMetrics = (
66
+ cases: CjkBenchCaseResult[]
67
+ ): CjkBenchMetrics => ({
68
+ recallAt5: round(average(cases.map((item) => item.metrics.recallAt5))),
69
+ recallAt10: round(average(cases.map((item) => item.metrics.recallAt10))),
70
+ mrr: round(average(cases.map((item) => item.metrics.mrr))),
71
+ ndcgAt10: round(average(cases.map((item) => item.metrics.ndcgAt10))),
72
+ zeroResultRate: round(
73
+ average(cases.map((item) => (item.zeroResult ? 1 : 0)))
74
+ ),
75
+ });
76
+
77
+ export const classifyCjkFailures = (
78
+ cases: CjkBenchCaseResult[]
79
+ ): CjkBenchFailure[] =>
80
+ cases.flatMap((item) => {
81
+ if (item.metrics.recallAt5 === 1) {
82
+ return [];
83
+ }
84
+ const reason = item.zeroResult
85
+ ? "zero-result"
86
+ : item.metrics.recallAt10 === 0
87
+ ? "not-in-top-10"
88
+ : "below-rank-5";
89
+ return [
90
+ {
91
+ queryId: item.queryId,
92
+ language: item.language,
93
+ category: item.category,
94
+ reason,
95
+ query: item.query,
96
+ expected: item.expected,
97
+ topDocs: item.topDocs.slice(0, 5),
98
+ },
99
+ ];
100
+ });
101
+
102
+ export const summarizeCjkLanguage = (
103
+ language: CjkBenchLanguage,
104
+ cases: CjkBenchCaseResult[]
105
+ ): CjkBenchLanguageResult => ({
106
+ language,
107
+ queryCount: cases.length,
108
+ metrics: summarizeCjkMetrics(cases),
109
+ failures: classifyCjkFailures(cases),
110
+ });
@@ -94,3 +94,122 @@ export interface BenchOutput {
94
94
  export type BenchResult =
95
95
  | { success: true; data: BenchOutput }
96
96
  | { success: false; error: string; isValidation?: boolean };
97
+
98
+ export const CJK_BENCH_LANGUAGES = ["zh", "ja", "ko"] as const;
99
+ export type CjkBenchLanguage = (typeof CJK_BENCH_LANGUAGES)[number];
100
+
101
+ export type CjkBenchCategory =
102
+ | "exact-term"
103
+ | "filename"
104
+ | "identifier"
105
+ | "mixed-script"
106
+ | "normalization"
107
+ | "punctuation"
108
+ | "ranking"
109
+ | "token-boundary";
110
+
111
+ export type CjkBenchLane =
112
+ | "bm25"
113
+ | "hybrid-no-models"
114
+ | "substring-raw"
115
+ | "substring-nfc";
116
+
117
+ export interface CjkBenchMetrics {
118
+ recallAt5: number;
119
+ recallAt10: number;
120
+ mrr: number;
121
+ ndcgAt10: number;
122
+ zeroResultRate: number;
123
+ }
124
+
125
+ export interface CjkBenchLatency {
126
+ coldQueryMs: number;
127
+ warmQuery: {
128
+ p50Ms: number;
129
+ p95Ms: number;
130
+ meanMs: number;
131
+ };
132
+ }
133
+
134
+ export interface CjkBenchFailure {
135
+ queryId: string;
136
+ language: CjkBenchLanguage;
137
+ category: CjkBenchCategory;
138
+ reason: "below-rank-5" | "not-in-top-10" | "zero-result";
139
+ query: string;
140
+ expected: string[];
141
+ topDocs: string[];
142
+ }
143
+
144
+ export interface CjkBenchCaseResult {
145
+ queryId: string;
146
+ language: CjkBenchLanguage;
147
+ category: CjkBenchCategory;
148
+ query: string;
149
+ expected: string[];
150
+ normalization?: {
151
+ form: "NFC" | "NFKC";
152
+ source: string;
153
+ target: string;
154
+ };
155
+ topDocs: string[];
156
+ metrics: Omit<CjkBenchMetrics, "zeroResultRate">;
157
+ zeroResult: boolean;
158
+ warmLatencyMs: number;
159
+ error?: string;
160
+ }
161
+
162
+ export interface CjkBenchLanguageResult {
163
+ language: CjkBenchLanguage;
164
+ queryCount: number;
165
+ metrics: CjkBenchMetrics;
166
+ failures: CjkBenchFailure[];
167
+ }
168
+
169
+ export interface CjkBenchLaneResult {
170
+ id: CjkBenchLane;
171
+ description: string;
172
+ config: Record<string, string | number | boolean | null>;
173
+ queryCount: number;
174
+ metrics: CjkBenchMetrics;
175
+ latency: CjkBenchLatency;
176
+ languages: CjkBenchLanguageResult[];
177
+ cases: CjkBenchCaseResult[];
178
+ }
179
+
180
+ export interface CjkBenchOutput {
181
+ schemaVersion: 1;
182
+ generatedAt: string;
183
+ benchmark: "gno-cjk-lexical-degradation";
184
+ corpus: {
185
+ fixtureVersion: number;
186
+ documentCount: number;
187
+ queryCount: number;
188
+ languages: CjkBenchLanguage[];
189
+ provenance: string;
190
+ fingerprint: string;
191
+ };
192
+ runtime: {
193
+ bun: string;
194
+ platform: string;
195
+ arch: string;
196
+ sqlite: string;
197
+ };
198
+ index: {
199
+ tokenizer: string;
200
+ buildMs: number;
201
+ bytes: number;
202
+ pageCount: number;
203
+ pageSize: number;
204
+ vocabularyTerms: number;
205
+ vocabularyDocuments: number;
206
+ tokenOccurrences: number;
207
+ };
208
+ fingerprints: {
209
+ config: string;
210
+ runtime: string;
211
+ tokenizer: string;
212
+ result: string;
213
+ };
214
+ lanes: CjkBenchLaneResult[];
215
+ }