@crvouga/atlas 1.4.0 → 1.6.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.
- package/CHANGELOG.md +12 -0
- package/docs/ui.md +32 -2
- package/llms-full.txt +32 -2
- package/package.json +1 -1
- package/packages/visualizer/README.md +28 -0
- package/packages/visualizer/src/evidence/artifacts.tsx +17 -8
- package/packages/visualizer/src/evidence/behavior-panel.tsx +291 -0
- package/packages/visualizer/src/evidence/behavior.test.ts +99 -0
- package/packages/visualizer/src/evidence/behavior.ts +268 -0
- package/packages/visualizer/src/evidence/check-detail.tsx +4 -0
- package/packages/visualizer/src/evidence/feature-page.tsx +82 -36
- package/packages/visualizer/src/evidence/gherkin.ts +31 -1
- package/packages/visualizer/src/evidence/spec-index.tsx +48 -15
- package/packages/visualizer/src/explorer/Explorer.tsx +105 -25
- package/packages/visualizer/src/explorer/RunsView.tsx +173 -35
- package/packages/visualizer/src/explorer/catalog.tsx +9 -5
- package/packages/visualizer/src/explorer/search-notes.tsx +74 -0
- package/packages/visualizer/src/explorer/search.ts +16 -6
- package/packages/visualizer/src/find/Finder.tsx +127 -81
- package/packages/visualizer/src/find/behaviors.ts +205 -0
- package/packages/visualizer/src/find/corpus.ts +21 -7
- package/packages/visualizer/src/find/doc-search.ts +66 -0
- package/packages/visualizer/src/find/query.ts +247 -0
- package/packages/visualizer/src/find/rank.ts +195 -47
- package/packages/visualizer/src/find/search.test.ts +322 -0
- package/packages/visualizer/src/find/semantic-search.ts +153 -0
- package/packages/visualizer/src/find/semantic.ts +2 -2
|
@@ -1,3 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module Ranked search
|
|
3
|
+
*
|
|
4
|
+
* The one matching engine behind every search box: the finder, the test list, the runs list and the results of one run.
|
|
5
|
+
* It indexes documents (`FindDoc`) by their weighted fields, matches words by stem, everyday synonym, spelling and
|
|
6
|
+
* the start of a longer word, applies the structured parts of a query (`query.ts`), and says why each document matched.
|
|
7
|
+
*/
|
|
8
|
+
import { parseSearch, passesFilters, type DocFacets, type ParsedSearch } from './query';
|
|
9
|
+
|
|
1
10
|
/** Everyday words a new person uses for the same action. The first word is the class id. */
|
|
2
11
|
const CLASSES: readonly (readonly string[])[] = [
|
|
3
12
|
['complete', 'done', 'finish', 'mark', 'check'],
|
|
@@ -27,10 +36,15 @@ const TYPE_PHRASES: readonly { readonly notation: string; readonly left: string;
|
|
|
27
36
|
const TYPE_OF = new Map<string, string>();
|
|
28
37
|
for (const entry of TYPE_WORDS) for (const word of entry.words) TYPE_OF.set(word, entry.notation);
|
|
29
38
|
|
|
30
|
-
|
|
39
|
+
/** How much a word counts in each field of a document. */
|
|
40
|
+
export const FIELD_WEIGHT = { title: 5, steps: 3, description: 2, metadata: 1, path: 0.5 } as const;
|
|
31
41
|
|
|
42
|
+
/** Where in a document a word was found. `metadata` is domain, tags, labels, environments and producers. */
|
|
32
43
|
export type FieldName = keyof typeof FIELD_WEIGHT;
|
|
33
44
|
|
|
45
|
+
const FIELD_NAMES = Object.keys(FIELD_WEIGHT) as FieldName[];
|
|
46
|
+
|
|
47
|
+
/** One searchable text of a document and where it comes from. */
|
|
34
48
|
export type FindField = { readonly name: FieldName; readonly text: string };
|
|
35
49
|
|
|
36
50
|
export type FindGroup = 'Flows' | 'Specs' | 'Runs';
|
|
@@ -57,6 +71,7 @@ export type FindTarget = {
|
|
|
57
71
|
};
|
|
58
72
|
};
|
|
59
73
|
|
|
74
|
+
/** A searchable thing: a flow, a spec, a scenario, a recorded behavior or a run. */
|
|
60
75
|
export type FindDoc = {
|
|
61
76
|
readonly id: string;
|
|
62
77
|
readonly group: FindGroup;
|
|
@@ -69,9 +84,30 @@ export type FindDoc = {
|
|
|
69
84
|
readonly related: readonly string[];
|
|
70
85
|
readonly target: FindTarget;
|
|
71
86
|
readonly recording: FindTarget | null;
|
|
87
|
+
/** What structured filters (`status:`, `has:video`…) read. Without it, a document passes no filter. */
|
|
88
|
+
readonly facets?: DocFacets;
|
|
72
89
|
};
|
|
73
90
|
|
|
74
|
-
|
|
91
|
+
/** A matched document, and why it matched. */
|
|
92
|
+
export type Ranked = {
|
|
93
|
+
readonly doc: FindDoc;
|
|
94
|
+
readonly score: number;
|
|
95
|
+
readonly snippet: string;
|
|
96
|
+
/** Where the query's words were found. */
|
|
97
|
+
readonly fields?: readonly FieldName[];
|
|
98
|
+
/** True when a quoted phrase or an identifier was found literally. */
|
|
99
|
+
readonly exact?: boolean;
|
|
100
|
+
/** The literal phrases or identifiers found. */
|
|
101
|
+
readonly literals?: readonly string[];
|
|
102
|
+
/** True when a word matched only by a close spelling or the start of a longer word. */
|
|
103
|
+
readonly fuzzy?: boolean;
|
|
104
|
+
/** True when a distinctive query word is missing: a weaker match, listed below those that explain every word. */
|
|
105
|
+
readonly partial?: boolean;
|
|
106
|
+
/** True when it is listed because a matched document relates to it, not because it matched. */
|
|
107
|
+
readonly related?: boolean;
|
|
108
|
+
/** True when only the meaning of the query, not its words, brought it here. */
|
|
109
|
+
readonly semantic?: boolean;
|
|
110
|
+
};
|
|
75
111
|
|
|
76
112
|
const CLASS_OF = new Map<string, string>();
|
|
77
113
|
for (const members of CLASSES) {
|
|
@@ -137,8 +173,9 @@ function damerau(a: string, b: string, limit: number): number {
|
|
|
137
173
|
return rows[a.length]![b.length]!;
|
|
138
174
|
}
|
|
139
175
|
|
|
140
|
-
type Posting = { readonly doc: number; readonly weight: number; readonly snippet: string; readonly snippetRank: number };
|
|
176
|
+
type Posting = { readonly doc: number; readonly weight: number; readonly snippet: string; readonly snippetRank: number; readonly fields: number };
|
|
141
177
|
|
|
178
|
+
/** Documents indexed for matching. Build once with {@link indexCorpus}; query many times. */
|
|
142
179
|
export type FindIndex = {
|
|
143
180
|
readonly docs: readonly FindDoc[];
|
|
144
181
|
readonly postings: ReadonlyMap<string, readonly Posting[]>;
|
|
@@ -152,6 +189,7 @@ function snippetRank(name: FieldName): number {
|
|
|
152
189
|
return 0;
|
|
153
190
|
}
|
|
154
191
|
|
|
192
|
+
/** Indexes documents by the stems of the words in their fields. */
|
|
155
193
|
export function indexCorpus(docs: readonly FindDoc[]): FindIndex {
|
|
156
194
|
const postings = new Map<string, Posting[]>();
|
|
157
195
|
const best = new Map<string, Posting>();
|
|
@@ -170,13 +208,15 @@ export function indexCorpus(docs: readonly FindDoc[]): FindIndex {
|
|
|
170
208
|
if (stemmed.length >= 4 && !surfaces.has(stemmed)) surfaces.set(stemmed, canonical(stemmed));
|
|
171
209
|
const prior = best.get(term);
|
|
172
210
|
const fromField = rank > 0 ? field.text : '';
|
|
173
|
-
|
|
174
|
-
|
|
211
|
+
const bit = 1 << FIELD_NAMES.indexOf(field.name);
|
|
212
|
+
if (!prior) best.set(term, { doc, weight, snippet: fromField, snippetRank: rank, fields: bit });
|
|
213
|
+
else {
|
|
175
214
|
best.set(term, {
|
|
176
215
|
doc,
|
|
177
216
|
weight: Math.max(weight, prior.weight),
|
|
178
217
|
snippet: rank > prior.snippetRank ? fromField : prior.snippet,
|
|
179
|
-
snippetRank: Math.max(rank, prior.snippetRank)
|
|
218
|
+
snippetRank: Math.max(rank, prior.snippetRank),
|
|
219
|
+
fields: prior.fields | bit
|
|
180
220
|
});
|
|
181
221
|
}
|
|
182
222
|
}
|
|
@@ -204,6 +244,11 @@ function candidatesFor(piece: string, index: FindIndex): Candidate[] {
|
|
|
204
244
|
if (piece.length >= 4) {
|
|
205
245
|
const limit = piece.length > 6 ? 2 : 1;
|
|
206
246
|
const consider = (surface: string, term: string) => {
|
|
247
|
+
if (surface.length > piece.length && surface.startsWith(piece)) {
|
|
248
|
+
// The start of a longer word, so a word is found while it is still being typed.
|
|
249
|
+
if ((factors.get(term) ?? 0) < 0.6) factors.set(term, 0.6);
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
207
252
|
if (Math.abs(surface.length - piece.length) > limit) return;
|
|
208
253
|
const dist = damerau(piece, surface, limit);
|
|
209
254
|
if (dist <= 0 || dist > limit) return;
|
|
@@ -308,9 +353,13 @@ function lineSnippet(text: string, terms: ReadonlySet<string>): string {
|
|
|
308
353
|
return best.length > 140 ? `${best.slice(0, 137)}…` : best;
|
|
309
354
|
}
|
|
310
355
|
|
|
311
|
-
type Scored = Ranked & { readonly misses: number };
|
|
356
|
+
type Scored = Ranked & { readonly misses: number; readonly index: number };
|
|
312
357
|
|
|
313
|
-
|
|
358
|
+
/**
|
|
359
|
+
* Scores every document that holds at least one query word. A word is matched through its candidates (itself, its
|
|
360
|
+
* everyday synonyms, close spellings, longer words it starts); `allow` leaves documents out before they are scored.
|
|
361
|
+
*/
|
|
362
|
+
function scoreAll(index: FindIndex, query: string, allow?: (docIndex: number) => boolean): Scored[] {
|
|
314
363
|
const pieces: string[] = [];
|
|
315
364
|
const seen = new Set<string>();
|
|
316
365
|
for (const piece of query.toLowerCase().split(/[^a-z0-9]+/)) {
|
|
@@ -328,36 +377,61 @@ function scoreAll(index: FindIndex, query: string): Scored[] {
|
|
|
328
377
|
return { candidates, rare: df * 2 <= total };
|
|
329
378
|
});
|
|
330
379
|
const queryTerms = new Set(queries.flatMap((entry) => entry.candidates.map((candidate) => candidate.term)));
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
for (const
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
const
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
380
|
+
const rareCount = queries.filter((entry) => entry.rare).length;
|
|
381
|
+
type Acc = { score: number; rareHits: number; snippet: string; snippetRank: number; fields: number; fuzzy: boolean };
|
|
382
|
+
const acc = new Map<number, Acc>();
|
|
383
|
+
for (const entry of queries) {
|
|
384
|
+
const best = new Map<number, { value: number; snippet: string; rank: number; fields: number; factor: number }>();
|
|
385
|
+
for (const candidate of entry.candidates) {
|
|
386
|
+
const postings = index.postings.get(candidate.term);
|
|
387
|
+
if (!postings) continue;
|
|
388
|
+
const weightOfTerm = idf(index.df.get(candidate.term) ?? 0, total);
|
|
389
|
+
for (const posting of postings) {
|
|
390
|
+
if (allow && !allow(posting.doc)) continue;
|
|
391
|
+
const value = weightOfTerm * posting.weight * candidate.factor;
|
|
392
|
+
const prior = best.get(posting.doc);
|
|
393
|
+
if (!prior) best.set(posting.doc, { value, snippet: posting.snippet, rank: posting.snippetRank, fields: posting.fields, factor: candidate.factor });
|
|
394
|
+
else {
|
|
395
|
+
if (value > prior.value) {
|
|
396
|
+
prior.value = value;
|
|
397
|
+
prior.factor = candidate.factor;
|
|
398
|
+
prior.fields = posting.fields;
|
|
399
|
+
}
|
|
400
|
+
if (posting.snippetRank > prior.rank) {
|
|
401
|
+
prior.rank = posting.snippetRank;
|
|
402
|
+
prior.snippet = posting.snippet;
|
|
403
|
+
}
|
|
350
404
|
}
|
|
351
405
|
}
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
406
|
+
}
|
|
407
|
+
for (const [doc, hit] of best) {
|
|
408
|
+
const own = acc.get(doc) ?? { score: 0, rareHits: 0, snippet: '', snippetRank: 0, fields: 0, fuzzy: false };
|
|
409
|
+
own.score += hit.value;
|
|
410
|
+
if (entry.rare) own.rareHits++;
|
|
411
|
+
own.fields |= hit.fields;
|
|
412
|
+
if (hit.factor < 1) own.fuzzy = true;
|
|
413
|
+
if (hit.rank > own.snippetRank && hit.snippet) {
|
|
414
|
+
own.snippetRank = hit.rank;
|
|
415
|
+
own.snippet = hit.snippet;
|
|
357
416
|
}
|
|
417
|
+
acc.set(doc, own);
|
|
358
418
|
}
|
|
359
|
-
|
|
360
|
-
|
|
419
|
+
}
|
|
420
|
+
const out: Scored[] = [];
|
|
421
|
+
for (const [docIndex, hit] of acc) {
|
|
422
|
+
if (hit.score <= 0) continue;
|
|
423
|
+
const fields = FIELD_NAMES.filter((_, at) => (hit.fields & (1 << at)) !== 0);
|
|
424
|
+
out.push({
|
|
425
|
+
doc: index.docs[docIndex]!,
|
|
426
|
+
index: docIndex,
|
|
427
|
+
score: hit.score,
|
|
428
|
+
misses: rareCount - hit.rareHits,
|
|
429
|
+
snippet: lineSnippet(hit.snippet, queryTerms),
|
|
430
|
+
fields,
|
|
431
|
+
...(hit.fuzzy ? { fuzzy: true } : {})
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
return out;
|
|
361
435
|
}
|
|
362
436
|
|
|
363
437
|
/** Journeys first, then failing runs. An empty box stays a short list of stories. */
|
|
@@ -367,41 +441,115 @@ export function suggest(docs: readonly FindDoc[], limit = 40): Ranked[] {
|
|
|
367
441
|
return [...journeys, ...failing].slice(0, limit).map((doc) => ({ doc, score: 0, snippet: '' }));
|
|
368
442
|
}
|
|
369
443
|
|
|
444
|
+
const textOf = new WeakMap<FindDoc, string>();
|
|
445
|
+
/** Everything a document says, lowercased, for literal (quoted or identifier) matches. */
|
|
446
|
+
function literalText(doc: FindDoc): string {
|
|
447
|
+
let text = textOf.get(doc);
|
|
448
|
+
if (text === undefined) {
|
|
449
|
+
text = [doc.id, doc.title, doc.subtitle, ...doc.fields.map((field) => field.text)].join('\n').toLowerCase();
|
|
450
|
+
textOf.set(doc, text);
|
|
451
|
+
}
|
|
452
|
+
return text;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/** The literal phrases and identifiers of a search that a document contains, or null when it lacks one. */
|
|
456
|
+
function literalsIn(doc: FindDoc, parsed: ParsedSearch): string[] | null {
|
|
457
|
+
const found: string[] = [];
|
|
458
|
+
const text = literalText(doc);
|
|
459
|
+
for (const literal of [...parsed.phrases, ...parsed.identifiers]) {
|
|
460
|
+
if (!text.includes(literal)) return null;
|
|
461
|
+
found.push(literal);
|
|
462
|
+
}
|
|
463
|
+
return found;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/** How a document matched the words, phrases and identifiers of a search (not its filters). */
|
|
467
|
+
export type TextMatch = Ranked & { readonly order: number; readonly misses: number };
|
|
468
|
+
|
|
469
|
+
/**
|
|
470
|
+
* The documents that match the text of a search: its words (every distinctive word found, unless `partial`), and every
|
|
471
|
+
* quoted phrase and identifier literally. Filters are not applied here; callers apply them with `passesFilters`, so
|
|
472
|
+
* a count can leave one out. Returns null when the search has no text at all. Hits are best first: exact before
|
|
473
|
+
* fuzzy, then by score and title.
|
|
474
|
+
*
|
|
475
|
+
* With `partial: true` a document that misses a distinctive word is kept, flagged `partial`, after those that do not.
|
|
476
|
+
*/
|
|
477
|
+
export function matchText(index: FindIndex, parsed: ParsedSearch, options: { readonly partial?: boolean; readonly words?: string; readonly allow?: (doc: FindDoc) => boolean; readonly snippets?: boolean } = {}): TextMatch[] | null {
|
|
478
|
+
const words = options.words ?? parsed.words;
|
|
479
|
+
const literal = parsed.phrases.length + parsed.identifiers.length > 0;
|
|
480
|
+
const hasWords = tokens(words).length > 0;
|
|
481
|
+
if (!hasWords && !literal) return null;
|
|
482
|
+
const allow = options.allow;
|
|
483
|
+
const pass = (docIndex: number) => {
|
|
484
|
+
const doc = index.docs[docIndex]!;
|
|
485
|
+
return (!allow || allow(doc)) && (!literal || literalsIn(doc, parsed) !== null);
|
|
486
|
+
};
|
|
487
|
+
let hits: TextMatch[];
|
|
488
|
+
if (hasWords) {
|
|
489
|
+
hits = [];
|
|
490
|
+
for (const scored of scoreAll(index, words, pass)) {
|
|
491
|
+
if (scored.misses > 0 && !options.partial) continue;
|
|
492
|
+
const literals = literal ? literalsIn(scored.doc, parsed) : [];
|
|
493
|
+
hits.push({ doc: scored.doc, score: scored.score, snippet: options.snippets === false ? '' : scored.snippet, fields: scored.fields, ...(scored.fuzzy ? { fuzzy: true } : {}), ...(scored.misses > 0 ? { partial: true } : {}), ...(literals?.length ? { exact: true, literals } : {}), order: scored.index, misses: scored.misses });
|
|
494
|
+
}
|
|
495
|
+
} else {
|
|
496
|
+
hits = [];
|
|
497
|
+
index.docs.forEach((doc, order) => {
|
|
498
|
+
if (!pass(order)) return;
|
|
499
|
+
hits.push({ doc, score: 0, snippet: '', exact: true, literals: literalsIn(doc, parsed) ?? [], order, misses: 0 });
|
|
500
|
+
});
|
|
501
|
+
}
|
|
502
|
+
const compare = (a: TextMatch, b: TextMatch) =>
|
|
503
|
+
a.misses - b.misses ||
|
|
504
|
+
Number(b.exact === true) - Number(a.exact === true) ||
|
|
505
|
+
b.score - a.score ||
|
|
506
|
+
(a.doc.title < b.doc.title ? -1 : a.doc.title > b.doc.title ? 1 : 0);
|
|
507
|
+
return hits.sort(compare);
|
|
508
|
+
}
|
|
509
|
+
|
|
370
510
|
/**
|
|
371
|
-
* Rank documents for a query. A document that misses a
|
|
372
|
-
* document that explains it, so a word that is in almost every row cannot win on its own.
|
|
373
|
-
* Matching a document also lists its `related` rows.
|
|
511
|
+
* Rank documents for a query typed in a search box. A document that misses a distinctive word sinks below every
|
|
512
|
+
* document that explains it (and is flagged `partial`), so a word that is in almost every row cannot win on its own.
|
|
513
|
+
* Matching a document also lists its `related` rows. Filters and quoted phrases in the query narrow the result; a
|
|
514
|
+
* kind word (`gherkin`) keeps only that kind of spec unless `kinds` is false.
|
|
374
515
|
*/
|
|
375
|
-
export function rankIndex(index: FindIndex, query: string, limit = 40): Ranked[] {
|
|
376
|
-
const
|
|
377
|
-
const
|
|
378
|
-
|
|
516
|
+
export function rankIndex(index: FindIndex, query: string, limit = 40, options: { readonly kinds?: boolean } = {}): Ranked[] {
|
|
517
|
+
const parsed = parseSearch(query);
|
|
518
|
+
const kinds = options.kinds !== false;
|
|
519
|
+
const { notations, rest } = kinds ? splitQuery(parsed.words) : { notations: new Set<string>(), rest: parsed.words };
|
|
520
|
+
const allows = (doc: FindDoc) => (notations.size === 0 || notations.has(notationOf(doc))) && passesFilters(doc.facets, parsed.filters);
|
|
521
|
+
const matched = matchText(index, parsed, { partial: true, words: rest, allow: allows });
|
|
522
|
+
if (matched === null) {
|
|
523
|
+
if (parsed.tokens.length > 0) {
|
|
524
|
+
return index.docs.filter(allows).slice(0, limit).map((doc) => ({ doc, score: 0, snippet: '' }));
|
|
525
|
+
}
|
|
379
526
|
if (notations.size === 0) return suggest(index.docs, limit);
|
|
380
527
|
return listOfType(index.docs.filter(allows), limit);
|
|
381
528
|
}
|
|
382
|
-
const scored = scoreAll(index, rest).filter((entry) => entry.score > 0 && allows(entry.doc));
|
|
383
|
-
scored.sort((a, b) => a.misses - b.misses || b.score - a.score || (a.doc.title < b.doc.title ? -1 : a.doc.title > b.doc.title ? 1 : 0));
|
|
384
529
|
const byId = new Map(index.docs.map((doc) => [doc.id, doc]));
|
|
385
530
|
const seen = new Set<string>();
|
|
386
531
|
const out: Ranked[] = [];
|
|
387
|
-
|
|
532
|
+
const own = new Map(matched.map((hit) => [hit.doc.id, hit]));
|
|
533
|
+
for (const hit of matched) {
|
|
388
534
|
if (out.length >= limit) break;
|
|
389
535
|
if (!seen.has(hit.doc.id)) {
|
|
390
|
-
|
|
536
|
+
const { order: _order, misses: _misses, ...ranked } = hit;
|
|
537
|
+
out.push(ranked);
|
|
391
538
|
seen.add(hit.doc.id);
|
|
392
539
|
}
|
|
393
540
|
for (const id of hit.doc.related) {
|
|
394
541
|
if (out.length >= limit || seen.has(id)) continue;
|
|
395
542
|
const doc = byId.get(id);
|
|
396
543
|
if (!doc || !allows(doc)) continue;
|
|
397
|
-
const
|
|
398
|
-
out.push({ doc, score: hit.score, snippet:
|
|
544
|
+
const inherited = own.get(id);
|
|
545
|
+
out.push({ doc, score: hit.score, snippet: inherited?.snippet || hit.snippet, ...(inherited ? {} : { related: true as const }) });
|
|
399
546
|
seen.add(id);
|
|
400
547
|
}
|
|
401
548
|
}
|
|
402
549
|
return out;
|
|
403
550
|
}
|
|
404
551
|
|
|
552
|
+
/** Index `docs` and rank them for `query`: see {@link rankIndex}. */
|
|
405
553
|
export function rank(docs: readonly FindDoc[], query: string, limit = 40): Ranked[] {
|
|
406
554
|
return rankIndex(indexCorpus(docs), query, limit);
|
|
407
555
|
}
|