diff --git a/src/brainy.ts b/src/brainy.ts index edcba3d4..a97bdde2 100644 --- a/src/brainy.ts +++ b/src/brainy.ts @@ -8486,7 +8486,23 @@ export class Brainy implements BrainyInterface { // Rank by score (top offset+limit), then drop the offset — identical ordering // to a full `sort((a, b) => b.score - a.score)` + slice, but the native // `sort:topK` provider can compute only the page instead of the full sort. - if (results.length >= offset + limit) { + // + // ONLY when score IS the requested order. An explicit `orderBy` names a + // different ordering key, and this block cannot serve it: it ranks by + // score and CUTS the page, so the tail's `orderBy` sort below either + // never runs at all (the early return, when there is no `connected` / + // `fusion` work left) or runs over a page that score already chose — + // ordering eight rows relevance picked instead of the eight the field + // ordering asks for. Both readings were silent: `find({ query, where, + // orderBy })` answered in score order while `find({ where, orderBy })` + // answered in field order, and nothing said the request had been dropped. + // + // With `orderBy` present the candidate set falls through UNCUT to the + // tail, which orders it in full and pages that ordering — "page last", + // the graph-first law applied to ordering rather than to filtering. The + // set is bounded by the legs (the text matches inside the universe plus + // the beam walk's `limit * 2`), not by the store. + if (!params.orderBy && results.length >= offset + limit) { const k = offset + limit const order = rankIndicesByScore(results.map(r => r.score), k, true) results = reorderByIndices(results, order).slice(offset, k) diff --git a/tests/integration/find-orderby-every-path.test.ts b/tests/integration/find-orderby-every-path.test.ts new file mode 100644 index 00000000..7637a79b --- /dev/null +++ b/tests/integration/find-orderby-every-path.test.ts @@ -0,0 +1,244 @@ +/** + * @module tests/integration/find-orderby-every-path + * @description `orderBy` IS THE ORDER — on every find() path, not just the + * metadata-only one. + * + * THE DEFECT. `find({ where, orderBy })` (metadata only) answered in field + * order. `find({ query, where, orderBy })` and `find({ vector, where, orderBy })` + * answered in SCORE order, silently: the vector/filter block ranked the fused + * candidates by score, cut the page, and returned early — the tail's `orderBy` + * sort sat below that early return and never ran. Nothing threw, nothing warned, + * and the two paths disagreed about what "ordered by rank" means. A caller + * paging `orderBy: 'rank', order: 'desc'` over a hybrid find got relevance + * order wearing an ordering request's clothes. + * + * Where `connected` or `fusion` kept the tail alive the defect changed shape + * rather than disappearing: the block had already CUT the page by score, so the + * tail ordered the rows relevance had chosen instead of the rows the ordering + * asks for — a correctly sorted page of the wrong rows. + * + * The early cut fires only once the candidate set reaches `offset + limit` + * rows, which is why small fixtures never saw it: below that threshold the + * block falls through and the tail's sort does apply. That is the whole shape + * of the bug — an ordering that is correct until there is enough data to matter. + * + * THE LAW. An explicit `orderBy` displaces score as the ordering key on every + * path. The candidate set the path produced is ordered IN FULL and the page is + * cut from that ordering — the graph-first law's "page last", applied to + * ordering rather than to filtering. Score-ranked early paging is for the + * default (no `orderBy`) case only, where score IS the requested order. + * + * THE PIN. Differential, against the metadata-only path — the one path that + * always honoured `orderBy`. + * + * WHAT THE DIFFERENTIAL CAN AND CANNOT CLAIM. `orderBy` orders the candidate + * set; it does not enlarge it. The hybrid legs are bounded by construction (the + * text leg and the beam walk each take `limit * 2`), so a differential against + * the metadata-only path — whose universe is every matching row — is only + * meaningful where those bounds provably cover the universe. The fixture is + * sized so they do (12 rows, `limit` 6 → a `limit * 2` = 12-row text leg), and + * the covering is ASSERTED from the leg's own output rather than assumed. This + * pin is about ordering, and it says nothing about recall. + */ +import { describe, it, expect, beforeAll } from 'vitest' +import { Brainy } from '../../src/brainy' +import { NounType, VerbType } from '../../src/types/graphTypes' +import { resolveEntityId } from '../../src/utils/idNormalization' + +/** Embedding width of the default model — the row vectors must match it. */ +const DIM = 384 + +/** A deterministic, per-row-distinct unit vector (no embedder in the fixture). */ +function seededVector(seed: number): number[] { + const v = new Array(DIM) + for (let i = 0; i < DIM; i++) { + v[i] = Math.sin((i + 1) * 0.11 + seed * 0.37) * 0.5 + Math.cos((i + 1) * 0.05 + seed * 0.13) * 0.3 + } + const magnitude = Math.sqrt(v.reduce((sum, x) => sum + x * x, 0)) + return v.map((x) => x / magnitude) +} + +/** + * Ranks, shuffled — so no scoring order can reproduce them by luck, and the + * ordering the pins assert is visibly not the insertion order either. + */ +const RANKS = [7, 3, 11, 1, 9, 5, 12, 2, 10, 4, 8, 6] +const ROWS = RANKS.length +/** The page size every pin uses: `limit * 2` covers the whole universe. */ +const LIMIT = 6 +/** The neighbour subset — the graph-first universe — and its own page size. */ +const NEIGHBOURS = 8 +const GRAPH_LIMIT = 4 + +describe('find(): orderBy is the order on every path', () => { + let brain: Brainy + const QUERY = 'orbital telemetry' + const anchor = 'ordering-anchor' + const neighbourIds: string[] = [] + + beforeAll(async () => { + brain = new Brainy({ requireSubtype: false, storage: { type: 'memory' } }) + await brain.init() + + let seed = 1 + await brain.add({ + id: anchor, + data: 'ground station anchor record', + type: NounType.Thing, + metadata: { lane: 'anchor', rank: 0 }, + vector: seededVector(seed++) + }) + + for (let i = 0; i < ROWS; i++) { + const id = `row-${i}` + await brain.add({ + id, + // EVERY row carries both query words, so the text leg reaches all of + // them and the hybrid candidate set covers the whole universe. + data: `orbital telemetry packet ${i} recorded downlink`, + type: NounType.Document, + metadata: { lane: 'alpha', rank: RANKS[i] }, + vector: seededVector(seed++) + }) + if (i < NEIGHBOURS) { + await brain.relate({ from: anchor, to: id, type: VerbType.RelatedTo }) + neighbourIds.push(resolveEntityId(id)) + } + } + }) + + it('the fixture: the hybrid candidate set covers the whole filter universe', async () => { + const universe: string[] = await (brain as any).filterIdsBelted({ lane: 'alpha' }) + expect(universe).toHaveLength(ROWS) + + // The text leg is bounded at `limit * 2`; the fixture is sized so that + // bound reaches every row in the universe. This is the precondition the + // differential below rests on — asserted from the leg itself. + const textScored = await (brain as any).executeTextSearchScored(QUERY, LIMIT * 2, universe) + expect(textScored).toHaveLength(ROWS) + + // And the candidate set is large enough to trigger the score-ranked early + // cut this pin exists to keep out of an ordered query's way. + expect(ROWS).toBeGreaterThanOrEqual(LIMIT) + }) + + it('metadata-only + orderBy: the reference ordering', async () => { + const rows = await brain.find({ + where: { lane: 'alpha' }, + orderBy: 'rank', + order: 'desc', + limit: LIMIT + } as any) + expect(rows.map((r: any) => r.metadata.rank)).toEqual([12, 11, 10, 9, 8, 7]) + }) + + it('hybrid (query + where) + orderBy: the same page as the metadata-only path', async () => { + const params = { where: { lane: 'alpha' }, orderBy: 'rank', order: 'desc' as const, limit: LIMIT } + const expected = await brain.find(params as any) + const actual = await brain.find({ ...params, query: QUERY } as any) + + expect(actual).toHaveLength(expected.length) + expect(actual.map((r: any) => r.id)).toEqual(expected.map((r: any) => r.id)) + expect(actual.map((r: any) => r.metadata.rank)).toEqual([12, 11, 10, 9, 8, 7]) + }) + + it('hybrid + orderBy asc: the ordering key is honoured in both directions', async () => { + const params = { where: { lane: 'alpha' }, orderBy: 'rank', order: 'asc' as const, limit: LIMIT } + const expected = await brain.find(params as any) + const actual = await brain.find({ ...params, query: QUERY } as any) + + expect(actual.map((r: any) => r.id)).toEqual(expected.map((r: any) => r.id)) + expect(actual.map((r: any) => r.metadata.rank)).toEqual([1, 2, 3, 4, 5, 6]) + }) + + it('hybrid + orderBy + offset: page two is page two of the ORDERING', async () => { + const params = { + where: { lane: 'alpha' }, + orderBy: 'rank', + order: 'desc' as const, + limit: LIMIT, + offset: LIMIT + } + const expected = await brain.find(params as any) + const actual = await brain.find({ ...params, query: QUERY } as any) + + expect(actual).toHaveLength(LIMIT) + expect(actual.map((r: any) => r.id)).toEqual(expected.map((r: any) => r.id)) + expect(actual.map((r: any) => r.metadata.rank)).toEqual([6, 5, 4, 3, 2, 1]) + }) + + it('hybrid + orderBy: paging walks the ordering monotonically, no row twice', async () => { + const seen: number[] = [] + for (let offset = 0; offset < ROWS; offset += LIMIT) { + const page = await brain.find({ + query: QUERY, + where: { lane: 'alpha' }, + orderBy: 'rank', + order: 'desc', + limit: LIMIT, + offset + } as any) + seen.push(...page.map((r: any) => r.metadata.rank)) + } + expect(seen).toHaveLength(ROWS) + expect(new Set(seen).size).toBe(ROWS) + // Strictly descending across every page boundary. + for (let i = 1; i < seen.length; i++) expect(seen[i]).toBeLessThan(seen[i - 1]) + }) + + it('vector + where + orderBy: field order, not distance order', async () => { + // The beam walk takes `limit * 2` = the whole universe here, so the page is + // the true top of the ordering — which distance order cannot produce. + const rows = await brain.find({ + vector: seededVector(1000), + where: { lane: 'alpha' }, + orderBy: 'rank', + order: 'desc', + limit: LIMIT + } as any) + expect(rows.map((r: any) => r.metadata.rank)).toEqual([12, 11, 10, 9, 8, 7]) + }) + + it('graph-first (query + connected + where) + orderBy: the neighbour set, ordered', async () => { + const actual = await brain.find({ + query: QUERY, + connected: { from: anchor, direction: 'out' as const }, + where: { lane: 'alpha' }, + orderBy: 'rank', + order: 'desc', + limit: GRAPH_LIMIT + } as any) + + expect(actual).toHaveLength(GRAPH_LIMIT) + const neighbours = new Set(neighbourIds) + for (const r of actual) expect(neighbours.has(r.id)).toBe(true) + + // The ordering covers the whole neighbour set, so the page holds the + // highest ranks AMONG THE NEIGHBOURS — not the ones the score ranking + // happened to surface first and the tail then sorted among themselves. + const expectedRanks = RANKS.slice(0, NEIGHBOURS) + .sort((a, b) => b - a) + .slice(0, GRAPH_LIMIT) + expect(expectedRanks).toEqual([12, 11, 9, 7]) + expect(actual.map((r: any) => r.metadata.rank)).toEqual(expectedRanks) + }) + + it('fusion + orderBy: the ordering survives the fusion rescore', async () => { + const actual = await brain.find({ + query: QUERY, + where: { lane: 'alpha' }, + fusion: 'weighted', + orderBy: 'rank', + order: 'desc', + limit: LIMIT + } as any) + expect(actual.map((r: any) => r.metadata.rank)).toEqual([12, 11, 10, 9, 8, 7]) + }) + + it('no orderBy: score order still stands (the default is untouched)', async () => { + const rows = await brain.find({ query: QUERY, where: { lane: 'alpha' }, limit: LIMIT } as any) + expect(rows).toHaveLength(LIMIT) + const scores = rows.map((r: any) => r.score) + for (let i = 1; i < scores.length; i++) expect(scores[i]).toBeLessThanOrEqual(scores[i - 1]) + }) +})