Skip to content

Commit a864069

Browse files
heskewclaude
andcommitted
fix(test): repair integration helpers — fetch for QUERY verb, server-side distances for boundaries
supertest has no API for non-standard HTTP verbs, so the QUERY helper never executed; replaced with fetch. Threshold boundaries now come from the server's own $distance values: vectors are stored as float32, so float64-predicted boundaries differ at ~1e-8 and exact-boundary assertions can't hold. le(0) now exercises a true zero threshold through the real query path. All 4 integration tests pass. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 93f56ed commit a864069

1 file changed

Lines changed: 60 additions & 62 deletions

File tree

integrationTests/server/vector-index-integrity.test.ts

Lines changed: 60 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@
3838
* Related fixes: commit 251e5b73 (fix(hnsw): six data-integrity fixes)
3939
*/
4040
import { suite, test, before, after } from 'node:test';
41-
import { ok } from 'node:assert/strict';
41+
import { ok, strictEqual } from 'node:assert/strict';
4242
import { setTimeout as sleep } from 'node:timers/promises';
4343
import { startHarper, teardownHarper, type ContextWithHarper } from '@harperfast/integration-testing';
4444
// @ts-expect-error no type declarations on .mjs utils
@@ -100,19 +100,6 @@ function seedVector(seed: number, dims: number = 8): number[] {
100100
return v.map((x) => x * inv);
101101
}
102102

103-
/** Cosine distance — matches the HNSW default metric. */
104-
function cosineDistance(a: number[], b: number[]): number {
105-
let dot = 0;
106-
let magA = 0;
107-
let magB = 0;
108-
for (let i = 0; i < a.length; i++) {
109-
dot += a[i] * b[i];
110-
magA += a[i] * a[i];
111-
magB += b[i] * b[i];
112-
}
113-
return 1 - dot / ((Math.sqrt(magA) || 1) * (Math.sqrt(magB) || 1));
114-
}
115-
116103
// ---------------------------------------------------------------------------
117104
// HTTP helpers
118105
// ---------------------------------------------------------------------------
@@ -129,24 +116,37 @@ async function vectorSearch(
129116
headers: Record<string, string>,
130117
resourcePath: string,
131118
target: number[],
132-
opts: { limit?: number } = {}
119+
opts: { limit?: number; select?: string[] } = {}
133120
): Promise<any[]> {
134121
const body: any = {
135122
sort: { attribute: 'embedding', target, distance: 'cosine' },
136123
};
137124
if (opts.limit !== undefined) body.limit = opts.limit;
125+
if (opts.select !== undefined) body.select = opts.select;
126+
return queryResource(httpURL, headers, resourcePath, body);
127+
}
138128

139-
const resp = await request(httpURL)
140-
.query(resourcePath)
141-
.set(headers)
142-
.set('Content-Type', 'application/json')
143-
.method('QUERY' as any)
144-
.send(body);
145-
129+
/**
130+
* Issue an HTTP QUERY request via fetch — supertest/superagent has no API for
131+
* non-standard verbs, while undici's fetch passes custom method tokens through.
132+
*/
133+
async function queryResource(
134+
httpURL: string,
135+
headers: Record<string, string>,
136+
resourcePath: string,
137+
body: any
138+
): Promise<any[]> {
139+
const resp = await fetch(`${httpURL}${resourcePath}`, {
140+
method: 'QUERY',
141+
headers: { ...headers, 'Content-Type': 'application/json' },
142+
body: JSON.stringify(body),
143+
});
144+
const text = await resp.text();
146145
if (resp.status !== 200) {
147-
throw new Error(`QUERY ${resourcePath} returned ${resp.status}: ${JSON.stringify(resp.body)}`);
146+
throw new Error(`QUERY ${resourcePath} returned ${resp.status}: ${text}`);
148147
}
149-
return Array.isArray(resp.body) ? resp.body : [];
148+
const data = JSON.parse(text);
149+
return Array.isArray(data) ? data : [];
150150
}
151151

152152
/**
@@ -167,18 +167,7 @@ async function vectorThresholdSearch(
167167
const body = {
168168
conditions: [{ attribute: 'embedding', comparator, value, target }],
169169
};
170-
171-
const resp = await request(httpURL)
172-
.query(resourcePath)
173-
.set(headers)
174-
.set('Content-Type', 'application/json')
175-
.method('QUERY' as any)
176-
.send(body);
177-
178-
if (resp.status !== 200) {
179-
throw new Error(`QUERY threshold ${resourcePath} returned ${resp.status}: ${JSON.stringify(resp.body)}`);
180-
}
181-
return Array.isArray(resp.body) ? resp.body : [];
170+
return queryResource(httpURL, headers, resourcePath, body);
182171
}
183172

184173
/** POST a record to the REST endpoint. */
@@ -416,47 +405,56 @@ suite('HNSW vector-index data-integrity (integration)', (ctx: ContextWithHarper)
416405
const headers = client.headers;
417406
const path = `/${TABLE}/`;
418407

419-
// 2-D vectors for exact, predictable cosine distances:
420-
// [1,0] vs [1,0] → distance ≈ 0 (exact)
421-
// [1,0] vs [1/√2, 1/√2] → distance ≈ 0.293 (near)
422-
// [1,0] vs [0,1] → distance = 1 (far)
408+
// 2-D vectors at three well-separated cosine distances from [1,0]:
409+
// [1,0] → distance 0 (exact; representable in float32, so exactly 0)
410+
// [1/√2, 1/√2] → distance ≈0.293 (near)
411+
// [0,1] → distance 1 (far)
412+
// Vectors are STORED as float32, so a float64 prediction of the near distance
413+
// differs from the server's computed value at ~1e-8. Boundary assertions must
414+
// therefore use the server's own $distance values, not locally computed ones.
423415
const INV_SQRT2 = 1 / Math.sqrt(2);
424416
const target = [1, 0];
425-
const exactVec = [1, 0];
426-
const nearVec = [INV_SQRT2, INV_SQRT2];
427-
const farVec = [0, 1];
428-
429-
const dExact = cosineDistance(target, exactVec);
430-
const dNear = cosineDistance(target, nearVec);
431-
432-
await insertRecord(httpURL, headers, path, { id: 'exact', embedding: exactVec });
433-
await insertRecord(httpURL, headers, path, { id: 'near', embedding: nearVec });
434-
await insertRecord(httpURL, headers, path, { id: 'far', embedding: farVec });
435417

436-
// 3a. le(dNear) must include both "exact" (dist < boundary) and "near"
437-
// (dist == boundary — this is the fix).
418+
await insertRecord(httpURL, headers, path, { id: 'exact', embedding: [1, 0] });
419+
await insertRecord(httpURL, headers, path, { id: 'near', embedding: [INV_SQRT2, INV_SQRT2] });
420+
await insertRecord(httpURL, headers, path, { id: 'far', embedding: [0, 1] });
421+
422+
// Fetch the actual stored distances; these are the exact values the threshold
423+
// filter compares against (same candidate-distance computation, no rerank on
424+
// a non-quantized index).
425+
const ranked = await vectorSearch(httpURL, headers, path, target, { select: ['id', '$distance'] });
426+
const distanceOf = (id: string) => {
427+
const rec = ranked.find((r: any) => r.id === id);
428+
ok(rec && typeof rec.$distance === 'number', `expected $distance for '${id}', got ${JSON.stringify(rec)}`);
429+
return rec.$distance as number;
430+
};
431+
const dExact = distanceOf('exact');
432+
const dNear = distanceOf('near');
433+
strictEqual(dExact, 0, `[1,0] is float32-representable; self-distance must be exactly 0, got ${dExact}`);
434+
435+
// le(dNear) must include both "exact" (dist < boundary) and "near"
436+
// (dist == boundary — this is the <= fix).
438437
const leNear = await vectorThresholdSearch(httpURL, headers, path, target, 'le', dNear);
439438
const leNearIds = new Set(leNear.map((r: any) => r.id));
440-
ok(leNearIds.has('exact'), `le(dNear) must include 'exact' (${dExact}${dNear})`);
439+
ok(leNearIds.has('exact'), `le(dNear) must include 'exact' (0${dNear})`);
441440
ok(leNearIds.has('near'), `le(dNear) must include 'near' at exact boundary distance ${dNear}`);
442441
ok(!leNearIds.has('far'), `le(dNear) must not include 'far' (distance 1 > ${dNear})`);
443442

444-
// 3b. lt(dNear) must include "exact" but NOT "near" (strict).
443+
// lt(dNear) must include "exact" but NOT "near" (strict).
445444
const ltNear = await vectorThresholdSearch(httpURL, headers, path, target, 'lt', dNear);
446445
const ltNearIds = new Set(ltNear.map((r: any) => r.id));
447-
ok(ltNearIds.has('exact'), `lt(dNear) must include 'exact' (${dExact} < ${dNear})`);
446+
ok(ltNearIds.has('exact'), `lt(dNear) must include 'exact' (0 < ${dNear})`);
448447
ok(!ltNearIds.has('near'), `lt(dNear) must NOT include 'near' at boundary (strict less-than)`);
449448

450-
// 3c. le(~0) must return only the exact-match record.
451-
// Pre-fix falsy-0 issue: if limit was checked with a truthy guard (if (limit))
452-
// rather than (limit !== undefined), le(0) would skip the filter entirely.
453-
const epsilon = 1e-10;
454-
const leZero = await vectorThresholdSearch(httpURL, headers, path, target, 'le', dExact + epsilon);
449+
// le(0) must return only the exact-match record. Pre-fix falsy-0 issue: a truthy
450+
// guard (if (limit)) skipped the filter entirely for a threshold of 0, returning
451+
// every record. dExact is exactly 0, so this exercises the real boundary.
452+
const leZero = await vectorThresholdSearch(httpURL, headers, path, target, 'le', dExact);
455453
const leZeroIds = new Set(leZero.map((r: any) => r.id));
456-
ok(leZeroIds.has('exact'), `le(~0) must include the exact-match record`);
454+
ok(leZeroIds.has('exact'), `le(0) must include the exact-match record`);
457455
ok(
458456
!leZeroIds.has('near') && !leZeroIds.has('far'),
459-
`le(~0) must return only the exact match; got ${JSON.stringify([...leZeroIds])}`
457+
`le(0) must return only the exact match; got ${JSON.stringify([...leZeroIds])}`
460458
);
461459
});
462460

0 commit comments

Comments
 (0)