@astryxdesign/cli 0.7.2 → 0.7.3-canary.06872b4
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/README.md +99 -66
- package/api/build/_adapter.d.mts +58 -0
- package/api/build/_adapter.mjs +115 -3
- package/api/build/blocks/blocks.d.mts +42 -0
- package/api/build/blocks/blocks.mjs +169 -0
- package/api/build/blocks/blocks.test.mjs +108 -0
- package/api/build/blocks/blocks.weights.json +1 -0
- package/api/build/build.mjs +25 -6
- package/api/build/kit/answer.d.mts +14 -0
- package/api/build/kit/answer.mjs +85 -0
- package/api/build/kit/answer.test.mjs +38 -0
- package/api/build/kit/kind.d.mts +25 -0
- package/api/build/kit/kind.mjs +170 -0
- package/api/build/kit/kit.d.mts +2 -4
- package/api/build/kit/kit.mjs +8 -398
- package/api/build/kit/rank.d.mts +3 -32
- package/api/build/kit/rank.mjs +25 -342
- package/api/build/kit/rank.test.mjs +2 -1
- package/api/build/kit/signals.d.mts +60 -0
- package/api/build/kit/signals.mjs +284 -0
- package/api/build/kit/start.d.mts +32 -0
- package/api/build/kit/start.mjs +163 -0
- package/api/build/kit/words.d.mts +72 -0
- package/api/build/kit/words.mjs +202 -0
- package/api/build/tree.d.mts +20 -0
- package/api/build/tree.mjs +52 -0
- package/api/build/tree.test.mjs +177 -0
- package/api/build/tree.weights.json +1 -0
- package/api/component/component.mjs +16 -2
- package/api/component/component.test.mjs +15 -0
- package/api/discover/_adapter.mjs +36 -9
- package/api/discover/_adapter.test.mjs +42 -0
- package/api/docs/_adapter.mjs +17 -5
- package/api/docs/docs.test.mjs +7 -1
- package/api/doctor/doctor.mjs +86 -12
- package/api/doctor/doctor.test.mjs +28 -3
- package/api/init/init.doc.mjs +20 -6
- package/api/init/init.test.mjs +18 -2
- package/api/init/init.type.d.mts +3 -3
- package/api/init/init.type.mjs +3 -3
- package/api/init/remove/remove.mjs +17 -4
- package/api/init/remove/remove.test.mjs +114 -2
- package/api/init/run/run.mjs +69 -6
- package/api/integration/authoring-checks.mjs +83 -9
- package/api/integration/pack-check.mjs +123 -4
- package/api/integration/pack-check.test.mjs +87 -0
- package/api/integration/validate-integration-case-disk.test.mjs +111 -0
- package/api/integration/validate-integration-gaps.test.mjs +1964 -0
- package/api/integration/validate-integration.d.mts +11 -2
- package/api/integration/validate-integration.mjs +226 -32
- package/api/integration/validate-integration.type.d.mts +58 -1
- package/api/integration/validate-integration.type.mjs +45 -2
- package/api/integration/validateIntegration.doc.mjs +1 -1
- package/api/search/ORDERING.md +28 -0
- package/api/search/_adapter.d.mts +66 -2
- package/api/search/_adapter.mjs +118 -10
- package/api/search/_candidate-score.d.mts +40 -0
- package/api/search/_candidate-score.mjs +238 -0
- package/api/search/_cards.json +6098 -0
- package/api/search/_compound.d.mts +50 -0
- package/api/search/_compound.mjs +170 -0
- package/api/search/_doc-searcher.d.mts +4 -0
- package/api/search/_doc-searcher.mjs +23 -0
- package/api/search/_doc-searcher.test.mjs +36 -0
- package/api/search/_doc-searcher.weights.json +1 -0
- package/api/search/_doc-text.d.mts +30 -0
- package/api/search/_doc-text.mjs +87 -0
- package/api/search/_entries.d.mts +52 -0
- package/api/search/_entries.mjs +158 -0
- package/api/search/_features.d.mts +35 -0
- package/api/search/_features.mjs +155 -0
- package/api/search/_gather-components.d.mts +26 -0
- package/api/search/_gather-components.mjs +284 -0
- package/api/search/_gather-docs.d.mts +20 -0
- package/api/search/_gather-docs.mjs +254 -0
- package/api/search/_gather-hooks.d.mts +12 -0
- package/api/search/_gather-hooks.mjs +52 -0
- package/api/search/_gather-templates.d.mts +25 -0
- package/api/search/_gather-templates.mjs +107 -0
- package/api/search/_lexical.d.mts +135 -0
- package/api/search/_lexical.mjs +351 -0
- package/api/search/_main-searcher.d.mts +19 -0
- package/api/search/_main-searcher.mjs +115 -0
- package/api/search/_main-searcher.test.mjs +106 -0
- package/api/search/_main-searcher.weights.json +1 -0
- package/api/search/_module-doc.d.mts +32 -0
- package/api/search/_module-doc.mjs +78 -0
- package/api/search/_order.d.mts +32 -0
- package/api/search/_order.mjs +162 -0
- package/api/search/_order.test.mjs +72 -0
- package/api/search/_priority.d.mts +50 -0
- package/api/search/_priority.mjs +139 -0
- package/api/search/_query-words.d.mts +25 -0
- package/api/search/_query-words.mjs +92 -0
- package/api/search/_query.d.mts +65 -0
- package/api/search/_query.mjs +176 -0
- package/api/search/_result.d.mts +97 -0
- package/api/search/_result.mjs +150 -0
- package/api/search/_score.d.mts +60 -0
- package/api/search/_score.mjs +283 -0
- package/api/search/_synonyms.d.mts +21 -0
- package/api/search/_synonyms.mjs +67 -0
- package/api/search/_template-searcher.d.mts +4 -0
- package/api/search/_template-searcher.mjs +23 -0
- package/api/search/_template-searcher.test.mjs +40 -0
- package/api/search/_template-searcher.weights.json +1 -0
- package/api/search/_titles.d.mts +33 -0
- package/api/search/_titles.mjs +65 -0
- package/api/search/_words.d.mts +64 -0
- package/api/search/_words.mjs +157 -0
- package/api/search/search.d.mts +22 -305
- package/api/search/search.doc.mjs +7 -5
- package/api/search/search.mjs +53 -2483
- package/api/search/search.type.d.mts +6 -1
- package/api/search/search.type.mjs +6 -1
- package/api/search/tree.d.mts +15 -0
- package/api/search/tree.mjs +40 -0
- package/api/search/tree.test.mjs +66 -0
- package/api/search/tree.weights.json +1 -0
- package/api/theme/build/build.packed-old-core.test.mjs +1 -0
- package/api/upgrade/_adapter.d.mts +57 -9
- package/api/upgrade/_adapter.mjs +198 -28
- package/api/upgrade/registry/registry.d.mts +10 -4
- package/api/upgrade/registry/registry.mjs +33 -16
- package/api/upgrade/registry/registry.test.mjs +85 -0
- package/api/upgrade/run/run.d.mts +4 -2
- package/api/upgrade/run/run.mjs +13 -2
- package/api/upgrade/run/run.test.mjs +417 -3
- package/api/upgrade/upgrade.doc.mjs +10 -5
- package/api/upgrade/upgrade.mjs +13 -2
- package/api/upgrade/upgrade.test.mjs +194 -0
- package/api/upgrade/upgrade.type.d.mts +5 -3
- package/api/upgrade/upgrade.type.mjs +8 -6
- package/assets/codemods/transforms/v0.7.0/__tests__/validate-data-token-ownership.test.mjs +11 -0
- package/assets/codemods/transforms/v0.7.0/validate-data-token-ownership.mjs +3 -3
- package/assets/docs/is-my-code-good.doc.mjs +287 -0
- package/assets/docs/tree/api.doc.mjs +6 -0
- package/assets/docs/tree/host-entry.doc.mjs +128 -0
- package/assets/docs/tree/template-grading-rubric.doc.mjs +16 -37
- package/assets/docs/tree/tokens-data.doc.mjs +2 -2
- package/assets/docs/working-with-ai.doc.mjs +6 -2
- package/assets/templates/themes/butter/butterTheme.ts +4 -2
- package/assets/templates/themes/stone/stoneTheme.ts +4 -2
- package/assets/templates/themes/y2k/y2kTheme.ts +4 -2
- package/authoring/debug/parse.d.mts +1 -1
- package/clients/cli/bin/astryx.mjs +7 -78
- package/clients/cli/commands/doctor.mjs +30 -0
- package/clients/cli/commands/doctor.test.mjs +65 -0
- package/clients/cli/commands/init.behavior.test.mjs +230 -1
- package/clients/cli/commands/init.doc.mjs +28 -9
- package/clients/cli/commands/init.flag-help.test.mjs +1 -0
- package/clients/cli/commands/init.mjs +2 -2
- package/clients/cli/commands/outside-project.test.mjs +2 -1
- package/clients/cli/commands/setup-nudge.test.mjs +61 -0
- package/clients/cli/commands/upgrade.doc.mjs +4 -2
- package/clients/cli/commands/upgrade.mjs +9 -2
- package/clients/cli/commands/upgrade.subfolder-path.test.mjs +97 -0
- package/clients/cli/index.mjs +19 -1
- package/clients/cli/run.mjs +108 -0
- package/foundation/agent-docs/agent-doc-state.d.mts +20 -7
- package/foundation/agent-docs/agent-doc-state.mjs +35 -8
- package/foundation/agent-docs/agent-docs.d.mts +100 -12
- package/foundation/agent-docs/agent-docs.mjs +1010 -99
- package/foundation/agent-docs/agent-docs.path-safety.test.mjs +1239 -17
- package/foundation/agent-docs/agent-docs.test.mjs +678 -35
- package/foundation/config/project.mjs +20 -22
- package/foundation/config/project.test.mjs +56 -0
- package/foundation/discovery/component-discovery.d.mts +37 -0
- package/foundation/discovery/component-discovery.mjs +80 -5
- package/foundation/discovery/component-loader.d.mts +1 -1
- package/foundation/discovery/docs-discovery.mjs +14 -1
- package/foundation/discovery/module-exports.d.mts +41 -0
- package/foundation/discovery/module-exports.mjs +253 -0
- package/foundation/discovery/template-adapter.mjs +18 -1
- package/foundation/discovery/template-needs.mjs +1 -44
- package/foundation/discovery/theme-discovery.mjs +147 -3
- package/foundation/doc-compiler/doc-loads.test.mjs +8 -0
- package/foundation/doc-compiler/read.mjs +59 -1
- package/foundation/doc-compiler/read.test.mjs +11 -2
- package/foundation/fs/paths.d.mts +70 -8
- package/foundation/fs/paths.mjs +556 -38
- package/foundation/fs/paths.test.mjs +430 -54
- package/foundation/integrations/autolink.d.mts +2 -1
- package/foundation/integrations/autolink.mjs +4 -13
- package/foundation/integrations/computer-integrations.d.mts +6 -3
- package/foundation/integrations/computer-integrations.mjs +38 -9
- package/foundation/integrations/contribution-inventory.d.mts +15 -20
- package/foundation/integrations/contribution-inventory.mjs +41 -5
- package/foundation/integrations/integrations.d.mts +27 -1
- package/foundation/integrations/integrations.mjs +61 -2
- package/foundation/integrations/validate-contributions.d.mts +115 -0
- package/foundation/integrations/validate-contributions.mjs +1168 -13
- package/foundation/release/handoff.d.mts +23 -4
- package/foundation/release/handoff.mjs +175 -8
- package/foundation/release/run-context.mjs +14 -0
- package/foundation/response/response-types.doc.mjs +1 -1
- package/foundation/route/route.d.mts +285 -0
- package/foundation/route/route.mjs +324 -0
- package/foundation/route/route.test.mjs +153 -0
- package/foundation/runtime/hosted-runtime.test.mjs +403 -0
- package/foundation/runtime/locked-versions.d.mts +32 -0
- package/foundation/runtime/locked-versions.mjs +65 -0
- package/foundation/runtime/package-root.d.mts +91 -0
- package/foundation/runtime/package-root.mjs +174 -0
- package/foundation/runtime/package-set.d.mts +62 -0
- package/foundation/runtime/package-set.mjs +130 -0
- package/foundation/runtime/package-set.test.mjs +432 -0
- package/host/door-conformance.test.mjs +274 -0
- package/host/index.d.ts +116 -0
- package/host/index.mjs +295 -0
- package/host/index.test.mjs +302 -0
- package/package.json +14 -9
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file The small routing helper the commands that pick among candidates
|
|
5
|
+
* share (build, search): two node kinds and the runner that walks them.
|
|
6
|
+
*
|
|
7
|
+
* A searcher answers a request in its command's own result shape, or returns
|
|
8
|
+
* null to pass. A dispatcher scores nothing: it names the children that should
|
|
9
|
+
* answer, best first, with a confidence from 0 to 1. Below its floor, or when
|
|
10
|
+
* none of the children it named answers, its fallback searcher answers. A
|
|
11
|
+
* dispatcher's children may be dispatchers, so trees nest. A classifier is a
|
|
12
|
+
* dispatcher whose route is a static word-weight classifier: the request's
|
|
13
|
+
* tokens vote for a class, and a class with a searcher takes the request when
|
|
14
|
+
* the classifier is sure enough.
|
|
15
|
+
*
|
|
16
|
+
* A tree file types the helpers for its command once, by taking them from
|
|
17
|
+
* `routeKit` typed as `RouteKit<Request, Context, Answer>` (see
|
|
18
|
+
* api/search/tree.mjs); each node is then plain `{id, summary, run}`.
|
|
19
|
+
*
|
|
20
|
+
* Every node gets the same context, so any node can be run, measured, and
|
|
21
|
+
* tuned on its own: `runTree` takes an optional `tuning` with weights that
|
|
22
|
+
* replace a node's shipped weights for one call, and a trace callback that
|
|
23
|
+
* sees every step. Neither ever reaches a command's output.
|
|
24
|
+
*
|
|
25
|
+
* @input a tree of nodes, a request, and the context the command's adapter
|
|
26
|
+
* gathered
|
|
27
|
+
* @output the first answer a searcher gives
|
|
28
|
+
* @position foundation; api/build and api/search build their trees with it
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* @template Request, Context, Answer
|
|
33
|
+
* @typedef {object} Searcher
|
|
34
|
+
* @property {'searcher'} kind
|
|
35
|
+
* @property {string} id
|
|
36
|
+
* @property {string} summary One plain line: what it answers.
|
|
37
|
+
* @property {(request: Request, ctx: Context, weights: unknown) => Answer | null | Promise<Answer | null>} run
|
|
38
|
+
* `weights` is a tuning override for this node, or null for its shipped weights.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* @typedef {object} Route
|
|
43
|
+
* @property {readonly string[]} ids The children to try, best first.
|
|
44
|
+
* @property {number} confidence From 0 to 1.
|
|
45
|
+
*/
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* @template Request, Context
|
|
49
|
+
* @typedef {object} Dispatcher
|
|
50
|
+
* @property {'dispatcher'} kind
|
|
51
|
+
* @property {string} id
|
|
52
|
+
* @property {string} summary
|
|
53
|
+
* @property {readonly string[]} children
|
|
54
|
+
* @property {number} floor Below this confidence the fallback answers.
|
|
55
|
+
* @property {string} fallback A searcher's id.
|
|
56
|
+
* @property {(request: Request, ctx: Context, weights: unknown) => Route | Promise<Route>} route
|
|
57
|
+
*/
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* @template Request, Context, Answer
|
|
61
|
+
* @typedef {Searcher<Request, Context, Answer> | Dispatcher<Request, Context>} TreeNode
|
|
62
|
+
*/
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* @template Request, Context, Answer
|
|
66
|
+
* @typedef {object} Tree
|
|
67
|
+
* @property {string} root
|
|
68
|
+
* @property {ReadonlyMap<string, TreeNode<Request, Context, Answer>>} nodes
|
|
69
|
+
*/
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* One step the runner took.
|
|
73
|
+
* @typedef {object} TraceStep
|
|
74
|
+
* @property {string} id
|
|
75
|
+
* @property {'searcher' | 'dispatcher'} kind
|
|
76
|
+
* @property {number} depth
|
|
77
|
+
* @property {boolean} fallback Whether this step ran as a fallback.
|
|
78
|
+
* @property {Route} [route] A dispatcher's route.
|
|
79
|
+
* @property {boolean} [answered] Whether a searcher answered.
|
|
80
|
+
*/
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* @typedef {object} Tuning
|
|
84
|
+
* @property {Readonly<Record<string, unknown>>} [weights] Weights by node id, replacing that node's shipped weights for this call.
|
|
85
|
+
* @property {(step: TraceStep) => void} [onTrace]
|
|
86
|
+
*/
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Static classifier weights: one row of numbers per token, one number per
|
|
90
|
+
* class. A request's score for a class is the sum of its tokens' numbers.
|
|
91
|
+
* @typedef {object} ClassifierWeights
|
|
92
|
+
* @property {readonly string[]} classes
|
|
93
|
+
* @property {Readonly<Record<string, readonly number[]>>} tokens
|
|
94
|
+
*/
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* @template Request, Context
|
|
98
|
+
* @typedef {object} ClassifierSpec
|
|
99
|
+
* @property {string} id
|
|
100
|
+
* @property {string} summary
|
|
101
|
+
* @property {Readonly<Record<string, string>>} searchers Class name -> the
|
|
102
|
+
* searcher that answers it. A class with no searcher goes to the fallback.
|
|
103
|
+
* @property {string} fallback The searcher that keeps every other request.
|
|
104
|
+
* @property {number} floor A searcher takes a request only when its class's
|
|
105
|
+
* probability is at least this.
|
|
106
|
+
* @property {(request: Request, ctx: Context) => readonly string[]} features
|
|
107
|
+
* The request's tokens.
|
|
108
|
+
* @property {(ctx: Context) => ClassifierWeights} weights The shipped weights,
|
|
109
|
+
* which the command's adapter put in the context.
|
|
110
|
+
*/
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* The helpers, typed for one command's request, context, and answer.
|
|
114
|
+
* @template Request, Context, Answer
|
|
115
|
+
* @typedef {object} RouteKit
|
|
116
|
+
* @property {(node: Omit<Searcher<Request, Context, Answer>, 'kind'>) => Searcher<Request, Context, Answer>} defineSearcher
|
|
117
|
+
* @property {(node: Omit<Dispatcher<Request, Context>, 'kind'>) => Dispatcher<Request, Context>} defineDispatcher
|
|
118
|
+
* @property {(spec: ClassifierSpec<Request, Context>) => Dispatcher<Request, Context>} defineClassifier
|
|
119
|
+
* @property {(root: string, nodes: readonly TreeNode<Request, Context, Answer>[]) => Tree<Request, Context, Answer>} defineTree
|
|
120
|
+
*/
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* The probability of each class for these tokens: a softmax over the summed
|
|
124
|
+
* rows of the tokens the weights know.
|
|
125
|
+
* @param {ClassifierWeights} weights
|
|
126
|
+
* @param {readonly string[]} tokens
|
|
127
|
+
* @returns {number[]}
|
|
128
|
+
*/
|
|
129
|
+
export function classify(weights, tokens) {
|
|
130
|
+
const score = weights.classes.map(() => 0);
|
|
131
|
+
for (const token of tokens) {
|
|
132
|
+
const row = Object.hasOwn(weights.tokens, token)
|
|
133
|
+
? weights.tokens[token]
|
|
134
|
+
: undefined;
|
|
135
|
+
if (row) for (let k = 0; k < score.length; k++) score[k] += row[k];
|
|
136
|
+
}
|
|
137
|
+
const top = Math.max(...score);
|
|
138
|
+
const e = score.map(x => Math.exp(x - top));
|
|
139
|
+
const sum = e.reduce((a, b) => a + b, 0);
|
|
140
|
+
return e.map(x => x / sum);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* A dispatcher whose route is a static classifier. The most probable class
|
|
145
|
+
* (the first on a tie) is handed to its searcher; a class with no searcher,
|
|
146
|
+
* or the fallback's own class, stays with the fallback. Tuning weights for
|
|
147
|
+
* this node replace its shipped weights.
|
|
148
|
+
* @template Request, Context
|
|
149
|
+
* @param {ClassifierSpec<Request, Context>} spec
|
|
150
|
+
* @returns {Dispatcher<Request, Context>}
|
|
151
|
+
*/
|
|
152
|
+
export function defineClassifier(spec) {
|
|
153
|
+
const {searchers, fallback} = spec;
|
|
154
|
+
return defineDispatcher({
|
|
155
|
+
id: spec.id,
|
|
156
|
+
summary: spec.summary,
|
|
157
|
+
children: [...new Set([fallback, ...Object.values(searchers)])],
|
|
158
|
+
floor: spec.floor,
|
|
159
|
+
fallback,
|
|
160
|
+
route: (request, ctx, override) => {
|
|
161
|
+
const weights =
|
|
162
|
+
/** @type {ClassifierWeights | null} */ (override) ?? spec.weights(ctx);
|
|
163
|
+
const p = classify(weights, spec.features(request, ctx));
|
|
164
|
+
const best = p.indexOf(Math.max(...p));
|
|
165
|
+
const target = searchers[weights.classes[best]];
|
|
166
|
+
return target && target !== fallback
|
|
167
|
+
? {ids: [target], confidence: p[best]}
|
|
168
|
+
: {ids: [fallback], confidence: 1};
|
|
169
|
+
},
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* @template Request, Context, Answer
|
|
175
|
+
* @param {Omit<Searcher<Request, Context, Answer>, 'kind'>} node
|
|
176
|
+
* @returns {Searcher<Request, Context, Answer>}
|
|
177
|
+
*/
|
|
178
|
+
export function defineSearcher(node) {
|
|
179
|
+
return Object.freeze({...node, kind: /** @type {const} */ ('searcher')});
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* @template Request, Context
|
|
184
|
+
* @param {Omit<Dispatcher<Request, Context>, 'kind'>} node
|
|
185
|
+
* @returns {Dispatcher<Request, Context>}
|
|
186
|
+
*/
|
|
187
|
+
export function defineDispatcher(node) {
|
|
188
|
+
return Object.freeze({...node, kind: /** @type {const} */ ('dispatcher')});
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* A tree from its nodes. Throws on a duplicate id, a missing root, child, or
|
|
193
|
+
* fallback, a fallback that is not a searcher, or a dispatcher cycle.
|
|
194
|
+
* @template Request, Context, Answer
|
|
195
|
+
* @param {string} root
|
|
196
|
+
* @param {readonly TreeNode<Request, Context, Answer>[]} list
|
|
197
|
+
* @returns {Tree<Request, Context, Answer>}
|
|
198
|
+
*/
|
|
199
|
+
export function defineTree(root, list) {
|
|
200
|
+
/** @type {Map<string, TreeNode<Request, Context, Answer>>} */
|
|
201
|
+
const nodes = new Map();
|
|
202
|
+
for (const node of list) {
|
|
203
|
+
if (nodes.has(node.id)) throw new Error(`duplicate node "${node.id}"`);
|
|
204
|
+
nodes.set(node.id, node);
|
|
205
|
+
}
|
|
206
|
+
if (!nodes.has(root)) throw new Error(`no root node "${root}"`);
|
|
207
|
+
for (const node of nodes.values()) {
|
|
208
|
+
if (node.kind !== 'dispatcher') continue;
|
|
209
|
+
for (const id of [...node.children, node.fallback]) {
|
|
210
|
+
if (!nodes.has(id)) {
|
|
211
|
+
throw new Error(`"${node.id}" names a missing node "${id}"`);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
if (nodes.get(node.fallback)?.kind !== 'searcher') {
|
|
215
|
+
throw new Error(
|
|
216
|
+
`"${node.id}" falls back to "${node.fallback}", which is not a searcher`,
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
/** @param {string} id @param {string[]} path */
|
|
221
|
+
const visit = (id, path) => {
|
|
222
|
+
if (path.includes(id)) {
|
|
223
|
+
throw new Error(`dispatcher cycle ${[...path, id].join(' -> ')}`);
|
|
224
|
+
}
|
|
225
|
+
const node = nodes.get(id);
|
|
226
|
+
if (node?.kind === 'dispatcher') {
|
|
227
|
+
for (const child of node.children) visit(child, [...path, id]);
|
|
228
|
+
}
|
|
229
|
+
};
|
|
230
|
+
visit(root, []);
|
|
231
|
+
return Object.freeze({root, nodes});
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Runs the tree from its root, or from `start` to run one node alone: a
|
|
236
|
+
* dispatcher below its floor runs its fallback; otherwise the children it
|
|
237
|
+
* names run in order and the first answer wins; when none answers, the
|
|
238
|
+
* fallback runs.
|
|
239
|
+
* @template Request, Context, Answer
|
|
240
|
+
* @param {Tree<Request, Context, Answer>} tree
|
|
241
|
+
* @param {Request} request
|
|
242
|
+
* @param {Context} ctx
|
|
243
|
+
* @param {{tuning?: Tuning, start?: string}} [options]
|
|
244
|
+
* @returns {Promise<Answer | null>}
|
|
245
|
+
*/
|
|
246
|
+
export async function runTree(tree, request, ctx, options = {}) {
|
|
247
|
+
const start = options.start ?? tree.root;
|
|
248
|
+
if (!tree.nodes.has(start)) throw new Error(`no node "${start}"`);
|
|
249
|
+
return runNode(tree, start, request, ctx, options.tuning, 0, false);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* @template Request, Context, Answer
|
|
254
|
+
* @param {Tree<Request, Context, Answer>} tree
|
|
255
|
+
* @param {string} id
|
|
256
|
+
* @param {Request} request
|
|
257
|
+
* @param {Context} ctx
|
|
258
|
+
* @param {Tuning | undefined} tuning
|
|
259
|
+
* @param {number} depth
|
|
260
|
+
* @param {boolean} fallback
|
|
261
|
+
* @returns {Promise<Answer | null>}
|
|
262
|
+
*/
|
|
263
|
+
async function runNode(tree, id, request, ctx, tuning, depth, fallback) {
|
|
264
|
+
const node = /** @type {TreeNode<Request, Context, Answer>} */ (
|
|
265
|
+
tree.nodes.get(id)
|
|
266
|
+
);
|
|
267
|
+
const weights =
|
|
268
|
+
tuning?.weights && Object.hasOwn(tuning.weights, id)
|
|
269
|
+
? tuning.weights[id]
|
|
270
|
+
: null;
|
|
271
|
+
if (node.kind === 'searcher') {
|
|
272
|
+
const answer = (await node.run(request, ctx, weights)) ?? null;
|
|
273
|
+
tuning?.onTrace?.({
|
|
274
|
+
id,
|
|
275
|
+
kind: 'searcher',
|
|
276
|
+
depth,
|
|
277
|
+
fallback,
|
|
278
|
+
answered: answer !== null,
|
|
279
|
+
});
|
|
280
|
+
return answer;
|
|
281
|
+
}
|
|
282
|
+
const route = await node.route(request, ctx, weights);
|
|
283
|
+
for (const child of route.ids) {
|
|
284
|
+
if (!node.children.includes(child)) {
|
|
285
|
+
throw new Error(
|
|
286
|
+
`"${id}" routed to "${child}", which is not one of its children`,
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
tuning?.onTrace?.({
|
|
291
|
+
id,
|
|
292
|
+
kind: 'dispatcher',
|
|
293
|
+
depth,
|
|
294
|
+
fallback,
|
|
295
|
+
route: {ids: [...route.ids], confidence: route.confidence},
|
|
296
|
+
});
|
|
297
|
+
if (route.confidence >= node.floor) {
|
|
298
|
+
for (const child of route.ids) {
|
|
299
|
+
const answer = await runNode(
|
|
300
|
+
tree,
|
|
301
|
+
child,
|
|
302
|
+
request,
|
|
303
|
+
ctx,
|
|
304
|
+
tuning,
|
|
305
|
+
depth + 1,
|
|
306
|
+
false,
|
|
307
|
+
);
|
|
308
|
+
if (answer !== null) return answer;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
return runNode(tree, node.fallback, request, ctx, tuning, depth + 1, true);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* The helpers in one object, for a tree file to type once (see the file
|
|
316
|
+
* comment).
|
|
317
|
+
* @type {RouteKit<any, any, any>}
|
|
318
|
+
*/
|
|
319
|
+
export const routeKit = {
|
|
320
|
+
defineSearcher,
|
|
321
|
+
defineDispatcher,
|
|
322
|
+
defineClassifier,
|
|
323
|
+
defineTree,
|
|
324
|
+
};
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
import {describe, expect, it} from 'vitest';
|
|
4
|
+
import {
|
|
5
|
+
classify,
|
|
6
|
+
defineDispatcher,
|
|
7
|
+
defineSearcher,
|
|
8
|
+
defineTree,
|
|
9
|
+
runTree,
|
|
10
|
+
} from './route.mjs';
|
|
11
|
+
|
|
12
|
+
/** @typedef {{query: string}} Req */
|
|
13
|
+
/** @typedef {{names: string[]}} Ctx */
|
|
14
|
+
|
|
15
|
+
const ctx = {names: ['Button', 'ButtonGroup', 'Card']};
|
|
16
|
+
|
|
17
|
+
const main = defineSearcher({
|
|
18
|
+
id: 'main',
|
|
19
|
+
summary: 'Names that contain the query.',
|
|
20
|
+
/** @param {Req} request @param {Ctx} c */
|
|
21
|
+
run: (request, c) => {
|
|
22
|
+
const results = c.names.filter(n =>
|
|
23
|
+
n.toLowerCase().includes(request.query),
|
|
24
|
+
);
|
|
25
|
+
return results.length ? results : null;
|
|
26
|
+
},
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
const exact = defineSearcher({
|
|
30
|
+
id: 'exact',
|
|
31
|
+
summary: 'The one name the query spells.',
|
|
32
|
+
/** @param {Req} request @param {Ctx} c @param {unknown} weights */
|
|
33
|
+
run: (request, c, weights) => {
|
|
34
|
+
const hit = c.names.find(n => n === request.query);
|
|
35
|
+
const prefix =
|
|
36
|
+
/** @type {{prefix?: string} | null} */ (weights)?.prefix ?? '';
|
|
37
|
+
return hit ? [`${prefix}${hit}`] : null;
|
|
38
|
+
},
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
const root = (confidence = 1) =>
|
|
42
|
+
defineDispatcher({
|
|
43
|
+
id: 'root',
|
|
44
|
+
summary: 'Identifiers go to exact lookup.',
|
|
45
|
+
children: ['exact', 'main'],
|
|
46
|
+
floor: 0.5,
|
|
47
|
+
fallback: 'main',
|
|
48
|
+
/** @param {Req} request */
|
|
49
|
+
route: request =>
|
|
50
|
+
/^[A-Z]/.test(request.query)
|
|
51
|
+
? {ids: ['exact'], confidence}
|
|
52
|
+
: {ids: [], confidence: 0},
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
const tree = (confidence = 1) =>
|
|
56
|
+
defineTree('root', [root(confidence), exact, main]);
|
|
57
|
+
|
|
58
|
+
describe('defineTree', () => {
|
|
59
|
+
it('stamps each kind', () => {
|
|
60
|
+
expect(main.kind).toBe('searcher');
|
|
61
|
+
expect(root().kind).toBe('dispatcher');
|
|
62
|
+
});
|
|
63
|
+
it('rejects a duplicate, a missing child, a fallback that is not a searcher, and a cycle', () => {
|
|
64
|
+
expect(() => defineTree('main', [main, main])).toThrow(/duplicate/);
|
|
65
|
+
expect(() => defineTree('root', [root(), main])).toThrow(
|
|
66
|
+
/missing node "exact"/,
|
|
67
|
+
);
|
|
68
|
+
const toRoot = defineDispatcher({...root(), fallback: 'root'});
|
|
69
|
+
expect(() => defineTree('root', [toRoot, exact, main])).toThrow(
|
|
70
|
+
/not a searcher/,
|
|
71
|
+
);
|
|
72
|
+
const a = defineDispatcher({...root(), id: 'a', children: ['b']});
|
|
73
|
+
const b = defineDispatcher({...root(), id: 'b', children: ['a']});
|
|
74
|
+
expect(() => defineTree('a', [a, b, main])).toThrow(/cycle a -> b -> a/);
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
describe('runTree', () => {
|
|
79
|
+
it('takes the first routed answer at or above the floor', async () => {
|
|
80
|
+
expect(await runTree(tree(), {query: 'Button'}, ctx)).toEqual(['Button']);
|
|
81
|
+
});
|
|
82
|
+
it('runs the fallback below the floor', async () => {
|
|
83
|
+
/** @type {any[]} */
|
|
84
|
+
const steps = [];
|
|
85
|
+
const answer = await runTree(tree(0.2), {query: 'Button'}, ctx, {
|
|
86
|
+
tuning: {onTrace: step => steps.push(step)},
|
|
87
|
+
});
|
|
88
|
+
expect(answer).toBeNull();
|
|
89
|
+
expect(steps.map(s => [s.id, s.fallback])).toEqual([
|
|
90
|
+
['root', false],
|
|
91
|
+
['main', true],
|
|
92
|
+
]);
|
|
93
|
+
});
|
|
94
|
+
it('runs the fallback when no routed child answers', async () => {
|
|
95
|
+
expect(await runTree(tree(), {query: 'card'}, ctx)).toEqual(['Card']);
|
|
96
|
+
expect(await runTree(tree(), {query: 'Car'}, ctx)).toBeNull();
|
|
97
|
+
});
|
|
98
|
+
it('passes a node its tuning weights and traces every step', async () => {
|
|
99
|
+
/** @type {any[]} */
|
|
100
|
+
const steps = [];
|
|
101
|
+
const answer = await runTree(tree(), {query: 'Button'}, ctx, {
|
|
102
|
+
tuning: {
|
|
103
|
+
weights: {exact: {prefix: '*'}},
|
|
104
|
+
onTrace: step => steps.push(step),
|
|
105
|
+
},
|
|
106
|
+
});
|
|
107
|
+
expect(answer).toEqual(['*Button']);
|
|
108
|
+
expect(steps).toEqual([
|
|
109
|
+
{
|
|
110
|
+
id: 'root',
|
|
111
|
+
kind: 'dispatcher',
|
|
112
|
+
depth: 0,
|
|
113
|
+
fallback: false,
|
|
114
|
+
route: {ids: ['exact'], confidence: 1},
|
|
115
|
+
},
|
|
116
|
+
{
|
|
117
|
+
id: 'exact',
|
|
118
|
+
kind: 'searcher',
|
|
119
|
+
depth: 1,
|
|
120
|
+
fallback: false,
|
|
121
|
+
answered: true,
|
|
122
|
+
},
|
|
123
|
+
]);
|
|
124
|
+
});
|
|
125
|
+
it('runs one node alone with start', async () => {
|
|
126
|
+
expect(
|
|
127
|
+
await runTree(tree(), {query: 'button'}, ctx, {start: 'main'}),
|
|
128
|
+
).toEqual(['Button', 'ButtonGroup']);
|
|
129
|
+
});
|
|
130
|
+
it('rejects a route to a node that is not a child', async () => {
|
|
131
|
+
const stray = defineDispatcher({
|
|
132
|
+
...root(),
|
|
133
|
+
route: () => ({ids: ['root'], confidence: 1}),
|
|
134
|
+
});
|
|
135
|
+
await expect(
|
|
136
|
+
runTree(defineTree('root', [stray, exact, main]), {query: 'x'}, ctx),
|
|
137
|
+
).rejects.toThrow(/not one of its children/);
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
describe('classify', () => {
|
|
142
|
+
it('ignores a token that is only an Object.prototype name', () => {
|
|
143
|
+
const weights = {classes: ['a', 'b'], tokens: {button: [2, 0]}};
|
|
144
|
+
const probs = classify(weights, [
|
|
145
|
+
'button',
|
|
146
|
+
'constructor',
|
|
147
|
+
'__proto__',
|
|
148
|
+
'toString',
|
|
149
|
+
]);
|
|
150
|
+
expect(probs.every(Number.isFinite)).toBe(true);
|
|
151
|
+
expect(probs).toEqual(classify(weights, ['button']));
|
|
152
|
+
});
|
|
153
|
+
});
|