pikelet-wasm 0.10.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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;
@@ -64,8 +69,33 @@ export declare function httpRangeSource(url: string, options?: {
64
69
  * the child's origin and directory (LAYERED_PROFILE.md 5.1.1).
65
70
  */
66
71
  redirectGuard?: (target: string) => void;
72
+ /**
73
+ * The fetch used for every request. Default nodeFetch under Node
74
+ * (the package's node export condition), else globalThis.fetch. A runtime
75
+ * whose fetch transparently decodes response bodies can pass one that does
76
+ * not.
77
+ */
78
+ fetch?: (input: string, init?: {
79
+ method?: string;
80
+ headers?: Record<string, string>;
81
+ redirect?: 'follow' | 'manual' | 'error';
82
+ }) => Promise<any>;
67
83
  }): HttpRangeSource;
68
84
 
85
+ /**
86
+ * Node only (the 'node' export condition): a fetch over node:http/node:https
87
+ * that never decompresses, sends Accept-Encoding: identity and refuses any
88
+ * other Content-Encoding. httpRangeSource defaults to it under Node.
89
+ */
90
+ export declare function nodeFetch(input: string, init?: {
91
+ method?: 'GET' | 'HEAD';
92
+ headers?: Record<string, string>;
93
+ redirect?: 'follow' | 'manual' | 'error';
94
+ signal?: unknown;
95
+ /** Inflate a gzip/deflate body a server sent despite identity (crawled HTML); Brotli/zstd still refused. */
96
+ inflateGzip?: boolean;
97
+ }): Promise<any>;
98
+
69
99
  export interface CompleteQueryResult {
70
100
  /**
71
101
  * The pack's calibrated verdict on this retrieval. On packs calibrated
@@ -84,6 +114,22 @@ export interface CompleteQueryResult {
84
114
  * other way round.
85
115
  */
86
116
  results: Array<{ id: number; distance: number } & Record<string, unknown>>;
117
+ /**
118
+ * Which passage grounded the verdict and which of the query's content
119
+ * words it contains. Present when a fitted calibrator scored the query
120
+ * (absent on 'unscored'). An asked-for value in `uncovered` is one no top
121
+ * passage states, whatever matchQuality says.
122
+ */
123
+ grounding?: {
124
+ /** Id of the passage the coverage score was taken from. */
125
+ recordId: number | null;
126
+ /** Index of that passage in the fused (or distance-ordered) window. */
127
+ passageIndex: number;
128
+ /** Weighted share of the query's content words the passage contains, 0-1. */
129
+ coverage: number;
130
+ covered: string[];
131
+ uncovered: string[];
132
+ };
87
133
  }
88
134
 
89
135
  export interface CompleteSearch {
@@ -105,6 +151,10 @@ export interface CompleteSearch {
105
151
  */
106
152
  license: string | null;
107
153
  records: number;
154
+ /** Rows in the index: equal to records unless a row map groups several rows per record. */
155
+ indexRows: number;
156
+ /** The policy behind a multi-row index (COMPLETE_PROFILE.md 3.9), e.g. sentence windows; null otherwise. */
157
+ rowMap: { kind: string; sentences: number; rows: number } | null;
108
158
  dim: number;
109
159
  metric: string | number;
110
160
  encoder: Record<string, unknown>;
@@ -156,6 +206,13 @@ export interface CompleteSearch {
156
206
  */
157
207
  residentScan: 'engine' | 'js';
158
208
  sampleQueries: string[];
209
+ /**
210
+ * The file's parts in file order: 'header' (fixed header, manifest and
211
+ * segment table) followed by each segment of a known kind ('index',
212
+ * 'corpus', 'lexical', 'query-interp', 'evaluation', ...). Absolute
213
+ * byte offsets.
214
+ */
215
+ layout: Array<{ kind: string; offset: number; length: number }>;
159
216
  };
160
217
  /**
161
218
  * Verify the index's lazy vector rows against the identity-anchored
@@ -191,6 +248,8 @@ export interface CompleteSearch {
191
248
  }): Promise<CompleteQueryResult>;
192
249
  /** Hydrate one corpus record by id (verified per record on format 2). */
193
250
  record(id: number): Promise<Record<string, unknown>>;
251
+ /** The absolute byte range one corpus record's stored bytes occupy in the file. */
252
+ recordRange(id: number): { offset: number; length: number };
194
253
  /** The evaluation segment's JSON object, or null when the artifact carries none. */
195
254
  evaluation(): Promise<Record<string, unknown> | null>;
196
255
  close(): Promise<void>;
@@ -25,6 +25,7 @@ import { loadStudentModel, embedTextWithStudent } from './student-embedder.mjs';
25
25
  import { computeMatchQuality, computePreSearchAbstention } from './student-abstention.mjs';
26
26
  import { createAbstentionScorer } from './retrieval-abstention.mjs';
27
27
  import { openLexicalIndex, openLexicalIndexLazy } from './lexical.mjs';
28
+ import { parseRowMapSegment } from './rowmap.mjs';
28
29
  import { PikeletSketchArtifact } from '../src/artifact/pikelet-artifact.js';
29
30
  import { MAGIC, HEADER_BYTES, TABLE_ENTRY_BYTES, KINDS, KIND_NAMES } from './format.mjs';
30
31
 
@@ -769,7 +770,7 @@ export async function openPikeletFile(input, options = {}) {
769
770
  // influences a returned result. Small segments (kinds 1/2) keep the
770
771
  // one-read eager path unchanged.
771
772
  const qiDeferrable = qi.length > LAZY_QI_BYTES;
772
- const [qiFirst, sketch, tables, lexicalBytes] = await Promise.all([
773
+ const [qiFirst, sketch, tables, lexicalBytes, rowMapBytes] = await Promise.all([
773
774
  (async () => {
774
775
  if (qiDeferrable) {
775
776
  return readChecked(source, qi.offset, Math.min(16, qi.length), 'query-interp header', maxReadBytes, fileBytes);
@@ -813,6 +814,17 @@ export async function openPikeletFile(input, options = {}) {
813
814
  }
814
815
  return { bytes };
815
816
  })() : null,
817
+ // Row map (kind 8, OPTIONAL): a few bytes per record, read
818
+ // eagerly and digest-verified. Present when the index holds
819
+ // several vectors per record (sentence windows).
820
+ segments.has('rowmap') ? (async () => {
821
+ const seg = segments.get('rowmap');
822
+ const bytes = await readChecked(source, seg.offset, seg.length, 'row map segment', maxReadBytes, fileBytes);
823
+ if (await sha256hex(bytes) !== seg.sha256) {
824
+ throw new Error('.pikelet row map segment failed hash verification');
825
+ }
826
+ return bytes;
827
+ })() : null,
816
828
  ]);
817
829
 
818
830
  const lexicalIndex = lexicalBytes
@@ -1057,7 +1069,18 @@ export async function openPikeletFile(input, options = {}) {
1057
1069
 
1058
1070
  // Index: the embedded sketch artifact, opened in the wave above
1059
1071
  // against a segment-windowed source (resident hash verified there).
1060
- if (sketch.count !== declaredRecords) {
1072
+ // The index binds to the corpus positionally (3.4) unless a row map
1073
+ // (3.9) says how its rows group into records; the manifest names the
1074
+ // policy that produced the rows, so a row map can neither be dropped
1075
+ // nor added without changing the identity.
1076
+ const rowMapPolicy = manifest.index?.rowMap ?? null;
1077
+ if (!!rowMapPolicy !== !!rowMapBytes) {
1078
+ throw new Error(rowMapPolicy
1079
+ ? '.pikelet manifest declares index.rowMap but carries no row map segment'
1080
+ : '.pikelet carries a row map segment its manifest does not declare (index.rowMap)');
1081
+ }
1082
+ const rowMap = rowMapBytes ? parseRowMapSegment(rowMapBytes, declaredRecords, sketch.count) : null;
1083
+ if (!rowMap && sketch.count !== declaredRecords) {
1061
1084
  throw new Error(`.pikelet index count ${sketch.count} != corpus records ${declaredRecords}`);
1062
1085
  }
1063
1086
  if (sketch.dim !== undefined && sketch.dim !== dim) {
@@ -1243,6 +1266,180 @@ export async function openPikeletFile(input, options = {}) {
1243
1266
  if (closed) throw new Error('.pikelet reader is closed');
1244
1267
  };
1245
1268
 
1269
+ // The rerank's default coalescing gap. The identity-verified manifest
1270
+ // may carry a fetch hint: cluster-ordered layouts only coalesce at a
1271
+ // gap at least matched to their cluster geometry (the wiki pack's
1272
+ // k-means layout needs ~16 KiB; a 2 KiB gap squanders it — measured
1273
+ // 440 vs 200 requests/query at C=600). The hint is a floor: a wider
1274
+ // gap merges a superset of the same runs, so a source that prefers
1275
+ // more (HTTP's 256 KiB) keeps its own. undefined leaves the sketch
1276
+ // reader on the source's preference. A chain reads it per tier
1277
+ // through __chainTier.
1278
+ const rerankGapHint = Number.isFinite(manifest.recommendedGap)
1279
+ ? Math.max(manifest.recommendedGap, Number(source.preferredGapBytes) || 0)
1280
+ : undefined;
1281
+
1282
+ // The one retrieval path behind query(): resident sketch scan plus
1283
+ // BM25 candidates, exact rerank, rank fusion and phrase pinning.
1284
+ // Compile-time calibration fits on exactly this (through
1285
+ // __calibrationEvidence below), so the evidence a verdict was fit on
1286
+ // is the evidence query() scores. excludeIds removes records from
1287
+ // both candidate sources before either is cut to size — calibration
1288
+ // ablations use it; query() never does.
1289
+ const retrieveCandidates = async (trimmed, context, k, queryOptions, excludeIds = null) => {
1290
+ let fusedFull = null;
1291
+ // Hybrid retrieval when the artifact carries a lexical
1292
+ // segment: the BM25 top matches join the sketch's exact
1293
+ // rerank as extra candidates (scored by true vector
1294
+ // distance like any candidate — this is what recovers
1295
+ // known-item lookups the sketch scan's top-C missed),
1296
+ // and the final ordering fuses the distance ranking
1297
+ // with the BM25 ranking by reciprocal rank (RRF, k=60,
1298
+ // untuned). Abstention still scores the distance-sorted
1299
+ // top-k exactly as calibrated; fusion affects result
1300
+ // order only. fullRerankOutput returns every reranked
1301
+ // candidate (all already fetched and exactly scored) so
1302
+ // fusion sees the true distance of every lexical match.
1303
+ // Common query terms give every matching document a
1304
+ // small, near-tied BM25 score (idf collapses them);
1305
+ // that flat mass carries no ranking information and
1306
+ // dilutes rank fusion. Keep only hits within a factor
1307
+ // of the best lexical score — for a known-item lookup
1308
+ // that is typically the one document holding the rare
1309
+ // terms.
1310
+ // retrieval: 'hybrid' (default when the artifact
1311
+ // carries a lexical segment) | 'vector' | 'lexical' —
1312
+ // the last two exist for measurement and debugging
1313
+ // (retrieval-quality bakeoffs run all three on one
1314
+ // artifact). Abstention scores the distance-sorted
1315
+ // top-k identically in every mode.
1316
+ // 'augmented' unions the lexical candidates into the
1317
+ // exact rerank but keeps pure distance order — the mode
1318
+ // for workloads scored against exact nearest neighbors,
1319
+ // where RRF's reordering reads as loss by definition.
1320
+ const retrieval = queryOptions.retrieval ?? 'hybrid';
1321
+ if (!['hybrid', 'vector', 'lexical', 'augmented'].includes(retrieval)) {
1322
+ throw new Error(`query() retrieval must be hybrid, vector, lexical, or augmented, got ${retrieval}`);
1323
+ }
1324
+ // An excluded record must not take a candidate slot on either
1325
+ // side, so both sources skip it before cutting to size — BM25
1326
+ // before its top-n sort (scored with the full corpus's term
1327
+ // statistics, the one way this differs from a corpus without
1328
+ // those records) and the sketch before its top-C cut.
1329
+ const excluded = excludeIds && excludeIds.size ? excludeIds : null;
1330
+ let excludeBits = null;
1331
+ if (excluded) {
1332
+ excludeBits = new Uint8Array(Math.ceil(sketch.count / 8));
1333
+ for (const id of excluded) {
1334
+ // With a row map, excluding a record masks every row it owns.
1335
+ const [from, to] = rowMap ? [rowMap.offsets[id], rowMap.offsets[id + 1]] : [id, id + 1];
1336
+ for (let row = from; row < to; row++) excludeBits[row >> 3] |= 1 << (row & 7);
1337
+ }
1338
+ }
1339
+ const lexicalRaw = lexicalIndex && retrieval !== 'vector'
1340
+ ? await lexicalIndex.search(trimmed, LEXICAL_CANDIDATES, excluded ? { exclude: (id) => excluded.has(id) } : {})
1341
+ : [];
1342
+ const lexicalHits = lexicalRaw.filter((h) => h.score >= lexicalRaw[0].score / LEXICAL_CUTOFF);
1343
+ // With a row map (3.9) the sketch searches rows: a record's lexical
1344
+ // match joins the rerank with every row it owns, k is scaled to the
1345
+ // mean rows per record, and the reranked rows collapse to records
1346
+ // below, each scored by its best row.
1347
+ const extraRows = rowMap
1348
+ ? lexicalHits.flatMap((h) => Array.from({ length: rowMap.offsets[h.id + 1] - rowMap.offsets[h.id] }, (_, i) => rowMap.offsets[h.id] + i))
1349
+ : lexicalHits.map((h) => h.id);
1350
+ const searchK = rowMap ? Math.min(sketch.count, Math.ceil(k * sketch.count / recordCount)) : k;
1351
+ const rerankedRows = (await sketch.search(context.vector, searchK, {
1352
+ rerank: queryOptions.rerank,
1353
+ parallelism: queryOptions.parallelism ?? queryOptions.rerankParallelism ?? options.rerankParallelism,
1354
+ gap: queryOptions.gap ?? queryOptions.rerankGap ?? options.rerankGap ?? rerankGapHint,
1355
+ maxRangeBytes: queryOptions.maxRangeBytes ?? options.rerankMaxRangeBytes,
1356
+ // Present once background staging finished; the
1357
+ // sketch reader ignores it for any query whose C
1358
+ // exceeds the scanner's buffers.
1359
+ ...(scanner ? { scanner } : {}),
1360
+ ...(extraRows.length ? { extraCandidates: extraRows } : {}),
1361
+ ...(excludeBits ? { exclude: excludeBits } : {}),
1362
+ // Always return the full reranked candidate list
1363
+ // (already fetched and exactly scored) rather than
1364
+ // truncating to k: abstention is fit at a fixed
1365
+ // top-10 window (calibrate.mjs) independent of the
1366
+ // caller's k, and needs that full window to score
1367
+ // consistently — see retrieval-abstention.mjs.
1368
+ fullRerankOutput: true,
1369
+ })).results;
1370
+ // The rerank output is distance-ordered, so a record's first row
1371
+ // is its best: keeping first occurrences gives the records in
1372
+ // order of their best row's distance.
1373
+ let searched = rerankedRows;
1374
+ if (rowMap) {
1375
+ const seen = new Set();
1376
+ searched = [];
1377
+ for (const row of rerankedRows) {
1378
+ const id = rowMap.owner[row.id];
1379
+ if (!seen.has(id)) { seen.add(id); searched.push({ id, distance: row.distance }); }
1380
+ }
1381
+ }
1382
+ const hits = searched.slice(0, k);
1383
+ // Full-window fused order (not k-truncated) for scoring:
1384
+ // coverage grounds its verdict in the passage fusion
1385
+ // actually ranks first, and calibrate.mjs fits it the
1386
+ // same way — a k-truncated fused list would silently
1387
+ // drop the fused top hit for callers requesting a small k.
1388
+ if (retrieval === 'lexical') {
1389
+ const byId = new Map(searched.map((hit) => [hit.id, hit]));
1390
+ fusedFull = lexicalHits.map((h) => byId.get(h.id)).filter(Boolean);
1391
+ } else if (retrieval !== 'augmented' && lexicalHits.length) {
1392
+ const lexRank = new Map(lexicalHits.map((h, i) => [h.id, i]));
1393
+ fusedFull = fuseCandidates(searched, lexicalHits.map((h) => h.id));
1394
+ // One batched hydration for everything this block and
1395
+ // scoreQuality's coverage term (below) will need, in
1396
+ // place of two separate staged fetches (phrase-pin's
1397
+ // own, then a later cold start for coverage): over a
1398
+ // remote range-read source each hydrate() a caller
1399
+ // hasn't already paid for is its own network round
1400
+ // trip, and sequential/staged awaits pay that cost
1401
+ // once per stage instead of once per query. The
1402
+ // union of phrase-pin's candidates (top lexical
1403
+ // hits) and coverage's (fusedFull's top few — phrase
1404
+ // pinning only ever reorders that window, never
1405
+ // changes its membership) is known right here, so
1406
+ // both stages can share one round trip. A record
1407
+ // that fails to hydrate here does not fail the
1408
+ // query: the id is just missing from the map, and
1409
+ // each site below falls through exactly as it would
1410
+ // on a cold cache miss (recs[i] undefined; coverage
1411
+ // sees fewer usable passages) — hydrate() raising
1412
+ // for a bad id is still a real failure, but only
1413
+ // once a site actually needs that specific record,
1414
+ // not speculatively for every candidate touched here.
1415
+ const phraseCandidates = lexicalHits.slice(0, 3);
1416
+ const coverageCandidates = fusedFull.slice(0, COVERAGE_BATCH_COUNT);
1417
+ const batchIds = [...new Set([...phraseCandidates, ...coverageCandidates].map((h) => h.id))];
1418
+ const batch = new Map();
1419
+ await Promise.all(batchIds.map(async (id) => {
1420
+ try { batch.set(id, await hydrate(id)); } catch { /* see comment above */ }
1421
+ }));
1422
+ // Phrase pinning: a verbatim occurrence of the query (>= 3
1423
+ // content words) in one of the top lexical hits is the
1424
+ // strongest evidence of support there is; rank it first.
1425
+ const norm = (t) => String(t || '').toLowerCase().replace(/[^a-z0-9' ]+/g, ' ').replace(/\s+/g, ' ').trim();
1426
+ const phrase = norm(trimmed);
1427
+ if (phrase.split(' ').length >= 3) {
1428
+ for (const h of phraseCandidates) {
1429
+ if (norm(batch.get(h.id)?.text).includes(phrase)) {
1430
+ fusedFull = [searched.find((x) => x.id === h.id), ...fusedFull.filter((x) => x.id !== h.id)].filter(Boolean);
1431
+ break;
1432
+ }
1433
+ }
1434
+ }
1435
+ // Rank transparency: a consumer can see a verbatim hit sitting
1436
+ // at fused rank 3 behind two semantic near-misses.
1437
+ for (const [i, hit] of fusedFull.entries()) { hit.fusedRank = i + 1; hit.lexicalRank = lexRank.has(hit.id) ? lexRank.get(hit.id) + 1 : null; hit.vectorRank = searched.findIndex((x) => x.id === hit.id) + 1; }
1438
+ }
1439
+ const fused = retrieval === 'augmented' ? null : fusedFull?.slice(0, k) ?? null;
1440
+ return { hits, fused, searched, fusedFull };
1441
+ };
1442
+
1246
1443
  return {
1247
1444
  info() {
1248
1445
  return {
@@ -1270,6 +1467,11 @@ export async function openPikeletFile(input, options = {}) {
1270
1467
  license: typeof manifest.corpus?.provenance?.license === 'string'
1271
1468
  ? manifest.corpus.provenance.license : null,
1272
1469
  records: recordCount,
1470
+ // Index rows and the policy that produced them when the
1471
+ // index holds several vectors per record (row map, 3.9);
1472
+ // otherwise one row per record and rowMap is null.
1473
+ indexRows: sketch.count,
1474
+ rowMap: rowMapPolicy ? { ...rowMapPolicy } : null,
1273
1475
  dim,
1274
1476
  metric: manifest.metric,
1275
1477
  encoder: encoderInfo,
@@ -1303,6 +1505,16 @@ export async function openPikeletFile(input, options = {}) {
1303
1505
  }
1304
1506
  : null,
1305
1507
  sampleQueries: Array.isArray(manifest.sampleQueries) ? manifest.sampleQueries : [],
1508
+ // Where each part of the file lives, in file order: the
1509
+ // header (fixed header, manifest and segment table, up to
1510
+ // the first segment's aligned offset) and every segment of
1511
+ // a known kind. Lets a viewer attribute range reads to
1512
+ // what they fetched.
1513
+ layout: [
1514
+ { kind: 'header', offset: 0, length: align16(HEADER_BYTES + manifestBytes + tableBytes) },
1515
+ ...[...segments].map(([kind, seg]) => ({ kind, offset: seg.offset, length: seg.length }))
1516
+ .sort((a, b) => a.offset - b.offset),
1517
+ ],
1306
1518
  };
1307
1519
  },
1308
1520
 
@@ -1328,127 +1540,7 @@ export async function openPikeletFile(input, options = {}) {
1328
1540
  let fused = null;
1329
1541
  let searched = null;
1330
1542
  let fusedFull = null;
1331
- if (!pre) {
1332
- // Hybrid retrieval when the artifact carries a lexical
1333
- // segment: the BM25 top matches join the sketch's exact
1334
- // rerank as extra candidates (scored by true vector
1335
- // distance like any candidate — this is what recovers
1336
- // known-item lookups the sketch scan's top-C missed),
1337
- // and the final ordering fuses the distance ranking
1338
- // with the BM25 ranking by reciprocal rank (RRF, k=60,
1339
- // untuned). Abstention still scores the distance-sorted
1340
- // top-k exactly as calibrated; fusion affects result
1341
- // order only. fullRerankOutput returns every reranked
1342
- // candidate (all already fetched and exactly scored) so
1343
- // fusion sees the true distance of every lexical match.
1344
- // Common query terms give every matching document a
1345
- // small, near-tied BM25 score (idf collapses them);
1346
- // that flat mass carries no ranking information and
1347
- // dilutes rank fusion. Keep only hits within a factor
1348
- // of the best lexical score — for a known-item lookup
1349
- // that is typically the one document holding the rare
1350
- // terms.
1351
- // retrieval: 'hybrid' (default when the artifact
1352
- // carries a lexical segment) | 'vector' | 'lexical' —
1353
- // the last two exist for measurement and debugging
1354
- // (retrieval-quality bakeoffs run all three on one
1355
- // artifact). Abstention scores the distance-sorted
1356
- // top-k identically in every mode.
1357
- // 'augmented' unions the lexical candidates into the
1358
- // exact rerank but keeps pure distance order — the mode
1359
- // for workloads scored against exact nearest neighbors,
1360
- // where RRF's reordering reads as loss by definition.
1361
- const retrieval = queryOptions.retrieval ?? 'hybrid';
1362
- if (!['hybrid', 'vector', 'lexical', 'augmented'].includes(retrieval)) {
1363
- throw new Error(`query() retrieval must be hybrid, vector, lexical, or augmented, got ${retrieval}`);
1364
- }
1365
- const lexicalRaw = lexicalIndex && retrieval !== 'vector'
1366
- ? await lexicalIndex.search(trimmed, LEXICAL_CANDIDATES) : [];
1367
- const lexicalHits = lexicalRaw.filter((h) => h.score >= lexicalRaw[0].score / LEXICAL_CUTOFF);
1368
- searched = (await sketch.search(context.vector, k, {
1369
- rerank: queryOptions.rerank,
1370
- parallelism: queryOptions.parallelism ?? queryOptions.rerankParallelism ?? options.rerankParallelism,
1371
- // The identity-verified manifest may carry a fetch
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),
1379
- maxRangeBytes: queryOptions.maxRangeBytes ?? options.rerankMaxRangeBytes,
1380
- // Present once background staging finished; the
1381
- // sketch reader ignores it for any query whose C
1382
- // exceeds the scanner's buffers.
1383
- ...(scanner ? { scanner } : {}),
1384
- ...(lexicalHits.length ? { extraCandidates: lexicalHits.map((h) => h.id) } : {}),
1385
- // Always return the full reranked candidate list
1386
- // (already fetched and exactly scored) rather than
1387
- // truncating to k: abstention is fit at a fixed
1388
- // top-10 window (calibrate.mjs) independent of the
1389
- // caller's k, and needs that full window to score
1390
- // consistently — see retrieval-abstention.mjs.
1391
- fullRerankOutput: true,
1392
- })).results;
1393
- hits = searched.slice(0, k);
1394
- // Full-window fused order (not k-truncated) for scoring:
1395
- // coverage grounds its verdict in the passage fusion
1396
- // actually ranks first, and calibrate.mjs fits it the
1397
- // same way — a k-truncated fused list would silently
1398
- // drop the fused top hit for callers requesting a small k.
1399
- if (retrieval === 'lexical') {
1400
- const byId = new Map(searched.map((hit) => [hit.id, hit]));
1401
- fusedFull = lexicalHits.map((h) => byId.get(h.id)).filter(Boolean);
1402
- } else if (retrieval !== 'augmented' && lexicalHits.length) {
1403
- const lexRank = new Map(lexicalHits.map((h, i) => [h.id, i]));
1404
- fusedFull = fuseCandidates(searched, lexicalHits.map((h) => h.id));
1405
- // One batched hydration for everything this block and
1406
- // scoreQuality's coverage term (below) will need, in
1407
- // place of two separate staged fetches (phrase-pin's
1408
- // own, then a later cold start for coverage): over a
1409
- // remote range-read source each hydrate() a caller
1410
- // hasn't already paid for is its own network round
1411
- // trip, and sequential/staged awaits pay that cost
1412
- // once per stage instead of once per query. The
1413
- // union of phrase-pin's candidates (top lexical
1414
- // hits) and coverage's (fusedFull's top few — phrase
1415
- // pinning only ever reorders that window, never
1416
- // changes its membership) is known right here, so
1417
- // both stages can share one round trip. A record
1418
- // that fails to hydrate here does not fail the
1419
- // query: the id is just missing from the map, and
1420
- // each site below falls through exactly as it would
1421
- // on a cold cache miss (recs[i] undefined; coverage
1422
- // sees fewer usable passages) — hydrate() raising
1423
- // for a bad id is still a real failure, but only
1424
- // once a site actually needs that specific record,
1425
- // not speculatively for every candidate touched here.
1426
- const phraseCandidates = lexicalHits.slice(0, 3);
1427
- const coverageCandidates = fusedFull.slice(0, COVERAGE_BATCH_COUNT);
1428
- const batchIds = [...new Set([...phraseCandidates, ...coverageCandidates].map((h) => h.id))];
1429
- const batch = new Map();
1430
- await Promise.all(batchIds.map(async (id) => {
1431
- try { batch.set(id, await hydrate(id)); } catch { /* see comment above */ }
1432
- }));
1433
- // Phrase pinning: a verbatim occurrence of the query (>= 3
1434
- // content words) in one of the top lexical hits is the
1435
- // strongest evidence of support there is; rank it first.
1436
- const norm = (t) => String(t || '').toLowerCase().replace(/[^a-z0-9' ]+/g, ' ').replace(/\s+/g, ' ').trim();
1437
- const phrase = norm(trimmed);
1438
- if (phrase.split(' ').length >= 3) {
1439
- for (const h of phraseCandidates) {
1440
- if (norm(batch.get(h.id)?.text).includes(phrase)) {
1441
- fusedFull = [searched.find((x) => x.id === h.id), ...fusedFull.filter((x) => x.id !== h.id)].filter(Boolean);
1442
- break;
1443
- }
1444
- }
1445
- }
1446
- // Rank transparency: a consumer can see a verbatim hit sitting
1447
- // at fused rank 3 behind two semantic near-misses.
1448
- for (const [i, hit] of fusedFull.entries()) { hit.fusedRank = i + 1; hit.lexicalRank = lexRank.has(hit.id) ? lexRank.get(hit.id) + 1 : null; hit.vectorRank = searched.findIndex((x) => x.id === hit.id) + 1; }
1449
- }
1450
- fused = retrieval === 'augmented' ? null : fusedFull?.slice(0, k) ?? null;
1451
- }
1543
+ if (!pre) ({ hits, fused, searched, fusedFull } = await retrieveCandidates(trimmed, context, k, queryOptions));
1452
1544
  // Abstention scores the full reranked candidate window
1453
1545
  // (searched), not the k-truncated hits: calibrate.mjs fits
1454
1546
  // its signals at a fixed top-10 (top-5 for coverage)
@@ -1484,12 +1576,42 @@ export async function openPikeletFile(input, options = {}) {
1484
1576
  return { matchQuality: quality.match_quality, confidence: quality.confidence, results, ...(grounding ? { grounding } : {}) };
1485
1577
  },
1486
1578
 
1579
+ /**
1580
+ * The retrieval evidence query() scores, for compile-time
1581
+ * calibration: `vector` is the distance-ordered rerank window
1582
+ * d0/margin/mean10 read, `fused` the order coverage reads its
1583
+ * passages from. Calibration consumes this instead of
1584
+ * reimplementing retrieval, so a verdict is fit on the evidence
1585
+ * it is later scored on. Not part of the documented reader API.
1586
+ */
1587
+ async __calibrationEvidence(text, { excludeIds = null } = {}) {
1588
+ assertOpen();
1589
+ const trimmed = String(text || '').trim();
1590
+ if (!trimmed) throw new Error('query text is required');
1591
+ // The 10-wide window calibration fits at; any k up to the
1592
+ // pack's rerank depth gives query() the same candidates.
1593
+ const k = Math.min(10, Math.max(recordCount, 1));
1594
+ const { searched, fusedFull } = await retrieveCandidates(trimmed, await embed(trimmed), k, {}, excludeIds);
1595
+ const plain = (list) => list.map((hit) => ({ id: hit.id, distance: hit.distance }));
1596
+ return { vector: plain(searched), fused: plain(fusedFull?.length ? fusedFull : searched) };
1597
+ },
1598
+
1487
1599
  /** Hydrate one corpus record by id (verified per record on format 2). */
1488
1600
  async record(id) {
1489
1601
  assertOpen();
1490
1602
  return hydrate(id);
1491
1603
  },
1492
1604
 
1605
+ /** The absolute byte range one corpus record's stored bytes occupy in the file. */
1606
+ recordRange(id) {
1607
+ assertOpen();
1608
+ if (!Number.isSafeInteger(id) || id < 0 || id >= recordCount) {
1609
+ throw new Error(`.pikelet record id ${id} is outside the corpus`);
1610
+ }
1611
+ const start = recordOffsets[id];
1612
+ return { offset: corpus.offset + start, length: recordOffsets[id + 1] - start };
1613
+ },
1614
+
1493
1615
  /**
1494
1616
  * Full pass over the index's lazy vector rows against the
1495
1617
  * identity-anchored vectorsSha256 (streamed where possible).
@@ -1566,6 +1688,9 @@ export async function openPikeletFile(input, options = {}) {
1566
1688
  recordCount,
1567
1689
  dim,
1568
1690
  sketch,
1691
+ // A chain maps sketch rows to records positionally, so
1692
+ // it needs to know when a member's rows are not records.
1693
+ rowMap,
1569
1694
  lexicalIndex,
1570
1695
  hydrate,
1571
1696
  readRecordBytes,
@@ -1588,6 +1713,10 @@ export async function openPikeletFile(input, options = {}) {
1588
1713
  // the kernel. A getter, not a captured value, because
1589
1714
  // staging completes after open() returns.
1590
1715
  get scanner() { return scanner; },
1716
+ // The rerank's default coalescing gap for this member
1717
+ // (manifest hint as a floor under the source's own), so
1718
+ // a chain fetches each tier's rows as this member would.
1719
+ rerankGap: rerankGapHint,
1591
1720
  // The base's OWN encoder, for `append` (6.1): "a kind-3
1592
1721
  // base carries the teacher, and append loads it from the
1593
1722
  // base's own query-interp segment — no model download, no
@@ -0,0 +1,15 @@
1
+ // The Node entry of 'pikelet-wasm/complete': the portable reader, except
2
+ // that httpRangeSource defaults to nodeFetch (node:http/node:https, no
3
+ // automatic decompression, Content-Encoding other than identity refused)
4
+ // instead of the runtime's fetch, which decodes whatever a server labels as
5
+ // encoded. Pass options.fetch to choose otherwise.
6
+
7
+ import { httpRangeSource as portableHttpRangeSource } from './sources.mjs';
8
+ import { nodeFetch } from './node-fetch.mjs';
9
+
10
+ export * from './index.mjs';
11
+ export { nodeFetch };
12
+
13
+ export function httpRangeSource(url, options = {}) {
14
+ return portableHttpRangeSource(url, { fetch: nodeFetch, ...options });
15
+ }