pikelet-wasm 0.10.1 → 0.12.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.
@@ -69,8 +69,33 @@ export declare function httpRangeSource(url: string, options?: {
69
69
  * the child's origin and directory (LAYERED_PROFILE.md 5.1.1).
70
70
  */
71
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>;
72
83
  }): HttpRangeSource;
73
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
+
74
99
  export interface CompleteQueryResult {
75
100
  /**
76
101
  * The pack's calibrated verdict on this retrieval. On packs calibrated
@@ -126,6 +151,10 @@ export interface CompleteSearch {
126
151
  */
127
152
  license: string | null;
128
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;
129
158
  dim: number;
130
159
  metric: string | number;
131
160
  encoder: Record<string, unknown>;
@@ -177,6 +206,13 @@ export interface CompleteSearch {
177
206
  */
178
207
  residentScan: 'engine' | 'js';
179
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 }>;
180
216
  };
181
217
  /**
182
218
  * Verify the index's lazy vector rows against the identity-anchored
@@ -212,6 +248,8 @@ export interface CompleteSearch {
212
248
  }): Promise<CompleteQueryResult>;
213
249
  /** Hydrate one corpus record by id (verified per record on format 2). */
214
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 };
215
253
  /** The evaluation segment's JSON object, or null when the artifact carries none. */
216
254
  evaluation(): Promise<Record<string, unknown> | null>;
217
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) {
@@ -1256,6 +1279,167 @@ export async function openPikeletFile(input, options = {}) {
1256
1279
  ? Math.max(manifest.recommendedGap, Number(source.preferredGapBytes) || 0)
1257
1280
  : undefined;
1258
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
+
1259
1443
  return {
1260
1444
  info() {
1261
1445
  return {
@@ -1283,6 +1467,11 @@ export async function openPikeletFile(input, options = {}) {
1283
1467
  license: typeof manifest.corpus?.provenance?.license === 'string'
1284
1468
  ? manifest.corpus.provenance.license : null,
1285
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,
1286
1475
  dim,
1287
1476
  metric: manifest.metric,
1288
1477
  encoder: encoderInfo,
@@ -1316,6 +1505,16 @@ export async function openPikeletFile(input, options = {}) {
1316
1505
  }
1317
1506
  : null,
1318
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
+ ],
1319
1518
  };
1320
1519
  },
1321
1520
 
@@ -1341,120 +1540,7 @@ export async function openPikeletFile(input, options = {}) {
1341
1540
  let fused = null;
1342
1541
  let searched = null;
1343
1542
  let fusedFull = null;
1344
- if (!pre) {
1345
- // Hybrid retrieval when the artifact carries a lexical
1346
- // segment: the BM25 top matches join the sketch's exact
1347
- // rerank as extra candidates (scored by true vector
1348
- // distance like any candidate — this is what recovers
1349
- // known-item lookups the sketch scan's top-C missed),
1350
- // and the final ordering fuses the distance ranking
1351
- // with the BM25 ranking by reciprocal rank (RRF, k=60,
1352
- // untuned). Abstention still scores the distance-sorted
1353
- // top-k exactly as calibrated; fusion affects result
1354
- // order only. fullRerankOutput returns every reranked
1355
- // candidate (all already fetched and exactly scored) so
1356
- // fusion sees the true distance of every lexical match.
1357
- // Common query terms give every matching document a
1358
- // small, near-tied BM25 score (idf collapses them);
1359
- // that flat mass carries no ranking information and
1360
- // dilutes rank fusion. Keep only hits within a factor
1361
- // of the best lexical score — for a known-item lookup
1362
- // that is typically the one document holding the rare
1363
- // terms.
1364
- // retrieval: 'hybrid' (default when the artifact
1365
- // carries a lexical segment) | 'vector' | 'lexical' —
1366
- // the last two exist for measurement and debugging
1367
- // (retrieval-quality bakeoffs run all three on one
1368
- // artifact). Abstention scores the distance-sorted
1369
- // top-k identically in every mode.
1370
- // 'augmented' unions the lexical candidates into the
1371
- // exact rerank but keeps pure distance order — the mode
1372
- // for workloads scored against exact nearest neighbors,
1373
- // where RRF's reordering reads as loss by definition.
1374
- const retrieval = queryOptions.retrieval ?? 'hybrid';
1375
- if (!['hybrid', 'vector', 'lexical', 'augmented'].includes(retrieval)) {
1376
- throw new Error(`query() retrieval must be hybrid, vector, lexical, or augmented, got ${retrieval}`);
1377
- }
1378
- const lexicalRaw = lexicalIndex && retrieval !== 'vector'
1379
- ? await lexicalIndex.search(trimmed, LEXICAL_CANDIDATES) : [];
1380
- const lexicalHits = lexicalRaw.filter((h) => h.score >= lexicalRaw[0].score / LEXICAL_CUTOFF);
1381
- searched = (await sketch.search(context.vector, k, {
1382
- rerank: queryOptions.rerank,
1383
- parallelism: queryOptions.parallelism ?? queryOptions.rerankParallelism ?? options.rerankParallelism,
1384
- gap: queryOptions.gap ?? queryOptions.rerankGap ?? options.rerankGap ?? rerankGapHint,
1385
- maxRangeBytes: queryOptions.maxRangeBytes ?? options.rerankMaxRangeBytes,
1386
- // Present once background staging finished; the
1387
- // sketch reader ignores it for any query whose C
1388
- // exceeds the scanner's buffers.
1389
- ...(scanner ? { scanner } : {}),
1390
- ...(lexicalHits.length ? { extraCandidates: lexicalHits.map((h) => h.id) } : {}),
1391
- // Always return the full reranked candidate list
1392
- // (already fetched and exactly scored) rather than
1393
- // truncating to k: abstention is fit at a fixed
1394
- // top-10 window (calibrate.mjs) independent of the
1395
- // caller's k, and needs that full window to score
1396
- // consistently — see retrieval-abstention.mjs.
1397
- fullRerankOutput: true,
1398
- })).results;
1399
- hits = searched.slice(0, k);
1400
- // Full-window fused order (not k-truncated) for scoring:
1401
- // coverage grounds its verdict in the passage fusion
1402
- // actually ranks first, and calibrate.mjs fits it the
1403
- // same way — a k-truncated fused list would silently
1404
- // drop the fused top hit for callers requesting a small k.
1405
- if (retrieval === 'lexical') {
1406
- const byId = new Map(searched.map((hit) => [hit.id, hit]));
1407
- fusedFull = lexicalHits.map((h) => byId.get(h.id)).filter(Boolean);
1408
- } else if (retrieval !== 'augmented' && lexicalHits.length) {
1409
- const lexRank = new Map(lexicalHits.map((h, i) => [h.id, i]));
1410
- fusedFull = fuseCandidates(searched, lexicalHits.map((h) => h.id));
1411
- // One batched hydration for everything this block and
1412
- // scoreQuality's coverage term (below) will need, in
1413
- // place of two separate staged fetches (phrase-pin's
1414
- // own, then a later cold start for coverage): over a
1415
- // remote range-read source each hydrate() a caller
1416
- // hasn't already paid for is its own network round
1417
- // trip, and sequential/staged awaits pay that cost
1418
- // once per stage instead of once per query. The
1419
- // union of phrase-pin's candidates (top lexical
1420
- // hits) and coverage's (fusedFull's top few — phrase
1421
- // pinning only ever reorders that window, never
1422
- // changes its membership) is known right here, so
1423
- // both stages can share one round trip. A record
1424
- // that fails to hydrate here does not fail the
1425
- // query: the id is just missing from the map, and
1426
- // each site below falls through exactly as it would
1427
- // on a cold cache miss (recs[i] undefined; coverage
1428
- // sees fewer usable passages) — hydrate() raising
1429
- // for a bad id is still a real failure, but only
1430
- // once a site actually needs that specific record,
1431
- // not speculatively for every candidate touched here.
1432
- const phraseCandidates = lexicalHits.slice(0, 3);
1433
- const coverageCandidates = fusedFull.slice(0, COVERAGE_BATCH_COUNT);
1434
- const batchIds = [...new Set([...phraseCandidates, ...coverageCandidates].map((h) => h.id))];
1435
- const batch = new Map();
1436
- await Promise.all(batchIds.map(async (id) => {
1437
- try { batch.set(id, await hydrate(id)); } catch { /* see comment above */ }
1438
- }));
1439
- // Phrase pinning: a verbatim occurrence of the query (>= 3
1440
- // content words) in one of the top lexical hits is the
1441
- // strongest evidence of support there is; rank it first.
1442
- const norm = (t) => String(t || '').toLowerCase().replace(/[^a-z0-9' ]+/g, ' ').replace(/\s+/g, ' ').trim();
1443
- const phrase = norm(trimmed);
1444
- if (phrase.split(' ').length >= 3) {
1445
- for (const h of phraseCandidates) {
1446
- if (norm(batch.get(h.id)?.text).includes(phrase)) {
1447
- fusedFull = [searched.find((x) => x.id === h.id), ...fusedFull.filter((x) => x.id !== h.id)].filter(Boolean);
1448
- break;
1449
- }
1450
- }
1451
- }
1452
- // Rank transparency: a consumer can see a verbatim hit sitting
1453
- // at fused rank 3 behind two semantic near-misses.
1454
- 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; }
1455
- }
1456
- fused = retrieval === 'augmented' ? null : fusedFull?.slice(0, k) ?? null;
1457
- }
1543
+ if (!pre) ({ hits, fused, searched, fusedFull } = await retrieveCandidates(trimmed, context, k, queryOptions));
1458
1544
  // Abstention scores the full reranked candidate window
1459
1545
  // (searched), not the k-truncated hits: calibrate.mjs fits
1460
1546
  // its signals at a fixed top-10 (top-5 for coverage)
@@ -1490,12 +1576,42 @@ export async function openPikeletFile(input, options = {}) {
1490
1576
  return { matchQuality: quality.match_quality, confidence: quality.confidence, results, ...(grounding ? { grounding } : {}) };
1491
1577
  },
1492
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
+
1493
1599
  /** Hydrate one corpus record by id (verified per record on format 2). */
1494
1600
  async record(id) {
1495
1601
  assertOpen();
1496
1602
  return hydrate(id);
1497
1603
  },
1498
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
+
1499
1615
  /**
1500
1616
  * Full pass over the index's lazy vector rows against the
1501
1617
  * identity-anchored vectorsSha256 (streamed where possible).
@@ -1572,6 +1688,9 @@ export async function openPikeletFile(input, options = {}) {
1572
1688
  recordCount,
1573
1689
  dim,
1574
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,
1575
1694
  lexicalIndex,
1576
1695
  hydrate,
1577
1696
  readRecordBytes,
@@ -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
+ }
@@ -56,7 +56,9 @@ export const CHAIN_DEFAULTS = Object.freeze({
56
56
  * [base, layer1, layer2, ...]. Every member is opened through
57
57
  * openPikeletFile(), so a string is a path and an object is a range source.
58
58
  * @param {object} [options] forwarded to each member's open, plus:
59
- * readerDriftLimit, maxDepth.
59
+ * readerDriftLimit, maxDepth, and fetch (the transport for any parent the
60
+ * walk reaches over HTTP; default: the child source's own, else
61
+ * globalThis.fetch).
60
62
  */
61
63
  /**
62
64
  * 5.1.1: walk from a head to its base, resolving each parent by lineage
@@ -76,6 +78,7 @@ async function walkToBase(head, options) {
76
78
  const chain = [head];
77
79
  let cursor = head;
78
80
  let expectIdentity = null;
81
+ let childFetch = null;
79
82
  for (let hop = 0; hop <= maxDepth; hop++) {
80
83
  const peek = await readMemberManifest(cursor, options);
81
84
  if (expectIdentity !== null && peek.identity !== expectIdentity) {
@@ -89,6 +92,7 @@ async function walkToBase(head, options) {
89
92
  // string as a file path sent that second hop to fileSource.
90
93
  const childLocation = typeof cursor === 'string' ? cursor : (cursor.url ?? cursor.location ?? null);
91
94
  const childIsFile = typeof cursor === 'string' && !isHttpUrl(cursor);
95
+ if (typeof cursor === 'object' && typeof cursor?.fetch === 'function') childFetch = cursor.fetch;
92
96
  const located = await resolveParentLocation(peek.parent, {
93
97
  lineage,
94
98
  childLocation,
@@ -112,6 +116,9 @@ async function walkToBase(head, options) {
112
116
  // may name any origin, so its redirects are not confined.
113
117
  const confineTo = located.via === 'locator' ? childLocation : null;
114
118
  cursor = httpRangeSource(cursor, {
119
+ // The walk's transport: the caller's, else the one the
120
+ // child source was opened with.
121
+ ...((options.fetch || childFetch) ? { fetch: options.fetch || childFetch } : {}),
115
122
  ...(confineTo ? { redirectGuard: (target) => assertConfined(target, confineTo) } : {}),
116
123
  });
117
124
  await cursor.init();
@@ -200,6 +207,14 @@ export async function openPikeletChain(members, options = {}) {
200
207
  if (base.manifest.profile === LAYER_PROFILE) {
201
208
  throw new Error('the first chain member is a layer, not a base: a layer opened alone would serve a fraction of a corpus (4.1)');
202
209
  }
210
+ // A chain maps each member's sketch rows to records positionally
211
+ // (rowBase + local id). A member whose index holds several rows per
212
+ // record (row map, COMPLETE_PROFILE.md 3.9) would be misread, so it is
213
+ // refused until the layered profile defines rows.
214
+ if (tiersRaw.some((t) => t && t.rowMap)) {
215
+ throw new Error('a pack whose index holds several rows per record (sentence windows) cannot be part of a layered chain yet: '
216
+ + 'mount it on its own, or rebuild it without --sentence-windows to append to it');
217
+ }
203
218
 
204
219
  const chainMembers = [{
205
220
  depth: 0, identity: base.identity, rowBase: 0, records: base.recordCount,