@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.
@@ -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 {ReturnType<typeof decode>} d
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
- * A sparse vector through a signed byte matrix.
218
- * @param {Map<number, number>} v
219
- * @param {Int8Array} m
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
- * @param {number} dims
248
+ * @returns {{vector: Map<number, number>, projected: Float64Array}}
222
249
  */
223
- function project(v, m, scale, dims) {
224
- const out = new Float64Array(dims);
225
- for (const [i, x] of v) {
226
- const row = i * dims;
227
- for (let k = 0; k < dims; k++) out[k] += x * m[row + k];
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++) out[k] *= scale;
230
- return out;
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
- const vector = vectorOf(termsOf(text), d);
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 q = vectorOf(termsOf(request), d);
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
- let s = 0;
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), d, weights),
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, block, d, weights) {
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 q) {
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
- const row = i * weights.dims;
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);
@@ -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 and each node's
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;
@@ -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 and each node's
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
- return orderMatches(
98
- table,
99
- rank(table, rows, weights),
100
- lexical,
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
- options.limit ?? 20,
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.dfd75a6",
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.dfd75a6"
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.dfd75a6",
137
- "@astryxdesign/core": "0.7.12-canary.dfd75a6",
138
- "@astryxdesign/lab": "0.7.12-canary.dfd75a6",
139
- "@astryxdesign/theme-neutral": "0.7.12-canary.dfd75a6",
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",