@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.
Files changed (27) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/docs/ui.md +32 -2
  3. package/llms-full.txt +32 -2
  4. package/package.json +1 -1
  5. package/packages/visualizer/README.md +28 -0
  6. package/packages/visualizer/src/evidence/artifacts.tsx +17 -8
  7. package/packages/visualizer/src/evidence/behavior-panel.tsx +291 -0
  8. package/packages/visualizer/src/evidence/behavior.test.ts +99 -0
  9. package/packages/visualizer/src/evidence/behavior.ts +268 -0
  10. package/packages/visualizer/src/evidence/check-detail.tsx +4 -0
  11. package/packages/visualizer/src/evidence/feature-page.tsx +82 -36
  12. package/packages/visualizer/src/evidence/gherkin.ts +31 -1
  13. package/packages/visualizer/src/evidence/spec-index.tsx +48 -15
  14. package/packages/visualizer/src/explorer/Explorer.tsx +105 -25
  15. package/packages/visualizer/src/explorer/RunsView.tsx +173 -35
  16. package/packages/visualizer/src/explorer/catalog.tsx +9 -5
  17. package/packages/visualizer/src/explorer/search-notes.tsx +74 -0
  18. package/packages/visualizer/src/explorer/search.ts +16 -6
  19. package/packages/visualizer/src/find/Finder.tsx +127 -81
  20. package/packages/visualizer/src/find/behaviors.ts +205 -0
  21. package/packages/visualizer/src/find/corpus.ts +21 -7
  22. package/packages/visualizer/src/find/doc-search.ts +66 -0
  23. package/packages/visualizer/src/find/query.ts +247 -0
  24. package/packages/visualizer/src/find/rank.ts +195 -47
  25. package/packages/visualizer/src/find/search.test.ts +322 -0
  26. package/packages/visualizer/src/find/semantic-search.ts +153 -0
  27. 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
- export const FIELD_WEIGHT = { title: 5, steps: 3, description: 2, path: 0.5 } as const;
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
- export type Ranked = { readonly doc: FindDoc; readonly score: number; readonly snippet: string };
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
- if (!prior) best.set(term, { doc, weight, snippet: fromField, snippetRank: rank });
174
- else if (weight > prior.weight || rank > prior.snippetRank) {
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
- function scoreAll(index: FindIndex, query: string): Scored[] {
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
- return index.docs.map((doc, docIndex) => {
332
- let score = 0;
333
- let misses = 0;
334
- let snippet = '';
335
- let snippetRankSeen = 0;
336
- for (const entry of queries) {
337
- let best = 0;
338
- let bestSnippet = '';
339
- let bestRank = 0;
340
- let hit = false;
341
- for (const candidate of entry.candidates) {
342
- const posting = index.postings.get(candidate.term)?.find((item) => item.doc === docIndex);
343
- if (!posting) continue;
344
- hit = true;
345
- const value = idf(index.df.get(candidate.term) ?? 0, total) * posting.weight * candidate.factor;
346
- if (value > best) best = value;
347
- if (posting.snippetRank > bestRank) {
348
- bestRank = posting.snippetRank;
349
- bestSnippet = posting.snippet;
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
- if (hit) score += best;
353
- else if (entry.rare) misses++;
354
- if (bestRank > snippetRankSeen && bestSnippet) {
355
- snippetRankSeen = bestRank;
356
- snippet = bestSnippet;
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
- return { doc, score, misses, snippet: lineSnippet(snippet, queryTerms) };
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 rare query word sinks below every
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 { notations, rest } = splitQuery(query);
377
- const allows = (doc: FindDoc) => notations.size === 0 || notations.has(notationOf(doc));
378
- if (rest.trim().length === 0 || tokens(rest).length === 0) {
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
- for (const hit of scored) {
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
- out.push({ doc: hit.doc, score: hit.score, snippet: hit.snippet });
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 own = scored.find((entry) => entry.doc.id === id);
398
- out.push({ doc, score: hit.score, snippet: own?.snippet || hit.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
  }