pikelet-wasm 0.9.3 → 0.10.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +107 -0
- package/README.md +2 -2
- package/complete/encoder-kernels/encoder.node.wasm +0 -0
- package/complete/encoder-kernels/encoder.wasm +0 -0
- package/complete/index.d.ts +31 -0
- package/complete/index.mjs +20 -10
- package/complete/inline-transformer.mjs +54 -7
- package/complete/layer-reader.mjs +16 -0
- package/complete/lexical.mjs +50 -7
- package/complete/retrieval-abstention.mjs +6 -0
- package/complete/sources.mjs +6 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,113 @@ Through 0.6.0 these were published as `pancake-wasm` and
|
|
|
8
8
|
packages are pre-1.0: a minor bump may carry breaking changes, and each entry lists them
|
|
9
9
|
first.
|
|
10
10
|
|
|
11
|
+
## pikelet-wasm 0.10.1 / pikelet 0.10.1 — 2026-10-11
|
|
12
|
+
|
|
13
|
+
Layered packs now serve the same grounding and fetch tuning as single
|
|
14
|
+
files, HTTP readers make fewer, larger range requests, and lazily read
|
|
15
|
+
keyword indexes cache what they read. Upgrade if you serve layered packs
|
|
16
|
+
over `pikelet mcp`: under 0.10.0 they returned no `grounding`, the fact
|
|
17
|
+
check a relevance verdict leaves to the reader. Both packages move to
|
|
18
|
+
0.10.1 together, and `pikelet` requires `pikelet-wasm` ^0.10.1.
|
|
19
|
+
|
|
20
|
+
### Compatibility
|
|
21
|
+
|
|
22
|
+
- **HTTP range sources coalesce rerank rows within 256 KiB by default**
|
|
23
|
+
(was 16 KiB), and a pack's `recommendedGap` is a floor under the
|
|
24
|
+
source's own gap rather than a replacement for it. On the 456,153-record
|
|
25
|
+
Wikipedia pack a query makes about 38 range requests instead of 61 and
|
|
26
|
+
transfers about 2.3 MiB instead of 0.9 MiB; results are unchanged. Pass
|
|
27
|
+
`preferredGapBytes` to `httpRangeSource`, or `gap` to `query()`, to
|
|
28
|
+
choose otherwise.
|
|
29
|
+
|
|
30
|
+
### Fixed
|
|
31
|
+
|
|
32
|
+
- **A layered pack returns `grounding`.** The chain reader kept only the
|
|
33
|
+
inherited scorer's verdict and confidence, so `grounding.covered` and
|
|
34
|
+
`grounding.uncovered` were missing from chain queries and from MCP
|
|
35
|
+
`search` sections as soon as a pack gained a layer. `recordId` is the
|
|
36
|
+
chain-global id of the passage the coverage was taken from.
|
|
37
|
+
- **A layered pack honors fetch tuning.** `gap`, `parallelism` and
|
|
38
|
+
`maxRangeBytes` (and the mount-level `rerankGap`, `rerankParallelism`
|
|
39
|
+
and `rerankMaxRangeBytes`) did not reach the tiers, and a tier's
|
|
40
|
+
manifest `recommendedGap` never applied. Each tier now fetches as its
|
|
41
|
+
own file would.
|
|
42
|
+
- **Lazily read keyword indexes cache postings and share reads.** A
|
|
43
|
+
repeated query re-read every postings list, and query terms whose
|
|
44
|
+
entries share an uncached term-table window each read it. Postings are
|
|
45
|
+
now cached (4 MiB, by bytes), pending reads are shared, and a failed
|
|
46
|
+
read is retried by the next query rather than cached.
|
|
47
|
+
- **`CompleteQueryResult` declares `grounding`** in the TypeScript
|
|
48
|
+
types; `query()` already returned it.
|
|
49
|
+
|
|
50
|
+
## pikelet-wasm 0.10.0 / pikelet 0.10.0 — 2026-10-09
|
|
51
|
+
|
|
52
|
+
`matchQuality` changes meaning for packs compiled with this release: it
|
|
53
|
+
now measures retrieval relevance — whether the pack covers the
|
|
54
|
+
question's topic — instead of trying to say whether the answer is there.
|
|
55
|
+
Packs already built keep their behavior, since the calibration ships
|
|
56
|
+
inside each file. The inline query encoder runs about 1.65× faster in
|
|
57
|
+
Node.js and Chromium and about 2.2× in WebKit with identical embeddings,
|
|
58
|
+
and readers can now run 12-layer and 4-bit encoders. Both packages move
|
|
59
|
+
to 0.10.0 together, and `pikelet` requires `pikelet-wasm` ^0.10.0.
|
|
60
|
+
|
|
61
|
+
### Compatibility
|
|
62
|
+
|
|
63
|
+
- **Newly compiled packs score relevance.** `none` means the pack does
|
|
64
|
+
not cover the question's topic; a question about a covered topic whose
|
|
65
|
+
specific fact the pack lacks now comes back `strong` or `weak` instead
|
|
66
|
+
of `none`. Do not read `strong` as "answered": an asked-for value in a
|
|
67
|
+
result's `grounding.uncovered` is one no passage states. On the
|
|
68
|
+
repository's labeled questions, answerable questions called `none`
|
|
69
|
+
went from 12 of 44 to 0 (docs) and 26 of 50 to 0 (Veyra registry).
|
|
70
|
+
Rebuild a pack to pick this up; packs compiled by 0.9.x keep their
|
|
71
|
+
calibration.
|
|
72
|
+
- **The calibration summary changes shape.** The evaluation segment's
|
|
73
|
+
`calibration` block gains `method` (`self-templates-v6`) and `target`
|
|
74
|
+
(`relevance`); `cvAuc` is now measured against off-topic queries;
|
|
75
|
+
`cvAucHard`, answerable vs. in-domain-unanswerable, is reported but no
|
|
76
|
+
longer gates the fit; `stage1FloorAuc` is gone. Corpora that shipped
|
|
77
|
+
`unscored` for lack of hard negatives or a low hard-negative AUC now
|
|
78
|
+
calibrate.
|
|
79
|
+
- **MCP wording changes.** The `search` description, the note on a
|
|
80
|
+
`none` result, the server instructions and `verify_pack`'s calibration
|
|
81
|
+
note describe relevance; `verify_pack`'s calibration block gains
|
|
82
|
+
`method` and `target` (null on older packs). Tool names and result
|
|
83
|
+
shapes are unchanged.
|
|
84
|
+
- **Packs that declare a layer count other than 6, or 4-bit weights,
|
|
85
|
+
open only in 0.10.0 readers**; 0.9.x readers refuse them at parse. No
|
|
86
|
+
published pack declares either.
|
|
87
|
+
|
|
88
|
+
### Added
|
|
89
|
+
|
|
90
|
+
- **Inline encoders with 1 to 12 layers and u4 weights.** A kind-3
|
|
91
|
+
declaration may give `layout.L` from 1 to 12 and `layout.Q: 4` with
|
|
92
|
+
`layout.B: 32` (u4 weights, two per byte, an f32 scale and offset per
|
|
93
|
+
block of 32); `Q` absent means u8 in blocks of 64, as before. Exported
|
|
94
|
+
as `KERNEL_MAX_LAYERS`. The parser and the embedder check the layer
|
|
95
|
+
count, the format and the blob length before the kernel runs. A u4
|
|
96
|
+
BGE-small-en-v1.5 blob is 23.9 MiB against MiniLM's 24.3 and matches
|
|
97
|
+
PyTorch at cosine 0.985 (1.000 against the same weights fake-quantized).
|
|
98
|
+
`compile` has no flag for these layouts yet.
|
|
99
|
+
|
|
100
|
+
### Changed
|
|
101
|
+
|
|
102
|
+
- **The inline encoder runs its projections over tiles of four tokens**
|
|
103
|
+
instead of one token at a time, so each layer's weights stream through
|
|
104
|
+
memory once per tile. Median query encoding, before → after: about
|
|
105
|
+
23 → 14 ms for a 10-word question and 213 → 125 ms for 100 words in
|
|
106
|
+
Chromium; 32 → 14 and 285 → 134 ms in WebKit. Embeddings are
|
|
107
|
+
bit-identical, so existing packs and their verification vectors are
|
|
108
|
+
unaffected.
|
|
109
|
+
- **Calibration** (`compile`, `compact` refits) fits verified on-topic
|
|
110
|
+
probes against off-topic ones — the built-in foreign bank, minus
|
|
111
|
+
entries the corpus turns out to cover, plus gibberish — and places
|
|
112
|
+
`none` at the 95th percentile of the off-topic scores. The
|
|
113
|
+
in-domain-unanswerable probe classes are still generated, for the
|
|
114
|
+
`cvAucHard` diagnostic only.
|
|
115
|
+
- **Documentation** (both READMEs, `docs/how-a-query-runs.md`,
|
|
116
|
+
`docs/veyra-ablation.md`) describes `matchQuality` as relevance.
|
|
117
|
+
|
|
11
118
|
## pikelet-wasm 0.9.3 / pikelet 0.9.3 — 2026-10-06
|
|
12
119
|
|
|
13
120
|
Hardening for readers that mount packs from other publishers or hosts:
|
package/README.md
CHANGED
|
@@ -133,7 +133,7 @@ claude \
|
|
|
133
133
|
--mcp-config examples/one-file-search/web/public/veyra.mcp.json
|
|
134
134
|
```
|
|
135
135
|
|
|
136
|
-
All three packs derive from the same small synthetic Station Veyra corpus, with controlled differences in the Tovash evidence. Ask where the Tovash project is housed: the full pack supports Chamber 17, the modified pack supports Chamber 43, and the ablated pack should
|
|
136
|
+
All three packs derive from the same small synthetic Station Veyra corpus, with controlled differences in the Tovash evidence. Ask where the Tovash project is housed: the full pack supports Chamber 17, the modified pack supports Chamber 43, and the ablated pack should not support any chamber.
|
|
137
137
|
|
|
138
138
|
---
|
|
139
139
|
|
|
@@ -153,7 +153,7 @@ A layer records its parent's identity, and its location when the parent sits in
|
|
|
153
153
|
|
|
154
154
|
## Match quality and abstention
|
|
155
155
|
|
|
156
|
-
A nearest neighbour is not automatically evidence that a corpus
|
|
156
|
+
A nearest neighbour is not automatically evidence that a corpus covers a question. At build time Pikelet fits a classifier over retrieval signals — distances, and how much of the question the top passages actually contain — against queries the corpus does not cover ([details](docs/how-a-query-runs.md#how-abstention-is-decided)), and results carry `matchQuality: strong | weak | none`. It measures retrieval relevance: `none` means the pack does not cover the question's topic; `strong` means it does, not that a returned passage states the specific fact asked for. For that, each result lists the question's words no top passage contains (`grounding.uncovered`), and an asked-for value in that list is unsupported whatever `matchQuality` says. When calibration can't separate on-topic from off-topic queries reliably, Pikelet reports `matchQuality: unscored` and records why calibration was skipped, rather than manufacturing confidence. In the library, a `none` verdict withholds `results` by default; pass `query(text, { showAbstained: true })` to see the raw retrieval anyway — `matchQuality` and `confidence` are unaffected either way. MCP `search` does the opposite: it returns results under `none` with a note telling the model the support is indirect, because the calibrator can misjudge a paraphrase; `showAbstained: false` withholds them.
|
|
157
157
|
|
|
158
158
|
`matchQuality` is evidence about retrieval support. It is **not** a guarantee that an LLM will never hallucinate.
|
|
159
159
|
|
|
Binary file
|
|
Binary file
|
package/complete/index.d.ts
CHANGED
|
@@ -41,6 +41,11 @@ export interface HttpRangeSource extends CompleteRangeSource {
|
|
|
41
41
|
|
|
42
42
|
export declare function httpRangeSource(url: string, options?: {
|
|
43
43
|
preferredParallelism?: number;
|
|
44
|
+
/**
|
|
45
|
+
* Rows of the vector rerank closer than this many bytes are fetched in
|
|
46
|
+
* one request, reading the bytes between them. Default 256 KiB. A pack's
|
|
47
|
+
* manifest recommendedGap applies only when it is wider.
|
|
48
|
+
*/
|
|
44
49
|
preferredGapBytes?: number;
|
|
45
50
|
/** Cap on the one-time full download when a host ignores Range, enforced on received bytes. Default 64 MiB. */
|
|
46
51
|
maxFullFallbackBytes?: number;
|
|
@@ -67,6 +72,14 @@ export declare function httpRangeSource(url: string, options?: {
|
|
|
67
72
|
}): HttpRangeSource;
|
|
68
73
|
|
|
69
74
|
export interface CompleteQueryResult {
|
|
75
|
+
/**
|
|
76
|
+
* The pack's calibrated verdict on this retrieval. On packs calibrated
|
|
77
|
+
* by pikelet 0.10 or later (evaluation calibration.target 'relevance')
|
|
78
|
+
* it measures retrieval relevance: 'none' means the pack does not appear
|
|
79
|
+
* to cover the query's topic, 'strong' that it does — not that a
|
|
80
|
+
* returned passage states the specific fact asked for. 'unscored': the
|
|
81
|
+
* pack ships no fitted calibrator.
|
|
82
|
+
*/
|
|
70
83
|
matchQuality: 'strong' | 'weak' | 'none' | 'unscored';
|
|
71
84
|
confidence?: number;
|
|
72
85
|
/**
|
|
@@ -76,6 +89,22 @@ export interface CompleteQueryResult {
|
|
|
76
89
|
* other way round.
|
|
77
90
|
*/
|
|
78
91
|
results: Array<{ id: number; distance: number } & Record<string, unknown>>;
|
|
92
|
+
/**
|
|
93
|
+
* Which passage grounded the verdict and which of the query's content
|
|
94
|
+
* words it contains. Present when a fitted calibrator scored the query
|
|
95
|
+
* (absent on 'unscored'). An asked-for value in `uncovered` is one no top
|
|
96
|
+
* passage states, whatever matchQuality says.
|
|
97
|
+
*/
|
|
98
|
+
grounding?: {
|
|
99
|
+
/** Id of the passage the coverage score was taken from. */
|
|
100
|
+
recordId: number | null;
|
|
101
|
+
/** Index of that passage in the fused (or distance-ordered) window. */
|
|
102
|
+
passageIndex: number;
|
|
103
|
+
/** Weighted share of the query's content words the passage contains, 0-1. */
|
|
104
|
+
coverage: number;
|
|
105
|
+
covered: string[];
|
|
106
|
+
uncovered: string[];
|
|
107
|
+
};
|
|
79
108
|
}
|
|
80
109
|
|
|
81
110
|
export interface CompleteSearch {
|
|
@@ -297,6 +326,8 @@ export declare function verifyHostEncoder(
|
|
|
297
326
|
|
|
298
327
|
/** Kernel layout compiled into the kind-3 encoder (mirrors encoder.cpp). */
|
|
299
328
|
export declare const KERNEL_LAYOUT: Record<'V' | 'P' | 'T' | 'D' | 'F' | 'L' | 'B' | 'H', number>;
|
|
329
|
+
/** Largest layer count (layout L) the kind-3 kernel runs; L may be 1 to this. */
|
|
330
|
+
export declare const KERNEL_MAX_LAYERS: number;
|
|
300
331
|
export declare function expectedBlobBytes(layout?: Record<string, number>): number;
|
|
301
332
|
export declare function parseInlineTransformerEncoder(encoderBytes: Uint8Array): {
|
|
302
333
|
declaration: Record<string, unknown>;
|
package/complete/index.mjs
CHANGED
|
@@ -29,7 +29,7 @@ import { PikeletSketchArtifact } from '../src/artifact/pikelet-artifact.js';
|
|
|
29
29
|
import { MAGIC, HEADER_BYTES, TABLE_ENTRY_BYTES, KINDS, KIND_NAMES } from './format.mjs';
|
|
30
30
|
|
|
31
31
|
import {
|
|
32
|
-
KERNEL_LAYOUT, expectedBlobBytes, parseInlineTransformerEncoder,
|
|
32
|
+
KERNEL_LAYOUT, KERNEL_MAX_LAYERS, expectedBlobBytes, parseInlineTransformerEncoder,
|
|
33
33
|
createInlineTransformerEmbedder, INLINE_TEST_VECTOR_TEXTS,
|
|
34
34
|
buildInlineTestVectors, verifyInlineTestVectors,
|
|
35
35
|
validateExpectedEmbedding, validateTestVectorTolerance,
|
|
@@ -44,7 +44,7 @@ export { openLexicalIndex } from './lexical.mjs';
|
|
|
44
44
|
export { fuseCandidates, FUSION_DEFAULTS } from './fusion.mjs';
|
|
45
45
|
import { fuseCandidates } from './fusion.mjs';
|
|
46
46
|
export {
|
|
47
|
-
KERNEL_LAYOUT, expectedBlobBytes, parseInlineTransformerEncoder,
|
|
47
|
+
KERNEL_LAYOUT, KERNEL_MAX_LAYERS, expectedBlobBytes, parseInlineTransformerEncoder,
|
|
48
48
|
createInlineTransformerEmbedder, INLINE_TEST_VECTOR_TEXTS,
|
|
49
49
|
buildInlineTestVectors, verifyInlineTestVectors,
|
|
50
50
|
validateExpectedEmbedding, validateTestVectorTolerance,
|
|
@@ -1243,6 +1243,19 @@ export async function openPikeletFile(input, options = {}) {
|
|
|
1243
1243
|
if (closed) throw new Error('.pikelet reader is closed');
|
|
1244
1244
|
};
|
|
1245
1245
|
|
|
1246
|
+
// The rerank's default coalescing gap. The identity-verified manifest
|
|
1247
|
+
// may carry a fetch hint: cluster-ordered layouts only coalesce at a
|
|
1248
|
+
// gap at least matched to their cluster geometry (the wiki pack's
|
|
1249
|
+
// k-means layout needs ~16 KiB; a 2 KiB gap squanders it — measured
|
|
1250
|
+
// 440 vs 200 requests/query at C=600). The hint is a floor: a wider
|
|
1251
|
+
// gap merges a superset of the same runs, so a source that prefers
|
|
1252
|
+
// more (HTTP's 256 KiB) keeps its own. undefined leaves the sketch
|
|
1253
|
+
// reader on the source's preference. A chain reads it per tier
|
|
1254
|
+
// through __chainTier.
|
|
1255
|
+
const rerankGapHint = Number.isFinite(manifest.recommendedGap)
|
|
1256
|
+
? Math.max(manifest.recommendedGap, Number(source.preferredGapBytes) || 0)
|
|
1257
|
+
: undefined;
|
|
1258
|
+
|
|
1246
1259
|
return {
|
|
1247
1260
|
info() {
|
|
1248
1261
|
return {
|
|
@@ -1368,14 +1381,7 @@ export async function openPikeletFile(input, options = {}) {
|
|
|
1368
1381
|
searched = (await sketch.search(context.vector, k, {
|
|
1369
1382
|
rerank: queryOptions.rerank,
|
|
1370
1383
|
parallelism: queryOptions.parallelism ?? queryOptions.rerankParallelism ?? options.rerankParallelism,
|
|
1371
|
-
|
|
1372
|
-
// hint: cluster-ordered layouts only coalesce at a
|
|
1373
|
-
// gap matched to their cluster geometry (the wiki
|
|
1374
|
-
// pack's k-means layout needs ~16 KiB; the default
|
|
1375
|
-
// 2 KiB squanders it — measured 440 vs 200
|
|
1376
|
-
// requests/query at C=600).
|
|
1377
|
-
gap: queryOptions.gap ?? queryOptions.rerankGap ?? options.rerankGap
|
|
1378
|
-
?? (Number.isFinite(manifest.recommendedGap) ? manifest.recommendedGap : undefined),
|
|
1384
|
+
gap: queryOptions.gap ?? queryOptions.rerankGap ?? options.rerankGap ?? rerankGapHint,
|
|
1379
1385
|
maxRangeBytes: queryOptions.maxRangeBytes ?? options.rerankMaxRangeBytes,
|
|
1380
1386
|
// Present once background staging finished; the
|
|
1381
1387
|
// sketch reader ignores it for any query whose C
|
|
@@ -1588,6 +1594,10 @@ export async function openPikeletFile(input, options = {}) {
|
|
|
1588
1594
|
// the kernel. A getter, not a captured value, because
|
|
1589
1595
|
// staging completes after open() returns.
|
|
1590
1596
|
get scanner() { return scanner; },
|
|
1597
|
+
// The rerank's default coalescing gap for this member
|
|
1598
|
+
// (manifest hint as a floor under the source's own), so
|
|
1599
|
+
// a chain fetches each tier's rows as this member would.
|
|
1600
|
+
rerankGap: rerankGapHint,
|
|
1591
1601
|
// The base's OWN encoder, for `append` (6.1): "a kind-3
|
|
1592
1602
|
// base carries the teacher, and append loads it from the
|
|
1593
1603
|
// base's own query-interp segment — no model download, no
|
|
@@ -5,8 +5,13 @@ const decoder = new TextDecoder();
|
|
|
5
5
|
// The wasm kernel compiles these in (encoder-spike/encoder.cpp `constexpr`
|
|
6
6
|
// block) and walks the weight blob with a bare running cursor — it never
|
|
7
7
|
// reads the declaration. Declaration/blob skew is therefore only catchable
|
|
8
|
-
// here, host-side, before the first forward.
|
|
8
|
+
// here, host-side, before the first forward. Two values are exceptions,
|
|
9
|
+
// taken by the kernel per call: the layer count, any L from 1 to
|
|
10
|
+
// KERNEL_MAX_LAYERS (KERNEL_LAYOUT.L is MiniLM-L6's, the default), and the
|
|
11
|
+
// weight format, Q bits per weight in blocks of B — u8 in blocks of 64
|
|
12
|
+
// (Q absent or 8, every pack before Q existed) or u4 in blocks of 32.
|
|
9
13
|
export const KERNEL_LAYOUT = { V: 30522, P: 512, T: 2, D: 384, F: 1536, L: 6, B: 64, H: 12 };
|
|
14
|
+
export const KERNEL_MAX_LAYERS = 12;
|
|
10
15
|
const KERNEL_MAX_SEQ = 512;
|
|
11
16
|
// Smallest window that still frames interior tokens between [CLS] and [SEP]
|
|
12
17
|
// with room to make progress; see the maxTokens check in
|
|
@@ -14,11 +19,11 @@ const KERNEL_MAX_SEQ = 512;
|
|
|
14
19
|
const MIN_DECLARED_MAX_TOKENS = 8;
|
|
15
20
|
|
|
16
21
|
// Byte size the kernel's fill_layout() will consume for a given layout:
|
|
17
|
-
// each quantized matrix is rows*cols
|
|
18
|
-
// offsets; biases and layer norms are plain f32.
|
|
22
|
+
// each quantized matrix is rows*cols weights of Q bits plus per-block f32
|
|
23
|
+
// scales and offsets; biases and layer norms are plain f32.
|
|
19
24
|
export function expectedBlobBytes(layout = KERNEL_LAYOUT) {
|
|
20
|
-
const { V, P, T, D, F, L, B } = layout;
|
|
21
|
-
const quant = (rows, cols) => rows * cols + rows * (cols / B) * 4 * 2;
|
|
25
|
+
const { V, P, T, D, F, L, B, Q = 8 } = layout;
|
|
26
|
+
const quant = (rows, cols) => (rows * cols * Q) / 8 + rows * (cols / B) * 4 * 2;
|
|
22
27
|
const layer = 4 * quant(D, D) + 4 * (D * 4) // wq,wk,wv,wo + their biases
|
|
23
28
|
+ 2 * (D * 4) // ln1 gamma/beta
|
|
24
29
|
+ quant(F, D) + F * 4 // wu + bu
|
|
@@ -27,6 +32,33 @@ export function expectedBlobBytes(layout = KERNEL_LAYOUT) {
|
|
|
27
32
|
return quant(V, D) + quant(P, D) + quant(T, D) + 2 * (D * 4) + L * layer;
|
|
28
33
|
}
|
|
29
34
|
|
|
35
|
+
// The declared layer count, which the kernel takes per call. Its
|
|
36
|
+
// Layout struct holds KERNEL_MAX_LAYERS layers (encoder.cpp MAXL), and it
|
|
37
|
+
// returns -3 outside 1..MAXL rather than walking past them.
|
|
38
|
+
function kernelLayers(layout) {
|
|
39
|
+
const layers = layout.L;
|
|
40
|
+
if (!Number.isInteger(layers) || layers < 1 || layers > KERNEL_MAX_LAYERS) {
|
|
41
|
+
throw new Error(`.pikelet inline-encoder declares layout L=${layers}; `
|
|
42
|
+
+ `the kernel runs 1 to ${KERNEL_MAX_LAYERS} layers`);
|
|
43
|
+
}
|
|
44
|
+
return layers;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// The declared weight format as the kernel's bits argument. The kernel's
|
|
48
|
+
// block size is fixed per format, so B must be the one Q implies.
|
|
49
|
+
const KERNEL_WEIGHT_FORMATS = { 8: 64, 4: 32 };
|
|
50
|
+
function kernelWeightBits(layout) {
|
|
51
|
+
// Only an absent Q means u8 (packs from before Q existed); null is not
|
|
52
|
+
// absent, and expectedBlobBytes() would size it as zero-bit weights.
|
|
53
|
+
const bits = layout.Q === undefined ? 8 : layout.Q;
|
|
54
|
+
if (!Object.hasOwn(KERNEL_WEIGHT_FORMATS, bits) || typeof bits !== 'number'
|
|
55
|
+
|| layout.B !== KERNEL_WEIGHT_FORMATS[bits]) {
|
|
56
|
+
throw new Error(`.pikelet inline-encoder declares layout Q=${layout.Q} B=${layout.B}; `
|
|
57
|
+
+ 'the kernel runs u8 weights in blocks of 64 (Q absent or 8) or u4 in blocks of 32 (Q=4)');
|
|
58
|
+
}
|
|
59
|
+
return bits;
|
|
60
|
+
}
|
|
61
|
+
|
|
30
62
|
export function parseInlineTransformerEncoder(encoderBytes) {
|
|
31
63
|
const view = new DataView(encoderBytes.buffer, encoderBytes.byteOffset, encoderBytes.byteLength);
|
|
32
64
|
const declLen = view.getUint32(0, true);
|
|
@@ -38,7 +70,12 @@ export function parseInlineTransformerEncoder(encoderBytes) {
|
|
|
38
70
|
const declaration = JSON.parse(decoder.decode(encoderBytes.subarray(12, 12 + declLen)));
|
|
39
71
|
const blob = encoderBytes.subarray(12 + declLen + vocabLen);
|
|
40
72
|
const layout = declaration.layout || {};
|
|
73
|
+
// L, Q and B first: expectedBlobBytes() below scales with them, so they
|
|
74
|
+
// must be bounded before they size anything.
|
|
75
|
+
kernelLayers(layout);
|
|
76
|
+
kernelWeightBits(layout);
|
|
41
77
|
for (const key of Object.keys(KERNEL_LAYOUT)) {
|
|
78
|
+
if (key === 'L' || key === 'B') continue;
|
|
42
79
|
if (layout[key] !== KERNEL_LAYOUT[key]) {
|
|
43
80
|
throw new Error(`.pikelet inline-encoder declares layout ${key}=${layout[key]}; `
|
|
44
81
|
+ `the compiled kernel requires ${key}=${KERNEL_LAYOUT[key]}`);
|
|
@@ -80,6 +117,16 @@ export async function createInlineTransformerEmbedder({ declaration, vocabText,
|
|
|
80
117
|
if (vocabSize > KERNEL_LAYOUT.V) {
|
|
81
118
|
throw new Error(`inline-encoder vocab has ${vocabSize} entries; the kernel's embedding table holds ${KERNEL_LAYOUT.V}`);
|
|
82
119
|
}
|
|
120
|
+
// Like maxTokens below, checked here too because this function is also
|
|
121
|
+
// called directly, without parseInlineTransformerEncoder(): the kernel
|
|
122
|
+
// walks the blob for exactly this many layers in this weight format.
|
|
123
|
+
const layers = kernelLayers({ L: declaration.layout?.L ?? KERNEL_LAYOUT.L });
|
|
124
|
+
const blockSize = declaration.layout?.B ?? KERNEL_LAYOUT.B;
|
|
125
|
+
const bits = kernelWeightBits({ Q: declaration.layout?.Q, B: blockSize });
|
|
126
|
+
const blobBytes = expectedBlobBytes({ ...KERNEL_LAYOUT, L: layers, Q: bits, B: blockSize });
|
|
127
|
+
if (blob.length !== blobBytes) {
|
|
128
|
+
throw new Error(`inline-encoder blob is ${blob.length} bytes but ${layers} layers of u${bits} weights imply ${blobBytes}`);
|
|
129
|
+
}
|
|
83
130
|
const tokenizer = createWordPiece(vocabText);
|
|
84
131
|
const EM = await createEncoder();
|
|
85
132
|
const dim = declaration.dim;
|
|
@@ -121,7 +168,7 @@ export async function createInlineTransformerEmbedder({ declaration, vocabText,
|
|
|
121
168
|
throw new Error(`inline encoder window is ${tokenIds.length} tokens for a ${maxSeq}-token buffer`);
|
|
122
169
|
}
|
|
123
170
|
new Int32Array(EM.HEAP32.buffer, idsPtr, tokenIds.length).set(tokenIds);
|
|
124
|
-
const rc = EM._encoder_forward(blobPtr, idsPtr, tokenIds.length, hiddenPtr, 0);
|
|
171
|
+
const rc = EM._encoder_forward(blobPtr, idsPtr, tokenIds.length, hiddenPtr, 0, layers, bits);
|
|
125
172
|
if (rc !== tokenIds.length) throw new Error(`inline encoder failed: ${rc}`);
|
|
126
173
|
const hidden = new Float32Array(EM.HEAPF32.buffer, hiddenPtr, tokenIds.length * dim);
|
|
127
174
|
if (usesClsPooling) {
|
|
@@ -181,7 +228,7 @@ export async function createInlineTransformerEmbedder({ declaration, vocabText,
|
|
|
181
228
|
throw new Error(`inline encoder window is ${tokenIds.length} tokens for a ${maxSeq}-token buffer`);
|
|
182
229
|
}
|
|
183
230
|
new Int32Array(EM.HEAP32.buffer, idsPtr, tokenIds.length).set(tokenIds);
|
|
184
|
-
const rc = EM._encoder_forward(blobPtr, idsPtr, tokenIds.length, hiddenPtr, 0);
|
|
231
|
+
const rc = EM._encoder_forward(blobPtr, idsPtr, tokenIds.length, hiddenPtr, 0, layers, bits);
|
|
185
232
|
if (rc !== tokenIds.length) throw new Error(`inline encoder failed: ${rc}`);
|
|
186
233
|
const hidden = new Float32Array(EM.HEAPF32.buffer, hiddenPtr, tokenIds.length * dim);
|
|
187
234
|
// Offset by 1: tokenIds[0] is [CLS], so idSlice's tokens start at
|
|
@@ -465,6 +465,13 @@ export async function openPikeletChain(members, options = {}) {
|
|
|
465
465
|
// tier has no live deletion (maskCounts, computed at mount).
|
|
466
466
|
const out = await t.reader.sketch.search(vector, Math.min(k, t.records), {
|
|
467
467
|
rerank: queryOptions.rerank,
|
|
468
|
+
// The single-file reader's fetch tuning, with the same
|
|
469
|
+
// precedence: the query's own value, then the mount's,
|
|
470
|
+
// then this tier's default gap (its manifest hint as a
|
|
471
|
+
// floor under its source's preference).
|
|
472
|
+
parallelism: queryOptions.parallelism ?? queryOptions.rerankParallelism ?? options.rerankParallelism,
|
|
473
|
+
gap: queryOptions.gap ?? queryOptions.rerankGap ?? options.rerankGap ?? t.reader.rerankGap,
|
|
474
|
+
maxRangeBytes: queryOptions.maxRangeBytes ?? options.rerankMaxRangeBytes,
|
|
468
475
|
...(maskCounts[j] > 0 ? { exclude: masks[j] } : {}),
|
|
469
476
|
// The tier's own staged scan kernel. Read per query, not
|
|
470
477
|
// captured at mount: staging finishes in the background
|
|
@@ -528,6 +535,7 @@ export async function openPikeletChain(members, options = {}) {
|
|
|
528
535
|
// to prevent.
|
|
529
536
|
let matchQuality = 'unscored';
|
|
530
537
|
let confidence;
|
|
538
|
+
let grounding = null;
|
|
531
539
|
if (calibration.status === 'inherited' && base.scoreQuality) {
|
|
532
540
|
// Coverage reads the fit's passagesNeeded from the FULL
|
|
533
541
|
// fused window; top.slice(0, 3) gave it one passage at k=1.
|
|
@@ -553,6 +561,13 @@ export async function openPikeletChain(members, options = {}) {
|
|
|
553
561
|
if (scored && scored.match_quality) {
|
|
554
562
|
matchQuality = scored.match_quality;
|
|
555
563
|
if (Number.isFinite(scored.confidence)) confidence = scored.confidence;
|
|
564
|
+
// Which passage grounded the verdict and which query
|
|
565
|
+
// words no passage contains, as the single-file
|
|
566
|
+
// reader returns it; recordId is the chain-global id
|
|
567
|
+
// of the passage coverage scored.
|
|
568
|
+
if (scored.grounding) {
|
|
569
|
+
grounding = { ...scored.grounding, recordId: passageOrder[scored.grounding.passageIndex]?.id ?? null };
|
|
570
|
+
}
|
|
556
571
|
}
|
|
557
572
|
} else if (calibration.status === 'none') {
|
|
558
573
|
matchQuality = 'unscored';
|
|
@@ -567,6 +582,7 @@ export async function openPikeletChain(members, options = {}) {
|
|
|
567
582
|
matchQuality,
|
|
568
583
|
...(confidence === undefined ? {} : { confidence }),
|
|
569
584
|
results: withheld ? [] : results,
|
|
585
|
+
...(grounding ? { grounding } : {}),
|
|
570
586
|
identity: chainMembers[chainMembers.length - 1].identity,
|
|
571
587
|
calibration: {
|
|
572
588
|
status: calibration.status,
|
package/complete/lexical.mjs
CHANGED
|
@@ -80,15 +80,58 @@ export async function openLexicalIndexLazy(read, segLength) {
|
|
|
80
80
|
const doclenOf = (id) => doclenView.getUint32(4 * id, true);
|
|
81
81
|
|
|
82
82
|
const WINDOW = 512;
|
|
83
|
-
|
|
84
|
-
|
|
83
|
+
// Both caches hold the pending read, not its result, so concurrent
|
|
84
|
+
// lookups of one window (two query terms whose hashes land in it) or one
|
|
85
|
+
// postings list share a single read; a failed read is evicted so the
|
|
86
|
+
// next query retries rather than re-throwing a cached rejection.
|
|
87
|
+
const windowCache = new Map(); // `${startIdx}:${count}` -> Promise<entry bytes> (small LRU)
|
|
88
|
+
function readWindow(startIdx, count) {
|
|
85
89
|
const key = `${startIdx}:${count}`;
|
|
86
90
|
const hit = windowCache.get(key);
|
|
87
|
-
if (hit)
|
|
88
|
-
|
|
91
|
+
if (hit) {
|
|
92
|
+
windowCache.delete(key);
|
|
93
|
+
windowCache.set(key, hit);
|
|
94
|
+
return hit;
|
|
95
|
+
}
|
|
96
|
+
const pending = read(termTableOffset + 24 * startIdx, 24 * count);
|
|
89
97
|
if (windowCache.size >= 64) windowCache.delete(windowCache.keys().next().value);
|
|
90
|
-
windowCache.set(key,
|
|
91
|
-
|
|
98
|
+
windowCache.set(key, pending);
|
|
99
|
+
pending.catch(() => { if (windowCache.get(key) === pending) windowCache.delete(key); });
|
|
100
|
+
return pending;
|
|
101
|
+
}
|
|
102
|
+
// Postings, LRU by bytes: a repeated or overlapping query re-reads only
|
|
103
|
+
// the terms it has not seen. A list larger than POSTINGS_CACHE_MAX_ENTRY
|
|
104
|
+
// (a very common term) is read every time rather than evicting
|
|
105
|
+
// everything else.
|
|
106
|
+
const POSTINGS_CACHE_BYTES = 4 * 1024 * 1024;
|
|
107
|
+
const POSTINGS_CACHE_MAX_ENTRY = 1024 * 1024;
|
|
108
|
+
const postingsCache = new Map(); // postingsRel -> { pending, bytes }
|
|
109
|
+
let postingsCachedBytes = 0;
|
|
110
|
+
function readPostings(rel, len) {
|
|
111
|
+
const hit = postingsCache.get(rel);
|
|
112
|
+
if (hit && hit.bytes === len) {
|
|
113
|
+
postingsCache.delete(rel);
|
|
114
|
+
postingsCache.set(rel, hit);
|
|
115
|
+
return hit.pending;
|
|
116
|
+
}
|
|
117
|
+
const pending = read(postingsOffset + rel, len);
|
|
118
|
+
if (len > POSTINGS_CACHE_MAX_ENTRY) return pending;
|
|
119
|
+
const entry = { pending, bytes: len };
|
|
120
|
+
if (hit) postingsCachedBytes -= hit.bytes;
|
|
121
|
+
postingsCache.set(rel, entry);
|
|
122
|
+
postingsCachedBytes += len;
|
|
123
|
+
while (postingsCachedBytes > POSTINGS_CACHE_BYTES && postingsCache.size > 1) {
|
|
124
|
+
const [oldRel, old] = postingsCache.entries().next().value;
|
|
125
|
+
postingsCache.delete(oldRel);
|
|
126
|
+
postingsCachedBytes -= old.bytes;
|
|
127
|
+
}
|
|
128
|
+
pending.catch(() => {
|
|
129
|
+
if (postingsCache.get(rel) === entry) {
|
|
130
|
+
postingsCache.delete(rel);
|
|
131
|
+
postingsCachedBytes -= len;
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
return pending;
|
|
92
135
|
}
|
|
93
136
|
const entryAt = (bytes, i) => {
|
|
94
137
|
const view = new DataView(bytes.buffer, bytes.byteOffset + 24 * i, 24);
|
|
@@ -195,7 +238,7 @@ export async function openLexicalIndexLazy(read, segLength) {
|
|
|
195
238
|
throw new Error('.pikelet lexical postings out of bounds');
|
|
196
239
|
}
|
|
197
240
|
}
|
|
198
|
-
const postings = await Promise.all(found.map((e) =>
|
|
241
|
+
const postings = await Promise.all(found.map((e) => readPostings(e.postingsRel, e.postingsLen)));
|
|
199
242
|
const scores = new Map();
|
|
200
243
|
found.forEach((e, i) => {
|
|
201
244
|
const bytes = postings[i];
|
|
@@ -4,6 +4,12 @@
|
|
|
4
4
|
// bloom filter, standardized and passed through the fitted logistic model.
|
|
5
5
|
// Verdict semantics: 'answer' (strong match), 'weak' (closest match is
|
|
6
6
|
// distant — shown with a caveat), 'abstain' (nothing useful in the pack).
|
|
7
|
+
// What "match" means is the asset's: pikelet's self-templates-v6 assets
|
|
8
|
+
// (calibration.target 'relevance') are fit against off-topic queries, so
|
|
9
|
+
// 'abstain' means the pack does not cover the query's topic and 'answer'
|
|
10
|
+
// means it does — not that a passage states the asked-for fact. Earlier
|
|
11
|
+
// assets were fit against in-domain-unanswerable queries instead. The
|
|
12
|
+
// scoring math here is the same for both.
|
|
7
13
|
//
|
|
8
14
|
// Assets calibrated by pikelet's self-templates-v2+ may carry an additional
|
|
9
15
|
// grounding term (asset.coverage): the fraction of the query's content
|
package/complete/sources.mjs
CHANGED
|
@@ -102,7 +102,12 @@ export function httpRangeSource(url, options = {}) {
|
|
|
102
102
|
url,
|
|
103
103
|
size: undefined,
|
|
104
104
|
preferredParallelism: options.preferredParallelism ?? 32,
|
|
105
|
-
|
|
105
|
+
// Rows closer than this are fetched in one request, reading the
|
|
106
|
+
// bytes between them. Over HTTP a round trip costs far more than
|
|
107
|
+
// the bytes: on the 648.5 MB wiki pack in Chromium, 256 KiB took
|
|
108
|
+
// 61 -> 38 requests and about 2.8 -> 2.2 s per query against 16 KiB,
|
|
109
|
+
// at 0.9 -> 2.3 MiB transferred.
|
|
110
|
+
preferredGapBytes: options.preferredGapBytes ?? 256 * 1024,
|
|
106
111
|
|
|
107
112
|
async init() {
|
|
108
113
|
await resolveTarget();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pikelet-wasm",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.1",
|
|
4
4
|
"description": "Vector search engine and .pikelet artifact readers/builders, in WebAssembly. Compile a corpus into one self-contained, hash-verified search file, or use the underlying HNSW engine directly. Runs in Node.js, browsers, and Cloudflare Workers with no native dependencies.",
|
|
5
5
|
"main": "src/pikelet.js",
|
|
6
6
|
"types": "src/pikelet.d.ts",
|