@dzhechkov/memory 0.2.21 → 0.2.22
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/.dz-manifest.json +36 -16
- package/README.md +65 -8
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/json-backend.d.ts.map +1 -1
- package/dist/json-backend.js +21 -27
- package/dist/json-backend.js.map +1 -1
- package/dist/sqlite-backend.d.ts.map +1 -1
- package/dist/sqlite-backend.js +21 -26
- package/dist/sqlite-backend.js.map +1 -1
- package/dist/tokenize.d.ts +85 -0
- package/dist/tokenize.d.ts.map +1 -0
- package/dist/tokenize.js +102 -0
- package/dist/tokenize.js.map +1 -0
- package/package.json +1 -1
- package/sbom.json +65 -15
- package/src/index.ts +1 -0
- package/src/json-backend.ts +21 -28
- package/src/sqlite-backend.ts +22 -27
- package/src/tokenize.ts +104 -0
package/.dz-manifest.json
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
},
|
|
10
10
|
{
|
|
11
11
|
"path": "README.md",
|
|
12
|
-
"sha256": "
|
|
12
|
+
"sha256": "4560bc1052213616df71c0eca90758f976eaf9683cfda760cfa67d701e57604b"
|
|
13
13
|
},
|
|
14
14
|
{
|
|
15
15
|
"path": "dist/backend.d.ts",
|
|
@@ -77,19 +77,19 @@
|
|
|
77
77
|
},
|
|
78
78
|
{
|
|
79
79
|
"path": "dist/index.d.ts",
|
|
80
|
-
"sha256": "
|
|
80
|
+
"sha256": "e73b2a1fc8f83272323e24cc117943712c929c6e2daddaf407c2ed71ab7ca1b8"
|
|
81
81
|
},
|
|
82
82
|
{
|
|
83
83
|
"path": "dist/index.d.ts.map",
|
|
84
|
-
"sha256": "
|
|
84
|
+
"sha256": "b84d8f5995bd3a1285a69361a638af35eba6c64c9b4945268da942894d48aff6"
|
|
85
85
|
},
|
|
86
86
|
{
|
|
87
87
|
"path": "dist/index.js",
|
|
88
|
-
"sha256": "
|
|
88
|
+
"sha256": "be8ab9da014b2c9bf7472f342cebe2e9ae81d0867b3efbfb35f006ff811973c3"
|
|
89
89
|
},
|
|
90
90
|
{
|
|
91
91
|
"path": "dist/index.js.map",
|
|
92
|
-
"sha256": "
|
|
92
|
+
"sha256": "1c78cb041d2f482319f7a691330b313ed0235441835ec9ae78f8165a27eb3e5d"
|
|
93
93
|
},
|
|
94
94
|
{
|
|
95
95
|
"path": "dist/json-backend.d.ts",
|
|
@@ -97,15 +97,15 @@
|
|
|
97
97
|
},
|
|
98
98
|
{
|
|
99
99
|
"path": "dist/json-backend.d.ts.map",
|
|
100
|
-
"sha256": "
|
|
100
|
+
"sha256": "158819665930ed2cd3f86f4fa257b8b7bfa69d7638a69cfcc6d947be2c7d014c"
|
|
101
101
|
},
|
|
102
102
|
{
|
|
103
103
|
"path": "dist/json-backend.js",
|
|
104
|
-
"sha256": "
|
|
104
|
+
"sha256": "eb621bac0d3447892064fefb8c9e0f118cef1bf3ca6da538177afe4614320bb9"
|
|
105
105
|
},
|
|
106
106
|
{
|
|
107
107
|
"path": "dist/json-backend.js.map",
|
|
108
|
-
"sha256": "
|
|
108
|
+
"sha256": "662b463dd30f390aaa60527bca2df3b42894bb70de3162a55c24aee1718efb65"
|
|
109
109
|
},
|
|
110
110
|
{
|
|
111
111
|
"path": "dist/reflexion.d.ts",
|
|
@@ -129,15 +129,15 @@
|
|
|
129
129
|
},
|
|
130
130
|
{
|
|
131
131
|
"path": "dist/sqlite-backend.d.ts.map",
|
|
132
|
-
"sha256": "
|
|
132
|
+
"sha256": "62b60365ba990ae1be666b5190e4f01d4a60989ddc62cea687e3823a4f0bb2cc"
|
|
133
133
|
},
|
|
134
134
|
{
|
|
135
135
|
"path": "dist/sqlite-backend.js",
|
|
136
|
-
"sha256": "
|
|
136
|
+
"sha256": "664eb414c5438dda67b5e6b2e9c3281a20c38c4b85a1e9ee48b004a1e7c08d24"
|
|
137
137
|
},
|
|
138
138
|
{
|
|
139
139
|
"path": "dist/sqlite-backend.js.map",
|
|
140
|
-
"sha256": "
|
|
140
|
+
"sha256": "12ba0851550864589cb8fad02950cfc7ff468bdf0e87b429ba4bfc6a7294e28d"
|
|
141
141
|
},
|
|
142
142
|
{
|
|
143
143
|
"path": "dist/sqlite-probe.d.ts",
|
|
@@ -171,9 +171,25 @@
|
|
|
171
171
|
"path": "dist/sqlite-readonly.js.map",
|
|
172
172
|
"sha256": "03ddeec35c1389f249b0595f2612b8c3f86e5069b4c3f1be1e30eb6f6949bf1c"
|
|
173
173
|
},
|
|
174
|
+
{
|
|
175
|
+
"path": "dist/tokenize.d.ts",
|
|
176
|
+
"sha256": "5c1fcc80c8d6836c3b80dba6de50e98b06c70bc2d15c46b1392bb305af6e5e57"
|
|
177
|
+
},
|
|
178
|
+
{
|
|
179
|
+
"path": "dist/tokenize.d.ts.map",
|
|
180
|
+
"sha256": "ec47c8a2687a0f598c0e81a5871e9a011cf686880c8a34915af31a09701faddc"
|
|
181
|
+
},
|
|
182
|
+
{
|
|
183
|
+
"path": "dist/tokenize.js",
|
|
184
|
+
"sha256": "bc1b9368d3e375bed2293afead3cc5d23409f8f83fae2e8f9eb790542ab02635"
|
|
185
|
+
},
|
|
186
|
+
{
|
|
187
|
+
"path": "dist/tokenize.js.map",
|
|
188
|
+
"sha256": "8fd9e4236d32873ee9b9ea4b3d44f9e4dfffa09071dc3831e26956f88f662fcf"
|
|
189
|
+
},
|
|
174
190
|
{
|
|
175
191
|
"path": "package.json",
|
|
176
|
-
"sha256": "
|
|
192
|
+
"sha256": "ea0e5da14d9fdddbb6a5a6278610f0bcda3b98f1cae6287def6e5565f01a3ab6"
|
|
177
193
|
},
|
|
178
194
|
{
|
|
179
195
|
"path": "src/backend.ts",
|
|
@@ -193,11 +209,11 @@
|
|
|
193
209
|
},
|
|
194
210
|
{
|
|
195
211
|
"path": "src/index.ts",
|
|
196
|
-
"sha256": "
|
|
212
|
+
"sha256": "4ac9affbf4fbe220a7a23c08ce48a1d9ffc02c1ddc4774fd41abbeccd33d8993"
|
|
197
213
|
},
|
|
198
214
|
{
|
|
199
215
|
"path": "src/json-backend.ts",
|
|
200
|
-
"sha256": "
|
|
216
|
+
"sha256": "37b2e031e8dc9437b55b3b57ad83e2ebc962335d16c496834a7c4aed9a1770cc"
|
|
201
217
|
},
|
|
202
218
|
{
|
|
203
219
|
"path": "src/reflexion.ts",
|
|
@@ -205,7 +221,7 @@
|
|
|
205
221
|
},
|
|
206
222
|
{
|
|
207
223
|
"path": "src/sqlite-backend.ts",
|
|
208
|
-
"sha256": "
|
|
224
|
+
"sha256": "d91cff21f894ae44483b06807fdbbe4958bee00292c0cd85469e2c5f3374c055"
|
|
209
225
|
},
|
|
210
226
|
{
|
|
211
227
|
"path": "src/sqlite-probe.ts",
|
|
@@ -214,8 +230,12 @@
|
|
|
214
230
|
{
|
|
215
231
|
"path": "src/sqlite-readonly.ts",
|
|
216
232
|
"sha256": "c973b0eeae5567af4c519c178ccb48d496fa1cf2ad226363b26ecc2e7d11dc45"
|
|
233
|
+
},
|
|
234
|
+
{
|
|
235
|
+
"path": "src/tokenize.ts",
|
|
236
|
+
"sha256": "16b59783d4cf4023d810584564063b195e1cae83a4f4c4fd9d11751ad3d1e7be"
|
|
217
237
|
}
|
|
218
238
|
]
|
|
219
239
|
},
|
|
220
|
-
"signature": "
|
|
240
|
+
"signature": "gjCK4WP4Jqgmf4A4E33RyaxjTuEzDxR4UYJGIKskmig3Dpvb1v4nldWiPsfBhvtDPzyIrjxN1Do7FzYT4OHIAQ=="
|
|
221
241
|
}
|
package/README.md
CHANGED
|
@@ -21,18 +21,69 @@ with the whole store, reordered. MEASURED on two records (`hello world`, `anothe
|
|
|
21
21
|
`zebrafish` returned both, and so did `hello`, which matches exactly one. With no matches the sort
|
|
22
22
|
degenerates into confidence order — which is how it looked like "ranking by confidence".
|
|
23
23
|
|
|
24
|
-
Now the
|
|
24
|
+
Now the three situations are separated:
|
|
25
25
|
|
|
26
26
|
| The query | What comes back |
|
|
27
27
|
|---|---|
|
|
28
28
|
| has usable terms and matches nothing | nothing |
|
|
29
29
|
| has usable terms and matches some records | only those |
|
|
30
|
-
|
|
|
30
|
+
| was PROVIDED but tokenizes to NO usable terms (pure punctuation/whitespace/empty) | nothing — a named `no-searchable-terms` reason, not a silent full-store dump (`recall-short-terms`, 2026-09-15; see below) |
|
|
31
|
+
| has NO text field at all (`{}` — a browse, not a search) | the whole store, ranked by confidence — unchanged, because nothing was ever expressible to filter on |
|
|
31
32
|
|
|
32
33
|
The filter is `overlap > 0`, never a tuned threshold: a weak match is still a match, and zero overlap
|
|
33
34
|
is not a weak match. Both backends take the same rule, so a store's answers never depend on which one
|
|
34
35
|
is installed. Note that "matched" is measured over the record's TEXT and its `skillId`.
|
|
35
36
|
|
|
37
|
+
## Short terms — `x`, `C`, `Go`, `ID`, `db` are now searchable (`recall-short-terms`, 2026-09-15)
|
|
38
|
+
|
|
39
|
+
Until 2026-09-15 `tokenize` dropped every token of length <= 1 code point (`memory/src/tokenize.ts`,
|
|
40
|
+
shared by both backends since this feature — FR-1). Single- and two-character alphanumeric entities
|
|
41
|
+
are real in this domain (`C`, `Go`, `R`, `ID`, `db`, ADR letter variants), so a record whose entire
|
|
42
|
+
text is `x` was never found by querying `x`: the query tokenized to zero terms and fell into the
|
|
43
|
+
"nothing to search by" branch, which used to dump the whole store instead of the specific match
|
|
44
|
+
(backlog 529c31ab, MEASURED by Codex 2026-08-22).
|
|
45
|
+
|
|
46
|
+
The floor is gone: any non-empty run of `\p{L}\p{N}` characters is a token now, matched **only by
|
|
47
|
+
exact equality** — never a prefix or stem (`stemOf`, in each backend, already refuses anything under
|
|
48
|
+
5 code points, so this was already the rule for 1-2 char terms; recall-short-terms only widens which
|
|
49
|
+
terms REACH that rule). `Go concurrency` finds a record about Go, not one about "going".
|
|
50
|
+
|
|
51
|
+
`tokenize.ts` also exports `hasSearchableTerms(text)` — the single place that decides whether a query
|
|
52
|
+
falls into the "no-searchable-terms" branch above — and `noSearchableTermsReason(text)`, which returns
|
|
53
|
+
the literal `'no-searchable-terms'` string or `undefined`. Both backends call `hasSearchableTerms`
|
|
54
|
+
directly for the branch decision now (fix-round 1, Codex HIGH-1) rather than reimplementing it as
|
|
55
|
+
`terms.length === 0`; `dz recall`'s CLI printer calls `noSearchableTermsReason` (re-exported from
|
|
56
|
+
`@dzhechkov/harness-core`) so the reason it prints — `no searchable terms in "<query>" (only
|
|
57
|
+
punctuation/whitespace) — reason: no-searchable-terms` — can never drift from what the backends
|
|
58
|
+
actually decided.
|
|
59
|
+
|
|
60
|
+
**No stop-list, by design.** Every alphanumeric token is searchable, including one-letter ones —
|
|
61
|
+
`a`, `i`, `и`, `в` all count. A one-letter query can legitimately match every record that contains
|
|
62
|
+
that letter as a standalone token; the caller's own `limit` bounds the flood, this package does not
|
|
63
|
+
maintain a stop-list to pre-filter it (measured: 50 records each containing token `a`, query `a`,
|
|
64
|
+
`limit: 5` → exactly 5 hits, in both backends — `test/short-terms.test.ts`).
|
|
65
|
+
|
|
66
|
+
**Shipping FTS5 (fix-round 1, Codex HIGH-2/MEDIUM-5).** `test/short-terms.test.ts`'s twins corpus
|
|
67
|
+
now runs BOTH with FTS5 forced off (the keyword-overlap CONTROL — both backends run the identical
|
|
68
|
+
algorithm, so any divergence can only be the shared tokenizer) and with FTS5 left ON, the shipping
|
|
69
|
+
default. The FTS5-on comparison checks the MATCHED SET, not ranking order: FTS5's bm25 and the
|
|
70
|
+
keyword path's hit-count relevance are different algorithms and may legitimately tie-break ties
|
|
71
|
+
differently even when they agree on which records qualify. Separately, single-character Cyrillic,
|
|
72
|
+
CJK, and astral-plane terms are each planted and then FOUND via shipping FTS5 — proving they are
|
|
73
|
+
reachable, not merely that an absent one returns empty (which `tokenize-unicode.test.ts`'s A7
|
|
74
|
+
already covered).
|
|
75
|
+
|
|
76
|
+
`tokenize()` also `.normalize('NFC')`s before splitting (fix-round 1, HIGH-2, MEASURED via a
|
|
77
|
+
`better-sqlite3` probe against an in-memory FTS5 table): precomposed `café` (`é` = U+00E9) and
|
|
78
|
+
decomposed `café` (`e` + combining acute U+0301) are the same word to a reader and to shipping
|
|
79
|
+
FTS5's own tokenizer, which already matched both forms (and even bare `cafe`) to each other. Without
|
|
80
|
+
NFC, this tokenizer disagreed with itself across representations — the combining mark falls in the
|
|
81
|
+
SEPARATOR class, so decomposed `café` tokenized to `cafe` (accent silently dropped) while
|
|
82
|
+
precomposed `café` tokenized to the distinct string `café`. NFC-normalizing first fixes that
|
|
83
|
+
representation bug: both forms now tokenize to the identical `café`.
|
|
84
|
+
|
|
85
|
+
**Diacritics are FOLDED, in both backends (Codex r2, lead fix).** Shipping FTS5's default `unicode61` tokenizer strips diacritics (`remove_diacritics=1`): a MATCH for plain `cafe` finds a row stored as `café`. The keyword-overlap path (JSON backend, or sqlite with FTS5 off) used to keep `café` and `cafe` as two tokens — an exact-parity violation between the two backends of one store. `tokenize()` therefore folds diacritics the way `unicode61` does — for LATIN script only (NFD, drop combining marks after a Latin base letter, NFC back), so both paths answer alike (tested in both backends: `cafe`/`café`/decomposed `café` and `ano`/`año` fold; Cyrillic `й`≠`и`, `ё`≠`е` do NOT — MEASURED against FTS5, which keeps them distinct; and a Latin letter carrying TWO marks — `ộ`, `ǘ` — is NOT folded either, exactly as `remove_diacritics=1` leaves it, measured: `MATCH 'o'` does not return the `ộ` row). **Named limit, inherited from the shipping engine:** words that differ only by an accent (`año`/`ano`, `côté`/`cote`) conflate — exactly as FTS5 already conflated them; parity was chosen over a narrower keyword rule.
|
|
86
|
+
|
|
36
87
|
## Backend strategy
|
|
37
88
|
|
|
38
89
|
The default `JsonFileBackend` is **pure JavaScript with zero dependencies** — no
|
|
@@ -83,9 +134,10 @@ missing, the writer creates/rebuilds FTS5 on open while the read-only store only
|
|
|
83
134
|
the two on that specific old/incomplete schema; not fixed here, a named limit.
|
|
84
135
|
|
|
85
136
|
**Реестр мутаций:** этот пакет несёт собственный `test/mutation-registry.json` (ADR-001 C-1…C-3 —
|
|
86
|
-
опенер
|
|
137
|
+
опенер чтения; `short-terms-searchable` — AC-4, `recall-short-terms`, 2026-09-15, единый токенизатор
|
|
138
|
+
в `src/tokenize.ts`) — `dz mutation-gate --package packages/@dzhechkov/memory --only <id>` мутирует
|
|
87
139
|
названное свойство в scratch-копии и требует, чтобы `npx vitest run test/sqlite-readonly.test.ts
|
|
88
|
-
test/sqlite-backend.test.ts` покраснел; форму реестра проверяет
|
|
140
|
+
test/sqlite-backend.test.ts test/short-terms.test.ts` покраснел; форму реестра проверяет
|
|
89
141
|
`test/mutation-registry-shape.test.ts`.
|
|
90
142
|
|
|
91
143
|
## Status
|
|
@@ -94,7 +146,9 @@ test/sqlite-backend.test.ts` покраснел; форму реестра пр
|
|
|
94
146
|
|
|
95
147
|
## Status
|
|
96
148
|
|
|
97
|
-
`0.2.
|
|
149
|
+
`0.2.22` — ONE shared `tokenize()` (`src/tokenize.ts`) for both backends: no length floor (one-character terms are searchable), Latin-only diacritic folding measured against FTS5 `unicode61 remove_diacritics=1` (one combining mark folds; two marks, Cyrillic `й`/`ё` do not), `hasSearchableTerms` / `noSearchableTermsReason` exported, a query that tokenizes to nothing returns `[]` (feature recall-short-terms, 2026-09-15; `@dzhechkov/harness-core` ≥ 0.8.35 re-exports the reason helper).
|
|
150
|
+
|
|
151
|
+
`0.2.22` — ships `openSqliteReadOnly` (read-only opener ladder: in-place → tmp copy → honest error; feature store-readonly-reads, 2026-09-12) — the export `@dzhechkov/harness-core` ≥ 0.8.31 imports; `0.2.19` was a signature-only republish; `0.2.11` shipped with a stale manifest.
|
|
98
152
|
|
|
99
153
|
`0.2.11` — a query that matched nothing returns nothing (see above). `0.2.10` — the lexical tokenizer is Unicode-aware. It split on `[^a-z0-9]+`, so every non-Latin letter
|
|
100
154
|
was a separator and a Cyrillic query produced **zero terms**: the FTS5 branch was skipped, relevance
|
|
@@ -106,6 +160,9 @@ floor counts code points rather than UTF-16 units.
|
|
|
106
160
|
No migration is needed: FTS5's own tokenizer always indexed the text correctly — only the query was
|
|
107
161
|
being stripped of its terms on the way out.
|
|
108
162
|
|
|
109
|
-
**
|
|
110
|
-
|
|
111
|
-
|
|
163
|
+
**Left unchanged HERE, on purpose:** at the time of this fix, a query that yielded no terms still
|
|
164
|
+
returned the store — a contract deliberately NOT folded into this alphabet-only fix, pinned by four
|
|
165
|
+
existing tests that commented it as intended. That contract was narrowed by a LATER, SEPARATE
|
|
166
|
+
decision — `recall-short-terms` (2026-09-15, see above): the one-character floor was removed, and a
|
|
167
|
+
PROVIDED query that still yields zero terms (pure punctuation) now returns empty, not the store. A
|
|
168
|
+
query with no text field at all still returns the store — that half of the old contract survives.
|
package/dist/index.d.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
/** Package version. Kept in sync with `package.json`. */
|
|
7
7
|
export declare const MEMORY_VERSION: string;
|
|
8
8
|
export type { MemoryBackend, MemoryQuery, MemoryRecord } from './backend.js';
|
|
9
|
+
export { tokenize, hasSearchableTerms, noSearchableTermsReason } from './tokenize.js';
|
|
9
10
|
export { JsonFileBackend } from './json-backend.js';
|
|
10
11
|
export type { JsonFileBackendOptions } from './json-backend.js';
|
|
11
12
|
export { selectBackend } from './cascade.js';
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAIH,yDAAyD;AACzD,eAAO,MAAM,cAAc,EAAE,MACuD,CAAC;AAErF,YAAY,EAAE,aAAa,EAAE,WAAW,EAAE,YAAY,EAAE,MAAM,cAAc,CAAC;AAC7E,OAAO,EAAE,eAAe,EAAE,MAAM,mBAAmB,CAAC;AACpD,YAAY,EAAE,sBAAsB,EAAE,MAAM,mBAAmB,CAAC;AAChE,OAAO,EAAE,aAAa,EAAE,MAAM,cAAc,CAAC;AAC7C,YAAY,EAAE,YAAY,EAAE,aAAa,EAAE,MAAM,cAAc,CAAC;AAChE,OAAO,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AACpD,YAAY,EAAE,oBAAoB,EAAE,MAAM,qBAAqB,CAAC;AAChE,OAAO,EAAE,kBAAkB,EAAE,mBAAmB,EAAE,MAAM,sBAAsB,CAAC;AAC/E,YAAY,EAAE,cAAc,EAAE,aAAa,EAAE,mBAAmB,EAAE,MAAM,sBAAsB,CAAC;AAC/F,OAAO,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAChD,YAAY,EAAE,kBAAkB,EAAE,MAAM,mBAAmB,CAAC;AAC5D,OAAO,EAAE,SAAS,EAAE,MAAM,gBAAgB,CAAC;AAC3C,YAAY,EAAE,cAAc,EAAE,MAAM,gBAAgB,CAAC;AACrD,OAAO,EAAE,oBAAoB,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AACjE,YAAY,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AACjD,OAAO,EAAE,oBAAoB,EAAE,oBAAoB,EAAE,eAAe,EAAE,cAAc,EAAE,MAAM,eAAe,CAAC;AAC5G,YAAY,EAAE,YAAY,EAAE,YAAY,EAAE,MAAM,eAAe,CAAC"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAIH,yDAAyD;AACzD,eAAO,MAAM,cAAc,EAAE,MACuD,CAAC;AAErF,YAAY,EAAE,aAAa,EAAE,WAAW,EAAE,YAAY,EAAE,MAAM,cAAc,CAAC;AAC7E,OAAO,EAAE,QAAQ,EAAE,kBAAkB,EAAE,uBAAuB,EAAE,MAAM,eAAe,CAAC;AACtF,OAAO,EAAE,eAAe,EAAE,MAAM,mBAAmB,CAAC;AACpD,YAAY,EAAE,sBAAsB,EAAE,MAAM,mBAAmB,CAAC;AAChE,OAAO,EAAE,aAAa,EAAE,MAAM,cAAc,CAAC;AAC7C,YAAY,EAAE,YAAY,EAAE,aAAa,EAAE,MAAM,cAAc,CAAC;AAChE,OAAO,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AACpD,YAAY,EAAE,oBAAoB,EAAE,MAAM,qBAAqB,CAAC;AAChE,OAAO,EAAE,kBAAkB,EAAE,mBAAmB,EAAE,MAAM,sBAAsB,CAAC;AAC/E,YAAY,EAAE,cAAc,EAAE,aAAa,EAAE,mBAAmB,EAAE,MAAM,sBAAsB,CAAC;AAC/F,OAAO,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAChD,YAAY,EAAE,kBAAkB,EAAE,MAAM,mBAAmB,CAAC;AAC5D,OAAO,EAAE,SAAS,EAAE,MAAM,gBAAgB,CAAC;AAC3C,YAAY,EAAE,cAAc,EAAE,MAAM,gBAAgB,CAAC;AACrD,OAAO,EAAE,oBAAoB,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AACjE,YAAY,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AACjD,OAAO,EAAE,oBAAoB,EAAE,oBAAoB,EAAE,eAAe,EAAE,cAAc,EAAE,MAAM,eAAe,CAAC;AAC5G,YAAY,EAAE,YAAY,EAAE,YAAY,EAAE,MAAM,eAAe,CAAC"}
|
package/dist/index.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import { createRequire } from 'node:module';
|
|
7
7
|
/** Package version. Kept in sync with `package.json`. */
|
|
8
8
|
export const MEMORY_VERSION = createRequire(import.meta.url)('../package.json').version;
|
|
9
|
+
export { tokenize, hasSearchableTerms, noSearchableTermsReason } from './tokenize.js';
|
|
9
10
|
export { JsonFileBackend } from './json-backend.js';
|
|
10
11
|
export { selectBackend } from './cascade.js';
|
|
11
12
|
export { SqliteBackend } from './sqlite-backend.js';
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AAE5C,yDAAyD;AACzD,MAAM,CAAC,MAAM,cAAc,GACxB,aAAa,CAAC,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,iBAAiB,CAAyB,CAAC,OAAO,CAAC;AAGrF,OAAO,EAAE,eAAe,EAAE,MAAM,mBAAmB,CAAC;AAEpD,OAAO,EAAE,aAAa,EAAE,MAAM,cAAc,CAAC;AAE7C,OAAO,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AAEpD,OAAO,EAAE,kBAAkB,EAAE,mBAAmB,EAAE,MAAM,sBAAsB,CAAC;AAE/E,OAAO,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAEhD,OAAO,EAAE,SAAS,EAAE,MAAM,gBAAgB,CAAC;AAE3C,OAAO,EAAE,oBAAoB,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAEjE,OAAO,EAAE,oBAAoB,EAAE,oBAAoB,EAAE,eAAe,EAAE,cAAc,EAAE,MAAM,eAAe,CAAC"}
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AAE5C,yDAAyD;AACzD,MAAM,CAAC,MAAM,cAAc,GACxB,aAAa,CAAC,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,iBAAiB,CAAyB,CAAC,OAAO,CAAC;AAGrF,OAAO,EAAE,QAAQ,EAAE,kBAAkB,EAAE,uBAAuB,EAAE,MAAM,eAAe,CAAC;AACtF,OAAO,EAAE,eAAe,EAAE,MAAM,mBAAmB,CAAC;AAEpD,OAAO,EAAE,aAAa,EAAE,MAAM,cAAc,CAAC;AAE7C,OAAO,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AAEpD,OAAO,EAAE,kBAAkB,EAAE,mBAAmB,EAAE,MAAM,sBAAsB,CAAC;AAE/E,OAAO,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAEhD,OAAO,EAAE,SAAS,EAAE,MAAM,gBAAgB,CAAC;AAE3C,OAAO,EAAE,oBAAoB,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAEjE,OAAO,EAAE,oBAAoB,EAAE,oBAAoB,EAAE,eAAe,EAAE,cAAc,EAAE,MAAM,eAAe,CAAC"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"json-backend.d.ts","sourceRoot":"","sources":["../src/json-backend.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAKH,OAAO,KAAK,EAAE,aAAa,EAAE,WAAW,EAAE,YAAY,EAAE,MAAM,cAAc,CAAC;
|
|
1
|
+
{"version":3,"file":"json-backend.d.ts","sourceRoot":"","sources":["../src/json-backend.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAKH,OAAO,KAAK,EAAE,aAAa,EAAE,WAAW,EAAE,YAAY,EAAE,MAAM,cAAc,CAAC;AA4C7E,2CAA2C;AAC3C,MAAM,WAAW,sBAAsB;IACrC,uEAAuE;IACvE,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;CAC5B;AAED,sFAAsF;AACtF,qBAAa,eAAgB,YAAW,aAAa;IACnD,QAAQ,CAAC,IAAI,eAAe;IAE5B,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAmC;IAC3D,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAqB;gBAElC,OAAO,GAAE,sBAA2B;IAIhD,6EAA6E;WAChE,IAAI,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,eAAe,CAAC;IAM7D;;;;OAIG;IACH,MAAM,CAAC,QAAQ,CAAC,QAAQ,EAAE,MAAM,GAAG,eAAe;IAMlD,GAAG,CAAC,MAAM,EAAE,YAAY,GAAG,OAAO,CAAC,IAAI,CAAC;IAKxC,KAAK,CAAC,KAAK,EAAE,WAAW,GAAG,OAAO,CAAC,YAAY,EAAE,CAAC;IAIlD,4EAA4E;IAC5E,SAAS,CAAC,KAAK,EAAE,WAAW,GAAG,YAAY,EAAE;IAwC7C,GAAG,IAAI,OAAO,CAAC,YAAY,EAAE,CAAC;IAI9B,+CAA+C;IAC/C,OAAO,IAAI,YAAY,EAAE;IAIzB,MAAM,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAKjC,gFAAgF;IAChF,UAAU,CAAC,EAAE,EAAE,MAAM,GAAG,IAAI;IAI5B,KAAK,IAAI,OAAO,CAAC,MAAM,CAAC;IAIxB;;;;;;;OAOG;IACH,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC;IAerB,qFAAqF;IACrF,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC;IAKrB,gDAAgD;IAChD,QAAQ,IAAI,IAAI;CAMjB"}
|
package/dist/json-backend.js
CHANGED
|
@@ -9,30 +9,8 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
11
11
|
import { dirname } from 'node:path';
|
|
12
|
+
import { tokenize, hasSearchableTerms } from './tokenize.js';
|
|
12
13
|
const DEFAULT_LIMIT = 20;
|
|
13
|
-
/**
|
|
14
|
-
* Split text into lowercase word tokens of length > 1.
|
|
15
|
-
*
|
|
16
|
-
* The class is `\p{L}\p{N}`, not `a-z0-9`. Until 2026-08-21 it was ASCII-only, so every non-Latin
|
|
17
|
-
* letter was a SEPARATOR and a Cyrillic query produced ZERO tokens — the FTS5 branch was then skipped
|
|
18
|
-
* entirely, `relevanceOf` returned 0 for every record, and the sort collapsed onto its confidence
|
|
19
|
-
* tie-break. MEASURED on a 267-record clone of the real brain: RU top-1 0/10 against EN 10/10, while
|
|
20
|
-
* 63% of real recall traffic is Cyrillic. The INDEX was never wrong — FTS5's own tokenizer handles
|
|
21
|
-
* Cyrillic — so nothing on disk needed migrating; only the query was being stripped of its terms.
|
|
22
|
-
*
|
|
23
|
-
* `\p{L}` admits letters and `\p{N}` digits; it does NOT admit `"`, `*`, `(` or any other FTS5
|
|
24
|
-
* operator, which is what keeps the joined terms safe to interpolate into a MATCH expression.
|
|
25
|
-
*/
|
|
26
|
-
function tokenize(text) {
|
|
27
|
-
return text
|
|
28
|
-
.toLowerCase()
|
|
29
|
-
.split(/[^\p{L}\p{N}]+/u)
|
|
30
|
-
// Count CODE POINTS, not UTF-16 units. `token.length` counts units, so a single astral letter
|
|
31
|
-
// (`𐐀`, one character, two units) would slip past a floor meant to reject one-character words —
|
|
32
|
-
// an accidental threshold change smuggled in by the alphabet change (cross-family review,
|
|
33
|
-
// 2026-08-21). The promise was "the alphabet, not the thresholds"; this keeps it.
|
|
34
|
-
.filter((token) => [...token].length > 1);
|
|
35
|
-
}
|
|
36
14
|
/**
|
|
37
15
|
* Crude prefix-stem for morphology-bearing languages — feature recall-ru-morphology.
|
|
38
16
|
*
|
|
@@ -116,7 +94,22 @@ export class JsonFileBackend {
|
|
|
116
94
|
/** Synchronous {@link JsonFileBackend.query} — same ranking, no Promise. */
|
|
117
95
|
querySync(query) {
|
|
118
96
|
const limit = query.limit ?? DEFAULT_LIMIT;
|
|
119
|
-
const
|
|
97
|
+
const noText = query.text === undefined;
|
|
98
|
+
const terms = noText ? [] : tokenize(query.text);
|
|
99
|
+
// FR-3 (recall-short-terms): text was SUPPLIED but tokenized to nothing (pure
|
|
100
|
+
// punctuation/whitespace/empty). Until 2026-09-15 this silently returned the WHOLE STORE,
|
|
101
|
+
// indistinguishable from "here is everything you asked for". Honest behavior for an
|
|
102
|
+
// unsatisfiable search is EMPTY, same as any other failed search (ADR-001's own
|
|
103
|
+
// "no-match-means-no-results"). A query with NO text field at all is a different intent —
|
|
104
|
+
// "browse everything", never attempted as a search — untouched below.
|
|
105
|
+
//
|
|
106
|
+
// Fix-round 1 (Codex HIGH-1): this used to reimplement the decision as `terms.length === 0` —
|
|
107
|
+
// a hand-rolled copy of `hasSearchableTerms`'s own logic, forkable exactly the way the old
|
|
108
|
+
// per-backend `tokenize()` was. Calling the shared helper directly closes that gap; `terms`
|
|
109
|
+
// is still computed above for `relevanceOf` below, but the BRANCH decision is never re-derived.
|
|
110
|
+
if (!noText && !hasSearchableTerms(query.text)) {
|
|
111
|
+
return []; // reason: 'no-searchable-terms' — see tokenize.ts `noSearchableTermsReason`
|
|
112
|
+
}
|
|
120
113
|
let candidates = [...this.records.values()];
|
|
121
114
|
if (query.skillId !== undefined) {
|
|
122
115
|
candidates = candidates.filter((record) => record.skillId === query.skillId);
|
|
@@ -128,9 +121,10 @@ export class JsonFileBackend {
|
|
|
128
121
|
b.record.timestamp.localeCompare(a.record.timestamp));
|
|
129
122
|
// ASKING and finding nothing returns nothing. Without this the keyword path RANKS by overlap and
|
|
130
123
|
// never EXCLUDES, so every query returned the whole store reordered — MEASURED on two records:
|
|
131
|
-
// `zebrafish` (matches neither) AND `hello` (matches one) both returned both.
|
|
132
|
-
//
|
|
133
|
-
//
|
|
124
|
+
// `zebrafish` (matches neither) AND `hello` (matches one) both returned both. `noText` is the
|
|
125
|
+
// ONLY way `terms.length === 0` reaches this point now (the provided-but-unsearchable case
|
|
126
|
+
// returned above) — nothing was ever expressible to filter on, so the store still comes back
|
|
127
|
+
// ranked by confidence; that distinction is the whole decision (ADR-001).
|
|
134
128
|
const filtered = terms.length > 0 ? ranked.filter((entry) => entry.relevance > 0) : ranked;
|
|
135
129
|
return filtered.slice(0, limit).map((entry) => entry.record);
|
|
136
130
|
}
|
package/dist/json-backend.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"json-backend.js","sourceRoot":"","sources":["../src/json-backend.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,EAAE,UAAU,EAAE,SAAS,EAAE,YAAY,EAAE,UAAU,EAAE,MAAM,EAAE,aAAa,EAAE,MAAM,SAAS,CAAC;AACjG,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;
|
|
1
|
+
{"version":3,"file":"json-backend.js","sourceRoot":"","sources":["../src/json-backend.ts"],"names":[],"mappings":"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"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"sqlite-backend.d.ts","sourceRoot":"","sources":["../src/sqlite-backend.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAMH,OAAO,KAAK,EAAE,aAAa,EAAE,WAAW,EAAE,YAAY,EAAE,MAAM,cAAc,CAAC;AAE7E,OAAO,KAAK,EAAE,mBAAmB,EAAE,aAAa,EAAE,MAAM,sBAAsB,CAAC;
|
|
1
|
+
{"version":3,"file":"sqlite-backend.d.ts","sourceRoot":"","sources":["../src/sqlite-backend.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAMH,OAAO,KAAK,EAAE,aAAa,EAAE,WAAW,EAAE,YAAY,EAAE,MAAM,cAAc,CAAC;AAE7E,OAAO,KAAK,EAAE,mBAAmB,EAAE,aAAa,EAAE,MAAM,sBAAsB,CAAC;AAiF/E,gFAAgF;AAChF,eAAO,MAAM,eAAe,gKAK3B,CAAC;AAEF,eAAO,MAAM,qBAAqB,oLAKjC,CAAC;AASF,eAAO,MAAM,OAAO,iCAAiC,CAAC;AACtD,eAAO,MAAM,SAAS,+CAA+C,CAAC;AACtE,eAAO,MAAM,YAAY,oDAAoD,CAAC;AAE9E;;;;;;;;;;;GAWG;AACH,wBAAgB,qBAAqB,CACnC,KAAK,EAAE;IAAE,GAAG,CAAC,EAAE,GAAG,CAAC;IAAC,QAAQ,CAAC,EAAE,GAAG,CAAC;IAAC,GAAG,EAAE,GAAG,CAAC;IAAC,OAAO,EAAE,GAAG,CAAA;CAAE,EAC5D,OAAO,EAAE,OAAO,EAChB,KAAK,EAAE,WAAW,GACjB,YAAY,EAAE,CAqFhB;AAED,iCAAiC;AACjC,MAAM,WAAW,oBAAoB;IACnC,wCAAwC;IACxC,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;CAC3B;AAED;;;;;GAKG;AACH,qBAAa,aAAc,YAAW,aAAa;IACjD,QAAQ,CAAC,IAAI,YAAY;IAEzB,OAAO,CAAC,QAAQ,CAAC,EAAE,CAAM;IACzB,OAAO,CAAC,QAAQ,CAAC,UAAU,CAAM;IACjC,OAAO,CAAC,QAAQ,CAAC,UAAU,CAAM;IACjC,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAM;IAC9B,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAM;IAChC,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAM;IAClC,OAAO,CAAC,QAAQ,CAAC,aAAa,CAAkB;IAChD,OAAO,CAAC,QAAQ,CAAC,kBAAkB,CAAkB;IACrD,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAU;gBAEtB,EAAE,EAAE,GAAG;IAyBnB,4DAA4D;IAC5D,MAAM,CAAC,IAAI,CAAC,QAAQ,EAAE,MAAM,GAAG,aAAa;IAW5C;;;;;;OAMG;IACH,MAAM,CAAC,YAAY,CAAC,QAAQ,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,mBAAmB,GAAG,aAAa;IAmBhF,GAAG,CAAC,MAAM,EAAE,YAAY,GAAG,OAAO,CAAC,IAAI,CAAC;IAaxC,KAAK,CAAC,KAAK,EAAE,WAAW,GAAG,OAAO,CAAC,YAAY,EAAE,CAAC;IAIlD;;;;OAIG;IACH,SAAS,CAAC,KAAK,EAAE,WAAW,GAAG,YAAY,EAAE;IAQ7C,GAAG,IAAI,OAAO,CAAC,YAAY,EAAE,CAAC;IAI9B,6CAA6C;IAC7C,OAAO,IAAI,YAAY,EAAE;IAIzB,MAAM,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAKjC,sIAAsI;IACtI,UAAU,CAAC,EAAE,EAAE,MAAM,GAAG,IAAI;IAI5B,KAAK,IAAI,OAAO,CAAC,MAAM,CAAC;IAIxB,oEAAoE;IACpE,OAAO,CAAC,OAAO,EAAE,SAAS,YAAY,EAAE,GAAG,IAAI;IAiB/C,qCAAqC;IACrC,KAAK,IAAI,IAAI;CAGd;AAED,kDAAkD;AAClD,wBAAgB,WAAW,CAAC,GAAG,EAAE,GAAG,GAAG,YAAY,CAUlD"}
|
package/dist/sqlite-backend.js
CHANGED
|
@@ -11,31 +11,9 @@ import { createRequire } from 'node:module';
|
|
|
11
11
|
import { mkdirSync } from 'node:fs';
|
|
12
12
|
import { dirname } from 'node:path';
|
|
13
13
|
import { openSqliteReadOnly, SqliteReadOnlyStore } from './sqlite-readonly.js';
|
|
14
|
+
import { tokenize, hasSearchableTerms } from './tokenize.js';
|
|
14
15
|
const require = createRequire(import.meta.url);
|
|
15
16
|
const DEFAULT_LIMIT = 20;
|
|
16
|
-
/**
|
|
17
|
-
* Split text into lowercase word tokens of length > 1.
|
|
18
|
-
*
|
|
19
|
-
* The class is `\p{L}\p{N}`, not `a-z0-9`. Until 2026-08-21 it was ASCII-only, so every non-Latin
|
|
20
|
-
* letter was a SEPARATOR and a Cyrillic query produced ZERO tokens — the FTS5 branch was then skipped
|
|
21
|
-
* entirely, `relevanceOf` returned 0 for every record, and the sort collapsed onto its confidence
|
|
22
|
-
* tie-break. MEASURED on a 267-record clone of the real brain: RU top-1 0/10 against EN 10/10, while
|
|
23
|
-
* 63% of real recall traffic is Cyrillic. The INDEX was never wrong — FTS5's own tokenizer handles
|
|
24
|
-
* Cyrillic — so nothing on disk needed migrating; only the query was being stripped of its terms.
|
|
25
|
-
*
|
|
26
|
-
* `\p{L}` admits letters and `\p{N}` digits; it does NOT admit `"`, `*`, `(` or any other FTS5
|
|
27
|
-
* operator, which is what keeps the joined terms safe to interpolate into a MATCH expression.
|
|
28
|
-
*/
|
|
29
|
-
function tokenize(text) {
|
|
30
|
-
return text
|
|
31
|
-
.toLowerCase()
|
|
32
|
-
.split(/[^\p{L}\p{N}]+/u)
|
|
33
|
-
// Count CODE POINTS, not UTF-16 units. `token.length` counts units, so a single astral letter
|
|
34
|
-
// (`𐐀`, one character, two units) would slip past a floor meant to reject one-character words —
|
|
35
|
-
// an accidental threshold change smuggled in by the alphabet change (cross-family review,
|
|
36
|
-
// 2026-08-21). The promise was "the alphabet, not the thresholds"; this keeps it.
|
|
37
|
-
.filter((token) => [...token].length > 1);
|
|
38
|
-
}
|
|
39
17
|
/**
|
|
40
18
|
* Crude prefix-stem for morphology-bearing languages — feature recall-ru-morphology.
|
|
41
19
|
*
|
|
@@ -191,7 +169,23 @@ export function searchPreparedRecords(stmts, hasFts5, query) {
|
|
|
191
169
|
}
|
|
192
170
|
}
|
|
193
171
|
// Keyword overlap fallback
|
|
194
|
-
const
|
|
172
|
+
const noText = query.text === undefined;
|
|
173
|
+
const terms = noText ? [] : tokenize(query.text);
|
|
174
|
+
// FR-3 (recall-short-terms): a query that SUPPLIED text but tokenized to literally nothing
|
|
175
|
+
// (pure punctuation/whitespace/empty) has nothing to filter on, and until 2026-09-15 that
|
|
176
|
+
// silently dumped the WHOLE STORE — indistinguishable from "here is everything you asked for".
|
|
177
|
+
// That is the bug this feature names and fixes: honest behavior for an unsatisfiable search is
|
|
178
|
+
// EMPTY, the same "no-match-means-no-results" answer any other failed search gets (ADR-001). A
|
|
179
|
+
// query with NO text field at all is a different intent — "browse everything", never attempted
|
|
180
|
+
// as a search — and keeps the old whole-store answer below.
|
|
181
|
+
//
|
|
182
|
+
// Fix-round 1 (Codex HIGH-1): this used to reimplement the decision as `terms.length === 0` —
|
|
183
|
+
// a hand-rolled copy of `hasSearchableTerms`'s own logic, forkable exactly the way the old
|
|
184
|
+
// per-backend `tokenize()` was. Calling the shared helper directly closes that gap; `terms` is
|
|
185
|
+
// still computed above for `relevanceOf` below, but the BRANCH decision is never re-derived.
|
|
186
|
+
if (!noText && !hasSearchableTerms(query.text)) {
|
|
187
|
+
return []; // reason: 'no-searchable-terms' — see tokenize.ts `noSearchableTermsReason`
|
|
188
|
+
}
|
|
195
189
|
let rows;
|
|
196
190
|
if (query.skillId !== undefined) {
|
|
197
191
|
rows = stmts.bySkill.all(query.skillId);
|
|
@@ -207,8 +201,9 @@ export function searchPreparedRecords(stmts, hasFts5, query) {
|
|
|
207
201
|
b.record.timestamp.localeCompare(a.record.timestamp));
|
|
208
202
|
// The SAME guard the JSON backend applies, so a store's answers never depend on which backend is
|
|
209
203
|
// installed. This is the keyword FALLBACK; the FTS5 path above already returns zero honestly and
|
|
210
|
-
// is untouched.
|
|
211
|
-
//
|
|
204
|
+
// is untouched. `noText` is the ONLY way `terms.length === 0` reaches this point now (the
|
|
205
|
+
// provided-but-unsearchable case returned above) — a genuine "no query at all" browse, so nothing
|
|
206
|
+
// was ever expressible to filter on and the store comes back ranked by confidence (ADR-001).
|
|
212
207
|
const filtered = terms.length > 0 ? ranked.filter((entry) => entry.relevance > 0) : ranked;
|
|
213
208
|
return filtered.slice(0, limit).map((entry) => entry.record);
|
|
214
209
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"sqlite-backend.js","sourceRoot":"","sources":["../src/sqlite-backend.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AAC5C,OAAO,EAAE,SAAS,EAAE,MAAM,SAAS,CAAC;AACpC,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAGpC,OAAO,EAAE,kBAAkB,EAAE,mBAAmB,EAAE,MAAM,sBAAsB,CAAC;
|
|
1
|
+
{"version":3,"file":"sqlite-backend.js","sourceRoot":"","sources":["../src/sqlite-backend.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The shared lexical tokenizer — `sqlite-backend.ts` and `json-backend.ts` both import this
|
|
3
|
+
* instead of carrying their own copy (feature recall-short-terms, FR-1). A forked copy is exactly
|
|
4
|
+
* the class of bug this package has hit twice already: the two backends' tokenizers drifted once
|
|
5
|
+
* before (the Cyrillic-alphabet fix, 2026-08-21) and a reader whose ranking diverges from the
|
|
6
|
+
* writer's silently regresses recall.
|
|
7
|
+
*
|
|
8
|
+
* @packageDocumentation
|
|
9
|
+
*/
|
|
10
|
+
/**
|
|
11
|
+
* Split text into lowercase word tokens.
|
|
12
|
+
*
|
|
13
|
+
* The class is `\p{L}\p{N}`, not `a-z0-9` — see the 2026-08-21 Cyrillic-alphabet fix: an ASCII-only
|
|
14
|
+
* class made every non-Latin letter a SEPARATOR, so a Cyrillic query produced zero tokens.
|
|
15
|
+
*
|
|
16
|
+
* Until 2026-09-15 a token also had to be MORE THAN ONE code point (`[...token].length > 1`), so a
|
|
17
|
+
* query like `x`, `C`, or Cyrillic `на`'s single-letter cousin `и` produced ZERO usable terms and
|
|
18
|
+
* fell into the "nothing to search by" branch — which used to silently return the WHOLE STORE
|
|
19
|
+
* (backlog 529c31ab, MEASURED: a record whose entire text is `x` was never found by querying `x`,
|
|
20
|
+
* because the query never reached the ranker at all). Single- and two-character alphanumeric
|
|
21
|
+
* entities are real in this domain — `C`, `Go`, `R`, `ID`, `db`, ADR letter variants — so the floor
|
|
22
|
+
* is gone: ANY non-empty run of `\p{L}\p{N}` characters is now a token.
|
|
23
|
+
*
|
|
24
|
+
* This introduces no new noise on its own: each backend's `stemOf` already refuses to stem
|
|
25
|
+
* anything under 5 code points, so a 1-2 character term is only ever matched by EXACT token
|
|
26
|
+
* equality, never a prefix (FR-2) — the widened floor and the no-stemming-below-5 rule are
|
|
27
|
+
* independent decisions that happen to compose safely.
|
|
28
|
+
*
|
|
29
|
+
* Filtering on `token.length > 0` (a JS UTF-16-unit length) rather than counting code points is
|
|
30
|
+
* deliberately fine here, unlike the old `> 1` floor: emptiness does not depend on surrogate
|
|
31
|
+
* pairs — a non-empty string always has `.length >= 1` and an empty one always has `.length === 0`,
|
|
32
|
+
* on BOTH measures. The code-point-vs-UTF-16-unit distinction only mattered for a POSITIVE
|
|
33
|
+
* threshold like the old `> 1` (where a lone astral letter, one code point but two UTF-16 units,
|
|
34
|
+
* could slip past it); it is moot once the floor is simply "not empty".
|
|
35
|
+
*
|
|
36
|
+
* `.normalize('NFC')` runs BEFORE lowercasing (fix-round 1, Codex HIGH-2, MEASURED — not merely
|
|
37
|
+
* argued). Precomposed `café` (4 code points, the `é` is U+00E9) and decomposed `café` (5 code
|
|
38
|
+
* points, `e` + combining acute U+0301) are the SAME text to a reader and to shipping FTS5 — a
|
|
39
|
+
* `better-sqlite3` probe (`node -e` against an in-memory `fts5` table) confirmed both forms and
|
|
40
|
+
* the bare ASCII `cafe` all MATCH each other under FTS5's default `unicode61` tokenizer. Without
|
|
41
|
+
* NFC, this tokenizer disagreed: the combining mark U+0301 is category `\p{M}` (Mark), not
|
|
42
|
+
* `\p{L}`, so it fell into the SEPARATOR class above — decomposed `café` tokenized to `cafe`
|
|
43
|
+
* (accent silently dropped) while precomposed `café` tokenized to the distinct string `café`
|
|
44
|
+
* (accent kept), so the keyword-overlap path treated two representations of ONE word as two
|
|
45
|
+
* DIFFERENT words. NFC first collapses both representations to the single precomposed form
|
|
46
|
+
* before the letter/number split ever runs, so both now tokenize to the identical `café`.
|
|
47
|
+
*
|
|
48
|
+
* **Diacritics are folded for LATIN script only — exactly the shipping engine's rule (Codex r2/r3,
|
|
49
|
+
* lead fix, MEASURED 2026-09-15 with a `better-sqlite3` probe against an in-memory `fts5` table):**
|
|
50
|
+
* FTS5's default `unicode61` tokenizer (`remove_diacritics=1`) strips diacritics from Latin
|
|
51
|
+
* characters (`cafe` MATCHes `café`, `ano` MATCHes `año`) but leaves other scripts alone
|
|
52
|
+
* (`й` does NOT match `и`, `ё` does NOT match `е`). The keyword-overlap path (JSON backend, sqlite
|
|
53
|
+
* with FTS5 off) must answer IDENTICALLY — parity between the two backends of one store is the
|
|
54
|
+
* requirement — so this tokenizer does the same: NFD, strip combining marks that follow a LATIN
|
|
55
|
+
* base letter only, NFC back (so `й` = `и` + U+0306 recomposes and stays a distinct letter). A
|
|
56
|
+
* global `\p{M}` strip (the first lead attempt) would have folded Cyrillic `й`→`и`, `ё`→`е` where
|
|
57
|
+
* FTS5 does not — a divergence in the script that carries 63% of real recall traffic. Named
|
|
58
|
+
* limit, inherited from the engine: Latin words that differ only by an accent (`año`/`ano`,
|
|
59
|
+
* `côté`/`cote`) conflate in BOTH backends, as FTS5 already conflated them.
|
|
60
|
+
* Edge measured (Codex r4): `remove_diacritics=1` leaves Latin letters with TWO combining marks
|
|
61
|
+
* alone (`ộ`, `ố`, `ấ`, `ǘ` — `MATCH 'o'` does not return the `ộ` row), so the fold applies only
|
|
62
|
+
* to a single mark; `remove_diacritics=2` semantics (fold everything) are deliberately NOT used.
|
|
63
|
+
*/
|
|
64
|
+
export declare function tokenize(text: string): string[];
|
|
65
|
+
/**
|
|
66
|
+
* Does `text` contain at least one searchable token?
|
|
67
|
+
*
|
|
68
|
+
* FR-3: the "nothing to search by" branch in each backend's keyword path must fire ONLY when this
|
|
69
|
+
* is `false` — i.e. only when tokenization finds literally nothing (pure punctuation, whitespace,
|
|
70
|
+
* or an empty string), never merely because every token happened to be short. That narrower branch
|
|
71
|
+
* used to answer with a silent full-store dump; it no longer does (see each backend's `querySync`),
|
|
72
|
+
* and this function is the one place that decides which queries land in it — named so the decision
|
|
73
|
+
* is discoverable, not reimplemented ad hoc at each call site.
|
|
74
|
+
*/
|
|
75
|
+
export declare function hasSearchableTerms(text: string): boolean;
|
|
76
|
+
/**
|
|
77
|
+
* The named reason a query's keyword-overlap path fell into the "no searchable terms" branch, or
|
|
78
|
+
* `undefined` when it didn't. Fix-round 1 (Codex HIGH-1): both backends used to reimplement the
|
|
79
|
+
* `hasSearchableTerms(text) === false` check as a hand-rolled `terms.length === 0`, and the reason
|
|
80
|
+
* string it corresponds to lived nowhere a caller — including `dz recall`'s CLI printer — could
|
|
81
|
+
* name without re-deriving it. This is the single source of truth for that name: a backend or a
|
|
82
|
+
* printer that wants to know WHY an empty result came back calls this instead of re-tokenizing.
|
|
83
|
+
*/
|
|
84
|
+
export declare function noSearchableTermsReason(text: string): 'no-searchable-terms' | undefined;
|
|
85
|
+
//# sourceMappingURL=tokenize.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"tokenize.d.ts","sourceRoot":"","sources":["../src/tokenize.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAqDG;AACH,wBAAgB,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,EAAE,CAa/C;AAED;;;;;;;;;GASG;AACH,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAExD;AAED;;;;;;;GAOG;AACH,wBAAgB,uBAAuB,CAAC,IAAI,EAAE,MAAM,GAAG,qBAAqB,GAAG,SAAS,CAEvF"}
|
package/dist/tokenize.js
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The shared lexical tokenizer — `sqlite-backend.ts` and `json-backend.ts` both import this
|
|
3
|
+
* instead of carrying their own copy (feature recall-short-terms, FR-1). A forked copy is exactly
|
|
4
|
+
* the class of bug this package has hit twice already: the two backends' tokenizers drifted once
|
|
5
|
+
* before (the Cyrillic-alphabet fix, 2026-08-21) and a reader whose ranking diverges from the
|
|
6
|
+
* writer's silently regresses recall.
|
|
7
|
+
*
|
|
8
|
+
* @packageDocumentation
|
|
9
|
+
*/
|
|
10
|
+
/**
|
|
11
|
+
* Split text into lowercase word tokens.
|
|
12
|
+
*
|
|
13
|
+
* The class is `\p{L}\p{N}`, not `a-z0-9` — see the 2026-08-21 Cyrillic-alphabet fix: an ASCII-only
|
|
14
|
+
* class made every non-Latin letter a SEPARATOR, so a Cyrillic query produced zero tokens.
|
|
15
|
+
*
|
|
16
|
+
* Until 2026-09-15 a token also had to be MORE THAN ONE code point (`[...token].length > 1`), so a
|
|
17
|
+
* query like `x`, `C`, or Cyrillic `на`'s single-letter cousin `и` produced ZERO usable terms and
|
|
18
|
+
* fell into the "nothing to search by" branch — which used to silently return the WHOLE STORE
|
|
19
|
+
* (backlog 529c31ab, MEASURED: a record whose entire text is `x` was never found by querying `x`,
|
|
20
|
+
* because the query never reached the ranker at all). Single- and two-character alphanumeric
|
|
21
|
+
* entities are real in this domain — `C`, `Go`, `R`, `ID`, `db`, ADR letter variants — so the floor
|
|
22
|
+
* is gone: ANY non-empty run of `\p{L}\p{N}` characters is now a token.
|
|
23
|
+
*
|
|
24
|
+
* This introduces no new noise on its own: each backend's `stemOf` already refuses to stem
|
|
25
|
+
* anything under 5 code points, so a 1-2 character term is only ever matched by EXACT token
|
|
26
|
+
* equality, never a prefix (FR-2) — the widened floor and the no-stemming-below-5 rule are
|
|
27
|
+
* independent decisions that happen to compose safely.
|
|
28
|
+
*
|
|
29
|
+
* Filtering on `token.length > 0` (a JS UTF-16-unit length) rather than counting code points is
|
|
30
|
+
* deliberately fine here, unlike the old `> 1` floor: emptiness does not depend on surrogate
|
|
31
|
+
* pairs — a non-empty string always has `.length >= 1` and an empty one always has `.length === 0`,
|
|
32
|
+
* on BOTH measures. The code-point-vs-UTF-16-unit distinction only mattered for a POSITIVE
|
|
33
|
+
* threshold like the old `> 1` (where a lone astral letter, one code point but two UTF-16 units,
|
|
34
|
+
* could slip past it); it is moot once the floor is simply "not empty".
|
|
35
|
+
*
|
|
36
|
+
* `.normalize('NFC')` runs BEFORE lowercasing (fix-round 1, Codex HIGH-2, MEASURED — not merely
|
|
37
|
+
* argued). Precomposed `café` (4 code points, the `é` is U+00E9) and decomposed `café` (5 code
|
|
38
|
+
* points, `e` + combining acute U+0301) are the SAME text to a reader and to shipping FTS5 — a
|
|
39
|
+
* `better-sqlite3` probe (`node -e` against an in-memory `fts5` table) confirmed both forms and
|
|
40
|
+
* the bare ASCII `cafe` all MATCH each other under FTS5's default `unicode61` tokenizer. Without
|
|
41
|
+
* NFC, this tokenizer disagreed: the combining mark U+0301 is category `\p{M}` (Mark), not
|
|
42
|
+
* `\p{L}`, so it fell into the SEPARATOR class above — decomposed `café` tokenized to `cafe`
|
|
43
|
+
* (accent silently dropped) while precomposed `café` tokenized to the distinct string `café`
|
|
44
|
+
* (accent kept), so the keyword-overlap path treated two representations of ONE word as two
|
|
45
|
+
* DIFFERENT words. NFC first collapses both representations to the single precomposed form
|
|
46
|
+
* before the letter/number split ever runs, so both now tokenize to the identical `café`.
|
|
47
|
+
*
|
|
48
|
+
* **Diacritics are folded for LATIN script only — exactly the shipping engine's rule (Codex r2/r3,
|
|
49
|
+
* lead fix, MEASURED 2026-09-15 with a `better-sqlite3` probe against an in-memory `fts5` table):**
|
|
50
|
+
* FTS5's default `unicode61` tokenizer (`remove_diacritics=1`) strips diacritics from Latin
|
|
51
|
+
* characters (`cafe` MATCHes `café`, `ano` MATCHes `año`) but leaves other scripts alone
|
|
52
|
+
* (`й` does NOT match `и`, `ё` does NOT match `е`). The keyword-overlap path (JSON backend, sqlite
|
|
53
|
+
* with FTS5 off) must answer IDENTICALLY — parity between the two backends of one store is the
|
|
54
|
+
* requirement — so this tokenizer does the same: NFD, strip combining marks that follow a LATIN
|
|
55
|
+
* base letter only, NFC back (so `й` = `и` + U+0306 recomposes and stays a distinct letter). A
|
|
56
|
+
* global `\p{M}` strip (the first lead attempt) would have folded Cyrillic `й`→`и`, `ё`→`е` where
|
|
57
|
+
* FTS5 does not — a divergence in the script that carries 63% of real recall traffic. Named
|
|
58
|
+
* limit, inherited from the engine: Latin words that differ only by an accent (`año`/`ano`,
|
|
59
|
+
* `côté`/`cote`) conflate in BOTH backends, as FTS5 already conflated them.
|
|
60
|
+
* Edge measured (Codex r4): `remove_diacritics=1` leaves Latin letters with TWO combining marks
|
|
61
|
+
* alone (`ộ`, `ố`, `ấ`, `ǘ` — `MATCH 'o'` does not return the `ộ` row), so the fold applies only
|
|
62
|
+
* to a single mark; `remove_diacritics=2` semantics (fold everything) are deliberately NOT used.
|
|
63
|
+
*/
|
|
64
|
+
export function tokenize(text) {
|
|
65
|
+
return text
|
|
66
|
+
// Latin-only diacritic fold = FTS5 unicode61 remove_diacritics=1 (see the doc comment):
|
|
67
|
+
// NFD → drop marks after a Latin base letter → NFC (non-Latin letters recompose untouched).
|
|
68
|
+
.normalize('NFD')
|
|
69
|
+
// Codex r4 (lead, MEASURED 2026-09-15 on fts5 in-memory): remove_diacritics=1 folds a Latin
|
|
70
|
+
// letter carrying ONE mark (é→e, ü→u, ñ→n) but NOT one carrying two (Vietnamese ộ/ố/ấ, ǘ stay
|
|
71
|
+
// distinct) — so only a SINGLE mark after a Latin base is dropped; multi-mark letters recompose.
|
|
72
|
+
.replace(/(\p{Script=Latin})\p{M}(?!\p{M})/gu, '$1')
|
|
73
|
+
.normalize('NFC')
|
|
74
|
+
.toLowerCase()
|
|
75
|
+
.split(/[^\p{L}\p{N}]+/u)
|
|
76
|
+
.filter((token) => token.length > 0);
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Does `text` contain at least one searchable token?
|
|
80
|
+
*
|
|
81
|
+
* FR-3: the "nothing to search by" branch in each backend's keyword path must fire ONLY when this
|
|
82
|
+
* is `false` — i.e. only when tokenization finds literally nothing (pure punctuation, whitespace,
|
|
83
|
+
* or an empty string), never merely because every token happened to be short. That narrower branch
|
|
84
|
+
* used to answer with a silent full-store dump; it no longer does (see each backend's `querySync`),
|
|
85
|
+
* and this function is the one place that decides which queries land in it — named so the decision
|
|
86
|
+
* is discoverable, not reimplemented ad hoc at each call site.
|
|
87
|
+
*/
|
|
88
|
+
export function hasSearchableTerms(text) {
|
|
89
|
+
return tokenize(text).length > 0;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* The named reason a query's keyword-overlap path fell into the "no searchable terms" branch, or
|
|
93
|
+
* `undefined` when it didn't. Fix-round 1 (Codex HIGH-1): both backends used to reimplement the
|
|
94
|
+
* `hasSearchableTerms(text) === false` check as a hand-rolled `terms.length === 0`, and the reason
|
|
95
|
+
* string it corresponds to lived nowhere a caller — including `dz recall`'s CLI printer — could
|
|
96
|
+
* name without re-deriving it. This is the single source of truth for that name: a backend or a
|
|
97
|
+
* printer that wants to know WHY an empty result came back calls this instead of re-tokenizing.
|
|
98
|
+
*/
|
|
99
|
+
export function noSearchableTermsReason(text) {
|
|
100
|
+
return hasSearchableTerms(text) ? undefined : 'no-searchable-terms';
|
|
101
|
+
}
|
|
102
|
+
//# sourceMappingURL=tokenize.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"tokenize.js","sourceRoot":"","sources":["../src/tokenize.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAqDG;AACH,MAAM,UAAU,QAAQ,CAAC,IAAY;IACnC,OAAO,IAAI;QACT,wFAAwF;QACxF,4FAA4F;SAC3F,SAAS,CAAC,KAAK,CAAC;QACjB,4FAA4F;QAC5F,8FAA8F;QAC9F,iGAAiG;SAChG,OAAO,CAAC,oCAAoC,EAAE,IAAI,CAAC;SACnD,SAAS,CAAC,KAAK,CAAC;SAChB,WAAW,EAAE;SACb,KAAK,CAAC,iBAAiB,CAAC;SACxB,MAAM,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,KAAK,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC;AACzC,CAAC;AAED;;;;;;;;;GASG;AACH,MAAM,UAAU,kBAAkB,CAAC,IAAY;IAC7C,OAAO,QAAQ,CAAC,IAAI,CAAC,CAAC,MAAM,GAAG,CAAC,CAAC;AACnC,CAAC;AAED;;;;;;;GAOG;AACH,MAAM,UAAU,uBAAuB,CAAC,IAAY;IAClD,OAAO,kBAAkB,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,qBAAqB,CAAC;AACtE,CAAC"}
|
package/package.json
CHANGED
package/sbom.json
CHANGED
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
"hashes": [
|
|
26
26
|
{
|
|
27
27
|
"alg": "SHA-256",
|
|
28
|
-
"content": "
|
|
28
|
+
"content": "4560bc1052213616df71c0eca90758f976eaf9683cfda760cfa67d701e57604b"
|
|
29
29
|
}
|
|
30
30
|
]
|
|
31
31
|
},
|
|
@@ -195,7 +195,7 @@
|
|
|
195
195
|
"hashes": [
|
|
196
196
|
{
|
|
197
197
|
"alg": "SHA-256",
|
|
198
|
-
"content": "
|
|
198
|
+
"content": "e73b2a1fc8f83272323e24cc117943712c929c6e2daddaf407c2ed71ab7ca1b8"
|
|
199
199
|
}
|
|
200
200
|
]
|
|
201
201
|
},
|
|
@@ -205,7 +205,7 @@
|
|
|
205
205
|
"hashes": [
|
|
206
206
|
{
|
|
207
207
|
"alg": "SHA-256",
|
|
208
|
-
"content": "
|
|
208
|
+
"content": "b84d8f5995bd3a1285a69361a638af35eba6c64c9b4945268da942894d48aff6"
|
|
209
209
|
}
|
|
210
210
|
]
|
|
211
211
|
},
|
|
@@ -215,7 +215,7 @@
|
|
|
215
215
|
"hashes": [
|
|
216
216
|
{
|
|
217
217
|
"alg": "SHA-256",
|
|
218
|
-
"content": "
|
|
218
|
+
"content": "be8ab9da014b2c9bf7472f342cebe2e9ae81d0867b3efbfb35f006ff811973c3"
|
|
219
219
|
}
|
|
220
220
|
]
|
|
221
221
|
},
|
|
@@ -225,7 +225,7 @@
|
|
|
225
225
|
"hashes": [
|
|
226
226
|
{
|
|
227
227
|
"alg": "SHA-256",
|
|
228
|
-
"content": "
|
|
228
|
+
"content": "1c78cb041d2f482319f7a691330b313ed0235441835ec9ae78f8165a27eb3e5d"
|
|
229
229
|
}
|
|
230
230
|
]
|
|
231
231
|
},
|
|
@@ -245,7 +245,7 @@
|
|
|
245
245
|
"hashes": [
|
|
246
246
|
{
|
|
247
247
|
"alg": "SHA-256",
|
|
248
|
-
"content": "
|
|
248
|
+
"content": "158819665930ed2cd3f86f4fa257b8b7bfa69d7638a69cfcc6d947be2c7d014c"
|
|
249
249
|
}
|
|
250
250
|
]
|
|
251
251
|
},
|
|
@@ -255,7 +255,7 @@
|
|
|
255
255
|
"hashes": [
|
|
256
256
|
{
|
|
257
257
|
"alg": "SHA-256",
|
|
258
|
-
"content": "
|
|
258
|
+
"content": "eb621bac0d3447892064fefb8c9e0f118cef1bf3ca6da538177afe4614320bb9"
|
|
259
259
|
}
|
|
260
260
|
]
|
|
261
261
|
},
|
|
@@ -265,7 +265,7 @@
|
|
|
265
265
|
"hashes": [
|
|
266
266
|
{
|
|
267
267
|
"alg": "SHA-256",
|
|
268
|
-
"content": "
|
|
268
|
+
"content": "662b463dd30f390aaa60527bca2df3b42894bb70de3162a55c24aee1718efb65"
|
|
269
269
|
}
|
|
270
270
|
]
|
|
271
271
|
},
|
|
@@ -325,7 +325,7 @@
|
|
|
325
325
|
"hashes": [
|
|
326
326
|
{
|
|
327
327
|
"alg": "SHA-256",
|
|
328
|
-
"content": "
|
|
328
|
+
"content": "62b60365ba990ae1be666b5190e4f01d4a60989ddc62cea687e3823a4f0bb2cc"
|
|
329
329
|
}
|
|
330
330
|
]
|
|
331
331
|
},
|
|
@@ -335,7 +335,7 @@
|
|
|
335
335
|
"hashes": [
|
|
336
336
|
{
|
|
337
337
|
"alg": "SHA-256",
|
|
338
|
-
"content": "
|
|
338
|
+
"content": "664eb414c5438dda67b5e6b2e9c3281a20c38c4b85a1e9ee48b004a1e7c08d24"
|
|
339
339
|
}
|
|
340
340
|
]
|
|
341
341
|
},
|
|
@@ -345,7 +345,7 @@
|
|
|
345
345
|
"hashes": [
|
|
346
346
|
{
|
|
347
347
|
"alg": "SHA-256",
|
|
348
|
-
"content": "
|
|
348
|
+
"content": "12ba0851550864589cb8fad02950cfc7ff468bdf0e87b429ba4bfc6a7294e28d"
|
|
349
349
|
}
|
|
350
350
|
]
|
|
351
351
|
},
|
|
@@ -429,6 +429,46 @@
|
|
|
429
429
|
}
|
|
430
430
|
]
|
|
431
431
|
},
|
|
432
|
+
{
|
|
433
|
+
"type": "file",
|
|
434
|
+
"name": "dist/tokenize.d.ts",
|
|
435
|
+
"hashes": [
|
|
436
|
+
{
|
|
437
|
+
"alg": "SHA-256",
|
|
438
|
+
"content": "5c1fcc80c8d6836c3b80dba6de50e98b06c70bc2d15c46b1392bb305af6e5e57"
|
|
439
|
+
}
|
|
440
|
+
]
|
|
441
|
+
},
|
|
442
|
+
{
|
|
443
|
+
"type": "file",
|
|
444
|
+
"name": "dist/tokenize.d.ts.map",
|
|
445
|
+
"hashes": [
|
|
446
|
+
{
|
|
447
|
+
"alg": "SHA-256",
|
|
448
|
+
"content": "ec47c8a2687a0f598c0e81a5871e9a011cf686880c8a34915af31a09701faddc"
|
|
449
|
+
}
|
|
450
|
+
]
|
|
451
|
+
},
|
|
452
|
+
{
|
|
453
|
+
"type": "file",
|
|
454
|
+
"name": "dist/tokenize.js",
|
|
455
|
+
"hashes": [
|
|
456
|
+
{
|
|
457
|
+
"alg": "SHA-256",
|
|
458
|
+
"content": "bc1b9368d3e375bed2293afead3cc5d23409f8f83fae2e8f9eb790542ab02635"
|
|
459
|
+
}
|
|
460
|
+
]
|
|
461
|
+
},
|
|
462
|
+
{
|
|
463
|
+
"type": "file",
|
|
464
|
+
"name": "dist/tokenize.js.map",
|
|
465
|
+
"hashes": [
|
|
466
|
+
{
|
|
467
|
+
"alg": "SHA-256",
|
|
468
|
+
"content": "8fd9e4236d32873ee9b9ea4b3d44f9e4dfffa09071dc3831e26956f88f662fcf"
|
|
469
|
+
}
|
|
470
|
+
]
|
|
471
|
+
},
|
|
432
472
|
{
|
|
433
473
|
"type": "file",
|
|
434
474
|
"name": "package.json",
|
|
@@ -439,7 +479,7 @@
|
|
|
439
479
|
},
|
|
440
480
|
{
|
|
441
481
|
"name": "dz:canonical-json-sha256-v2",
|
|
442
|
-
"value": "
|
|
482
|
+
"value": "ea0e5da14d9fdddbb6a5a6278610f0bcda3b98f1cae6287def6e5565f01a3ab6"
|
|
443
483
|
}
|
|
444
484
|
]
|
|
445
485
|
},
|
|
@@ -489,7 +529,7 @@
|
|
|
489
529
|
"hashes": [
|
|
490
530
|
{
|
|
491
531
|
"alg": "SHA-256",
|
|
492
|
-
"content": "
|
|
532
|
+
"content": "4ac9affbf4fbe220a7a23c08ce48a1d9ffc02c1ddc4774fd41abbeccd33d8993"
|
|
493
533
|
}
|
|
494
534
|
]
|
|
495
535
|
},
|
|
@@ -499,7 +539,7 @@
|
|
|
499
539
|
"hashes": [
|
|
500
540
|
{
|
|
501
541
|
"alg": "SHA-256",
|
|
502
|
-
"content": "
|
|
542
|
+
"content": "37b2e031e8dc9437b55b3b57ad83e2ebc962335d16c496834a7c4aed9a1770cc"
|
|
503
543
|
}
|
|
504
544
|
]
|
|
505
545
|
},
|
|
@@ -519,7 +559,7 @@
|
|
|
519
559
|
"hashes": [
|
|
520
560
|
{
|
|
521
561
|
"alg": "SHA-256",
|
|
522
|
-
"content": "
|
|
562
|
+
"content": "d91cff21f894ae44483b06807fdbbe4958bee00292c0cd85469e2c5f3374c055"
|
|
523
563
|
}
|
|
524
564
|
]
|
|
525
565
|
},
|
|
@@ -542,6 +582,16 @@
|
|
|
542
582
|
"content": "c973b0eeae5567af4c519c178ccb48d496fa1cf2ad226363b26ecc2e7d11dc45"
|
|
543
583
|
}
|
|
544
584
|
]
|
|
585
|
+
},
|
|
586
|
+
{
|
|
587
|
+
"type": "file",
|
|
588
|
+
"name": "src/tokenize.ts",
|
|
589
|
+
"hashes": [
|
|
590
|
+
{
|
|
591
|
+
"alg": "SHA-256",
|
|
592
|
+
"content": "16b59783d4cf4023d810584564063b195e1cae83a4f4c4fd9d11751ad3d1e7be"
|
|
593
|
+
}
|
|
594
|
+
]
|
|
545
595
|
}
|
|
546
596
|
]
|
|
547
597
|
}
|
package/src/index.ts
CHANGED
|
@@ -11,6 +11,7 @@ export const MEMORY_VERSION: string =
|
|
|
11
11
|
(createRequire(import.meta.url)('../package.json') as { version: string }).version;
|
|
12
12
|
|
|
13
13
|
export type { MemoryBackend, MemoryQuery, MemoryRecord } from './backend.js';
|
|
14
|
+
export { tokenize, hasSearchableTerms, noSearchableTermsReason } from './tokenize.js';
|
|
14
15
|
export { JsonFileBackend } from './json-backend.js';
|
|
15
16
|
export type { JsonFileBackendOptions } from './json-backend.js';
|
|
16
17
|
export { selectBackend } from './cascade.js';
|
package/src/json-backend.ts
CHANGED
|
@@ -12,33 +12,10 @@ import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync
|
|
|
12
12
|
import { dirname } from 'node:path';
|
|
13
13
|
|
|
14
14
|
import type { MemoryBackend, MemoryQuery, MemoryRecord } from './backend.js';
|
|
15
|
+
import { tokenize, hasSearchableTerms } from './tokenize.js';
|
|
15
16
|
|
|
16
17
|
const DEFAULT_LIMIT = 20;
|
|
17
18
|
|
|
18
|
-
/**
|
|
19
|
-
* Split text into lowercase word tokens of length > 1.
|
|
20
|
-
*
|
|
21
|
-
* The class is `\p{L}\p{N}`, not `a-z0-9`. Until 2026-08-21 it was ASCII-only, so every non-Latin
|
|
22
|
-
* letter was a SEPARATOR and a Cyrillic query produced ZERO tokens — the FTS5 branch was then skipped
|
|
23
|
-
* entirely, `relevanceOf` returned 0 for every record, and the sort collapsed onto its confidence
|
|
24
|
-
* tie-break. MEASURED on a 267-record clone of the real brain: RU top-1 0/10 against EN 10/10, while
|
|
25
|
-
* 63% of real recall traffic is Cyrillic. The INDEX was never wrong — FTS5's own tokenizer handles
|
|
26
|
-
* Cyrillic — so nothing on disk needed migrating; only the query was being stripped of its terms.
|
|
27
|
-
*
|
|
28
|
-
* `\p{L}` admits letters and `\p{N}` digits; it does NOT admit `"`, `*`, `(` or any other FTS5
|
|
29
|
-
* operator, which is what keeps the joined terms safe to interpolate into a MATCH expression.
|
|
30
|
-
*/
|
|
31
|
-
function tokenize(text: string): string[] {
|
|
32
|
-
return text
|
|
33
|
-
.toLowerCase()
|
|
34
|
-
.split(/[^\p{L}\p{N}]+/u)
|
|
35
|
-
// Count CODE POINTS, not UTF-16 units. `token.length` counts units, so a single astral letter
|
|
36
|
-
// (`𐐀`, one character, two units) would slip past a floor meant to reject one-character words —
|
|
37
|
-
// an accidental threshold change smuggled in by the alphabet change (cross-family review,
|
|
38
|
-
// 2026-08-21). The promise was "the alphabet, not the thresholds"; this keeps it.
|
|
39
|
-
.filter((token) => [...token].length > 1);
|
|
40
|
-
}
|
|
41
|
-
|
|
42
19
|
/**
|
|
43
20
|
* Crude prefix-stem for morphology-bearing languages — feature recall-ru-morphology.
|
|
44
21
|
*
|
|
@@ -125,7 +102,22 @@ export class JsonFileBackend implements MemoryBackend {
|
|
|
125
102
|
/** Synchronous {@link JsonFileBackend.query} — same ranking, no Promise. */
|
|
126
103
|
querySync(query: MemoryQuery): MemoryRecord[] {
|
|
127
104
|
const limit = query.limit ?? DEFAULT_LIMIT;
|
|
128
|
-
const
|
|
105
|
+
const noText = query.text === undefined;
|
|
106
|
+
const terms = noText ? [] : tokenize(query.text!);
|
|
107
|
+
// FR-3 (recall-short-terms): text was SUPPLIED but tokenized to nothing (pure
|
|
108
|
+
// punctuation/whitespace/empty). Until 2026-09-15 this silently returned the WHOLE STORE,
|
|
109
|
+
// indistinguishable from "here is everything you asked for". Honest behavior for an
|
|
110
|
+
// unsatisfiable search is EMPTY, same as any other failed search (ADR-001's own
|
|
111
|
+
// "no-match-means-no-results"). A query with NO text field at all is a different intent —
|
|
112
|
+
// "browse everything", never attempted as a search — untouched below.
|
|
113
|
+
//
|
|
114
|
+
// Fix-round 1 (Codex HIGH-1): this used to reimplement the decision as `terms.length === 0` —
|
|
115
|
+
// a hand-rolled copy of `hasSearchableTerms`'s own logic, forkable exactly the way the old
|
|
116
|
+
// per-backend `tokenize()` was. Calling the shared helper directly closes that gap; `terms`
|
|
117
|
+
// is still computed above for `relevanceOf` below, but the BRANCH decision is never re-derived.
|
|
118
|
+
if (!noText && !hasSearchableTerms(query.text!)) {
|
|
119
|
+
return []; // reason: 'no-searchable-terms' — see tokenize.ts `noSearchableTermsReason`
|
|
120
|
+
}
|
|
129
121
|
let candidates = [...this.records.values()];
|
|
130
122
|
if (query.skillId !== undefined) {
|
|
131
123
|
candidates = candidates.filter((record) => record.skillId === query.skillId);
|
|
@@ -140,9 +132,10 @@ export class JsonFileBackend implements MemoryBackend {
|
|
|
140
132
|
);
|
|
141
133
|
// ASKING and finding nothing returns nothing. Without this the keyword path RANKS by overlap and
|
|
142
134
|
// never EXCLUDES, so every query returned the whole store reordered — MEASURED on two records:
|
|
143
|
-
// `zebrafish` (matches neither) AND `hello` (matches one) both returned both.
|
|
144
|
-
//
|
|
145
|
-
//
|
|
135
|
+
// `zebrafish` (matches neither) AND `hello` (matches one) both returned both. `noText` is the
|
|
136
|
+
// ONLY way `terms.length === 0` reaches this point now (the provided-but-unsearchable case
|
|
137
|
+
// returned above) — nothing was ever expressible to filter on, so the store still comes back
|
|
138
|
+
// ranked by confidence; that distinction is the whole decision (ADR-001).
|
|
146
139
|
const filtered = terms.length > 0 ? ranked.filter((entry) => entry.relevance > 0) : ranked;
|
|
147
140
|
return filtered.slice(0, limit).map((entry) => entry.record);
|
|
148
141
|
}
|
package/src/sqlite-backend.ts
CHANGED
|
@@ -15,35 +15,12 @@ import { dirname } from 'node:path';
|
|
|
15
15
|
import type { MemoryBackend, MemoryQuery, MemoryRecord } from './backend.js';
|
|
16
16
|
import { openSqliteReadOnly, SqliteReadOnlyStore } from './sqlite-readonly.js';
|
|
17
17
|
import type { OpenReadOnlyOptions, ReadOnlyStore } from './sqlite-readonly.js';
|
|
18
|
+
import { tokenize, hasSearchableTerms } from './tokenize.js';
|
|
18
19
|
|
|
19
20
|
const require = createRequire(import.meta.url);
|
|
20
21
|
|
|
21
22
|
const DEFAULT_LIMIT = 20;
|
|
22
23
|
|
|
23
|
-
/**
|
|
24
|
-
* Split text into lowercase word tokens of length > 1.
|
|
25
|
-
*
|
|
26
|
-
* The class is `\p{L}\p{N}`, not `a-z0-9`. Until 2026-08-21 it was ASCII-only, so every non-Latin
|
|
27
|
-
* letter was a SEPARATOR and a Cyrillic query produced ZERO tokens — the FTS5 branch was then skipped
|
|
28
|
-
* entirely, `relevanceOf` returned 0 for every record, and the sort collapsed onto its confidence
|
|
29
|
-
* tie-break. MEASURED on a 267-record clone of the real brain: RU top-1 0/10 against EN 10/10, while
|
|
30
|
-
* 63% of real recall traffic is Cyrillic. The INDEX was never wrong — FTS5's own tokenizer handles
|
|
31
|
-
* Cyrillic — so nothing on disk needed migrating; only the query was being stripped of its terms.
|
|
32
|
-
*
|
|
33
|
-
* `\p{L}` admits letters and `\p{N}` digits; it does NOT admit `"`, `*`, `(` or any other FTS5
|
|
34
|
-
* operator, which is what keeps the joined terms safe to interpolate into a MATCH expression.
|
|
35
|
-
*/
|
|
36
|
-
function tokenize(text: string): string[] {
|
|
37
|
-
return text
|
|
38
|
-
.toLowerCase()
|
|
39
|
-
.split(/[^\p{L}\p{N}]+/u)
|
|
40
|
-
// Count CODE POINTS, not UTF-16 units. `token.length` counts units, so a single astral letter
|
|
41
|
-
// (`𐐀`, one character, two units) would slip past a floor meant to reject one-character words —
|
|
42
|
-
// an accidental threshold change smuggled in by the alphabet change (cross-family review,
|
|
43
|
-
// 2026-08-21). The promise was "the alphabet, not the thresholds"; this keeps it.
|
|
44
|
-
.filter((token) => [...token].length > 1);
|
|
45
|
-
}
|
|
46
|
-
|
|
47
24
|
/**
|
|
48
25
|
* Crude prefix-stem for morphology-bearing languages — feature recall-ru-morphology.
|
|
49
26
|
*
|
|
@@ -204,7 +181,24 @@ export function searchPreparedRecords(
|
|
|
204
181
|
}
|
|
205
182
|
|
|
206
183
|
// Keyword overlap fallback
|
|
207
|
-
const
|
|
184
|
+
const noText = query.text === undefined;
|
|
185
|
+
const terms = noText ? [] : tokenize(query.text!);
|
|
186
|
+
// FR-3 (recall-short-terms): a query that SUPPLIED text but tokenized to literally nothing
|
|
187
|
+
// (pure punctuation/whitespace/empty) has nothing to filter on, and until 2026-09-15 that
|
|
188
|
+
// silently dumped the WHOLE STORE — indistinguishable from "here is everything you asked for".
|
|
189
|
+
// That is the bug this feature names and fixes: honest behavior for an unsatisfiable search is
|
|
190
|
+
// EMPTY, the same "no-match-means-no-results" answer any other failed search gets (ADR-001). A
|
|
191
|
+
// query with NO text field at all is a different intent — "browse everything", never attempted
|
|
192
|
+
// as a search — and keeps the old whole-store answer below.
|
|
193
|
+
//
|
|
194
|
+
// Fix-round 1 (Codex HIGH-1): this used to reimplement the decision as `terms.length === 0` —
|
|
195
|
+
// a hand-rolled copy of `hasSearchableTerms`'s own logic, forkable exactly the way the old
|
|
196
|
+
// per-backend `tokenize()` was. Calling the shared helper directly closes that gap; `terms` is
|
|
197
|
+
// still computed above for `relevanceOf` below, but the BRANCH decision is never re-derived.
|
|
198
|
+
if (!noText && !hasSearchableTerms(query.text!)) {
|
|
199
|
+
return []; // reason: 'no-searchable-terms' — see tokenize.ts `noSearchableTermsReason`
|
|
200
|
+
}
|
|
201
|
+
|
|
208
202
|
let rows: any[];
|
|
209
203
|
if (query.skillId !== undefined) {
|
|
210
204
|
rows = stmts.bySkill.all(query.skillId);
|
|
@@ -223,8 +217,9 @@ export function searchPreparedRecords(
|
|
|
223
217
|
);
|
|
224
218
|
// The SAME guard the JSON backend applies, so a store's answers never depend on which backend is
|
|
225
219
|
// installed. This is the keyword FALLBACK; the FTS5 path above already returns zero honestly and
|
|
226
|
-
// is untouched.
|
|
227
|
-
//
|
|
220
|
+
// is untouched. `noText` is the ONLY way `terms.length === 0` reaches this point now (the
|
|
221
|
+
// provided-but-unsearchable case returned above) — a genuine "no query at all" browse, so nothing
|
|
222
|
+
// was ever expressible to filter on and the store comes back ranked by confidence (ADR-001).
|
|
228
223
|
const filtered = terms.length > 0 ? ranked.filter((entry) => entry.relevance > 0) : ranked;
|
|
229
224
|
return filtered.slice(0, limit).map((entry) => entry.record);
|
|
230
225
|
}
|
package/src/tokenize.ts
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The shared lexical tokenizer — `sqlite-backend.ts` and `json-backend.ts` both import this
|
|
3
|
+
* instead of carrying their own copy (feature recall-short-terms, FR-1). A forked copy is exactly
|
|
4
|
+
* the class of bug this package has hit twice already: the two backends' tokenizers drifted once
|
|
5
|
+
* before (the Cyrillic-alphabet fix, 2026-08-21) and a reader whose ranking diverges from the
|
|
6
|
+
* writer's silently regresses recall.
|
|
7
|
+
*
|
|
8
|
+
* @packageDocumentation
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Split text into lowercase word tokens.
|
|
13
|
+
*
|
|
14
|
+
* The class is `\p{L}\p{N}`, not `a-z0-9` — see the 2026-08-21 Cyrillic-alphabet fix: an ASCII-only
|
|
15
|
+
* class made every non-Latin letter a SEPARATOR, so a Cyrillic query produced zero tokens.
|
|
16
|
+
*
|
|
17
|
+
* Until 2026-09-15 a token also had to be MORE THAN ONE code point (`[...token].length > 1`), so a
|
|
18
|
+
* query like `x`, `C`, or Cyrillic `на`'s single-letter cousin `и` produced ZERO usable terms and
|
|
19
|
+
* fell into the "nothing to search by" branch — which used to silently return the WHOLE STORE
|
|
20
|
+
* (backlog 529c31ab, MEASURED: a record whose entire text is `x` was never found by querying `x`,
|
|
21
|
+
* because the query never reached the ranker at all). Single- and two-character alphanumeric
|
|
22
|
+
* entities are real in this domain — `C`, `Go`, `R`, `ID`, `db`, ADR letter variants — so the floor
|
|
23
|
+
* is gone: ANY non-empty run of `\p{L}\p{N}` characters is now a token.
|
|
24
|
+
*
|
|
25
|
+
* This introduces no new noise on its own: each backend's `stemOf` already refuses to stem
|
|
26
|
+
* anything under 5 code points, so a 1-2 character term is only ever matched by EXACT token
|
|
27
|
+
* equality, never a prefix (FR-2) — the widened floor and the no-stemming-below-5 rule are
|
|
28
|
+
* independent decisions that happen to compose safely.
|
|
29
|
+
*
|
|
30
|
+
* Filtering on `token.length > 0` (a JS UTF-16-unit length) rather than counting code points is
|
|
31
|
+
* deliberately fine here, unlike the old `> 1` floor: emptiness does not depend on surrogate
|
|
32
|
+
* pairs — a non-empty string always has `.length >= 1` and an empty one always has `.length === 0`,
|
|
33
|
+
* on BOTH measures. The code-point-vs-UTF-16-unit distinction only mattered for a POSITIVE
|
|
34
|
+
* threshold like the old `> 1` (where a lone astral letter, one code point but two UTF-16 units,
|
|
35
|
+
* could slip past it); it is moot once the floor is simply "not empty".
|
|
36
|
+
*
|
|
37
|
+
* `.normalize('NFC')` runs BEFORE lowercasing (fix-round 1, Codex HIGH-2, MEASURED — not merely
|
|
38
|
+
* argued). Precomposed `café` (4 code points, the `é` is U+00E9) and decomposed `café` (5 code
|
|
39
|
+
* points, `e` + combining acute U+0301) are the SAME text to a reader and to shipping FTS5 — a
|
|
40
|
+
* `better-sqlite3` probe (`node -e` against an in-memory `fts5` table) confirmed both forms and
|
|
41
|
+
* the bare ASCII `cafe` all MATCH each other under FTS5's default `unicode61` tokenizer. Without
|
|
42
|
+
* NFC, this tokenizer disagreed: the combining mark U+0301 is category `\p{M}` (Mark), not
|
|
43
|
+
* `\p{L}`, so it fell into the SEPARATOR class above — decomposed `café` tokenized to `cafe`
|
|
44
|
+
* (accent silently dropped) while precomposed `café` tokenized to the distinct string `café`
|
|
45
|
+
* (accent kept), so the keyword-overlap path treated two representations of ONE word as two
|
|
46
|
+
* DIFFERENT words. NFC first collapses both representations to the single precomposed form
|
|
47
|
+
* before the letter/number split ever runs, so both now tokenize to the identical `café`.
|
|
48
|
+
*
|
|
49
|
+
* **Diacritics are folded for LATIN script only — exactly the shipping engine's rule (Codex r2/r3,
|
|
50
|
+
* lead fix, MEASURED 2026-09-15 with a `better-sqlite3` probe against an in-memory `fts5` table):**
|
|
51
|
+
* FTS5's default `unicode61` tokenizer (`remove_diacritics=1`) strips diacritics from Latin
|
|
52
|
+
* characters (`cafe` MATCHes `café`, `ano` MATCHes `año`) but leaves other scripts alone
|
|
53
|
+
* (`й` does NOT match `и`, `ё` does NOT match `е`). The keyword-overlap path (JSON backend, sqlite
|
|
54
|
+
* with FTS5 off) must answer IDENTICALLY — parity between the two backends of one store is the
|
|
55
|
+
* requirement — so this tokenizer does the same: NFD, strip combining marks that follow a LATIN
|
|
56
|
+
* base letter only, NFC back (so `й` = `и` + U+0306 recomposes and stays a distinct letter). A
|
|
57
|
+
* global `\p{M}` strip (the first lead attempt) would have folded Cyrillic `й`→`и`, `ё`→`е` where
|
|
58
|
+
* FTS5 does not — a divergence in the script that carries 63% of real recall traffic. Named
|
|
59
|
+
* limit, inherited from the engine: Latin words that differ only by an accent (`año`/`ano`,
|
|
60
|
+
* `côté`/`cote`) conflate in BOTH backends, as FTS5 already conflated them.
|
|
61
|
+
* Edge measured (Codex r4): `remove_diacritics=1` leaves Latin letters with TWO combining marks
|
|
62
|
+
* alone (`ộ`, `ố`, `ấ`, `ǘ` — `MATCH 'o'` does not return the `ộ` row), so the fold applies only
|
|
63
|
+
* to a single mark; `remove_diacritics=2` semantics (fold everything) are deliberately NOT used.
|
|
64
|
+
*/
|
|
65
|
+
export function tokenize(text: string): string[] {
|
|
66
|
+
return text
|
|
67
|
+
// Latin-only diacritic fold = FTS5 unicode61 remove_diacritics=1 (see the doc comment):
|
|
68
|
+
// NFD → drop marks after a Latin base letter → NFC (non-Latin letters recompose untouched).
|
|
69
|
+
.normalize('NFD')
|
|
70
|
+
// Codex r4 (lead, MEASURED 2026-09-15 on fts5 in-memory): remove_diacritics=1 folds a Latin
|
|
71
|
+
// letter carrying ONE mark (é→e, ü→u, ñ→n) but NOT one carrying two (Vietnamese ộ/ố/ấ, ǘ stay
|
|
72
|
+
// distinct) — so only a SINGLE mark after a Latin base is dropped; multi-mark letters recompose.
|
|
73
|
+
.replace(/(\p{Script=Latin})\p{M}(?!\p{M})/gu, '$1')
|
|
74
|
+
.normalize('NFC')
|
|
75
|
+
.toLowerCase()
|
|
76
|
+
.split(/[^\p{L}\p{N}]+/u)
|
|
77
|
+
.filter((token) => token.length > 0);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Does `text` contain at least one searchable token?
|
|
82
|
+
*
|
|
83
|
+
* FR-3: the "nothing to search by" branch in each backend's keyword path must fire ONLY when this
|
|
84
|
+
* is `false` — i.e. only when tokenization finds literally nothing (pure punctuation, whitespace,
|
|
85
|
+
* or an empty string), never merely because every token happened to be short. That narrower branch
|
|
86
|
+
* used to answer with a silent full-store dump; it no longer does (see each backend's `querySync`),
|
|
87
|
+
* and this function is the one place that decides which queries land in it — named so the decision
|
|
88
|
+
* is discoverable, not reimplemented ad hoc at each call site.
|
|
89
|
+
*/
|
|
90
|
+
export function hasSearchableTerms(text: string): boolean {
|
|
91
|
+
return tokenize(text).length > 0;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The named reason a query's keyword-overlap path fell into the "no searchable terms" branch, or
|
|
96
|
+
* `undefined` when it didn't. Fix-round 1 (Codex HIGH-1): both backends used to reimplement the
|
|
97
|
+
* `hasSearchableTerms(text) === false` check as a hand-rolled `terms.length === 0`, and the reason
|
|
98
|
+
* string it corresponds to lived nowhere a caller — including `dz recall`'s CLI printer — could
|
|
99
|
+
* name without re-deriving it. This is the single source of truth for that name: a backend or a
|
|
100
|
+
* printer that wants to know WHY an empty result came back calls this instead of re-tokenizing.
|
|
101
|
+
*/
|
|
102
|
+
export function noSearchableTermsReason(text: string): 'no-searchable-terms' | undefined {
|
|
103
|
+
return hasSearchableTerms(text) ? undefined : 'no-searchable-terms';
|
|
104
|
+
}
|