@crvouga/atlas 0.2.0 → 0.4.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 +13 -1
- package/README.md +21 -2
- package/dist/reports/src/evidence-live.d.ts +6 -0
- package/dist/reports/src/evidence-live.js +35 -3
- package/dist/reports/src/evidence-types.d.ts +2 -0
- package/dist/reports/src/evidence-view.d.ts +15 -2
- package/dist/reports/src/evidence-view.js +31 -3
- package/dist/reports/src/export-evidence.js +2 -10
- package/dist/reports/src/frames.d.ts +3 -1
- package/dist/reports/src/frames.js +21 -0
- package/dist/reports/src/layer-axes.d.ts +18 -0
- package/dist/reports/src/layer-axes.js +51 -0
- package/dist/reports/src/layer-catalog.js +1 -1
- package/dist/reports/src/layer-merge.d.ts +1 -1
- package/dist/reports/src/layer-merge.js +9 -4
- package/dist/reports/src/layer-source.js +3 -0
- package/dist/reports/src/layer-types.d.ts +2 -0
- package/dist/reports/src/layers-route.js +2 -5
- package/dist/reports/src/protocol.d.ts +4 -0
- package/dist/reports/src/publish.d.ts +2 -0
- package/dist/reports/src/publish.js +6 -11
- package/dist/reports/src/recording-keys.js +4 -0
- package/dist/reports/src/report-parts.js +3 -0
- package/dist/reports/src/report-types.d.ts +19 -2
- package/dist/reports/src/source-entries.js +2 -12
- package/package.json +5 -4
- package/packages/reports/README.md +4 -2
- package/packages/reports/STORAGE.md +1 -1
- package/packages/visualizer/README.md +9 -6
- package/packages/visualizer/example/specs/coffee/ordering/cart.qnt +7 -0
- package/packages/visualizer/example/specs/coffee/ordering/checkout.openslo.yaml +10 -0
- package/packages/visualizer/example/specs/coffee/ordering/drink-price.dmn +24 -0
- package/packages/visualizer/example/specs/coffee/ordering/loyalty.feature +7 -0
- package/packages/visualizer/example/specs/coffee/ordering/ordering.feature +36 -0
- package/packages/visualizer/example/specs/coffee/ordering/payment.als +4 -0
- package/packages/visualizer/fixtures/failures/specs/coffee/ordering/cart.qnt +7 -0
- package/packages/visualizer/fixtures/failures/specs/coffee/ordering/checkout.openslo.yaml +10 -0
- package/packages/visualizer/fixtures/failures/specs/coffee/ordering/drink-price.dmn +24 -0
- package/packages/visualizer/fixtures/failures/specs/coffee/ordering/loyalty.feature +7 -0
- package/packages/visualizer/fixtures/failures/specs/coffee/ordering/ordering.feature +36 -0
- package/packages/visualizer/fixtures/failures/specs/coffee/ordering/payment.als +4 -0
- package/packages/visualizer/fixtures/full/specs/coffee/ordering/cart.qnt +7 -0
- package/packages/visualizer/fixtures/full/specs/coffee/ordering/checkout.openslo.yaml +10 -0
- package/packages/visualizer/fixtures/full/specs/coffee/ordering/drink-price.dmn +24 -0
- package/packages/visualizer/fixtures/full/specs/coffee/ordering/loyalty.feature +7 -0
- package/packages/visualizer/fixtures/full/specs/coffee/ordering/ordering.feature +36 -0
- package/packages/visualizer/fixtures/full/specs/coffee/ordering/payment.als +4 -0
- package/packages/visualizer/fixtures/malformed/specs/coffee/ordering/cart.qnt +7 -0
- package/packages/visualizer/fixtures/malformed/specs/coffee/ordering/checkout.openslo.yaml +10 -0
- package/packages/visualizer/fixtures/malformed/specs/coffee/ordering/drink-price.dmn +24 -0
- package/packages/visualizer/fixtures/malformed/specs/coffee/ordering/loyalty.feature +7 -0
- package/packages/visualizer/fixtures/malformed/specs/coffee/ordering/ordering.feature +36 -0
- package/packages/visualizer/fixtures/malformed/specs/coffee/ordering/payment.als +4 -0
- package/packages/visualizer/fixtures/partial/specs/coffee/ordering/cart.qnt +7 -0
- package/packages/visualizer/fixtures/partial/specs/coffee/ordering/checkout.openslo.yaml +10 -0
- package/packages/visualizer/fixtures/partial/specs/coffee/ordering/drink-price.dmn +24 -0
- package/packages/visualizer/fixtures/partial/specs/coffee/ordering/loyalty.feature +7 -0
- package/packages/visualizer/fixtures/partial/specs/coffee/ordering/ordering.feature +36 -0
- package/packages/visualizer/fixtures/partial/specs/coffee/ordering/payment.als +4 -0
- package/packages/visualizer/fixtures/removed/specs/coffee/ordering/cart.qnt +7 -0
- package/packages/visualizer/fixtures/removed/specs/coffee/ordering/checkout.openslo.yaml +10 -0
- package/packages/visualizer/fixtures/removed/specs/coffee/ordering/drink-price.dmn +24 -0
- package/packages/visualizer/fixtures/removed/specs/coffee/ordering/loyalty.feature +7 -0
- package/packages/visualizer/fixtures/removed/specs/coffee/ordering/ordering.feature +36 -0
- package/packages/visualizer/fixtures/removed/specs/coffee/ordering/payment.als +4 -0
- package/packages/visualizer/fixtures/spec-only/specs/coffee/ordering/cart.qnt +7 -0
- package/packages/visualizer/fixtures/spec-only/specs/coffee/ordering/checkout.openslo.yaml +10 -0
- package/packages/visualizer/fixtures/spec-only/specs/coffee/ordering/drink-price.dmn +24 -0
- package/packages/visualizer/fixtures/spec-only/specs/coffee/ordering/loyalty.feature +7 -0
- package/packages/visualizer/fixtures/spec-only/specs/coffee/ordering/ordering.feature +36 -0
- package/packages/visualizer/fixtures/spec-only/specs/coffee/ordering/payment.als +4 -0
- package/packages/visualizer/scripts/make-fixtures.ts +2 -0
- package/packages/visualizer/scripts/publish-data.ts +1 -2
- package/packages/visualizer/src/app/AppShell.tsx +11 -12
- package/packages/visualizer/src/app/gazetteer.module.css +12 -2
- package/packages/visualizer/src/app/gazetteer.tsx +38 -26
- package/packages/visualizer/src/components/Header.module.css +20 -4
- package/packages/visualizer/src/components/Header.tsx +7 -4
- package/packages/visualizer/src/data/model/build.ts +17 -5
- package/packages/visualizer/src/data/model/types.ts +10 -1
- package/packages/visualizer/src/evidence/api.ts +6 -1
- package/packages/visualizer/src/evidence/check-detail.tsx +35 -3
- package/packages/visualizer/src/evidence/evidence.module.css +20 -0
- package/packages/visualizer/src/evidence/marks.tsx +2 -0
- package/packages/visualizer/src/evidence/overview.tsx +3 -3
- package/packages/visualizer/src/evidence/recording-page.tsx +19 -2
- package/packages/visualizer/src/evidence/recording.module.css +4 -8
- package/packages/visualizer/src/evidence/recording.tsx +74 -83
- package/packages/visualizer/src/evidence/snapshot.tsx +5 -4
- package/packages/visualizer/src/evidence/sources-panel.tsx +88 -34
- package/packages/visualizer/src/evidence/spec-index.tsx +3 -11
- package/packages/visualizer/src/find/Finder.module.css +168 -0
- package/packages/visualizer/src/find/Finder.tsx +303 -0
- package/packages/visualizer/src/find/corpus.test.ts +77 -0
- package/packages/visualizer/src/find/corpus.ts +368 -0
- package/packages/visualizer/src/find/open.ts +51 -0
- package/packages/visualizer/src/find/rank.test.ts +237 -0
- package/packages/visualizer/src/find/rank.ts +405 -0
- package/packages/visualizer/src/find/semantic.test.ts +62 -0
- package/packages/visualizer/src/find/semantic.ts +93 -0
- package/packages/visualizer/src/find/semantic.worker.ts +40 -0
- package/packages/visualizer/src/looks/Stage.tsx +322 -0
- package/packages/visualizer/src/looks/collect.test.ts +158 -0
- package/packages/visualizer/src/looks/collect.ts +245 -0
- package/packages/visualizer/src/looks/stage.module.css +300 -0
- package/packages/visualizer/src/reachable.test.ts +1 -1
- package/packages/visualizer/src/state/ui-store.ts +6 -2
- package/packages/visualizer/src/views/SidePanel.tsx +39 -49
- package/packages/visualizer/vite/example-reports.ts +475 -0
- package/packages/visualizer/vite.config.ts +9 -1
- package/packages/visualizer/src/app/search-query.tsx +0 -14
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
/** Everyday words a new person uses for the same action. The first word is the class id. */
|
|
2
|
+
const CLASSES: readonly (readonly string[])[] = [
|
|
3
|
+
['complete', 'done', 'finish', 'mark', 'check'],
|
|
4
|
+
['add', 'create', 'new', 'insert'],
|
|
5
|
+
['remove', 'delete', 'clear', 'discard', 'throw']
|
|
6
|
+
];
|
|
7
|
+
|
|
8
|
+
const STOP = new Set(['a', 'an', 'the', 'as', 'to', 'of', 'and', 'or', 'they', 'them', 'when', 'their', 'it', 'is', 'on']);
|
|
9
|
+
|
|
10
|
+
/** Query words that mean a kind of spec. The first list is the notation; the rest are what people call it. */
|
|
11
|
+
const TYPE_WORDS: readonly { readonly notation: string; readonly words: readonly string[] }[] = [
|
|
12
|
+
{ notation: 'gherkin', words: ['gherkin', 'cucumber', 'feature', 'scenario'] },
|
|
13
|
+
{ notation: 'quint', words: ['quint', 'qnt', 'model'] },
|
|
14
|
+
{ notation: 'statechart', words: ['statechart', 'scxml'] },
|
|
15
|
+
{ notation: 'dmn', words: ['dmn'] },
|
|
16
|
+
{ notation: 'alloy', words: ['alloy'] },
|
|
17
|
+
{ notation: 'openslo', words: ['openslo', 'slo'] }
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
const TYPE_PHRASES: readonly { readonly notation: string; readonly left: string; readonly right: string }[] = [
|
|
21
|
+
{ notation: 'statechart', left: 'state', right: 'machine' },
|
|
22
|
+
{ notation: 'statechart', left: 'state', right: 'chart' },
|
|
23
|
+
{ notation: 'dmn', left: 'decision', right: 'table' },
|
|
24
|
+
{ notation: 'openslo', left: 'open', right: 'slo' }
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
const TYPE_OF = new Map<string, string>();
|
|
28
|
+
for (const entry of TYPE_WORDS) for (const word of entry.words) TYPE_OF.set(word, entry.notation);
|
|
29
|
+
|
|
30
|
+
export const FIELD_WEIGHT = { title: 5, steps: 3, description: 2, path: 0.5 } as const;
|
|
31
|
+
|
|
32
|
+
export type FieldName = keyof typeof FIELD_WEIGHT;
|
|
33
|
+
|
|
34
|
+
export type FindField = { readonly name: FieldName; readonly text: string };
|
|
35
|
+
|
|
36
|
+
export type FindGroup = 'Flows' | 'Specs' | 'Runs';
|
|
37
|
+
|
|
38
|
+
export const FIND_GROUPS: readonly FindGroup[] = ['Flows', 'Specs', 'Runs'];
|
|
39
|
+
|
|
40
|
+
export type FindKind = 'journey' | 'event' | 'screen' | 'spec' | 'scenario' | 'action' | 'run';
|
|
41
|
+
|
|
42
|
+
export type FindStatus = 'passed' | 'failed' | 'running' | 'not-run' | 'none';
|
|
43
|
+
|
|
44
|
+
export type FindTarget = {
|
|
45
|
+
readonly to: '/' | '/chart/$chartId' | '/specs/$kind';
|
|
46
|
+
readonly chartId?: string;
|
|
47
|
+
readonly kind?: string;
|
|
48
|
+
readonly search: {
|
|
49
|
+
readonly screen?: string;
|
|
50
|
+
readonly event?: string;
|
|
51
|
+
readonly journey?: string;
|
|
52
|
+
readonly spec?: string;
|
|
53
|
+
readonly check?: string;
|
|
54
|
+
readonly view?: 'recording';
|
|
55
|
+
};
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export type FindDoc = {
|
|
59
|
+
readonly id: string;
|
|
60
|
+
readonly group: FindGroup;
|
|
61
|
+
readonly kind: FindKind;
|
|
62
|
+
readonly title: string;
|
|
63
|
+
readonly subtitle: string;
|
|
64
|
+
readonly fields: readonly FindField[];
|
|
65
|
+
readonly status: FindStatus | null;
|
|
66
|
+
/** Listed whenever this document matches, even when their own words do not. */
|
|
67
|
+
readonly related: readonly string[];
|
|
68
|
+
readonly target: FindTarget;
|
|
69
|
+
readonly recording: FindTarget | null;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
export type Ranked = { readonly doc: FindDoc; readonly score: number; readonly snippet: string };
|
|
73
|
+
|
|
74
|
+
const CLASS_OF = new Map<string, string>();
|
|
75
|
+
for (const members of CLASSES) {
|
|
76
|
+
const id = members[0]!;
|
|
77
|
+
for (const member of members) CLASS_OF.set(member, id);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Inflections the stemmer strips, so a typo of "marking" still reaches the class of "mark". */
|
|
81
|
+
function inflections(word: string): string[] {
|
|
82
|
+
if (word.endsWith('e')) return [word, `${word}s`, `${word}d`, `${word.slice(0, -1)}ing`];
|
|
83
|
+
return [word, `${word}s`, `${word}ed`, `${word}ing`];
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const ALIASES: readonly { readonly surface: string; readonly term: string }[] = CLASSES.flatMap((members) => {
|
|
87
|
+
const id = members[0]!;
|
|
88
|
+
return members.flatMap((member) => inflections(member).map((surface) => ({ surface, term: id })));
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
export function stem(raw: string): string {
|
|
92
|
+
let word = raw;
|
|
93
|
+
if (word.length > 5 && word.endsWith('ing')) word = word.slice(0, -3);
|
|
94
|
+
else if (word.length > 4 && word.endsWith('ed')) word = word.slice(0, -2);
|
|
95
|
+
else if (word.length > 4 && word.endsWith('es')) word = word.slice(0, -2);
|
|
96
|
+
else if (word.length > 3 && word.endsWith('s') && !word.endsWith('ss')) word = word.slice(0, -1);
|
|
97
|
+
return word;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Class id when the word is an everyday verb, otherwise the stem. `completed` joins `complete`. */
|
|
101
|
+
export function canonical(raw: string): string {
|
|
102
|
+
const stemmed = stem(raw);
|
|
103
|
+
return CLASS_OF.get(raw) ?? CLASS_OF.get(stemmed) ?? CLASS_OF.get(`${stemmed}e`) ?? stemmed;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function tokens(text: string): string[] {
|
|
107
|
+
const out: string[] = [];
|
|
108
|
+
for (const piece of text.toLowerCase().split(/[^a-z0-9]+/)) {
|
|
109
|
+
if (!piece || STOP.has(piece)) continue;
|
|
110
|
+
const term = canonical(piece);
|
|
111
|
+
if (term && !STOP.has(term)) out.push(term);
|
|
112
|
+
}
|
|
113
|
+
return out;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function damerau(a: string, b: string, limit: number): number {
|
|
117
|
+
if (a === b) return 0;
|
|
118
|
+
if (Math.abs(a.length - b.length) > limit) return limit + 1;
|
|
119
|
+
const rows: number[][] = Array.from({ length: a.length + 1 }, () => new Array<number>(b.length + 1).fill(0));
|
|
120
|
+
for (let i = 0; i <= a.length; i++) rows[i]![0] = i;
|
|
121
|
+
for (let j = 0; j <= b.length; j++) rows[0]![j] = j;
|
|
122
|
+
for (let i = 1; i <= a.length; i++) {
|
|
123
|
+
let rowMin = i;
|
|
124
|
+
const prev = rows[i - 1]!;
|
|
125
|
+
const here = rows[i]!;
|
|
126
|
+
for (let j = 1; j <= b.length; j++) {
|
|
127
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
128
|
+
let value = Math.min(prev[j]! + 1, here[j - 1]! + 1, prev[j - 1]! + cost);
|
|
129
|
+
if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1]) value = Math.min(value, rows[i - 2]![j - 2]! + 1);
|
|
130
|
+
here[j] = value;
|
|
131
|
+
if (value < rowMin) rowMin = value;
|
|
132
|
+
}
|
|
133
|
+
if (rowMin > limit) return limit + 1;
|
|
134
|
+
}
|
|
135
|
+
return rows[a.length]![b.length]!;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
type Posting = { readonly doc: number; readonly weight: number; readonly snippet: string; readonly snippetRank: number };
|
|
139
|
+
|
|
140
|
+
export type FindIndex = {
|
|
141
|
+
readonly docs: readonly FindDoc[];
|
|
142
|
+
readonly postings: ReadonlyMap<string, readonly Posting[]>;
|
|
143
|
+
readonly surfaces: ReadonlyMap<string, string>;
|
|
144
|
+
readonly df: ReadonlyMap<string, number>;
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
function snippetRank(name: FieldName): number {
|
|
148
|
+
if (name === 'steps') return 2;
|
|
149
|
+
if (name === 'description') return 1;
|
|
150
|
+
return 0;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function indexCorpus(docs: readonly FindDoc[]): FindIndex {
|
|
154
|
+
const postings = new Map<string, Posting[]>();
|
|
155
|
+
const best = new Map<string, Posting>();
|
|
156
|
+
const surfaces = new Map<string, string>();
|
|
157
|
+
for (let doc = 0; doc < docs.length; doc++) {
|
|
158
|
+
best.clear();
|
|
159
|
+
for (const field of docs[doc]!.fields) {
|
|
160
|
+
const weight = FIELD_WEIGHT[field.name];
|
|
161
|
+
const rank = snippetRank(field.name);
|
|
162
|
+
for (const piece of field.text.toLowerCase().split(/[^a-z0-9]+/)) {
|
|
163
|
+
if (!piece || STOP.has(piece)) continue;
|
|
164
|
+
const term = canonical(piece);
|
|
165
|
+
if (!term || STOP.has(term)) continue;
|
|
166
|
+
if (piece.length >= 4 && !surfaces.has(piece)) surfaces.set(piece, term);
|
|
167
|
+
const stemmed = stem(piece);
|
|
168
|
+
if (stemmed.length >= 4 && !surfaces.has(stemmed)) surfaces.set(stemmed, canonical(stemmed));
|
|
169
|
+
const prior = best.get(term);
|
|
170
|
+
const fromField = rank > 0 ? field.text : '';
|
|
171
|
+
if (!prior) best.set(term, { doc, weight, snippet: fromField, snippetRank: rank });
|
|
172
|
+
else if (weight > prior.weight || rank > prior.snippetRank) {
|
|
173
|
+
best.set(term, {
|
|
174
|
+
doc,
|
|
175
|
+
weight: Math.max(weight, prior.weight),
|
|
176
|
+
snippet: rank > prior.snippetRank ? fromField : prior.snippet,
|
|
177
|
+
snippetRank: Math.max(rank, prior.snippetRank)
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
for (const [term, posting] of best) {
|
|
183
|
+
const list = postings.get(term);
|
|
184
|
+
if (list) list.push(posting);
|
|
185
|
+
else postings.set(term, [posting]);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
const df = new Map<string, number>();
|
|
189
|
+
for (const [term, list] of postings) df.set(term, list.length);
|
|
190
|
+
return { docs, postings, surfaces, df };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function idf(df: number, total: number): number {
|
|
194
|
+
return Math.log((total + 1) / (df + 0.5));
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
type Candidate = { readonly term: string; readonly factor: number };
|
|
198
|
+
|
|
199
|
+
function candidatesFor(piece: string, index: FindIndex): Candidate[] {
|
|
200
|
+
const factors = new Map<string, number>();
|
|
201
|
+
factors.set(canonical(piece), 1);
|
|
202
|
+
if (piece.length >= 4) {
|
|
203
|
+
const limit = piece.length > 6 ? 2 : 1;
|
|
204
|
+
const consider = (surface: string, term: string) => {
|
|
205
|
+
if (Math.abs(surface.length - piece.length) > limit) return;
|
|
206
|
+
const dist = damerau(piece, surface, limit);
|
|
207
|
+
if (dist <= 0 || dist > limit) return;
|
|
208
|
+
const factor = dist === 1 ? 0.85 : 0.7;
|
|
209
|
+
const prior = factors.get(term) ?? 0;
|
|
210
|
+
if (factor > prior) factors.set(term, factor);
|
|
211
|
+
};
|
|
212
|
+
for (const alias of ALIASES) consider(alias.surface, alias.term);
|
|
213
|
+
for (const [surface, term] of index.surfaces) consider(surface, term);
|
|
214
|
+
}
|
|
215
|
+
return [...factors].map(([term, factor]) => ({ term, factor }));
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function wordKeys(raw: string): string[] {
|
|
219
|
+
const stemmed = stem(raw);
|
|
220
|
+
return [raw, stemmed, stemmed.endsWith('e') ? stemmed.slice(0, -1) : `${stemmed}e`];
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function notationOfWord(piece: string): string | null {
|
|
224
|
+
for (const key of wordKeys(piece)) {
|
|
225
|
+
const exact = TYPE_OF.get(key);
|
|
226
|
+
if (exact) return exact;
|
|
227
|
+
}
|
|
228
|
+
if (piece.length < 5) return null;
|
|
229
|
+
if (CLASS_OF.has(piece) || CLASS_OF.has(stem(piece)) || CLASS_OF.has(`${stem(piece)}e`)) return null;
|
|
230
|
+
const limit = piece.length > 6 ? 2 : 1;
|
|
231
|
+
let best: { readonly notation: string; readonly dist: number } | null = null;
|
|
232
|
+
for (const [word, notation] of TYPE_OF) {
|
|
233
|
+
if (word.length < 5 || Math.abs(word.length - piece.length) > limit) continue;
|
|
234
|
+
const dist = damerau(piece, word, limit);
|
|
235
|
+
if (dist <= 0 || dist > limit) continue;
|
|
236
|
+
if (!best || dist < best.dist) best = { notation, dist };
|
|
237
|
+
}
|
|
238
|
+
return best?.notation ?? null;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function phraseNotation(left: string, right: string): string | null {
|
|
242
|
+
const lk = new Set(wordKeys(left));
|
|
243
|
+
const rk = new Set(wordKeys(right));
|
|
244
|
+
for (const phrase of TYPE_PHRASES) {
|
|
245
|
+
if (lk.has(phrase.left) && rk.has(phrase.right)) return phrase.notation;
|
|
246
|
+
}
|
|
247
|
+
return null;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** The kind of spec a row opens, or `statechart` for a place, action, or journey on the chart. */
|
|
251
|
+
export function notationOf(doc: FindDoc): string {
|
|
252
|
+
return doc.target.kind ?? (doc.target.to === '/chart/$chartId' ? 'statechart' : '');
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** Kind words in a query, and the query with those words removed. */
|
|
256
|
+
export function splitQuery(query: string): { readonly notations: ReadonlySet<string>; readonly rest: string } {
|
|
257
|
+
const parts = query.toLowerCase().split(/[^a-z0-9]+/).filter((piece) => piece && !STOP.has(piece));
|
|
258
|
+
const notations = new Set<string>();
|
|
259
|
+
const rest: string[] = [];
|
|
260
|
+
for (let i = 0; i < parts.length; i++) {
|
|
261
|
+
const next = parts[i + 1];
|
|
262
|
+
const phrase = next ? phraseNotation(parts[i]!, next) : null;
|
|
263
|
+
if (phrase) {
|
|
264
|
+
notations.add(phrase);
|
|
265
|
+
i++;
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
268
|
+
const notation = notationOfWord(parts[i]!);
|
|
269
|
+
if (notation) notations.add(notation);
|
|
270
|
+
else rest.push(parts[i]!);
|
|
271
|
+
}
|
|
272
|
+
return { notations, rest: rest.join(' ') };
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const KIND_ORDER: Record<FindKind, number> = {
|
|
276
|
+
journey: 0,
|
|
277
|
+
event: 1,
|
|
278
|
+
screen: 2,
|
|
279
|
+
scenario: 3,
|
|
280
|
+
action: 4,
|
|
281
|
+
spec: 5,
|
|
282
|
+
run: 6
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
function listOfType(docs: readonly FindDoc[], limit: number): Ranked[] {
|
|
286
|
+
return [...docs]
|
|
287
|
+
.sort((a, b) => KIND_ORDER[a.kind] - KIND_ORDER[b.kind] || (a.title < b.title ? -1 : a.title > b.title ? 1 : 0))
|
|
288
|
+
.slice(0, limit)
|
|
289
|
+
.map((doc) => ({ doc, score: 0, snippet: '' }));
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function lineSnippet(text: string, terms: ReadonlySet<string>): string {
|
|
293
|
+
let best = '';
|
|
294
|
+
let bestScore = 0;
|
|
295
|
+
for (const line of text.split(/\n|(?<=\.)\s+/)) {
|
|
296
|
+
const trimmed = line.trim();
|
|
297
|
+
if (!trimmed) continue;
|
|
298
|
+
let score = 0;
|
|
299
|
+
for (const term of tokens(trimmed)) if (terms.has(term)) score++;
|
|
300
|
+
if (score > bestScore) {
|
|
301
|
+
best = trimmed;
|
|
302
|
+
bestScore = score;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
if (bestScore === 0) return '';
|
|
306
|
+
return best.length > 140 ? `${best.slice(0, 137)}…` : best;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
type Scored = Ranked & { readonly misses: number };
|
|
310
|
+
|
|
311
|
+
function scoreAll(index: FindIndex, query: string): Scored[] {
|
|
312
|
+
const pieces: string[] = [];
|
|
313
|
+
const seen = new Set<string>();
|
|
314
|
+
for (const piece of query.toLowerCase().split(/[^a-z0-9]+/)) {
|
|
315
|
+
if (!piece || STOP.has(piece)) continue;
|
|
316
|
+
const exact = canonical(piece);
|
|
317
|
+
if (seen.has(exact)) continue;
|
|
318
|
+
seen.add(exact);
|
|
319
|
+
pieces.push(piece);
|
|
320
|
+
}
|
|
321
|
+
const total = index.docs.length;
|
|
322
|
+
const queries = pieces.map((piece) => {
|
|
323
|
+
const candidates = candidatesFor(piece, index);
|
|
324
|
+
const exact = canonical(piece);
|
|
325
|
+
const df = index.df.get(exact) ?? 0;
|
|
326
|
+
return { candidates, rare: df * 2 <= total };
|
|
327
|
+
});
|
|
328
|
+
const queryTerms = new Set(queries.flatMap((entry) => entry.candidates.map((candidate) => candidate.term)));
|
|
329
|
+
return index.docs.map((doc, docIndex) => {
|
|
330
|
+
let score = 0;
|
|
331
|
+
let misses = 0;
|
|
332
|
+
let snippet = '';
|
|
333
|
+
let snippetRankSeen = 0;
|
|
334
|
+
for (const entry of queries) {
|
|
335
|
+
let best = 0;
|
|
336
|
+
let bestSnippet = '';
|
|
337
|
+
let bestRank = 0;
|
|
338
|
+
let hit = false;
|
|
339
|
+
for (const candidate of entry.candidates) {
|
|
340
|
+
const posting = index.postings.get(candidate.term)?.find((item) => item.doc === docIndex);
|
|
341
|
+
if (!posting) continue;
|
|
342
|
+
hit = true;
|
|
343
|
+
const value = idf(index.df.get(candidate.term) ?? 0, total) * posting.weight * candidate.factor;
|
|
344
|
+
if (value > best) best = value;
|
|
345
|
+
if (posting.snippetRank > bestRank) {
|
|
346
|
+
bestRank = posting.snippetRank;
|
|
347
|
+
bestSnippet = posting.snippet;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
if (hit) score += best;
|
|
351
|
+
else if (entry.rare) misses++;
|
|
352
|
+
if (bestRank > snippetRankSeen && bestSnippet) {
|
|
353
|
+
snippetRankSeen = bestRank;
|
|
354
|
+
snippet = bestSnippet;
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
return { doc, score, misses, snippet: lineSnippet(snippet, queryTerms) };
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/** Journeys first, then failing runs. An empty box stays a short list of stories. */
|
|
362
|
+
export function suggest(docs: readonly FindDoc[], limit = 40): Ranked[] {
|
|
363
|
+
const journeys = docs.filter((doc) => doc.kind === 'journey');
|
|
364
|
+
const failing = docs.filter((doc) => doc.kind === 'run' && doc.status === 'failed');
|
|
365
|
+
return [...journeys, ...failing].slice(0, limit).map((doc) => ({ doc, score: 0, snippet: '' }));
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Rank documents for a query. A document that misses a rare query word sinks below every
|
|
370
|
+
* document that explains it, so a word that is in almost every row cannot win on its own.
|
|
371
|
+
* Matching a document also lists its `related` rows.
|
|
372
|
+
*/
|
|
373
|
+
export function rankIndex(index: FindIndex, query: string, limit = 40): Ranked[] {
|
|
374
|
+
const { notations, rest } = splitQuery(query);
|
|
375
|
+
const allows = (doc: FindDoc) => notations.size === 0 || notations.has(notationOf(doc));
|
|
376
|
+
if (rest.trim().length === 0 || tokens(rest).length === 0) {
|
|
377
|
+
if (notations.size === 0) return suggest(index.docs, limit);
|
|
378
|
+
return listOfType(index.docs.filter(allows), limit);
|
|
379
|
+
}
|
|
380
|
+
const scored = scoreAll(index, rest).filter((entry) => entry.score > 0 && allows(entry.doc));
|
|
381
|
+
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));
|
|
382
|
+
const byId = new Map(index.docs.map((doc) => [doc.id, doc]));
|
|
383
|
+
const seen = new Set<string>();
|
|
384
|
+
const out: Ranked[] = [];
|
|
385
|
+
for (const hit of scored) {
|
|
386
|
+
if (out.length >= limit) break;
|
|
387
|
+
if (!seen.has(hit.doc.id)) {
|
|
388
|
+
out.push({ doc: hit.doc, score: hit.score, snippet: hit.snippet });
|
|
389
|
+
seen.add(hit.doc.id);
|
|
390
|
+
}
|
|
391
|
+
for (const id of hit.doc.related) {
|
|
392
|
+
if (out.length >= limit || seen.has(id)) continue;
|
|
393
|
+
const doc = byId.get(id);
|
|
394
|
+
if (!doc || !allows(doc)) continue;
|
|
395
|
+
const own = scored.find((entry) => entry.doc.id === id);
|
|
396
|
+
out.push({ doc, score: hit.score, snippet: own?.snippet || hit.snippet });
|
|
397
|
+
seen.add(id);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
return out;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
export function rank(docs: readonly FindDoc[], query: string, limit = 40): Ranked[] {
|
|
404
|
+
return rankIndex(indexCorpus(docs), query, limit);
|
|
405
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { expect, test } from 'bun:test';
|
|
2
|
+
|
|
3
|
+
import type { FindDoc } from './rank';
|
|
4
|
+
import { applySemantic, cosine, SEMANTIC_FLOOR } from './semantic';
|
|
5
|
+
|
|
6
|
+
function doc(id: string, title: string): FindDoc {
|
|
7
|
+
return {
|
|
8
|
+
id,
|
|
9
|
+
group: 'Specs',
|
|
10
|
+
kind: 'scenario',
|
|
11
|
+
title,
|
|
12
|
+
subtitle: '',
|
|
13
|
+
fields: [{ name: 'title', text: title }],
|
|
14
|
+
status: null,
|
|
15
|
+
related: [],
|
|
16
|
+
target: { to: '/', search: {} },
|
|
17
|
+
recording: null
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
test('cosine is one for the same direction and zero at a right angle', () => {
|
|
22
|
+
expect(cosine(Float32Array.of(1, 0), Float32Array.of(2, 0))).toBeCloseTo(1);
|
|
23
|
+
expect(cosine(Float32Array.of(1, 0), Float32Array.of(0, 1))).toBeCloseTo(0);
|
|
24
|
+
expect(cosine(Float32Array.of(0, 0), Float32Array.of(1, 0))).toBe(0);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test('a close paraphrase joins the list and a vague one stays out', () => {
|
|
28
|
+
const lexicalDoc = doc('lexical', 'Marks all done');
|
|
29
|
+
const close = doc('close', 'Tick every item');
|
|
30
|
+
const vague = doc('vague', 'The weather');
|
|
31
|
+
const query = Float32Array.of(1, 0);
|
|
32
|
+
const vectors = new Map<string, Float32Array>([
|
|
33
|
+
['lexical', Float32Array.of(1, 0)],
|
|
34
|
+
['close', Float32Array.of(0.8, 0.6)],
|
|
35
|
+
['vague', Float32Array.of(0, 1)]
|
|
36
|
+
]);
|
|
37
|
+
const ranked = applySemantic(
|
|
38
|
+
[lexicalDoc, close, vague],
|
|
39
|
+
[{ doc: lexicalDoc, score: 10, snippet: 'When they tap "Mark all done"' }],
|
|
40
|
+
query,
|
|
41
|
+
vectors
|
|
42
|
+
);
|
|
43
|
+
expect(ranked.map((hit) => hit.doc.id)).toEqual(['lexical', 'close']);
|
|
44
|
+
expect(ranked[0]?.snippet).toContain('Mark all done');
|
|
45
|
+
expect(ranked[1]!.score).toBeLessThan(ranked[0]!.score);
|
|
46
|
+
expect(cosine(query, vectors.get('vague')!)).toBeLessThan(SEMANTIC_FLOOR);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('a paraphrase under the floor is left out', () => {
|
|
50
|
+
const lexicalDoc = doc('lexical', 'Marks all done');
|
|
51
|
+
const almost = doc('almost', 'Something nearby');
|
|
52
|
+
const ranked = applySemantic(
|
|
53
|
+
[lexicalDoc, almost],
|
|
54
|
+
[{ doc: lexicalDoc, score: 4, snippet: '' }],
|
|
55
|
+
Float32Array.of(1, 0),
|
|
56
|
+
new Map([
|
|
57
|
+
['lexical', Float32Array.of(1, 0)],
|
|
58
|
+
['almost', Float32Array.of(0.4, 0.9165)]
|
|
59
|
+
])
|
|
60
|
+
);
|
|
61
|
+
expect(ranked.map((hit) => hit.doc.id)).toEqual(['lexical']);
|
|
62
|
+
});
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import type { FindDoc, Ranked } from './rank';
|
|
2
|
+
|
|
3
|
+
/** Turns text into vectors. Tests pass a fake. The browser one loads a model on demand. */
|
|
4
|
+
export type Embedder = {
|
|
5
|
+
embed(texts: readonly string[]): Promise<readonly Float32Array[]>;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
/** Below this, a paraphrase is too vague to add to the list. */
|
|
9
|
+
export const SEMANTIC_FLOOR = 0.42;
|
|
10
|
+
|
|
11
|
+
export function cosine(a: Float32Array, b: Float32Array): number {
|
|
12
|
+
const length = Math.min(a.length, b.length);
|
|
13
|
+
let dot = 0;
|
|
14
|
+
let left = 0;
|
|
15
|
+
let right = 0;
|
|
16
|
+
for (let i = 0; i < length; i++) {
|
|
17
|
+
const x = a[i] ?? 0;
|
|
18
|
+
const y = b[i] ?? 0;
|
|
19
|
+
dot += x * y;
|
|
20
|
+
left += x * x;
|
|
21
|
+
right += y * y;
|
|
22
|
+
}
|
|
23
|
+
const scale = Math.sqrt(left) * Math.sqrt(right);
|
|
24
|
+
return scale === 0 ? 0 : dot / scale;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function textHash(text: string): string {
|
|
28
|
+
let hash = 2166136261;
|
|
29
|
+
for (let i = 0; i < text.length; i++) hash = Math.imul(hash ^ text.charCodeAt(i), 16777619);
|
|
30
|
+
return (hash >>> 0).toString(16);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function embedText(doc: FindDoc): string {
|
|
34
|
+
return [doc.title, doc.subtitle, ...doc.fields.map((field) => field.text)].join('\n');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Reorder lexical hits by cosine when both sides have a vector, and add a document the words
|
|
39
|
+
* missed when it is still close to the query. Lexical hits stay in the list either way.
|
|
40
|
+
*/
|
|
41
|
+
export function applySemantic(docs: readonly FindDoc[], lexical: readonly Ranked[], queryVector: Float32Array, vectors: ReadonlyMap<string, Float32Array>, limit = 40): Ranked[] {
|
|
42
|
+
let maxLexical = 1;
|
|
43
|
+
for (const hit of lexical) if (hit.score > maxLexical) maxLexical = hit.score;
|
|
44
|
+
const blended: Ranked[] = lexical.map((hit) => {
|
|
45
|
+
const vector = vectors.get(hit.doc.id);
|
|
46
|
+
if (!vector) return hit;
|
|
47
|
+
const similarity = cosine(queryVector, vector);
|
|
48
|
+
return { ...hit, score: hit.score * (0.65 + 0.35 * Math.max(0, similarity)) };
|
|
49
|
+
});
|
|
50
|
+
const seen = new Set(blended.map((hit) => hit.doc.id));
|
|
51
|
+
for (const doc of docs) {
|
|
52
|
+
if (seen.has(doc.id)) continue;
|
|
53
|
+
const vector = vectors.get(doc.id);
|
|
54
|
+
if (!vector) continue;
|
|
55
|
+
const similarity = cosine(queryVector, vector);
|
|
56
|
+
if (similarity < SEMANTIC_FLOOR) continue;
|
|
57
|
+
blended.push({ doc, score: similarity * maxLexical * 0.5, snippet: '' });
|
|
58
|
+
}
|
|
59
|
+
blended.sort((a, b) => b.score - a.score || (a.doc.title < b.doc.title ? -1 : a.doc.title > b.doc.title ? 1 : 0));
|
|
60
|
+
return blended.slice(0, limit);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
type EmbedRequest = { id: number; texts: string[] };
|
|
64
|
+
type EmbedResponse = { id: number; vectors?: number[][]; error?: string };
|
|
65
|
+
|
|
66
|
+
/** True when this module was loaded from a page, so a worker can fetch a model. Tests load it from disk. */
|
|
67
|
+
export function embeddingsAvailable(): boolean {
|
|
68
|
+
return import.meta.url.startsWith('http:') || import.meta.url.startsWith('https:');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** One worker for the page. The model is fetched into the browser cache, never shipped in the package. */
|
|
72
|
+
export function createWorkerEmbedder(): Embedder {
|
|
73
|
+
const worker = new Worker(new URL('./semantic.worker.ts', import.meta.url), { type: 'module' });
|
|
74
|
+
let next = 0;
|
|
75
|
+
const pending = new Map<number, { resolve: (vectors: Float32Array[]) => void; reject: (error: Error) => void }>();
|
|
76
|
+
worker.onmessage = (event: MessageEvent<EmbedResponse>) => {
|
|
77
|
+
const wait = pending.get(event.data.id);
|
|
78
|
+
if (!wait) return;
|
|
79
|
+
pending.delete(event.data.id);
|
|
80
|
+
if (event.data.error || !event.data.vectors) wait.reject(new Error(event.data.error ?? 'The embedder returned nothing'));
|
|
81
|
+
else wait.resolve(event.data.vectors.map((values) => Float32Array.from(values)));
|
|
82
|
+
};
|
|
83
|
+
return {
|
|
84
|
+
embed(texts) {
|
|
85
|
+
const id = next++;
|
|
86
|
+
return new Promise((resolve, reject) => {
|
|
87
|
+
pending.set(id, { resolve, reject });
|
|
88
|
+
const message: EmbedRequest = { id, texts: [...texts] };
|
|
89
|
+
worker.postMessage(message);
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Loads a small quantized MiniLM when the finder opens. The weights come from the network and
|
|
3
|
+
* stay in the browser cache. They are not part of the published package.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
type Extractor = (text: string, options: { pooling: 'mean'; normalize: true }) => Promise<{ data: Float32Array }>;
|
|
7
|
+
|
|
8
|
+
type Transformers = {
|
|
9
|
+
pipeline: (task: 'feature-extraction', model: string, options: { dtype: 'q8' }) => Promise<Extractor>;
|
|
10
|
+
env: { useBrowserCache: boolean; allowLocalModels: boolean };
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
let extractor: Promise<Extractor> | null = null;
|
|
14
|
+
|
|
15
|
+
function model(): Promise<Extractor> {
|
|
16
|
+
extractor ??= (async () => {
|
|
17
|
+
const href = 'https://cdn.jsdelivr.net/npm/@huggingface/transformers@3.8.1/+esm';
|
|
18
|
+
const mod = (await import(/* @vite-ignore */ href)) as Transformers;
|
|
19
|
+
mod.env.useBrowserCache = true;
|
|
20
|
+
mod.env.allowLocalModels = false;
|
|
21
|
+
return mod.pipeline('feature-extraction', 'Xenova/all-MiniLM-L6-v2', { dtype: 'q8' });
|
|
22
|
+
})();
|
|
23
|
+
return extractor;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
self.onmessage = (event: MessageEvent<{ id: number; texts: string[] }>) => {
|
|
27
|
+
const { id, texts } = event.data;
|
|
28
|
+
void model()
|
|
29
|
+
.then(async (embed) => {
|
|
30
|
+
const vectors: number[][] = [];
|
|
31
|
+
for (const text of texts) {
|
|
32
|
+
const result = await embed(text, { pooling: 'mean', normalize: true });
|
|
33
|
+
vectors.push([...result.data]);
|
|
34
|
+
}
|
|
35
|
+
self.postMessage({ id, vectors });
|
|
36
|
+
})
|
|
37
|
+
.catch((error: unknown) => {
|
|
38
|
+
self.postMessage({ id, error: error instanceof Error ? error.message : String(error) });
|
|
39
|
+
});
|
|
40
|
+
};
|