@astryxdesign/cli 0.7.12-canary.dfd75a6 → 0.7.12-canary.e171d97
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/api/build/blocks/blocks.d.mts +41 -0
- package/api/build/blocks/blocks.mjs +118 -68
- package/api/search/ORDERING.md +3 -0
- package/api/search/_adapter.d.mts +10 -2
- package/api/search/_adapter.mjs +13 -2
- package/api/search/_fit-order.d.mts +47 -0
- package/api/search/_fit-order.mjs +257 -0
- package/api/search/_main-searcher.mjs +15 -5
- package/assets/templates/themes/neutral/neutralTheme.ts +6 -0
- package/package.json +6 -6
|
@@ -22,6 +22,47 @@ export function wordsOf(text: string): string[];
|
|
|
22
22
|
* @returns {string[]}
|
|
23
23
|
*/
|
|
24
24
|
export function termsOf(text: string): string[];
|
|
25
|
+
/**
|
|
26
|
+
* A text's docs side for `matchOf`: its word vector and that vector through
|
|
27
|
+
* the doc matrix. Search scores its entries' own docs with it.
|
|
28
|
+
* @param {string} text
|
|
29
|
+
* @param {BlockPickerWeights} weights
|
|
30
|
+
* @returns {{vector: Map<number, number>, projected: Float64Array}}
|
|
31
|
+
*/
|
|
32
|
+
export function docSideOf(text: string, weights: BlockPickerWeights): {
|
|
33
|
+
vector: Map<number, number>;
|
|
34
|
+
projected: Float64Array;
|
|
35
|
+
};
|
|
36
|
+
/**
|
|
37
|
+
* A request's side for `matchOf`: its word vector and that vector through the
|
|
38
|
+
* request matrix.
|
|
39
|
+
* @param {string} request
|
|
40
|
+
* @param {BlockPickerWeights} weights
|
|
41
|
+
* @returns {{vector: Map<number, number>, projected: Float64Array}}
|
|
42
|
+
*/
|
|
43
|
+
export function requestSideOf(request: string, weights: BlockPickerWeights): {
|
|
44
|
+
vector: Map<number, number>;
|
|
45
|
+
projected: Float64Array;
|
|
46
|
+
};
|
|
47
|
+
/**
|
|
48
|
+
* The picker's match of a request to a docs text: their projections' dot
|
|
49
|
+
* product plus the lexical weight times their plain overlap (the cosine of
|
|
50
|
+
* their word vectors). Build's ranking also reads the overlap alone.
|
|
51
|
+
* @param {{vector: Map<number, number>, projected: Float64Array}} request
|
|
52
|
+
* @param {{vector: Map<number, number>, projected: Float64Array}} doc
|
|
53
|
+
* @param {BlockPickerWeights} weights
|
|
54
|
+
* @returns {{match: number, overlap: number}}
|
|
55
|
+
*/
|
|
56
|
+
export function matchOf(request: {
|
|
57
|
+
vector: Map<number, number>;
|
|
58
|
+
projected: Float64Array;
|
|
59
|
+
}, doc: {
|
|
60
|
+
vector: Map<number, number>;
|
|
61
|
+
projected: Float64Array;
|
|
62
|
+
}, weights: BlockPickerWeights): {
|
|
63
|
+
match: number;
|
|
64
|
+
overlap: number;
|
|
65
|
+
};
|
|
25
66
|
/**
|
|
26
67
|
* The blocks build names for a request, ranked by the picker.
|
|
27
68
|
* @param {string} request
|
|
@@ -163,34 +163,10 @@ export function termsOf(text) {
|
|
|
163
163
|
return out;
|
|
164
164
|
}
|
|
165
165
|
|
|
166
|
-
/**
|
|
167
|
-
* The weights, decoded once per weights object.
|
|
168
|
-
* @type {WeakMap<BlockPickerWeights, {index: Map<string, number>, idf: Float64Array, request: Int8Array, doc: Int8Array}>}
|
|
169
|
-
*/
|
|
170
|
-
const decoded = new WeakMap();
|
|
171
|
-
|
|
172
|
-
/** @param {BlockPickerWeights} weights */
|
|
173
|
-
function decode(weights) {
|
|
174
|
-
let d = decoded.get(weights);
|
|
175
|
-
if (!d) {
|
|
176
|
-
// Each byte is a signed weight stored offset by 128.
|
|
177
|
-
const bytes = (/** @type {string} */ s) =>
|
|
178
|
-
Int8Array.from(Buffer.from(s, 'base64'), b => b - 128);
|
|
179
|
-
d = {
|
|
180
|
-
index: new Map(weights.vocab.map((w, i) => [w, i])),
|
|
181
|
-
idf: Float64Array.from(weights.idf, x => x / 1024),
|
|
182
|
-
request: bytes(weights.request),
|
|
183
|
-
doc: bytes(weights.doc),
|
|
184
|
-
};
|
|
185
|
-
decoded.set(weights, d);
|
|
186
|
-
}
|
|
187
|
-
return d;
|
|
188
|
-
}
|
|
189
|
-
|
|
190
166
|
/**
|
|
191
167
|
* A text's sparse unit vector over the vocabulary: (1 + ln count) x idf.
|
|
192
168
|
* @param {string[]} terms
|
|
193
|
-
* @param {
|
|
169
|
+
* @param {{index: Map<string, number>, idf: Float64Array}} d
|
|
194
170
|
* @returns {Map<number, number>}
|
|
195
171
|
*/
|
|
196
172
|
function vectorOf(terms, d) {
|
|
@@ -214,20 +190,116 @@ function vectorOf(terms, d) {
|
|
|
214
190
|
}
|
|
215
191
|
|
|
216
192
|
/**
|
|
217
|
-
*
|
|
218
|
-
*
|
|
219
|
-
* @
|
|
193
|
+
* The vocabulary index and idf, read once per weights object, and each matrix
|
|
194
|
+
* row decoded when a side first uses it.
|
|
195
|
+
* @type {WeakMap<BlockPickerWeights, {index: Map<string, number>, idf: Float64Array, rows: Map<string, Int8Array>}>}
|
|
196
|
+
*/
|
|
197
|
+
const vocabularies = new WeakMap();
|
|
198
|
+
|
|
199
|
+
/** @param {BlockPickerWeights} weights */
|
|
200
|
+
function vocabularyOf(weights) {
|
|
201
|
+
let v = vocabularies.get(weights);
|
|
202
|
+
if (!v) {
|
|
203
|
+
v = {
|
|
204
|
+
index: new Map(weights.vocab.map((w, i) => [w, i])),
|
|
205
|
+
idf: Float64Array.from(weights.idf, x => x / 1024),
|
|
206
|
+
rows: new Map(),
|
|
207
|
+
};
|
|
208
|
+
vocabularies.set(weights, v);
|
|
209
|
+
}
|
|
210
|
+
return v;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* One row of a byte matrix, decoded from the base64 text alone: four
|
|
215
|
+
* characters hold three bytes, so the row's bytes sit inside the characters
|
|
216
|
+
* from its first byte's group to its last byte's group.
|
|
217
|
+
* @param {BlockPickerWeights} weights
|
|
218
|
+
* @param {'request' | 'doc'} matrix
|
|
219
|
+
* @param {number} i
|
|
220
|
+
* @returns {Int8Array}
|
|
221
|
+
*/
|
|
222
|
+
function rowOf(weights, matrix, i) {
|
|
223
|
+
const {rows} = vocabularyOf(weights);
|
|
224
|
+
const key = `${matrix}:${i}`;
|
|
225
|
+
let row = rows.get(key);
|
|
226
|
+
if (!row) {
|
|
227
|
+
const {dims} = weights;
|
|
228
|
+
const first = Math.floor((i * dims) / 3);
|
|
229
|
+
const last = Math.ceil(((i + 1) * dims) / 3);
|
|
230
|
+
const bytes = Buffer.from(
|
|
231
|
+
weights[matrix].slice(first * 4, last * 4),
|
|
232
|
+
'base64',
|
|
233
|
+
).subarray(i * dims - first * 3, i * dims - first * 3 + dims);
|
|
234
|
+
// Each byte is a signed weight stored offset by 128.
|
|
235
|
+
row = Int8Array.from(bytes, b => b - 128);
|
|
236
|
+
rows.set(key, row);
|
|
237
|
+
}
|
|
238
|
+
return row;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* A text's side for `matchOf` through one matrix: its word vector, and that
|
|
243
|
+
* vector times the matrix rows its words use, scaled.
|
|
244
|
+
* @param {string} text
|
|
245
|
+
* @param {BlockPickerWeights} weights
|
|
246
|
+
* @param {'request' | 'doc'} matrix
|
|
220
247
|
* @param {number} scale
|
|
221
|
-
* @
|
|
248
|
+
* @returns {{vector: Map<number, number>, projected: Float64Array}}
|
|
222
249
|
*/
|
|
223
|
-
function
|
|
224
|
-
const
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
250
|
+
function sideOf(text, weights, matrix, scale) {
|
|
251
|
+
const vector = vectorOf(termsOf(text), vocabularyOf(weights));
|
|
252
|
+
const {dims} = weights;
|
|
253
|
+
const projected = new Float64Array(dims);
|
|
254
|
+
for (const [i, x] of vector) {
|
|
255
|
+
const row = rowOf(weights, matrix, i);
|
|
256
|
+
for (let k = 0; k < dims; k++) projected[k] += x * row[k];
|
|
228
257
|
}
|
|
229
|
-
for (let k = 0; k < dims; k++)
|
|
230
|
-
return
|
|
258
|
+
for (let k = 0; k < dims; k++) projected[k] *= scale;
|
|
259
|
+
return {vector, projected};
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* A text's docs side for `matchOf`: its word vector and that vector through
|
|
264
|
+
* the doc matrix. Search scores its entries' own docs with it.
|
|
265
|
+
* @param {string} text
|
|
266
|
+
* @param {BlockPickerWeights} weights
|
|
267
|
+
* @returns {{vector: Map<number, number>, projected: Float64Array}}
|
|
268
|
+
*/
|
|
269
|
+
export function docSideOf(text, weights) {
|
|
270
|
+
return sideOf(text, weights, 'doc', weights.docScale);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* A request's side for `matchOf`: its word vector and that vector through the
|
|
275
|
+
* request matrix.
|
|
276
|
+
* @param {string} request
|
|
277
|
+
* @param {BlockPickerWeights} weights
|
|
278
|
+
* @returns {{vector: Map<number, number>, projected: Float64Array}}
|
|
279
|
+
*/
|
|
280
|
+
export function requestSideOf(request, weights) {
|
|
281
|
+
return sideOf(request, weights, 'request', weights.requestScale);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* The picker's match of a request to a docs text: their projections' dot
|
|
286
|
+
* product plus the lexical weight times their plain overlap (the cosine of
|
|
287
|
+
* their word vectors). Build's ranking also reads the overlap alone.
|
|
288
|
+
* @param {{vector: Map<number, number>, projected: Float64Array}} request
|
|
289
|
+
* @param {{vector: Map<number, number>, projected: Float64Array}} doc
|
|
290
|
+
* @param {BlockPickerWeights} weights
|
|
291
|
+
* @returns {{match: number, overlap: number}}
|
|
292
|
+
*/
|
|
293
|
+
export function matchOf(request, doc, weights) {
|
|
294
|
+
let s = 0;
|
|
295
|
+
for (let k = 0; k < weights.dims; k++)
|
|
296
|
+
s += request.projected[k] * doc.projected[k];
|
|
297
|
+
let overlap = 0;
|
|
298
|
+
for (const [i, x] of request.vector) {
|
|
299
|
+
const y = doc.vector.get(i);
|
|
300
|
+
if (y !== undefined) overlap += x * y;
|
|
301
|
+
}
|
|
302
|
+
return {match: s + weights.lexical * overlap, overlap};
|
|
231
303
|
}
|
|
232
304
|
|
|
233
305
|
/**
|
|
@@ -261,7 +333,6 @@ function readCatalog(catalog, components, weights) {
|
|
|
261
333
|
]);
|
|
262
334
|
const cached = catalogs.get(weights);
|
|
263
335
|
if (cached && cached.key === key) return cached;
|
|
264
|
-
const d = decode(weights);
|
|
265
336
|
const names = components.map(c => c.name).sort((a, b) => b.length - a.length);
|
|
266
337
|
const keywords = new Map(components.map(c => [c.name, c.keywords ?? []]));
|
|
267
338
|
/** @type {Map<string, {component: string, vector: Map<number, number>, projected: Float64Array}>} */
|
|
@@ -276,12 +347,7 @@ function readCatalog(catalog, components, weights) {
|
|
|
276
347
|
wordsOf(component).join(' '),
|
|
277
348
|
(keywords.get(component) ?? []).join(' '),
|
|
278
349
|
].join(' ');
|
|
279
|
-
|
|
280
|
-
blocks.set(b.name, {
|
|
281
|
-
component,
|
|
282
|
-
vector,
|
|
283
|
-
projected: project(vector, d.doc, weights.docScale, weights.dims),
|
|
284
|
-
});
|
|
350
|
+
blocks.set(b.name, {component, ...docSideOf(text, weights)});
|
|
285
351
|
}
|
|
286
352
|
// A component is named by its own words, or by a keyword at most three
|
|
287
353
|
// components share (weighted by how few share it).
|
|
@@ -378,26 +444,12 @@ export function pickBlocks(request, signals, weights) {
|
|
|
378
444
|
/** @type {Map<string, SearchResultEntry>} */
|
|
379
445
|
const entries = new Map();
|
|
380
446
|
if (own.length === 0) return {names: [], entries};
|
|
381
|
-
const d = decode(weights);
|
|
382
447
|
const cat = readCatalog(signals.catalog, signals.components, weights);
|
|
383
|
-
const
|
|
384
|
-
const eq = project(q, d.request, weights.requestScale, weights.dims);
|
|
385
|
-
const dot = (/** @type {Map<number, number>} */ v) => {
|
|
386
|
-
let s = 0;
|
|
387
|
-
for (const [i, x] of q) {
|
|
388
|
-
const y = v.get(i);
|
|
389
|
-
if (y !== undefined) s += x * y;
|
|
390
|
-
}
|
|
391
|
-
return s;
|
|
392
|
-
};
|
|
448
|
+
const side = requestSideOf(request, weights);
|
|
393
449
|
/** @type {Map<string, {match: number, overlap: number}>} */
|
|
394
450
|
const scored = new Map();
|
|
395
|
-
for (const [name, b] of cat.blocks)
|
|
396
|
-
|
|
397
|
-
for (let k = 0; k < weights.dims; k++) s += eq[k] * b.projected[k];
|
|
398
|
-
const overlap = dot(b.vector);
|
|
399
|
-
scored.set(name, {match: s + weights.lexical * overlap, overlap});
|
|
400
|
-
}
|
|
451
|
+
for (const [name, b] of cat.blocks)
|
|
452
|
+
scored.set(name, matchOf(side, b, weights));
|
|
401
453
|
const get = (/** @type {string} */ b) =>
|
|
402
454
|
scored.get(b) ?? {match: -Infinity, overlap: 0};
|
|
403
455
|
const ranked = [...scored.keys()].sort((a, b) => get(b).match - get(a).match);
|
|
@@ -480,7 +532,7 @@ export function pickBlocks(request, signals, weights) {
|
|
|
480
532
|
name,
|
|
481
533
|
package: block.package ?? '',
|
|
482
534
|
score: Math.max(1, Math.round(100 * Math.exp(x.score - max))),
|
|
483
|
-
reason: reasonFor(request, cat.blocks.get(name),
|
|
535
|
+
reason: reasonFor(request, side, cat.blocks.get(name), weights),
|
|
484
536
|
description: block.description ?? '',
|
|
485
537
|
displayName: block.displayName,
|
|
486
538
|
kind: 'block',
|
|
@@ -493,11 +545,11 @@ export function pickBlocks(request, signals, weights) {
|
|
|
493
545
|
/**
|
|
494
546
|
* Why a block fits: the request words that tie it to its docs most.
|
|
495
547
|
* @param {string} request
|
|
548
|
+
* @param {{vector: Map<number, number>, projected: Float64Array}} side the request's side
|
|
496
549
|
* @param {{vector: Map<number, number>, projected: Float64Array} | undefined} block
|
|
497
|
-
* @param {ReturnType<typeof decode>} d
|
|
498
550
|
* @param {BlockPickerWeights} weights
|
|
499
551
|
*/
|
|
500
|
-
function reasonFor(request,
|
|
552
|
+
function reasonFor(request, side, block, weights) {
|
|
501
553
|
if (!block) return 'fits by meaning';
|
|
502
554
|
const raw =
|
|
503
555
|
String(request)
|
|
@@ -507,16 +559,14 @@ function reasonFor(request, block, d, weights) {
|
|
|
507
559
|
const wordOf = new Map();
|
|
508
560
|
for (const w of raw)
|
|
509
561
|
if (!STOP.has(w) && !wordOf.has(stemOf(w))) wordOf.set(stemOf(w), w);
|
|
510
|
-
const q = vectorOf(termsOf(request), d);
|
|
511
562
|
/** @type {[string, number][]} */
|
|
512
563
|
const parts = [];
|
|
513
|
-
for (const [i, x] of
|
|
564
|
+
for (const [i, x] of side.vector) {
|
|
514
565
|
const term = weights.vocab[i];
|
|
515
566
|
if (term.includes('_')) continue;
|
|
567
|
+
const row = rowOf(weights, 'request', i);
|
|
516
568
|
let s = 0;
|
|
517
|
-
|
|
518
|
-
for (let k = 0; k < weights.dims; k++)
|
|
519
|
-
s += d.request[row + k] * block.projected[k];
|
|
569
|
+
for (let k = 0; k < weights.dims; k++) s += row[k] * block.projected[k];
|
|
520
570
|
s =
|
|
521
571
|
x * s * weights.requestScale +
|
|
522
572
|
weights.lexical * x * (block.vector.get(i) ?? 0);
|
package/api/search/ORDERING.md
CHANGED
|
@@ -6,12 +6,15 @@
|
|
|
6
6
|
|
|
7
7
|
`spec:AST-083` owns search's result order; this file describes the data that implements it. Inside one score band, a searcher's static weights decide the order. Matches with the same score move together as one block, placed by the learned position of the block's first match: moving each one on its own breaks search's existing rules for tied matches. Inside a block, the lexical ranker's order holds, except below the first result: there a block of one domain puts a part of the first result first (its name words start with the first result's; at most two parts lead across an answer), then the match whose matched words are rarer in the catalog. Matches never cross bands or kinds. There is no model runtime: every number is a plain static value read at load time.
|
|
8
8
|
|
|
9
|
+
After that order, one fit pass reads the first ten results (`_fit-order.mjs`). The first result stays. The others split into runs of one band and kind. Inside a run, each match gets a fit to the whole query from build's block-picker match (`../build/blocks/blocks.mjs` with `blocks.weights.json`) over the match's own docs: its name words, display name or title, description, and keywords. A match whose fit is under 1.7 times the run's median fit moves, in order, after the others in its run, unless its name spells two or more of the query's words. Such a match never moves down; it can still move up when others move after it. A run whose median fit is zero or less keeps its order. A query that is an entry's whole name skips the pass, as it skips the learned order. The pass always reads ten places before the limit cuts the answer. At the default limit, and at any limit of 10 or more, the set of results stays the same. A smaller limit shows the top of the new order, so it can show a different result. Build's loader (`loadBlockPicker` in `../build/_adapter.mjs`) reads and checks the block-picker weights for both commands. When the file is missing, unreadable, or not the picker's shape, the loader returns null and the pass keeps the order. The pass reads the weights only when a run holds two or more matches, and it decodes only the matrix rows that the query's words and those matches' docs use, so a search does not pay for the whole catalog.
|
|
10
|
+
|
|
9
11
|
## Files
|
|
10
12
|
|
|
11
13
|
| File | What it holds |
|
|
12
14
|
| ---------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
13
15
|
| `tree.weights.json` | The root classifier: for each query word or word pair, one number per class (component, hook, doc, template, theme, none). The query's tokens vote, and a topic searcher takes the query only when its class wins by the floor in `tree.mjs`. |
|
|
14
16
|
| `_main-searcher.weights.json`, `_doc-searcher.weights.json`, `_template-searcher.weights.json` | One weight, mean and spread per signal for each searcher (see `SIGNALS` and `PER_DOMAIN` in `_features.mjs`). A match's place inside its band is the weighted sum of its standardized signals. |
|
|
17
|
+
| `../build/blocks/blocks.weights.json` | The block picker's vocabulary, idf and two projection matrices, shared with build. The fit pass reads them through build's loader; search ships no copy. |
|
|
15
18
|
| `_cards.json` | One card per Core component and hook: what it is, the words people use for it, and its main props, written from the item's own source. Cards feed one signal, word overlap with the card. |
|
|
16
19
|
|
|
17
20
|
The signals are counts over the query and each entry's own text: the lexical score and rank, BM25 over the entry's name, docs text and card, how many query words the entry covers, whether the query holds the entry's name, and the classifier's guess of the domain.
|
|
@@ -67,8 +67,10 @@ export function gatherCandidatesReused(options: {
|
|
|
67
67
|
type?: string;
|
|
68
68
|
}): Promise<Gathered>;
|
|
69
69
|
/**
|
|
70
|
-
* The search tree's static data, read once: the entry cards
|
|
71
|
-
* weights file.
|
|
70
|
+
* The search tree's static data, read once: the entry cards, each node's
|
|
71
|
+
* weights file, and build's block-picker weights for the fit pass. Build's
|
|
72
|
+
* loader (loadBlockPicker) reads and checks the picker weights, only when the
|
|
73
|
+
* pass first needs them.
|
|
72
74
|
*
|
|
73
75
|
* These files are static, reviewed data that only order matches inside a score
|
|
74
76
|
* band; ORDERING.md beside them says what each holds and how
|
|
@@ -110,6 +112,12 @@ export type SearchData = {
|
|
|
110
112
|
* The template searcher's weights.
|
|
111
113
|
*/
|
|
112
114
|
template: SearcherWeights;
|
|
115
|
+
/**
|
|
116
|
+
* Build's block-picker weights, which the fit pass (_fit-order.mjs) reads;
|
|
117
|
+
* null when build's loader finds the file absent, unreadable, or the wrong
|
|
118
|
+
* shape.
|
|
119
|
+
*/
|
|
120
|
+
picker: import("../build/blocks/blocks.mjs").BlockPickerWeights | null;
|
|
113
121
|
};
|
|
114
122
|
export type SearchCatalog = Awaited<ReturnType<typeof gatherCandidates>> & {
|
|
115
123
|
data: SearchData;
|
package/api/search/_adapter.mjs
CHANGED
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
} from '../../foundation/fs/read-record.mjs';
|
|
17
17
|
import {isOutsideProject} from '../../foundation/env/project-scope.mjs';
|
|
18
18
|
import {coreDirOrThrow} from '../error.mjs';
|
|
19
|
+
import {loadBlockPicker} from '../build/_adapter.mjs';
|
|
19
20
|
import {CORELESS_DOMAINS} from './_query.mjs';
|
|
20
21
|
import {gatherComponents} from './_gather-components.mjs';
|
|
21
22
|
import {gatherHooks} from './_gather-hooks.mjs';
|
|
@@ -167,6 +168,10 @@ export function gatherCandidatesReused(options) {
|
|
|
167
168
|
* @property {SearcherWeights} main The main searcher's weights.
|
|
168
169
|
* @property {SearcherWeights} doc The doc searcher's weights.
|
|
169
170
|
* @property {SearcherWeights} template The template searcher's weights.
|
|
171
|
+
* @property {import('../build/blocks/blocks.mjs').BlockPickerWeights | null} picker
|
|
172
|
+
* Build's block-picker weights, which the fit pass (_fit-order.mjs) reads;
|
|
173
|
+
* null when build's loader finds the file absent, unreadable, or the wrong
|
|
174
|
+
* shape.
|
|
170
175
|
* @typedef {Awaited<ReturnType<typeof gatherCandidates>> & {data: SearchData}} SearchCatalog
|
|
171
176
|
*/
|
|
172
177
|
|
|
@@ -174,8 +179,10 @@ export function gatherCandidatesReused(options) {
|
|
|
174
179
|
let searchData = null;
|
|
175
180
|
|
|
176
181
|
/**
|
|
177
|
-
* The search tree's static data, read once: the entry cards
|
|
178
|
-
* weights file.
|
|
182
|
+
* The search tree's static data, read once: the entry cards, each node's
|
|
183
|
+
* weights file, and build's block-picker weights for the fit pass. Build's
|
|
184
|
+
* loader (loadBlockPicker) reads and checks the picker weights, only when the
|
|
185
|
+
* pass first needs them.
|
|
179
186
|
*
|
|
180
187
|
* These files are static, reviewed data that only order matches inside a score
|
|
181
188
|
* band; ORDERING.md beside them says what each holds and how
|
|
@@ -193,6 +200,10 @@ export function loadSearchData() {
|
|
|
193
200
|
main: read('./_main-searcher.weights.json'),
|
|
194
201
|
doc: read('./_doc-searcher.weights.json'),
|
|
195
202
|
template: read('./_template-searcher.weights.json'),
|
|
203
|
+
// Read on first use: most searches never reach the fit pass.
|
|
204
|
+
get picker() {
|
|
205
|
+
return loadBlockPicker();
|
|
206
|
+
},
|
|
196
207
|
};
|
|
197
208
|
}
|
|
198
209
|
return searchData;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// @generated by scripts/sync-api-types.mjs from the JSDoc in api/**/*.mjs.
|
|
2
|
+
// DO NOT EDIT — run `pnpm sync:api-types` to regenerate.
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The results with the first kept. Entries #2 to #10 split into runs of one
|
|
6
|
+
* band and kind. Inside a run, the entries that fit the query under FIT_BAR
|
|
7
|
+
* times the run's median fit move, in order, after the others, unless their
|
|
8
|
+
* name spells NAME_WORDS or more of the query's words. A run whose
|
|
9
|
+
* median fit is zero or less keeps its order. Nothing crosses a run, and
|
|
10
|
+
* nothing is added or dropped.
|
|
11
|
+
* @param {any[]} results
|
|
12
|
+
* @param {any[]} candidates
|
|
13
|
+
* @param {string} query
|
|
14
|
+
* @param {Weights | (() => Weights | null | undefined) | null | undefined} weights
|
|
15
|
+
* the block picker's weights, or a function that reads them; the function
|
|
16
|
+
* runs only when a run can change. Without weights the order stays
|
|
17
|
+
* @returns {any[]}
|
|
18
|
+
*/
|
|
19
|
+
export function orderByFit(results: any[], candidates: any[], query: string, weights: Weights | (() => Weights | null | undefined) | null | undefined): any[];
|
|
20
|
+
/**
|
|
21
|
+
* The search response with its results ordered by fit (`orderByFit`). Any
|
|
22
|
+
* other response passes through.
|
|
23
|
+
* @template T
|
|
24
|
+
* @param {T} response
|
|
25
|
+
* @param {any[]} candidates
|
|
26
|
+
* @param {string} query
|
|
27
|
+
* @param {Weights | (() => Weights | null | undefined) | null | undefined} weights
|
|
28
|
+
* @returns {T}
|
|
29
|
+
*/
|
|
30
|
+
export function withFitOrder<T>(response: T, candidates: any[], query: string, weights: Weights | (() => Weights | null | undefined) | null | undefined): T;
|
|
31
|
+
/**
|
|
32
|
+
* The search response with at most `limit` results.
|
|
33
|
+
* @template T
|
|
34
|
+
* @param {T} response
|
|
35
|
+
* @param {number} limit
|
|
36
|
+
* @returns {T}
|
|
37
|
+
*/
|
|
38
|
+
export function withLimit<T>(response: T, limit: number): T;
|
|
39
|
+
/** An entry stays in place when its fit is at least this times the median. */
|
|
40
|
+
export const FIT_BAR: 1.7;
|
|
41
|
+
/** The first result stays; the pass orders the places after it up to here. */
|
|
42
|
+
export const FIT_PLACES: 10;
|
|
43
|
+
export type Weights = import("../build/blocks/blocks.mjs").BlockPickerWeights;
|
|
44
|
+
export type Side = {
|
|
45
|
+
vector: Map<number, number>;
|
|
46
|
+
projected: Float64Array;
|
|
47
|
+
};
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file A last pass over a searcher's answer: inside each run of one score
|
|
5
|
+
* band and kind, the entries from #2 to #10 that fit the whole query much
|
|
6
|
+
* worse than the others in their run move, in order, after them.
|
|
7
|
+
* @input the ordered response, the gathered candidates, and the query
|
|
8
|
+
* @output the same response with the same results, maybe in a new order
|
|
9
|
+
* @position api/search; rankWith (_main-searcher.mjs) runs it after
|
|
10
|
+
* orderMatches, with the weights the adapter loads (loadSearchData) through
|
|
11
|
+
* build's own loader. The fit is build's block-picker match
|
|
12
|
+
* (build/blocks/blocks.mjs) with its shipped weights, so an entry the weights
|
|
13
|
+
* never saw is scored from its own docs the same way. Without readable
|
|
14
|
+
* weights the order stays.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import {
|
|
18
|
+
docSideOf,
|
|
19
|
+
matchOf,
|
|
20
|
+
requestSideOf,
|
|
21
|
+
stemOf,
|
|
22
|
+
termsOf,
|
|
23
|
+
wordsOf,
|
|
24
|
+
} from '../build/blocks/blocks.mjs';
|
|
25
|
+
import {bandOf} from './_order.mjs';
|
|
26
|
+
import {searchedComponentsOf} from './_result.mjs';
|
|
27
|
+
|
|
28
|
+
/** An entry stays in place when its fit is at least this times the median. */
|
|
29
|
+
export const FIT_BAR = 1.7;
|
|
30
|
+
|
|
31
|
+
/** An entry whose name spells at least this many query words never moves. */
|
|
32
|
+
const NAME_WORDS = 2;
|
|
33
|
+
|
|
34
|
+
/** The first result stays; the pass orders the places after it up to here. */
|
|
35
|
+
export const FIT_PLACES = 10;
|
|
36
|
+
|
|
37
|
+
/** @typedef {import('../build/blocks/blocks.mjs').BlockPickerWeights} Weights */
|
|
38
|
+
/** @typedef {{vector: Map<number, number>, projected: Float64Array}} Side */
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Each candidate by `domain:name`; a doc also by its topic and by
|
|
42
|
+
* `topic#section`. The first candidate with a key owns it.
|
|
43
|
+
* @type {WeakMap<any[], Map<string, any>>}
|
|
44
|
+
*/
|
|
45
|
+
const keyed = new WeakMap();
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Each candidate's docs side, computed when a result first needs it.
|
|
49
|
+
* @type {WeakMap<Weights, WeakMap<any, Side>>}
|
|
50
|
+
*/
|
|
51
|
+
const sides = new WeakMap();
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* An entry's docs text: its name words, display name or first title,
|
|
55
|
+
* description, and keywords.
|
|
56
|
+
* @param {any} c
|
|
57
|
+
*/
|
|
58
|
+
const docText = c =>
|
|
59
|
+
[
|
|
60
|
+
wordsOf(c.name).join(' '),
|
|
61
|
+
c._displayName ?? (Array.isArray(c.titles) ? c.titles[0] : '') ?? '',
|
|
62
|
+
c.description ?? '',
|
|
63
|
+
(Array.isArray(c.keywords) ? c.keywords : []).join(' '),
|
|
64
|
+
].join(' ');
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* @param {any[]} candidates
|
|
68
|
+
* @returns {Map<string, any>}
|
|
69
|
+
*/
|
|
70
|
+
function keysOf(candidates) {
|
|
71
|
+
let byKey = keyed.get(candidates);
|
|
72
|
+
if (!byKey) {
|
|
73
|
+
byKey = new Map();
|
|
74
|
+
for (const c of candidates) {
|
|
75
|
+
const keys = [`${c.domain}:${c._resultName ?? c.name}`];
|
|
76
|
+
if (c.domain === 'doc' && c._topic)
|
|
77
|
+
keys.push(
|
|
78
|
+
c._section ? `doc:${c._topic}#${c._section}` : `doc:${c._topic}`,
|
|
79
|
+
);
|
|
80
|
+
for (const k of keys) if (!byKey.has(k)) byKey.set(k, c);
|
|
81
|
+
}
|
|
82
|
+
keyed.set(candidates, byKey);
|
|
83
|
+
}
|
|
84
|
+
return byKey;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* @param {any} c
|
|
89
|
+
* @param {Weights} weights
|
|
90
|
+
* @returns {Side}
|
|
91
|
+
*/
|
|
92
|
+
function candidateSide(c, weights) {
|
|
93
|
+
let byCandidate = sides.get(weights);
|
|
94
|
+
if (!byCandidate) {
|
|
95
|
+
byCandidate = new WeakMap();
|
|
96
|
+
sides.set(weights, byCandidate);
|
|
97
|
+
}
|
|
98
|
+
let side = byCandidate.get(c);
|
|
99
|
+
if (!side) {
|
|
100
|
+
side = docSideOf(docText(c), weights);
|
|
101
|
+
byCandidate.set(c, side);
|
|
102
|
+
}
|
|
103
|
+
return side;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* A result's docs side: its candidate's, else one from the result's own name,
|
|
108
|
+
* display name and description.
|
|
109
|
+
* @param {any} r
|
|
110
|
+
* @param {any[]} candidates
|
|
111
|
+
* @param {Weights} weights
|
|
112
|
+
* @returns {Side}
|
|
113
|
+
*/
|
|
114
|
+
function resultSideOf(r, candidates, weights) {
|
|
115
|
+
const byKey = keysOf(candidates);
|
|
116
|
+
if (r.domain === 'doc' && r.section) {
|
|
117
|
+
const c = byKey.get(`doc:${r.name}#${r.section}`);
|
|
118
|
+
if (c) return candidateSide(c, weights);
|
|
119
|
+
}
|
|
120
|
+
const c = byKey.get(`${r.domain}:${r.name}`);
|
|
121
|
+
return c
|
|
122
|
+
? candidateSide(c, weights)
|
|
123
|
+
: docSideOf(
|
|
124
|
+
`${wordsOf(r.name).join(' ')} ${r.displayName ?? ''} ${r.description ?? ''}`,
|
|
125
|
+
weights,
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* A result's run: its score band and kind (doc or not), as orderMatches reads
|
|
131
|
+
* them.
|
|
132
|
+
* @param {any} r
|
|
133
|
+
*/
|
|
134
|
+
const runOf = r => `${bandOf(r.score ?? 0)}:${r.domain === 'doc'}`;
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* The results with the first kept. Entries #2 to #10 split into runs of one
|
|
138
|
+
* band and kind. Inside a run, the entries that fit the query under FIT_BAR
|
|
139
|
+
* times the run's median fit move, in order, after the others, unless their
|
|
140
|
+
* name spells NAME_WORDS or more of the query's words. A run whose
|
|
141
|
+
* median fit is zero or less keeps its order. Nothing crosses a run, and
|
|
142
|
+
* nothing is added or dropped.
|
|
143
|
+
* @param {any[]} results
|
|
144
|
+
* @param {any[]} candidates
|
|
145
|
+
* @param {string} query
|
|
146
|
+
* @param {Weights | (() => Weights | null | undefined) | null | undefined} weights
|
|
147
|
+
* the block picker's weights, or a function that reads them; the function
|
|
148
|
+
* runs only when a run can change. Without weights the order stays
|
|
149
|
+
* @returns {any[]}
|
|
150
|
+
*/
|
|
151
|
+
export function orderByFit(results, candidates, query, weights) {
|
|
152
|
+
if (!weights || results.length < 3) return results;
|
|
153
|
+
const end = Math.min(FIT_PLACES, results.length);
|
|
154
|
+
/** @type {[number, number][]} */
|
|
155
|
+
const runs = [];
|
|
156
|
+
for (let i = 1; i < end;) {
|
|
157
|
+
let j = i;
|
|
158
|
+
while (j < end && runOf(results[j]) === runOf(results[i])) j++;
|
|
159
|
+
runs.push([i, j]);
|
|
160
|
+
i = j;
|
|
161
|
+
}
|
|
162
|
+
// Only a run of two or more can change, so the weights stay unread when
|
|
163
|
+
// there is none.
|
|
164
|
+
if (!runs.some(([i, j]) => j - i > 1)) return results;
|
|
165
|
+
const w = typeof weights === 'function' ? weights() : weights;
|
|
166
|
+
if (!w) return results;
|
|
167
|
+
const request = requestSideOf(query, w);
|
|
168
|
+
const queryWords = new Set(termsOf(query).filter(t => !t.includes('_')));
|
|
169
|
+
/** @param {any} r */
|
|
170
|
+
const named = r =>
|
|
171
|
+
new Set(
|
|
172
|
+
wordsOf(r.name)
|
|
173
|
+
.map(stemOf)
|
|
174
|
+
.filter(word => queryWords.has(word)),
|
|
175
|
+
).size >= NAME_WORDS;
|
|
176
|
+
const out = results.slice();
|
|
177
|
+
let changed = false;
|
|
178
|
+
for (const [i, j] of runs) {
|
|
179
|
+
if (j - i < 2) continue;
|
|
180
|
+
const moved = demoteWeak(
|
|
181
|
+
results.slice(i, j),
|
|
182
|
+
request,
|
|
183
|
+
candidates,
|
|
184
|
+
w,
|
|
185
|
+
named,
|
|
186
|
+
);
|
|
187
|
+
moved.forEach((r, k) => {
|
|
188
|
+
if (r !== out[i + k]) changed = true;
|
|
189
|
+
out[i + k] = r;
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
return changed ? out : results;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* One run, with the entries under FIT_BAR times its median fit moved, in
|
|
197
|
+
* order, after the others. An entry whose name spells NAME_WORDS or more of
|
|
198
|
+
* the query's words stays with the others: its name is strong evidence.
|
|
199
|
+
* @param {any[]} run
|
|
200
|
+
* @param {Side} request
|
|
201
|
+
* @param {any[]} candidates
|
|
202
|
+
* @param {Weights} weights
|
|
203
|
+
* @param {(r: any) => boolean} named
|
|
204
|
+
* @returns {any[]}
|
|
205
|
+
*/
|
|
206
|
+
function demoteWeak(run, request, candidates, weights, named) {
|
|
207
|
+
const fits = run.map(
|
|
208
|
+
r => matchOf(request, resultSideOf(r, candidates, weights), weights).match,
|
|
209
|
+
);
|
|
210
|
+
const sorted = [...fits].sort((a, b) => a - b);
|
|
211
|
+
const n = sorted.length;
|
|
212
|
+
const median =
|
|
213
|
+
n % 2 ? sorted[(n - 1) / 2] : (sorted[n / 2 - 1] + sorted[n / 2]) / 2;
|
|
214
|
+
if (!(median > 0)) return run;
|
|
215
|
+
const bar = FIT_BAR * median;
|
|
216
|
+
const kept = run.map((r, k) => fits[k] >= bar || named(r));
|
|
217
|
+
return [...run.filter((_, k) => kept[k]), ...run.filter((_, k) => !kept[k])];
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* The search response with its results ordered by fit (`orderByFit`). Any
|
|
222
|
+
* other response passes through.
|
|
223
|
+
* @template T
|
|
224
|
+
* @param {T} response
|
|
225
|
+
* @param {any[]} candidates
|
|
226
|
+
* @param {string} query
|
|
227
|
+
* @param {Weights | (() => Weights | null | undefined) | null | undefined} weights
|
|
228
|
+
* @returns {T}
|
|
229
|
+
*/
|
|
230
|
+
export function withFitOrder(response, candidates, query, weights) {
|
|
231
|
+
const r = /** @type {any} */ (response);
|
|
232
|
+
const results = r?.data?.results;
|
|
233
|
+
if (!Array.isArray(results)) return response;
|
|
234
|
+
const ordered = orderByFit(results, candidates, query, weights);
|
|
235
|
+
if (ordered === results) return response;
|
|
236
|
+
const out = {...r, data: {...r.data, results: ordered}};
|
|
237
|
+
const searched = searchedComponentsOf.get(r);
|
|
238
|
+
if (searched) searchedComponentsOf.set(out, searched);
|
|
239
|
+
return /** @type {T} */ (out);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* The search response with at most `limit` results.
|
|
244
|
+
* @template T
|
|
245
|
+
* @param {T} response
|
|
246
|
+
* @param {number} limit
|
|
247
|
+
* @returns {T}
|
|
248
|
+
*/
|
|
249
|
+
export function withLimit(response, limit) {
|
|
250
|
+
const r = /** @type {any} */ (response);
|
|
251
|
+
const results = r?.data?.results;
|
|
252
|
+
if (!Array.isArray(results) || results.length <= limit) return response;
|
|
253
|
+
const out = {...r, data: {...r.data, results: results.slice(0, limit)}};
|
|
254
|
+
const searched = searchedComponentsOf.get(r);
|
|
255
|
+
if (searched) searchedComponentsOf.set(out, searched);
|
|
256
|
+
return /** @type {T} */ (out);
|
|
257
|
+
}
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
import {routeKit} from '../../foundation/route/route.mjs';
|
|
16
16
|
import {rank, signals} from './_features.mjs';
|
|
17
17
|
import {entryTable} from './_entries.mjs';
|
|
18
|
+
import {FIT_PLACES, withFitOrder, withLimit} from './_fit-order.mjs';
|
|
18
19
|
import {orderMatches} from './_order.mjs';
|
|
19
20
|
import {routeProbs} from './_query-words.mjs';
|
|
20
21
|
import {namesWhole, rankLexical} from './_lexical.mjs';
|
|
@@ -94,13 +95,22 @@ export function rankWith(request, catalog, weights) {
|
|
|
94
95
|
);
|
|
95
96
|
const route = routeProbs(catalog.data.router, query);
|
|
96
97
|
const rows = signals(table, query, lexical.data.results, route);
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
98
|
+
const limit = options.limit ?? 20;
|
|
99
|
+
// The fit pass reads the first FIT_PLACES results at every limit, so a
|
|
100
|
+
// smaller limit shows the top of that order.
|
|
101
|
+
const ordered = withFitOrder(
|
|
102
|
+
orderMatches(
|
|
103
|
+
table,
|
|
104
|
+
rank(table, rows, weights),
|
|
105
|
+
lexical,
|
|
106
|
+
query,
|
|
107
|
+
Math.max(limit, FIT_PLACES),
|
|
108
|
+
),
|
|
109
|
+
catalog.all,
|
|
101
110
|
query,
|
|
102
|
-
|
|
111
|
+
() => catalog.data.picker,
|
|
103
112
|
);
|
|
113
|
+
return withLimit(ordered, limit);
|
|
104
114
|
}
|
|
105
115
|
|
|
106
116
|
/** @type {import('./tree.mjs').SearchKit} */
|
|
@@ -361,7 +361,13 @@ export const neutralTheme = defineTheme({
|
|
|
361
361
|
padding: 'var(--spacing-1)',
|
|
362
362
|
},
|
|
363
363
|
},
|
|
364
|
+
// An unselected label sits on the translucent track, which lifts the
|
|
365
|
+
// page under it. In dark mode secondary text falls under 4.5:1 on the
|
|
366
|
+
// track over the surface, so it takes the same next stop.
|
|
364
367
|
'segmented-control-item': {
|
|
368
|
+
base: {
|
|
369
|
+
'--color-text-secondary': `light-dark(${neutral.light[30]}, ${neutral.dark[80]})`,
|
|
370
|
+
},
|
|
365
371
|
'size:sm': {
|
|
366
372
|
height: 'calc(var(--size-element-sm) - 8px)',
|
|
367
373
|
},
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@astryxdesign/cli",
|
|
3
|
-
"version": "0.7.12-canary.
|
|
3
|
+
"version": "0.7.12-canary.e171d97",
|
|
4
4
|
"displayName": "CLI",
|
|
5
5
|
"description": "Scaffold projects, browse templates, generate themes, and get agent-ready docs from the command line.",
|
|
6
6
|
"author": "Meta Open Source",
|
|
@@ -125,7 +125,7 @@
|
|
|
125
125
|
"zod": "^4.4.3"
|
|
126
126
|
},
|
|
127
127
|
"peerDependencies": {
|
|
128
|
-
"@astryxdesign/core": "0.7.12-canary.
|
|
128
|
+
"@astryxdesign/core": "0.7.12-canary.e171d97"
|
|
129
129
|
},
|
|
130
130
|
"peerDependenciesMeta": {
|
|
131
131
|
"@astryxdesign/core": {
|
|
@@ -133,10 +133,10 @@
|
|
|
133
133
|
}
|
|
134
134
|
},
|
|
135
135
|
"devDependencies": {
|
|
136
|
-
"@astryxdesign/charts": "0.7.12-canary.
|
|
137
|
-
"@astryxdesign/core": "0.7.12-canary.
|
|
138
|
-
"@astryxdesign/lab": "0.7.12-canary.
|
|
139
|
-
"@astryxdesign/theme-neutral": "0.7.12-canary.
|
|
136
|
+
"@astryxdesign/charts": "0.7.12-canary.e171d97",
|
|
137
|
+
"@astryxdesign/core": "0.7.12-canary.e171d97",
|
|
138
|
+
"@astryxdesign/lab": "0.7.12-canary.e171d97",
|
|
139
|
+
"@astryxdesign/theme-neutral": "0.7.12-canary.e171d97",
|
|
140
140
|
"@heroicons/react": "^2.2.0",
|
|
141
141
|
"@stylexjs/stylex": "^0.19.0",
|
|
142
142
|
"@types/babel__core": "^7.20.5",
|