sygnal 6.2.0 → 6.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/CHANGELOG.md +61 -0
  2. package/README.md +3 -1
  3. package/dist/ai.cjs.js +36 -0
  4. package/dist/ai.cjs.js.map +1 -1
  5. package/dist/ai.esm.js +1 -1
  6. package/dist/astro/server.cjs.js +3 -3
  7. package/dist/astro/server.cjs.js.map +1 -1
  8. package/dist/astro/server.mjs +3 -3
  9. package/dist/astro/server.mjs.map +1 -1
  10. package/dist/diagnostics.cjs.js +164 -3
  11. package/dist/diagnostics.cjs.js.map +1 -1
  12. package/dist/diagnostics.esm.js +164 -3
  13. package/dist/diagnostics.esm.js.map +1 -1
  14. package/dist/guide/agent.md +7 -1
  15. package/dist/guide/ai-cards.md +319 -0
  16. package/dist/guide/ai-chat.md +4 -0
  17. package/dist/guide/ai-compose.md +881 -0
  18. package/dist/guide/ai-decisions.md +16 -0
  19. package/dist/guide/http.md +1 -0
  20. package/dist/index.cjs.js +5695 -347
  21. package/dist/index.cjs.js.map +1 -1
  22. package/dist/index.d.ts +638 -8
  23. package/dist/index.esm.js +5685 -347
  24. package/dist/index.esm.js.map +1 -1
  25. package/dist/sygnal.min.js +1 -1
  26. package/dist/sygnal.min.js.map +1 -1
  27. package/dist/vike/config/package.json +1 -1
  28. package/dist/vike/onRenderClient.cjs.js +3 -2
  29. package/dist/vike/onRenderClient.cjs.js.map +1 -1
  30. package/dist/vike/onRenderClient.mjs +3 -2
  31. package/dist/vike/onRenderClient.mjs.map +1 -1
  32. package/llms.txt +6 -4
  33. package/package.json +2 -1
  34. package/src/ai.d.ts +642 -7
  35. package/src/ai.ts +6 -0
  36. package/src/collection.ts +2 -1
  37. package/src/core/instance.ts +6 -3
  38. package/src/core/markers/lazy.ts +7 -3
  39. package/src/core/markers/portal.ts +5 -2
  40. package/src/core/markers/suspense.ts +3 -2
  41. package/src/core/markers/transition.ts +6 -3
  42. package/src/core/registry.ts +9 -5
  43. package/src/cycle/dom/IsolateModule.ts +25 -22
  44. package/src/extra/ai/chat/behavior.ts +354 -81
  45. package/src/extra/ai/chat/driver.ts +7 -4
  46. package/src/extra/ai/compose/agent.ts +55 -0
  47. package/src/extra/ai/compose/ask.ts +147 -0
  48. package/src/extra/ai/compose/behavior.ts +183 -0
  49. package/src/extra/ai/compose/catalog.ts +211 -0
  50. package/src/extra/ai/compose/composed.ts +61 -0
  51. package/src/extra/ai/compose/decisions.ts +1649 -0
  52. package/src/extra/ai/compose/diag.ts +33 -0
  53. package/src/extra/ai/compose/index.ts +23 -0
  54. package/src/extra/ai/compose/layout.ts +441 -0
  55. package/src/extra/ai/compose/llm.ts +1120 -0
  56. package/src/extra/ai/compose/render.ts +159 -0
  57. package/src/extra/ai/compose/testing.ts +71 -0
  58. package/src/extra/ai/compose/types.ts +159 -0
  59. package/src/extra/ai/decide.ts +97 -1
  60. package/src/extra/ai/index.ts +6 -1
  61. package/src/extra/diagnostics/checks/next.ts +86 -2
  62. package/src/extra/diagnostics/codes.ts +54 -0
  63. package/src/extra/fetchDriver.ts +58 -27
  64. package/src/extra/lens.ts +15 -0
  65. package/src/extra/ssr.ts +3 -3
  66. package/src/extra/testing.ts +2 -0
  67. package/src/extra/widget.ts +3 -3
  68. package/src/index.d.ts +41 -0
  69. package/src/index.ts +8 -0
  70. package/src/lazy.ts +3 -2
  71. package/src/portal.ts +5 -2
  72. package/src/pragma/index.ts +2 -1
  73. package/src/suspense.ts +5 -2
  74. package/src/switchable.ts +2 -1
  75. package/src/transition.ts +5 -2
  76. package/src/vike/onRenderClient.ts +3 -2
@@ -0,0 +1,1649 @@
1
+ /*
2
+ * PLAN-9 B-1 / B-2 / B-4: `compose.decisions()`, the decision-model backend of `compose` (TypeSafe Jev,
3
+ * Ollama `nimble`, anything `decide()` reaches). It composes a layout from the catalog's parts with
4
+ * choice / noul questions only: the model never writes text. Ported from the research encodings,
5
+ * with their exact wording:
6
+ * - structure: dev-plans/research/ai-compose/structure/composer.mjs (TEXT, 189/210 on Jev);
7
+ * - params, records and edits: dev-plans/research/ai-compose/params-edits/params-edits.mjs (W).
8
+ *
9
+ * Steps (one `decide()` request each, through the HTTP sink):
10
+ * 0. categories (parts with a `category`; `prefilter: true`, or the default 'auto' when the select
11
+ * step would ask about more than PREFILTER_AT parts, PREFILTER_AT_SMALL with profile 'small'):
12
+ * `cat_<id>` per category (B-5: same accuracy at about half the tokens, one more call).
13
+ * 1. select (state: the request alone):
14
+ * use_<part> (omit / use; structure wording, params-edits wording for a part with params); count_<part> (0..maxUses) for a repeatable part;
15
+ * pick_<group> (omit + each member) for a `group` of alternatives;
16
+ * rec_<part>__<n> (n = 1..4; none + records) for a record part: "which <label> does user_request name
17
+ * first / second / …", over the records a lexical prefilter on name / aliases kept;
18
+ * param_<part>__<name> for an enum param or the bins of a number param, with not_stated
19
+ * (= the default); `type: 'text'` / `'list'` params keep their default (LLM-only; a part
20
+ * with a required one and no default is left out, SYG680);
21
+ * row: "side by side?" (a noul gate, when the catalog has the built-in `row`).
22
+ * Nothing chosen: nothing fits (1 call). One part in one possible place: done (1 call).
23
+ * 2. layout (state: the request + the selected elements): parent_<k> over the places `in`
24
+ * allows; with the row gate on, beside_<k> and left_<a>_vs_<b>; first_<a>_vs_<b> per pair;
25
+ * gap_<x>_in_<part> for a part used 2+ times.
26
+ * With 4+ elements that may share rows, also same_<a>_vs_<b> (a noul per pair), so a row longer
27
+ * than 3 (up to the row container's `max`) can be read: one beside answer per element names
28
+ * one neighbour, a chain that average-linkage splits.
29
+ * Reading is code: rows by average-linkage clustering of `beside` (`rowMean`), order by summed
30
+ * pairwise probabilities, pins, required parts, companions, containers present only when filled,
31
+ * `minSlots` unwrap, limits. The result goes through `finish()` (layout.ts), shared with `llm()`.
32
+ *
33
+ * Edits (B-4, params-edits.mjs §3, its wording): with a previous layout that has parts, at most 2 calls.
34
+ * edit (state: the request + current_page, each node described with its record, settings and
35
+ * place): remove_<node> (noul), simpler_<node> (read only under the `simpler` gate),
36
+ * set_<node>__<param> (keep + the other options; read only under the `change` gate), add_<part> /
37
+ * add_g_<group> for the parts not on the page (+ their params, + 2 record slots over the records
38
+ * the request names that aren't on the page), the gates swap / simpler / placed / change, and
39
+ * moved_1 / moved_2 (which node does the request move: slots over the nodes, a lookup).
40
+ * where (only when something moves or is added): where_<node> per mover over not_stated, top,
41
+ * bottom, above:/below:/beside:<node>, into:<container key>:<slot> (the places its `in` allows; a
42
+ * container not on the page is created).
43
+ * Rules: kept nodes keep their keys (the reply says `keyed`: the behavior doesn't re-key); a
44
+ * setting change keeps the key and is never a removal; added nodes get keys the previous layout
45
+ * never had; a replacement (swap + one removed + one added) takes the removed node's place and
46
+ * row unless its where says otherwise; a reference node (another mover's anchor) never moves; a
47
+ * simpler page is never empty; nothing to do at all reads as "nothing fits" (the layout kept).
48
+ * Every choice respects `maxOptions` (Ollama: 25): a larger one is split into chunks that repeat its
49
+ * head options, merged back before reading. `profile: 'small'` (G-755): pairwise order questions in
50
+ * both option orders, near-ties ordered by where the request names the parts.
51
+ *
52
+ * `pending` is plain JSON (the behavior keeps it in state): the step, the prompt, the record
53
+ * values the prefilter kept, the selected items. The questions are rebuilt from it to read a reply,
54
+ * so a reply is checked against the criteria that were sent (a choice outside them: SYG682).
55
+ *
56
+ * Side-effect free (0 B for apps that don't import it).
57
+ */
58
+ import {decide, choice, noul} from '../decide'
59
+ import {answers as answersFor} from '../answers'
60
+ import {report} from '../../diagnostics/index'
61
+ import {fromTree, toTree, finish} from './layout'
62
+
63
+ const ROOT = 'page'
64
+ const ROOT_SLOT = 'default'
65
+ const ROW = 'row'
66
+ const ROW_MIN = 0.5
67
+ /** rowable elements from which the layout step adds same-row nouls */
68
+ const SAME_ROW = 4
69
+ const sep = '__'
70
+
71
+ // ---------- wording (structure/composer.mjs TEXT and params-edits.mjs W, verbatim) ----------
72
+ export const TEXT = {
73
+ select: 'Is this element one of the things user_request asks to see? Choose use only when user_request names this element or describes what it shows. Choose omit for elements that are merely about the same topic, area or page as what user_request asks for.',
74
+ variant: 'Which of these elements does user_request ask for, by name or by what it shows? Choose omit when it asks for none of them. When it asks for this kind of element without saying which, choose the first one.',
75
+ count: (d: string) => `How many times does user_request want this element on the page: ${d}? Choose 0 when it does not ask for it.`,
76
+ omit: 'None of these elements is needed.',
77
+ category: 'Does user_request ask for anything in this area, by name or by what it shows?',
78
+ row: 'Does user_request explicitly ask for some elements to be placed side by side, next to each other, beside each other or in a row?',
79
+ parent: (d: string) => `Where does this element go: ${d}? Follow user_request when it says where. Otherwise choose the place whose description fits this element best.`,
80
+ beside: (d: string) => `Which element does user_request want side by side with this element, in the same row: ${d}? Choose none unless user_request asks for these elements to be next to each other, beside each other or in a row.`,
81
+ // new (rows of 4+): one `beside` answer per element names one neighbour, so a long row reads as a chain
82
+ same: (a: string, b: string) => `Does user_request want these two elements in the same row, side by side: ${a}; and ${b}? Answer no unless user_request asks for both of them to be next to each other or in one row.`,
83
+ gap: (d: string, n: number, x: string, guidance?: string) => `This page shows this element ${n} times: ${d}. Where does this other element go relative to those copies: ${x}? Follow user_request: it may say where each copy goes (for example at the top and again at the bottom) and what goes between them.${guidance ? ' When user_request does not say: ' + guidance : ''}`,
84
+ left: 'If these two elements are side by side in a row, which one is on the left? Follow user_request: the one it puts on the left, or names first when it lists them side by side, is on the left.',
85
+ first: (guidance?: string) => `Which of these two elements comes first on the page (higher up, or further left in a row)? Follow user_request: an element it wants at the top, first, above or on the left comes first; one it wants below, under, after, at the end or on the right comes later. When user_request does not say, ${guidance ? guidance + ' Otherwise ' : ''}the one user_request mentions first comes first.`,
86
+ }
87
+ const ORDINAL = ['', 'first', 'second', 'third', 'fourth', 'fifth', 'sixth', 'seventh', 'eighth']
88
+ export const W = {
89
+ select: 'Does user_request ask for this element, by name or by what it shows? Every element user_request names must be used, wherever on the page it wants it. Omit elements user_request does not ask for, even when related to the topic.',
90
+ param: (d: string, label: string, ask?: string) => `Consider this element: ${d}. ${ask ?? `Which ${label} does user_request ask for it?`} Choose not_stated when user_request does not mention a ${label}.`,
91
+ notStated: (label: string) => `user_request does not mention a ${label}`,
92
+ recordFamily: (d: string, label: string, names: string[]) => `${d}; one per ${label} that user_request names${names.length ? ` (the ${label}s: ${names.join(', ')})` : ''}`,
93
+ slot: (d: string, label: string, n: number) => `${d}. Which ${label} does user_request name ${ORDINAL[n] ?? `as number ${n}`}? Choose none when user_request names fewer than ${n} ${label}${n > 1 ? 's' : ''} from this list.`,
94
+ slotNone: (label: string, n: number) => `user_request names fewer than ${n} ${label}${n > 1 ? 's' : ''} from this list`,
95
+ }
96
+
97
+ // ---------- the catalog (normalized form, phase1-contract.md) ----------
98
+ const partIds = (c: any) => Object.keys(c.parts || {})
99
+ const partOf = (c: any, id: string) => c.parts?.[id]
100
+ const containerOf = (c: any, id: string) => c.containers?.[id]
101
+ const hasRows = (c: any) => !!containerOf(c, ROW)
102
+ const paramsOf = (part: any) => part?.params || {}
103
+ const labelOf = (name: string, p: any) => p.label ?? name
104
+ /**
105
+ * a param's kind: the normalized catalog's `kind` (types.ts: 'options' | 'number' | 'text' | 'list' |
106
+ * 'record'); a raw declaration (`options` / `from` / `type`) reads the same
107
+ */
108
+ const kindOf = (p: any): string => p?.kind ?? (typeof p?.from == 'function' ? 'record' : p?.options ? 'options' : p?.type ?? '')
109
+ /** the record param of a part (at most one) */
110
+ const recordParam = (part: any) => Object.keys(paramsOf(part)).find(k => kindOf(part.params[k]) == 'record')
111
+ /** the options a decision model picks from: an enum's options, a number param's bins */
112
+ const optionsOf = (p: any) => (kindOf(p) == 'number' ? p.bins : kindOf(p) == 'options' ? p.options : null) || {}
113
+ const askable = (p: any) => (kindOf(p) == 'options' || kindOf(p) == 'number') && Object.keys(optionsOf(p)).length > 1
114
+ /** a required param a decision model can't fill (free text with no default): the part is LLM-only */
115
+ export const llmOnly = (part: any) => Object.values(paramsOf(part)).some((p: any) => kindOf(p) != 'record' && !askable(p) && p.required && p.default === undefined)
116
+ const maxUses = (part: any) => Math.max(1, part?.maxUses ?? 1)
117
+ /** record slots per record part: "the first … fourth <label> the request names" (params-edits maxRecords) */
118
+ const RECORD_SLOTS = 4
119
+ const slotNames = (k: any) => Object.keys(k?.slots || {default: null})
120
+
121
+ /** the allowed places of a part or container as 'container:slot' strings */
122
+ function allowed(c: any, entry: any): string[] {
123
+ const out: string[] = []
124
+ for (const d of entry?.in || [ROOT]) {
125
+ if (d === ROOT) out.push(`${ROOT}:${ROOT_SLOT}`)
126
+ else if (d.includes(':')) { if (containerOf(c, d.split(':')[0])) out.push(d) }
127
+ else if (d !== ROW && containerOf(c, d)) for (const s of slotNames(containerOf(c, d))) out.push(`${d}:${s}`)
128
+ }
129
+ return out.length ? out : [`${ROOT}:${ROOT_SLOT}`]
130
+ }
131
+
132
+ // ---------- records: the lexical prefilter (params-edits.mjs narrowRecords) ----------
133
+ const words = (t: any) => String(t).toLowerCase().replace(/'s\b/g, '').match(/[\p{L}\p{N}]+/gu) || []
134
+ function recordsOf(part: any, rp: string, state: any): any[] {
135
+ let list: any
136
+ try { list = part.params[rp].from(state) } catch (_) { return [] }
137
+ return Array.isArray(list) ? list.filter((r: any) => r && r.value != null) : []
138
+ }
139
+ /** the records whose every name word (or every word of an alias) is in the prompt, best first */
140
+ export function narrowRecords(list: any[], prompt: string, max = 254) {
141
+ const have = new Set(words(prompt))
142
+ return list
143
+ .map(r => ({r, s: Math.max(...[r.name ?? r.value, ...(r.aliases || [])].map(n => { const w = words(n); return w.filter(x => have.has(x)).length / (w.length || 1) }))}))
144
+ .filter(x => x.s >= 1).slice(0, max).map(x => x.r)
145
+ }
146
+ const recordText = (r: any) => r.description ?? r.name ?? String(r.value)
147
+ const recordName = (r: any) => r.name ?? recordText(r).split(/[:,]/)[0]
148
+
149
+ // ---------- choices over a backend's candidate limit (params-edits.mjs chunkedChoice) ----------
150
+ /**
151
+ * A choice with more options than the backend takes (Ollama: 25, `maxOptions`) is split into
152
+ * chunks `<name>__c<k>`, each repeating the `head` options (not_stated / keep / none / omit); every
153
+ * choice goes through here, so every question respects the limit. `unchunk()` merges the answers
154
+ * back into `<name>` before reading.
155
+ */
156
+ function chunked(qs: any, meta: any, name: string, instructions: string, head: any, options: any, m: any, max: number) {
157
+ const entries = Object.entries(options)
158
+ const room = Math.max(1, max - Object.keys(head).length)
159
+ if (entries.length <= room) { qs[name] = choice(instructions, {...head, ...options}); meta[name] = m; return }
160
+ // even chunks (sizes differ by 1 at most), so none is left with a single option
161
+ const n = Math.ceil(entries.length / room)
162
+ for (let k = 0, i = 0; k < n; k++) {
163
+ const size = Math.floor(entries.length / n) + (k < entries.length % n ? 1 : 0)
164
+ qs[`${name}${sep}c${k}`] = choice(instructions, {...head, ...Object.fromEntries(entries.slice(i, i + size))})
165
+ meta[`${name}${sep}c${k}`] = {...m, base: name, chunk: k, head: Object.keys(head)}
166
+ i += size
167
+ }
168
+ }
169
+ /**
170
+ * the chunked answers merged under their base name: the chunk whose non-head answer is the most
171
+ * probable (its own answer object), else the first chunk (all answered a head option)
172
+ */
173
+ function unchunk(a: any, meta: any) {
174
+ const out = {...a}
175
+ const best: any = {}
176
+ for (const [name, m] of Object.entries<any>(meta)) {
177
+ if (m.base == null || !a[name]) continue
178
+ const x = a[name], b = best[m.base]
179
+ const head = (y: any) => m.head.includes(y.choice)
180
+ const p = (y: any) => y.probabilities?.[y.choice] ?? 0
181
+ if (!b || (head(b) && !head(x)) || (!head(b) && !head(x) && p(x) > p(b))) best[m.base] = x
182
+ }
183
+ return Object.assign(out, best)
184
+ }
185
+ const readChunked = (a: any, name: string) => a[name]
186
+
187
+ // ---------- step 0: categories ----------
188
+ const categoriesOf = (c: any) => {
189
+ const out: string[] = []
190
+ for (const id of partIds(c)) { const k = partOf(c, id).category; if (k != null && !out.includes(k)) out.push(k) }
191
+ return out
192
+ }
193
+ export function categoryStep(c: any, prompt: string) {
194
+ const questions: any = {}, meta: any = {}
195
+ for (const id of categoriesOf(c)) {
196
+ questions[`cat_${id}`] = choice(TEXT.category, {omit: 'Nothing in this area is needed.', use: c.categories?.[id] ?? id})
197
+ meta[`cat_${id}`] = {kind: 'cat', category: id}
198
+ }
199
+ return {state: {user_request: prompt}, questions, meta}
200
+ }
201
+
202
+ // ---------- step 1: select ----------
203
+ const said = new Set<string>()
204
+ function excluded(id: string) {
205
+ if (said.has(id)) return
206
+ said.add(id)
207
+ dev('SYG680', `part '${id}' has a required text param with no default: a decision model can't fill it, so compose.decisions() never places it (it is LLM-only)`, 'info')
208
+ }
209
+ function dev(code: string, message: string, severity: 'error' | 'warn' | 'info') {
210
+ try { report(code, {component: 'compose.decisions', message, severity}) } catch (e) { queueMicrotask(() => { throw e }) }
211
+ }
212
+
213
+ /** the parts the select step asks about: not required ones, not LLM-only ones, in the chosen categories */
214
+ function selectable(c: any, categories?: string[] | null) {
215
+ return partIds(c).filter(id => {
216
+ const p = partOf(c, id)
217
+ if (p.required) return false
218
+ if (llmOnly(p)) { excluded(id); return false }
219
+ return !categories || p.category == null || categories.includes(p.category)
220
+ })
221
+ }
222
+
223
+ /**
224
+ * The select questions. `records`: the record values the prefilter kept per record part (start()
225
+ * computes it; reply() and fake() rebuild the same questions from it). Returns `meta` per question.
226
+ */
227
+ export function selectStep(c: any, prompt: string, {categories = null, records = {}, max = 255}: any = {}) {
228
+ const questions: any = {}, meta: any = {}
229
+ const ids = selectable(c, categories).filter(id => !recordParam(partOf(c, id)) || records[id]?.length)
230
+ const seen = new Set<string>()
231
+ for (const id of ids) {
232
+ const part = partOf(c, id)
233
+ const rp = recordParam(part)
234
+ if (part.group != null) {
235
+ if (!seen.has(part.group)) {
236
+ seen.add(part.group)
237
+ const members = ids.filter(m => partOf(c, m).group === part.group)
238
+ const withRecords = members.some(m => recordParam(partOf(c, m)))
239
+ chunked(questions, meta, `pick_${part.group}`, withRecords ? W.select : TEXT.variant, {omit: TEXT.omit},
240
+ Object.fromEntries(members.map(m => {
241
+ const mp = partOf(c, m), mr = recordParam(mp)
242
+ return [m, mr ? W.recordFamily(mp.description, labelOf(mr, mp.params[mr]), records[m].map((r: any) => r.name)) : mp.description]
243
+ })), {kind: 'pick', group: part.group, members}, max)
244
+ }
245
+ } else if (!rp && maxUses(part) > 1) {
246
+ const criteria: any = {}
247
+ for (let n = 1; n <= maxUses(part); n++) criteria[n] = n === 1 ? 'Once.' : `${n} times, in different places on the page.`
248
+ chunked(questions, meta, `count_${id}`, TEXT.count(part.description), {0: 'Not on the page.'}, criteria, {kind: 'count', part: id}, max)
249
+ } else if (!rp) {
250
+ // a part with choice params takes params-edits' select wording: the strict structure wording
251
+ // drops a part the request names by a param value ("refunded orders", "complaints"), which
252
+ // the description leaves out on purpose (Jev, params set x3: 57/60 mixed, 46/60 strict only,
253
+ // 54/60 params wording only; the structure set keeps 64/70: its parts have no params)
254
+ const withParams = Object.values<any>(paramsOf(part)).some(askable)
255
+ questions[`use_${id}`] = choice(withParams ? W.select : TEXT.select, {omit: TEXT.omit, use: part.description})
256
+ meta[`use_${id}`] = {kind: 'use', part: id}
257
+ }
258
+ if (rp) {
259
+ const label = labelOf(rp, part.params[rp])
260
+ const options = Object.fromEntries(records[id].map((r: any) => [String(r.value), r.description]))
261
+ for (let n = 1; n <= RECORD_SLOTS; n++) {
262
+ chunked(questions, meta, `rec_${id}${sep}${n}`, W.slot(part.description, label, n), {none: W.slotNone(label, n)}, options, {kind: 'rec', part: id, n}, max)
263
+ }
264
+ }
265
+ }
266
+ // params: of the asked parts and the required ones (read only for the parts that are used)
267
+ const required = partIds(c).filter(id => partOf(c, id).required && !llmOnly(partOf(c, id)))
268
+ for (const id of [...required, ...ids]) {
269
+ const part = partOf(c, id)
270
+ for (const [name, p] of Object.entries<any>(paramsOf(part))) {
271
+ if (!askable(p)) continue
272
+ const label = labelOf(name, p)
273
+ chunked(questions, meta, `param_${id}${sep}${name}`, W.param(part.description, label, p.ask), {not_stated: W.notStated(label)},
274
+ Object.fromEntries(Object.entries(optionsOf(p)).map(([v, d]) => [String(v), d])), {kind: 'param', part: id, param: name}, max)
275
+ }
276
+ }
277
+ if (hasRows(c)) { questions.row = noul(TEXT.row); meta.row = {kind: 'row'} }
278
+ return {state: {user_request: prompt}, questions, meta}
279
+ }
280
+
281
+ /** the record values the prefilter keeps for each record part, as `{ value, name, description }` */
282
+ function prefilterRecords(c: any, state: any, prompt: string, categories: string[] | null, max: number) {
283
+ const out: any = {}
284
+ for (const id of selectable(c, categories)) {
285
+ const part = partOf(c, id), rp = recordParam(part)
286
+ if (!rp) continue
287
+ const kept = narrowRecords(recordsOf(part, rp, state), prompt, max - 1)
288
+ if (kept.length) out[id] = kept.map(r => ({value: r.value, name: recordName(r), description: recordText(r)}))
289
+ }
290
+ return out
291
+ }
292
+
293
+ const useProbability = (a: any) => (!a ? 0 : a.type === 'noul' ? a.noul : a.probabilities?.use ?? (a.choice === 'use' ? 1 : 0))
294
+ const keyPart = (s: string) => s.replace(/[^A-Za-z0-9_]/g, '_')
295
+
296
+ /** an enum / number param's value from its answer (not_stated or nothing: the default; only non-defaults kept) */
297
+ function readParams(c: any, id: string, a: any) {
298
+ const out: any = {}
299
+ for (const [name, p] of Object.entries<any>(paramsOf(partOf(c, id)))) {
300
+ if (!askable(p)) continue
301
+ const x = readChunked(a, `param_${id}${sep}${name}`)
302
+ const v = x?.choice
303
+ if (!v || v === 'not_stated' || !(v in optionsOf(p))) continue
304
+ const value = kindOf(p) == 'number' ? Number(v) : v
305
+ if (String(value) !== String(p.default)) out[name] = value
306
+ }
307
+ return out
308
+ }
309
+
310
+ /** describes a selected node to the model: description, record, params in force (params-edits describeNode) */
311
+ function describe(c: any, id: string, params: any, record?: any) {
312
+ const part = partOf(c, id)
313
+ let d = part.description
314
+ if (record) d += `, for ${record.description}`
315
+ for (const [name, p] of Object.entries<any>(paramsOf(part))) {
316
+ if (!askable(p)) continue
317
+ const v = params?.[name] ?? p.default
318
+ if (v === undefined) continue
319
+ d += `; ${labelOf(name, p)}: ${optionsOf(p)[v] ?? v}`
320
+ }
321
+ return d
322
+ }
323
+
324
+ /**
325
+ * select answers → { items: [{ key, part, params?, d }], row, unavailable }. Keys: the part id, `__2`
326
+ * for further copies, `<part>__<record>` for records. Adds required parts and companions; caps at
327
+ * limits.maxParts (keeping the most probable).
328
+ */
329
+ export function readSelect(c: any, a: any, {categories = null, records = {}, maxParts}: any = {}) {
330
+ const scored: any[] = [] // [{ part, params, record?, p }] in catalog order
331
+ const ids = selectable(c, categories).filter(id => !recordParam(partOf(c, id)) || records[id]?.length)
332
+ const seen = new Set<string>()
333
+ const prob = (x: any) => x?.probabilities?.[x.choice] ?? 1
334
+ const recordsFor = (id: string, atLeastOne: boolean) => {
335
+ const out: any[] = []
336
+ for (let n = 1; n <= RECORD_SLOTS; n++) {
337
+ const x = a[`rec_${id}${sep}${n}`]
338
+ if (!x) break
339
+ // the group choice already said "these records": slot 1 is the best record even when none wins
340
+ const best = Object.entries<any>(x.probabilities || {}).filter(([k]) => k !== 'none').sort((u, v) => v[1] - u[1])[0]
341
+ const v = n === 1 && atLeastOne && x.choice === 'none' && best ? best[0] : x.choice
342
+ if (v === 'none' || v == null) break
343
+ const r = records[id].find((r: any) => String(r.value) === v)
344
+ if (r && !out.some(o => o.r === r)) out.push({r, p: v === x.choice ? prob(x) : best[1]})
345
+ }
346
+ return out
347
+ }
348
+ const add = (id: string, p: number) => {
349
+ const rp = recordParam(partOf(c, id))
350
+ if (!rp) { scored.push({part: id, params: readParams(c, id, a), p}); return }
351
+ for (const {r, p: q} of recordsFor(id, partOf(c, id).group != null)) scored.push({part: id, params: {...readParams(c, id, a), [rp]: r.value}, record: r, p: q})
352
+ }
353
+ for (const id of ids) {
354
+ const part = partOf(c, id)
355
+ if (part.group != null) {
356
+ if (seen.has(part.group)) continue
357
+ seen.add(part.group)
358
+ const x = a[`pick_${part.group}`]
359
+ if (x && x.choice !== 'omit' && partOf(c, x.choice)?.group === part.group) add(x.choice, prob(x))
360
+ } else if (recordParam(part)) add(id, 1)
361
+ else if (maxUses(part) > 1) {
362
+ const x = a[`count_${id}`]
363
+ const n = Math.min(maxUses(part), Number(x?.choice ?? 0) || 0)
364
+ for (let i = 0; i < n; i++) add(id, prob(x))
365
+ } else {
366
+ const p = useProbability(a[`use_${id}`])
367
+ if (p >= 0.5) add(id, p)
368
+ }
369
+ }
370
+ if (!scored.length) return {items: [], row: false, unavailable: true}
371
+ const max = Math.min(c.limits?.maxParts ?? 12, maxParts ?? Infinity)
372
+ let list = scored
373
+ if (list.length > max) {
374
+ const keep = new Set([...list].sort((x, y) => y.p - x.p).slice(0, max))
375
+ list = list.filter(x => keep.has(x))
376
+ }
377
+ const required = partIds(c).filter(id => partOf(c, id).required && !llmOnly(partOf(c, id))).map(id => ({part: id, params: readParams(c, id, a)}))
378
+ list = [...required, ...list]
379
+ for (const x of [...list]) {
380
+ for (const id of partOf(c, x.part)?.companions || []) if (partOf(c, id) && !list.some(y => y.part === id)) list.push({part: id, params: {}})
381
+ }
382
+ // keys
383
+ const used = new Set<string>(), count: any = {}
384
+ const items = list.map(x => {
385
+ let key = x.record ? `${x.part}${sep}${keyPart(String(x.record.value))}` : x.part
386
+ if (!x.record) { count[x.part] = (count[x.part] || 0) + 1; if (count[x.part] > 1) key = `${x.part}${sep}${count[x.part]}` }
387
+ for (let i = 2; used.has(key); i++) key = `${key}_${i}`
388
+ used.add(key)
389
+ const params = Object.keys(x.params || {}).length ? x.params : undefined
390
+ return {key, part: x.part, ...(params && {params}), ...(x.record && {record: true}), d: describe(c, x.part, x.params, x.record)}
391
+ })
392
+ return {items, row: !!a.row && (a.row.noul ?? 0) >= 0.5, unavailable: false}
393
+ }
394
+
395
+ // ---------- step 2: layout ----------
396
+ const copyOf = (items: any[], key: string) => {
397
+ const it = items.find(i => i.key === key)
398
+ if (!it || it.record) return 1
399
+ return items.filter(i => i.part === it.part && !i.record).findIndex(i => i.key === key) + 1
400
+ }
401
+ const multiParts = (items: any[], keys: string[]) => new Set(keys.map(k => items.find(i => i.key === k)).filter(i => i && !i.record && copyOf(items, i.key) > 1).map(i => i.part))
402
+
403
+ /** everything the layout step needs to know about the selection */
404
+ export function plan(c: any, sel: any) {
405
+ const items = sel.items
406
+ const nodes: string[] = items.map((i: any) => i.key)
407
+ const containers: string[] = []
408
+ const entry = (k: string) => containerOf(c, k) && !items.some((i: any) => i.key === k) ? containerOf(c, k) : partOf(c, items.find((i: any) => i.key === k)?.part)
409
+ for (let i = 0; i < nodes.length; i++) {
410
+ for (const d of allowed(c, entry(nodes[i]))) {
411
+ const k = d.split(':')[0]
412
+ if (k !== ROOT && !nodes.includes(k)) { nodes.push(k); containers.push(k) }
413
+ }
414
+ }
415
+ const dests: any = {}
416
+ for (const k of nodes) {
417
+ // a container never goes into a container that accepts only parts (`accepts: 'leaves'`)
418
+ const box = !items.some((i: any) => i.key === k)
419
+ dests[k] = allowed(c, entry(k)).filter(d => { const x = d.split(':')[0]; return x !== k && (x === ROOT || nodes.includes(x)) && !(box && containerOf(c, x)?.accepts === 'leaves') })
420
+ if (!dests[k].length) dests[k] = [`${ROOT}:${ROOT_SLOT}`]
421
+ }
422
+ const leaves: string[] = items.map((i: any) => i.key)
423
+ const rowable = sel.row && hasRows(c)
424
+ ? leaves.filter(k => dests[k].some((d: string) => { const x = d.split(':')[0]; return x === ROOT || !containerOf(c, x)?.arranged }))
425
+ : []
426
+ return {nodes, containers, dests, leaves, rowable}
427
+ }
428
+
429
+ const pinOf = (c: any, items: any[], key: string) => partOf(c, items.find(i => i.key === key)?.part)?.pin
430
+ const describeCopy = (items: any[], key: string) => {
431
+ const it = items.find(i => i.key === key)
432
+ const n = copyOf(items, key)
433
+ return n > 1 ? `${it.d} (copy ${n})` : it.d
434
+ }
435
+ function destLabel(c: any, dest: string) {
436
+ const [k, slot] = dest.split(':')
437
+ if (k === ROOT) return `${containerOf(c, ROOT)?.description ?? c.description ?? 'The page'}, at the top level (not inside any section, tab or group)`
438
+ const x = containerOf(c, k)
439
+ return x?.slots?.[slot] || x?.description || k
440
+ }
441
+ const needsLayout = (p: any) => p.nodes.length > 1 && (p.leaves.length > 1 || p.nodes.some((k: string) => p.dests[k].length > 1))
442
+
443
+ export function layoutStep(c: any, prompt: string, sel: any, max = 255, debias = false) {
444
+ const p = plan(c, sel)
445
+ const items = sel.items
446
+ const questions: any = {}, meta: any = {}
447
+ const label = (k: string) => (items.some((i: any) => i.key === k) ? describeCopy(items, k) : containerOf(c, k)?.description ?? k)
448
+ for (const k of p.nodes) {
449
+ if (p.dests[k].length < 2) continue
450
+ chunked(questions, meta, `parent_${k}`, TEXT.parent(label(k)), {}, Object.fromEntries(p.dests[k].map((d: string) => [d, destLabel(c, d)])), {kind: 'parent', key: k}, max)
451
+ }
452
+ if (p.rowable.length > 1) {
453
+ for (const k of p.rowable) {
454
+ chunked(questions, meta, `beside_${k}`, TEXT.beside(label(k)), {none: 'No element: this one is not side by side with another.'},
455
+ Object.fromEntries(p.rowable.filter((o: string) => o !== k).map((o: string) => [o, label(o)])), {kind: 'beside', key: k}, max)
456
+ }
457
+ // 4+ elements that may share rows: also a same-row noul per pair, so rows longer than 3 (up to
458
+ // the row container's `max`) can be read (beside answers alone make a chain that average-linkage splits)
459
+ if (p.rowable.length >= SAME_ROW) p.rowable.forEach((a: string, i: number) => p.rowable.slice(i + 1).forEach((b: string) => {
460
+ questions[`same_${a}_vs_${b}`] = noul(TEXT.same(label(a), label(b)))
461
+ meta[`same_${a}_vs_${b}`] = {kind: 'same', a, b}
462
+ }))
463
+ p.rowable.forEach((a: string, i: number) => p.rowable.slice(i + 1).forEach((b: string) => {
464
+ for (const [x, y] of debias ? [[a, b], [b, a]] : [[a, b]]) {
465
+ questions[`left_${x}_vs_${y}`] = choice(TEXT.left, {[x]: label(x), [y]: label(y)})
466
+ meta[`left_${x}_vs_${y}`] = {kind: 'left', a: x, b: y}
467
+ }
468
+ }))
469
+ }
470
+ const free = p.leaves.filter(k => !pinOf(c, items, k))
471
+ const multi = multiParts(items, free)
472
+ const single = free.filter(k => !multi.has(items.find((i: any) => i.key === k).part))
473
+ // debias: each pair is asked in both orders too (a small model favours the first option)
474
+ single.forEach((a, i) => single.slice(i + 1).forEach(b => {
475
+ for (const [x, y] of debias ? [[a, b], [b, a]] : [[a, b]]) {
476
+ questions[`first_${x}_vs_${y}`] = choice(TEXT.first(c.guidance), {[x]: label(x), [y]: label(y)})
477
+ meta[`first_${x}_vs_${y}`] = {kind: 'first', a: x, b: y}
478
+ }
479
+ }))
480
+ for (const id of multi) {
481
+ const n = free.filter(k => items.find((i: any) => i.key === k).part === id).length
482
+ for (const x of single) {
483
+ const criteria: any = {0: `Above all ${n} copies of it (before the first one).`}
484
+ for (let g = 1; g < n; g++) criteria[g] = `Between copy ${g} and copy ${g + 1} of it.`
485
+ criteria[n] = `Below all ${n} copies of it (after the last one).`
486
+ chunked(questions, meta, `gap_${x}_in_${id}`, TEXT.gap(partOf(c, id).description, n, label(x), c.guidance), {}, criteria, {kind: 'gap', key: x, part: id}, max)
487
+ }
488
+ }
489
+ const state = {user_request: prompt, selected_elements: p.leaves.map(k => ({id: k, content: describeCopy(items, k)}))}
490
+ return {state, questions, meta, plan: p}
491
+ }
492
+
493
+ /**
494
+ * profile 'small': where the request first names each element (its name: the description before
495
+ * '(' or ':', or the part id's words; longer names claim their words first); Infinity when it
496
+ * doesn't. A tie-breaker for pairwise order, which a small model answers by option position.
497
+ */
498
+ export function mentions(c: any, items: any[], prompt: string): Record<string, number> {
499
+ const text = String(prompt).toLowerCase()
500
+ const claimed = new Array(text.length).fill(false)
501
+ const pos: any = {}
502
+ const phrases: Array<[string, string]> = []
503
+ for (const it of items) {
504
+ pos[it.key] = Infinity
505
+ if (it.record) continue
506
+ const head = String(partOf(c, it.part)?.description ?? '').split(/[(:;,]/)[0].trim().toLowerCase()
507
+ const id = it.part.replace(/([a-z])([A-Z])/g, '$1 $2').toLowerCase()
508
+ for (const ph of new Set([head, id])) if (ph) phrases.push([ph, it.key])
509
+ }
510
+ phrases.sort((x, y) => y[0].length - x[0].length)
511
+ for (const [ph, key] of phrases) {
512
+ if (pos[key] !== Infinity) continue
513
+ const re = new RegExp(`\\b${ph.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}s?\\b`, 'g')
514
+ for (let m; (m = re.exec(text));) {
515
+ if (claimed.slice(m.index, m.index + m[0].length).some(Boolean)) continue
516
+ for (let i = m.index; i < m.index + m[0].length; i++) claimed[i] = true
517
+ pos[key] = m.index
518
+ break
519
+ }
520
+ }
521
+ return pos
522
+ }
523
+ /** profile 'small': a pair whose mean answers differ by less than this goes by the mention order */
524
+ const TIE = 0.4
525
+ /** a pair's [x, y] win shares, a near-tie decided by where the request names them (`pos`) */
526
+ function pairWins(px: number, py: number, x: string, y: string, pos: Record<string, number> | null): [number, number] {
527
+ if (!pos || Math.abs(px - py) >= TIE || pos[x] === pos[y]) return [px, py]
528
+ return pos[x] < pos[y] ? [1, 0] : [0, 1]
529
+ }
530
+
531
+ /** pairwise answers → rank per leaf (0 = first): summed win probabilities; copies go into the gaps chosen */
532
+ function ranks(items: any[], leaves: string[], a: any, pos: Record<string, number> | null = null) {
533
+ const partKey = (k: string) => items.find(i => i.key === k).part
534
+ const multi = multiParts(items, leaves)
535
+ const single = leaves.filter(k => !multi.has(partKey(k)))
536
+ const wins: any = Object.fromEntries(single.map(k => [k, 0]))
537
+ single.forEach((x, i) => single.slice(i + 1).forEach(y => {
538
+ // the pair asked once, or in both orders (debias): the mean
539
+ const fs = [a[`first_${x}_vs_${y}`]?.probabilities, a[`first_${y}_vs_${x}`]?.probabilities].filter(Boolean)
540
+ if (!fs.length) return
541
+ const mean = (k: string) => fs.reduce((u, f) => u + (f[k] ?? 0), 0) / fs.length
542
+ const [wx, wy] = pairWins(mean(x), mean(y), x, y, pos)
543
+ wins[x] += wx
544
+ wins[y] += wy
545
+ }))
546
+ const sorted = [...single].sort((x, y) => wins[y] - wins[x] || single.indexOf(x) - single.indexOf(y))
547
+ const score: any = Object.fromEntries(sorted.map((k, i) => [k, i]))
548
+ for (const id of multi) {
549
+ const copies = leaves.filter(k => partKey(k) === id).sort((x, y) => copyOf(items, x) - copyOf(items, y))
550
+ const gap = (x: string) => {
551
+ const pr = a[`gap_${x}_in_${id}`]?.probabilities
552
+ return pr ? Object.entries<any>(pr).reduce((s, [g, q]) => s + Number(g) * q, 0) : copies.length
553
+ }
554
+ copies.forEach((k, i) => {
555
+ const before = sorted.filter(x => gap(x) < i + 0.5)
556
+ score[k] = (before.length ? Math.max(...before.map(x => score[x])) : -1) + 0.5 + i * 0.01
557
+ })
558
+ }
559
+ const out = [...leaves].sort((x, y) => score[x] - score[y] || leaves.indexOf(x) - leaves.indexOf(y))
560
+ return Object.fromEntries(out.map((k, i) => [k, i]))
561
+ }
562
+
563
+ /** layout answers → the composition as a Tree (contract format), before `finish()` */
564
+ export function readLayout(c: any, sel: any, a: any, {rowMean = 0.25, prompt = null}: any = {}): any[] {
565
+ const p = plan(c, sel)
566
+ const items = sel.items
567
+ const itemOf = (k: string) => items.find((i: any) => i.key === k)
568
+ const parent: any = {}
569
+ for (const k of p.nodes) {
570
+ const x = a[`parent_${k}`]?.choice
571
+ parent[k] = p.dests[k].includes(x) ? x : p.dests[k][0]
572
+ }
573
+ // cycles → the root
574
+ const nodeOf = (dest: string) => dest.split(':')[0]
575
+ for (const k of p.nodes) {
576
+ const seen = new Set([k])
577
+ let x = nodeOf(parent[k])
578
+ while (x !== ROOT) {
579
+ if (seen.has(x)) { parent[k] = `${ROOT}:${ROOT_SLOT}`; break }
580
+ seen.add(x)
581
+ x = nodeOf(parent[x])
582
+ }
583
+ }
584
+ const free = p.leaves.filter(k => !pinOf(c, items, k))
585
+ const pos = prompt != null ? mentions(c, items, prompt) : null
586
+ const fr = ranks(items, free, a, pos)
587
+ const rank: any = {}
588
+ for (const k of p.leaves) {
589
+ const pin = pinOf(c, items, k)
590
+ rank[k] = pin === 'start' ? -1000 + p.leaves.indexOf(k) : pin === 'end' ? 1000 + p.leaves.indexOf(k) : fr[k]
591
+ }
592
+ const tree: any = {[ROOT]: {part: ROOT, slots: {[ROOT_SLOT]: []}}}
593
+ for (const k of p.nodes) {
594
+ const it = itemOf(k)
595
+ tree[k] = it ? {part: it.part, params: it.params} : {part: k, slots: Object.fromEntries(slotNames(containerOf(c, k)).map(s => [s, []]))}
596
+ }
597
+ for (const k of p.nodes) { const [x, s] = parent[k].split(':'); tree[x].slots[s].push(k) }
598
+ // drop empty containers (repeat: a container may hold only empty containers)
599
+ for (let dropped = true; dropped;) {
600
+ dropped = false
601
+ for (const k of p.containers) {
602
+ if (!tree[k] || Object.values<any>(tree[k].slots).some(l => l.length)) continue
603
+ const [x, s] = parent[k].split(':')
604
+ tree[x].slots[s] = tree[x].slots[s].filter((y: string) => y !== k)
605
+ delete tree[k]
606
+ dropped = true
607
+ }
608
+ }
609
+ // unwrap containers with fewer non-empty slots than minSlots
610
+ for (const k of p.containers) {
611
+ const min = containerOf(c, k)?.minSlots
612
+ if (!tree[k] || !min) continue
613
+ const full = Object.values<any>(tree[k].slots).filter(l => l.length)
614
+ if (full.length >= min) continue
615
+ const [x, s] = parent[k].split(':')
616
+ const kids = full.flat()
617
+ for (const y of kids) parent[y] = `${x}:${s}`
618
+ tree[x].slots[s] = tree[x].slots[s].filter((y: string) => y !== k).concat(kids)
619
+ delete tree[k]
620
+ }
621
+ const rankOf = (k: string): number => {
622
+ if (k in rank) return rank[k]
623
+ const kids = tree[k]?.slots ? Object.values<any>(tree[k].slots).flat() : []
624
+ return kids.length ? Math.min(...kids.map(rankOf)) : Infinity
625
+ }
626
+ // rows: average-linkage clustering of `beside` among the siblings of one non-arranged slot
627
+ let rowN = 0
628
+ if (p.rowable.length > 1) {
629
+ const up: any = {}
630
+ const find = (x: string): string => (up[x] === undefined || up[x] === x ? x : (up[x] = find(up[x])))
631
+ const pt = (x: string, y: string) => {
632
+ const beside = Math.max(a[`beside_${x}`]?.probabilities?.[y] ?? 0, a[`beside_${y}`]?.probabilities?.[x] ?? 0)
633
+ const same = a[`same_${x}_vs_${y}`] ?? a[`same_${y}_vs_${x}`]
634
+ return same ? (beside + (same.noul ?? 0)) / 2 : beside
635
+ }
636
+ const rowMax = containerOf(c, ROW)?.max ?? Infinity
637
+ const bySlot = new Map<string, string[]>()
638
+ for (const k of p.rowable) if (tree[k]) { if (!bySlot.has(parent[k])) bySlot.set(parent[k], []); bySlot.get(parent[k])!.push(k) }
639
+ for (const members of bySlot.values()) {
640
+ const groups = members.map(k => [k])
641
+ for (;;) {
642
+ let best: any = null
643
+ groups.forEach((g, i) => groups.slice(i + 1).forEach((h, j) => {
644
+ const all = g.flatMap(x => h.map(y => pt(x, y)))
645
+ const m = all.reduce((u, v) => u + v, 0) / all.length
646
+ // one confident link and enough support overall (beside answers are sparse)
647
+ // and a row never grows past the row container's `max` (the built-in row: 4)
648
+ if (g.length + h.length <= rowMax && Math.max(...all) >= ROW_MIN && m >= rowMean && (!best || m > best.m)) best = {m, i, j: i + 1 + j}
649
+ }))
650
+ if (!best) break
651
+ groups[best.i] = groups[best.i].concat(groups[best.j])
652
+ groups.splice(best.j, 1)
653
+ }
654
+ for (const g of groups) for (const x of g.slice(1)) up[find(x)] = find(g[0])
655
+ }
656
+ for (const [id, t] of Object.entries<any>(tree)) {
657
+ if (!t.slots || (id !== ROOT && containerOf(c, t.part)?.arranged)) continue
658
+ for (const [s, list] of Object.entries<any>(t.slots)) {
659
+ const groups = new Map<string, string[]>()
660
+ for (const x of list) if (p.rowable.includes(x)) { const r = find(x); if (!groups.has(r)) groups.set(r, []); groups.get(r)!.push(x) }
661
+ let next = [...list]
662
+ for (const members of groups.values()) {
663
+ if (members.length < 2) continue
664
+ const rowKey = `row#${++rowN}`
665
+ tree[rowKey] = {part: ROW, slots: {default: members}}
666
+ next = next.filter(x => !members.includes(x)).concat(rowKey)
667
+ }
668
+ t.slots[s] = next
669
+ }
670
+ }
671
+ }
672
+ // order every slot; inside a row, left/right answers win
673
+ const leftWins = (k: string, members: string[]) => members.reduce((w, o) => {
674
+ if (o === k) return w
675
+ const prs = [a[`left_${k}_vs_${o}`]?.probabilities, a[`left_${o}_vs_${k}`]?.probabilities].filter(Boolean)
676
+ if (!prs.length) return w + (rankOf(k) < rankOf(o) ? 1 : 0)
677
+ const mean = (x: string) => prs.reduce((u: number, pr: any) => u + (pr[x] ?? 0), 0) / prs.length
678
+ return w + pairWins(mean(k), mean(o), k, o, pos)[0]
679
+ }, 0)
680
+ for (const t of Object.values<any>(tree)) {
681
+ if (!t.slots) continue
682
+ for (const s of Object.keys(t.slots)) {
683
+ if (t.part === ROW) {
684
+ const m = t.slots[s]
685
+ const w = Object.fromEntries(m.map((k: string) => [k, leftWins(k, m)]))
686
+ m.sort((x: string, y: string) => w[y] - w[x] || rankOf(x) - rankOf(y))
687
+ } else t.slots[s].sort((x: string, y: string) => rankOf(x) - rankOf(y))
688
+ }
689
+ }
690
+ return toContractTree(c, tree, c.limits?.maxDepth ?? 4)
691
+ }
692
+
693
+ /** the internal tree → the contract's Tree; a container deeper than maxDepth moves to the root */
694
+ function toContractTree(c: any, tree: any, maxDepth: number): any[] {
695
+ const overflow: string[] = []
696
+ const visit = (k: string, depth: number): any => {
697
+ const t = tree[k]
698
+ if (!t.slots) return {part: t.part, ...(t.params && {params: t.params})}
699
+ const kids: any = {}
700
+ for (const [s, list] of Object.entries<any>(t.slots)) {
701
+ const out: any[] = []
702
+ for (const x of list) {
703
+ if (depth + 1 > maxDepth && tree[x].slots) { overflow.push(x); continue }
704
+ out.push(visit(x, depth + 1))
705
+ }
706
+ if (out.length) kids[s] = out
707
+ }
708
+ const named = slotNames(t.part === ROW ? null : containerOf(c, t.part))
709
+ return {part: t.part, children: named.length === 1 && named[0] === ROOT_SLOT ? kids[ROOT_SLOT] || [] : kids}
710
+ }
711
+ const root = visit(ROOT, 0).children
712
+ for (const x of overflow) root.push(visit(x, 1))
713
+ return root
714
+ }
715
+
716
+ // ---------- replies: validation (SYG682) ----------
717
+ function validate(questions: any, a: any) {
718
+ const out: any = {}
719
+ let bad = 0
720
+ for (const [name, x] of Object.entries<any>(a || {})) {
721
+ const q = questions[name]
722
+ if (!q || !x) continue
723
+ if (q.type === 'choice' && !(String(x.choice) in q.criteria)) {
724
+ dev('SYG682', `the answer to '${name}' is ${JSON.stringify(x.choice)}, not one of its options (${Object.keys(q.criteria).join(', ')}): ignored`, 'warn')
725
+ bad++
726
+ continue
727
+ }
728
+ out[name] = q.type === 'choice' ? {...x, choice: String(x.choice)} : x
729
+ }
730
+ return {answers: out, bad}
731
+ }
732
+
733
+ // ---------- edits (B-4): params-edits.mjs editQuestions / readEdit / editWhereQuestions / applyEdit ----------
734
+ /**
735
+ * The edit wording, verbatim from params-edits.mjs W (66/66 tuned, 43/48 held-out on Jev);
736
+ * `into` (a container slot) is new: the research's edits were on flat pages with rows.
737
+ */
738
+ export const EDIT = {
739
+ remove: (d: string) => `Does user_request ask to take this element off the page (remove, delete, hide or drop it, or swap it for a different element)? The element: ${d}. Changing its settings (time range, filter, category, sort, number of rows) keeps it on the page. Answer no for elements user_request does not mention.`,
740
+ simplerGate: 'Does user_request ask to make the page simpler, shorter, cleaner or less busy, without naming what to remove?',
741
+ simpler: (d: string) => `This element is on the current page. To make the page simpler, would you remove it as secondary to the page's main purpose? Keep the most important elements. The element: ${d}`,
742
+ placeGate: 'Does user_request say where an element on the current page should go (at the top, at the bottom, first, last, above, below, next to another element)?',
743
+ changeGate: (labels: string[]) => `Does user_request ask to change a setting of an element already on the page (such as its ${labels.join(', ')})?`,
744
+ moveSlot: (n: number) => `Which element on the current page does user_request ask to move to another place (to the top, the bottom, above, below or next to another element)${n > 1 ? ', other than the ones already named' : ''}? ${n > 1 ? `Answer the ${ORDINAL[n] ?? n}` : 'Answer the first'} such element; an element named only as the reference point (the X in "next to X" or "below X") is not moved. Choose none when user_request asks to move fewer than ${n} element${n > 1 ? 's' : ''}.`,
745
+ moveSlotNone: (n: number) => `user_request asks to move fewer than ${n} element${n > 1 ? 's' : ''} already on the page`,
746
+ add: 'This element is not on the current page. Does user_request ask to add it or show it, by name or by what it shows? Answer omit when user_request does not ask for it.',
747
+ addSlot: (d: string, label: string, n: number) => `${d}. Which ${label} does user_request ask to add to the page ${ORDINAL[n] ?? `as number ${n}`}? Choose none when user_request asks to add fewer than ${n} ${label}${n > 1 ? 's' : ''} from this list.`,
748
+ swap: 'Does user_request ask to replace or swap one element for a different element (instead of, in place of, swap X for Y)?',
749
+ setParam: (d: string, label: string) => `This element is on the current page: ${d}. Does user_request ask to change its ${label}? Choose keep unless user_request asks for a different ${label}.`,
750
+ keepParam: (label: string, cur: string) => `keep the current ${label}: ${cur}`,
751
+ where: (d: string, added: boolean) => `${added ? 'This element is being added to the page' : 'This element is being moved'}: ${d}. Where does user_request want it?${added ? ' Choose not_stated when user_request does not say where.' : ''}`,
752
+ inPlace: (d: string) => `user_request does not say where: it takes the place of ${d}`,
753
+ notStatedAdded: 'user_request does not say where',
754
+ notStatedMoved: 'user_request does not ask to move it after all',
755
+ top: 'At the very top of the page',
756
+ bottom: 'At the very bottom of the page',
757
+ above: (d: string) => `Just above: ${d}`,
758
+ below: (d: string) => `Just below: ${d}`,
759
+ beside: (d: string) => `Next to, side by side with: ${d}`,
760
+ into: (label: string) => `Inside: ${label}`,
761
+ }
762
+ /** move slots and record add slots per edit (params-edits: moveSlots 2, recordSlots 2) */
763
+ const MOVE_SLOTS = 2
764
+ const ADD_SLOTS = 2
765
+
766
+ /** a leaf (a part node) of the current layout, as the edit questions describe it */
767
+ interface Leaf {key: string; part: string; params?: any; place: string; at: string; row: string | null; d: string; r?: string}
768
+
769
+ /** the parts of a layout in reading order, with their places (`place`: container part + slot, for `in`; `at`: container key + slot) */
770
+ export function editLeaves(c: any, layout: any, state: any): Leaf[] {
771
+ const out: Leaf[] = []
772
+ const nodes = layout?.nodes || {}
773
+ const seen = new Set<string>()
774
+ const visit = (key: string, place: string, at: string, row: string | null) => {
775
+ const n = nodes[key]
776
+ if (!n || seen.has(key)) return
777
+ seen.add(key)
778
+ if (n.slots) {
779
+ if (n.part === ROW) { for (const k of n.slots[ROOT_SLOT] || []) visit(k, place, at, key); return }
780
+ for (const [s, list] of Object.entries<any>(n.slots)) for (const k of list || []) visit(k, `${n.part}:${s}`, `${key}:${s}`, null)
781
+ return
782
+ }
783
+ const part = partOf(c, n.part)
784
+ if (!part) return
785
+ const rp = recordParam(part)
786
+ let r: string | undefined
787
+ if (rp && n.params?.[rp] != null) {
788
+ const rec = recordsOf(part, rp, state).find(x => String(x.value) === String(n.params[rp]))
789
+ r = rec ? recordText(rec) : String(n.params[rp])
790
+ }
791
+ const leaf: Leaf = {key, part: n.part, ...(n.params && {params: n.params}), place, at, row, d: '', ...(r !== undefined && {r})}
792
+ leaf.d = describeLeaf(c, leaf)
793
+ out.push(leaf)
794
+ }
795
+ const root = layout?.root ?? ROOT
796
+ seen.add(root)
797
+ for (const k of nodes[root]?.slots?.[ROOT_SLOT] || []) visit(k, `${ROOT}:${ROOT_SLOT}`, `${root}:${ROOT_SLOT}`, null)
798
+ return out
799
+ }
800
+ /** describeNode: the description, its record, the params in force, and where it is when not on the page itself */
801
+ function describeLeaf(c: any, l: {part: string; params?: any; r?: string; place?: string}) {
802
+ const d = describe(c, l.part, l.params, l.r !== undefined ? {description: l.r} : undefined)
803
+ return l.place && l.place !== `${ROOT}:${ROOT_SLOT}` ? `${d}; inside: ${destLabel(c, l.place)}` : d
804
+ }
805
+ /** question names per node key (keys may hold ':' and '~') */
806
+ function qnames(keys: string[]) {
807
+ const used = new Set<string>(), out: any = {}
808
+ for (const k of keys) {
809
+ let q = keyPart(k)
810
+ for (let i = 2; used.has(q); i++) q = `${keyPart(k)}_${i}`
811
+ used.add(q)
812
+ out[k] = q
813
+ }
814
+ return out
815
+ }
816
+ const identityKey = (c: any, part: string, params: any) => {
817
+ const rp = recordParam(partOf(c, part))
818
+ return rp && params?.[rp] != null ? `${part}:${params[rp]}` : part
819
+ }
820
+ function uniqueKey(base: string, used: Set<string>) {
821
+ let k = base
822
+ for (let i = 2; used.has(k); i++) k = `${base}~${i}`
823
+ used.add(k)
824
+ return k
825
+ }
826
+ const rowable = (c: any, place: string) => hasRows(c) && (place.split(':')[0] === ROOT || !containerOf(c, place.split(':')[0])?.arranged)
827
+
828
+ /** the parts an edit may add: selectable ones not at maxUses; record parts with records the prompt names that aren't on the page */
829
+ function addableParts(c: any, leaves: Leaf[], records: any) {
830
+ return selectable(c, null).filter(id => {
831
+ const part = partOf(c, id)
832
+ if (recordParam(part)) return !!records[id]?.length
833
+ return leaves.filter(l => l.part === id).length < maxUses(part)
834
+ })
835
+ }
836
+ /** per record part, the records the prompt names (the prefilter) that are not on the page */
837
+ function addRecords(c: any, state: any, prompt: string, leaves: Leaf[], max: number) {
838
+ const out: any = {}
839
+ for (const [id, list] of Object.entries<any>(prefilterRecords(c, state, prompt, null, max))) {
840
+ const rp = recordParam(partOf(c, id)) as string
841
+ const on = new Set(leaves.filter(l => l.part === id).map(l => String(l.params?.[rp])))
842
+ const left = list.filter((r: any) => !on.has(String(r.value)))
843
+ if (left.length) out[id] = left
844
+ }
845
+ return out
846
+ }
847
+
848
+ /** edit call 1: per node remove / simpler / setting changes, adds (+ their params and record slots), the gates, move slots */
849
+ export function editStep(c: any, pending: any, max = 255) {
850
+ const leaves: Leaf[] = pending.leaves, records = pending.records || {}
851
+ const q = qnames(leaves.map(l => l.key))
852
+ const questions: any = {}, meta: any = {}
853
+ for (const l of leaves) {
854
+ const k = q[l.key]
855
+ questions[`remove_${k}`] = noul(EDIT.remove(l.d)); meta[`remove_${k}`] = {kind: 'remove', key: l.key}
856
+ questions[`simpler_${k}`] = noul(EDIT.simpler(l.d)); meta[`simpler_${k}`] = {kind: 'simpler', key: l.key}
857
+ for (const [name, p] of Object.entries<any>(paramsOf(partOf(c, l.part)))) {
858
+ if (!askable(p)) continue
859
+ const label = labelOf(name, p), opts = optionsOf(p), cur = String(l.params?.[name] ?? p.default)
860
+ chunked(questions, meta, `set_${k}${sep}${name}`, EDIT.setParam(l.d, label), {keep: EDIT.keepParam(label, opts[cur] ?? cur)},
861
+ Object.fromEntries(Object.entries<any>(opts).filter(([v]) => String(v) !== cur).map(([v, d]) => [String(v), d])), {kind: 'set', key: l.key, param: name}, max)
862
+ }
863
+ }
864
+ const add = addableParts(c, leaves, records)
865
+ const groups = new Set<string>()
866
+ for (const id of add) {
867
+ const part = partOf(c, id), rp = recordParam(part)
868
+ if (part.group != null) {
869
+ if (groups.has(part.group)) continue
870
+ groups.add(part.group)
871
+ const members = add.filter(m => partOf(c, m).group === part.group)
872
+ chunked(questions, meta, `add_g_${part.group}`, EDIT.add, {omit: TEXT.omit}, Object.fromEntries(members.map(m => {
873
+ const mp = partOf(c, m), mr = recordParam(mp)
874
+ return [m, mr ? W.recordFamily(mp.description, labelOf(mr, mp.params[mr]), records[m].map((r: any) => r.name)) : mp.description]
875
+ })), {kind: 'addg', group: part.group, members}, max)
876
+ } else if (!rp) {
877
+ questions[`add_${id}`] = choice(EDIT.add, {omit: TEXT.omit, use: part.description}); meta[`add_${id}`] = {kind: 'add', part: id}
878
+ }
879
+ }
880
+ for (const id of add) {
881
+ const part = partOf(c, id), rp = recordParam(part)
882
+ if (rp) {
883
+ const label = labelOf(rp, part.params[rp])
884
+ const options = Object.fromEntries(records[id].map((r: any) => [String(r.value), r.description]))
885
+ for (let n = 1; n <= ADD_SLOTS; n++) chunked(questions, meta, `rec_${id}${sep}${n}`, EDIT.addSlot(part.description, label, n), {none: W.slotNone(label, n)}, options, {kind: 'rec', part: id, n}, max)
886
+ }
887
+ for (const [name, p] of Object.entries<any>(paramsOf(part))) {
888
+ if (!askable(p)) continue
889
+ const label = labelOf(name, p)
890
+ chunked(questions, meta, `param_${id}${sep}${name}`, W.param(part.description, label, p.ask), {not_stated: W.notStated(label)},
891
+ Object.fromEntries(Object.entries(optionsOf(p)).map(([v, d]) => [String(v), d])), {kind: 'param', part: id, param: name}, max)
892
+ }
893
+ }
894
+ questions.swap = noul(EDIT.swap); meta.swap = {kind: 'swap'}
895
+ questions.simpler = noul(EDIT.simplerGate); meta.simpler = {kind: 'simplerGate'}
896
+ questions.placed = noul(EDIT.placeGate); meta.placed = {kind: 'placed'}
897
+ // the settings named in the gate: the labels of the params on the page
898
+ const labels = [...new Set(leaves.flatMap(l => Object.entries<any>(paramsOf(partOf(c, l.part))).filter(([, p]) => askable(p)).map(([n, p]) => labelOf(n, p))))]
899
+ if (labels.length) { questions.change = noul(EDIT.changeGate(labels)); meta.change = {kind: 'change'} }
900
+ for (let n = 1; n <= MOVE_SLOTS; n++) {
901
+ chunked(questions, meta, `moved_${n}`, EDIT.moveSlot(n), {none: EDIT.moveSlotNone(n)}, Object.fromEntries(leaves.map(l => [l.key, l.d])), {kind: 'moved', n}, max)
902
+ }
903
+ return {state: {user_request: pending.prompt, current_page: leaves.map(l => l.d)}, questions, meta}
904
+ }
905
+
906
+ /**
907
+ * call-1 answers → the plan: { removed, moved, added: [{ key, part, params?, d }], params: { key: params }, swap }.
908
+ * A node whose settings change is never removed; `placed` with no move slot answered moves every
909
+ * node (its `where` not_stated keeps it); a vague "simpler" never empties the page.
910
+ */
911
+ export function readEdit(c: any, pending: any, a: any) {
912
+ const leaves: Leaf[] = pending.leaves, records = pending.records || {}
913
+ const q = qnames(leaves.map(l => l.key))
914
+ const on = (x: any) => (x?.noul ?? 0) >= 0.5
915
+ const plan: any = {removed: [], moved: [], added: [], params: {}, swap: on(a.swap)}
916
+ const simpler = on(a.simpler), changing = on(a.change)
917
+ const slotMoved: string[] = []
918
+ for (let n = 1; n <= MOVE_SLOTS; n++) {
919
+ const x = a[`moved_${n}`]?.choice
920
+ if (!x || x === 'none') break
921
+ if (!slotMoved.includes(x)) slotMoved.push(x)
922
+ }
923
+ for (const l of leaves) {
924
+ const k = q[l.key]
925
+ const next = {...(l.params || {})}
926
+ let changed = false
927
+ for (const [name, p] of Object.entries<any>(paramsOf(partOf(c, l.part)))) {
928
+ if (!askable(p)) continue
929
+ const v = changing ? a[`set_${k}${sep}${name}`]?.choice : null
930
+ if (!v || v === 'keep' || !(v in optionsOf(p))) continue
931
+ changed = true
932
+ const value = kindOf(p) == 'number' ? Number(v) : v
933
+ if (String(value) === String(p.default)) delete next[name]
934
+ else next[name] = value
935
+ }
936
+ if (!changed && (on(a[`remove_${k}`]) || (simpler && on(a[`simpler_${k}`])))) { plan.removed.push(l.key); continue }
937
+ if (slotMoved.includes(l.key)) plan.moved.push(l.key)
938
+ if (changed) plan.params[l.key] = next
939
+ }
940
+ if (slotMoved.length) plan.named = slotMoved
941
+ if (!plan.moved.length && on(a.placed)) plan.moved = leaves.map(l => l.key).filter(k => !plan.removed.includes(k))
942
+ if (plan.removed.length === leaves.length && simpler) plan.removed = plan.removed.filter((k: string) => on(a[`remove_${q[k]}`]))
943
+ // adds, in catalog order (record parts: in slot order); fresh keys, never one of the previous layout's
944
+ const used = new Set<string>(Object.keys(pending.previous?.nodes || {}))
945
+ for (const id of addableParts(c, leaves, records)) {
946
+ const part = partOf(c, id), rp = recordParam(part)
947
+ const asked = part.group != null ? a[`add_g_${part.group}`]?.choice === id : rp ? true : a[`add_${id}`]?.choice === 'use'
948
+ if (!asked) continue
949
+ const own = readParams(c, id, a)
950
+ const push = (params: any, r?: any) => {
951
+ const p = Object.keys(params).length ? params : undefined
952
+ plan.added.push({key: uniqueKey(identityKey(c, id, p), used), part: id, ...(p && {params: p}), d: describeLeaf(c, {part: id, params: p, ...(r && {r: r.description})})})
953
+ }
954
+ if (!rp) { push(own); continue }
955
+ // the group choice already said "these records": slot 1 is the best record even when none wins
956
+ const got: any[] = []
957
+ for (let n = 1; n <= ADD_SLOTS; n++) {
958
+ const x = a[`rec_${id}${sep}${n}`]
959
+ if (!x) break
960
+ const best = Object.entries<any>(x.probabilities || {}).filter(([k]) => k !== 'none').sort((u, v) => v[1] - u[1])[0]
961
+ const v = n === 1 && part.group != null && x.choice === 'none' && best ? best[0] : x.choice
962
+ if (v === 'none' || v == null) break
963
+ const r = records[id].find((r: any) => String(r.value) === v)
964
+ if (r && !got.includes(r)) got.push(r)
965
+ }
966
+ for (const r of got) push({...own, [rp]: r.value}, r)
967
+ }
968
+ return plan
969
+ }
970
+
971
+ /** the nodes that stay and the ones placed by call 2 (moved, then added; a replace: the added node takes the removed one's place) */
972
+ function editSets(c: any, pending: any) {
973
+ const leaves: Leaf[] = pending.leaves, p = pending.plan
974
+ const rep: string | null = p.swap && p.removed.length === 1 && p.added.length === 1 ? p.removed[0] : null
975
+ const stay = leaves.filter(l => !p.removed.includes(l.key) && !p.moved.includes(l.key))
976
+ // a moved node is described with its new settings
977
+ const moved = leaves.filter(l => p.moved.includes(l.key)).map(l => {
978
+ const m: any = {...l, exists: true}
979
+ if (p.params[l.key]) { m.params = p.params[l.key]; m.d = describeLeaf(c, m) }
980
+ return m
981
+ })
982
+ const added = p.added.map((x: any) => ({...x, exists: false, ...(rep && {replaces: rep})}))
983
+ return {rep, stay, movers: [...moved, ...added] as any[]}
984
+ }
985
+
986
+ /** a mover's question name */
987
+ const moverNames = (pending: any) => qnames([...pending.leaves.map((l: Leaf) => l.key), ...pending.plan.added.map((x: any) => x.key)])
988
+
989
+ /**
990
+ * Edit call 2 (only when something moves or is added): one `where_<key>` choice per mover over
991
+ * not_stated (added: the bottom, or its first allowed place; moved: it stays; a replacement: the
992
+ * removed node's place), top / bottom (when it may go on the page itself), above / below / beside
993
+ * each node that stays or moves (where its `in` allows), and `into:<container key>:<slot>` for each
994
+ * other place its `in` allows (a container not on the page is created).
995
+ */
996
+ export function whereStep(c: any, pending: any, max = 255) {
997
+ const {stay, movers} = editSets(c, pending)
998
+ const q = moverNames(pending)
999
+ const leafD = (k: string) => pending.leaves.find((l: Leaf) => l.key === k)?.d ?? k
1000
+ const nodes = pending.previous?.nodes || {}
1001
+ const questions: any = {}, meta: any = {}
1002
+ for (const m of movers) {
1003
+ const places = allowed(c, partOf(c, m.part))
1004
+ const onPage = places.includes(`${ROOT}:${ROOT_SLOT}`)
1005
+ const head: any = {not_stated: m.replaces ? EDIT.inPlace(leafD(m.replaces)) : m.exists ? EDIT.notStatedMoved : EDIT.notStatedAdded}
1006
+ if (onPage) { head.top = EDIT.top; head.bottom = EDIT.bottom }
1007
+ const options: any = {}
1008
+ for (const s of [...stay, ...movers.filter(o => o.exists && o.key !== m.key)]) {
1009
+ if (!places.includes(s.place)) continue
1010
+ options[`above:${s.key}`] = EDIT.above(s.d)
1011
+ options[`below:${s.key}`] = EDIT.below(s.d)
1012
+ if (rowable(c, s.place)) options[`beside:${s.key}`] = EDIT.beside(s.d)
1013
+ }
1014
+ for (const pl of places) {
1015
+ if (pl === `${ROOT}:${ROOT_SLOT}`) continue
1016
+ const [cid, slot] = pl.split(':')
1017
+ const keys = Object.keys(nodes).filter(k => nodes[k]?.part === cid && nodes[k].slots)
1018
+ for (const ck of keys.length ? keys : [cid]) options[`into:${ck}:${slot}`] = EDIT.into(destLabel(c, pl) + (keys.length > 1 ? ` (${ck})` : ''))
1019
+ }
1020
+ chunked(questions, meta, `where_${q[m.key]}`, EDIT.where(m.d, !m.exists), head, options, {kind: 'where', key: m.key}, max)
1021
+ }
1022
+ return {state: {user_request: pending.prompt, current_page: pending.leaves.map((l: Leaf) => l.d)}, questions, meta}
1023
+ }
1024
+
1025
+ /**
1026
+ * The plan + call-2 answers → the new layout (finished). Nodes that stay keep their keys, places,
1027
+ * rows and order; a setting change keeps the key; an added node or a replacement gets a fresh key.
1028
+ * A moved node that another mover is placed relative to is only the reference point and stays (of
1029
+ * two naming each other, the less sure one); a moved node answered not_stated stays; a
1030
+ * replacement answered not_stated takes the removed node's place (and row).
1031
+ */
1032
+ export function applyEdit(c: any, pending: any, a: any, state: any) {
1033
+ const {rep, movers} = editSets(c, pending)
1034
+ const plan = pending.plan, previous = pending.previous
1035
+ const q = moverNames(pending)
1036
+ const whereOf = (m: any) => a[`where_${q[m.key]}`]
1037
+ const w = (m: any): string => whereOf(m)?.choice ?? 'not_stated'
1038
+ const anchor = (m: any) => { const x = w(m); return /^(above|below|beside):/.test(x) ? x.slice(x.indexOf(':') + 1) : null }
1039
+ const conf = (m: any) => whereOf(m)?.confidence ?? 0
1040
+ // of two naming each other, the surer one moves (a tie: the one the move slots named first)
1041
+ const rank = (m: any) => { const i = (plan.named || []).indexOf(m.key); return i < 0 ? Infinity : i }
1042
+ const surer = (m: any, o: any) => conf(m) > conf(o) || (conf(m) === conf(o) && rank(m) < rank(o))
1043
+ const stays = new Set(movers.filter(m =>
1044
+ (m.exists && movers.some(o => o !== m && anchor(o) === m.key && !(anchor(m) === o.key && surer(m, o))))
1045
+ || ((m.exists || m.replaces) && w(m) === 'not_stated')).map(m => m.key))
1046
+ // the working tree
1047
+ type T = {key: string; part: string; params?: any; slots?: Record<string, T[]>}
1048
+ const nodes = previous.nodes
1049
+ const build = (k: string): T | null => {
1050
+ const n = nodes[k]
1051
+ if (!n) return null
1052
+ const t: T = {key: k, part: n.part, ...(n.params && {params: n.params})}
1053
+ if (n.slots) t.slots = Object.fromEntries(Object.entries<any>(n.slots).map(([s, l]) => [s, (l || []).map(build).filter(Boolean)]))
1054
+ return t
1055
+ }
1056
+ const root = build(previous.root) as T
1057
+ root.slots ||= {}
1058
+ root.slots[ROOT_SLOT] ||= []
1059
+ const top = root.slots[ROOT_SLOT]
1060
+ const locate = (key: string): {t: T; arr: T[]; i: number; parent: T} | null => {
1061
+ let out: any = null
1062
+ const go = (t: T) => {
1063
+ for (const s in t.slots) t.slots[s].forEach((x, i, arr) => { if (out) return; if (x.key === key) out = {t: x, arr, i, parent: t}; else if (x.slots) go(x) })
1064
+ }
1065
+ go(root)
1066
+ return out
1067
+ }
1068
+ const used = new Set<string>([...Object.keys(nodes), ...plan.added.map((x: any) => x.key)])
1069
+ const node = (m: any): T => ({key: m.key, part: m.part, ...(m.params && Object.keys(m.params).length && {params: m.params})})
1070
+ for (const [k, params] of Object.entries<any>(plan.params)) {
1071
+ const x = locate(k)
1072
+ if (!x) continue
1073
+ if (Object.keys(params).length) x.t.params = params
1074
+ else delete x.t.params
1075
+ }
1076
+ const repMover = rep ? movers.find(m => m.replaces) : null
1077
+ for (const k of plan.removed) {
1078
+ const x = locate(k)
1079
+ if (!x) continue
1080
+ if (repMover && k === rep && stays.has(repMover.key)) x.arr.splice(x.i, 1, node(repMover))
1081
+ else x.arr.splice(x.i, 1)
1082
+ }
1083
+ const detached: any = {}
1084
+ for (const m of movers) {
1085
+ if (!m.exists || stays.has(m.key)) continue
1086
+ const x = locate(m.key)
1087
+ if (x) { x.arr.splice(x.i, 1); detached[m.key] = x.t }
1088
+ }
1089
+ for (const m of movers) {
1090
+ if (stays.has(m.key)) continue
1091
+ const n = detached[m.key] ?? node(m)
1092
+ const x = w(m), op = x.split(':')[0]
1093
+ if (op === 'top') { top.unshift(n); continue }
1094
+ if (op === 'above' || op === 'below' || op === 'beside') {
1095
+ const t = locate(x.slice(x.indexOf(':') + 1))
1096
+ if (!t) { top.push(n); continue }
1097
+ if (op === 'beside') {
1098
+ if (t.parent.part === ROW) t.arr.splice(t.i + 1, 0, n)
1099
+ else t.arr.splice(t.i, 1, {key: uniqueKey(ROW, used), part: ROW, slots: {[ROOT_SLOT]: [t.t, n]}})
1100
+ continue
1101
+ }
1102
+ // above / below a node in a row: above / below the row
1103
+ const b = t.parent.part === ROW ? locate(t.parent.key) ?? t : t
1104
+ b.arr.splice(b.i + (op === 'below' ? 1 : 0), 0, n)
1105
+ continue
1106
+ }
1107
+ if (op === 'into') {
1108
+ const rest = x.slice(5), j = rest.lastIndexOf(':'), ck = rest.slice(0, j), slot = rest.slice(j + 1)
1109
+ const t = locate(ck)?.t
1110
+ if (t?.slots) { (t.slots[slot] ||= []).push(n); continue }
1111
+ if (containerOf(c, ck)) {
1112
+ const ct: T = {key: uniqueKey(ck, used), part: ck, slots: Object.fromEntries(slotNames(containerOf(c, ck)).map(s => [s, [] as T[]]))}
1113
+ ;(ct.slots![slot] ||= []).push(n)
1114
+ top.push(ct)
1115
+ continue
1116
+ }
1117
+ }
1118
+ // bottom; an added node with no stated place (finish() moves a part that can't be on the page to its first place)
1119
+ top.push(n)
1120
+ }
1121
+ const out: any = {}
1122
+ const emit = (t: T) => {
1123
+ out[t.key] = {part: t.part, ...(t.params && {params: t.params}), ...(t.slots && {slots: Object.fromEntries(Object.entries(t.slots).map(([s, l]) => [s, l.map(x => (emit(x), x.key))]))})}
1124
+ }
1125
+ emit(root)
1126
+ return finish(c, {root: previous.root, nodes: out}, state)
1127
+ }
1128
+
1129
+ // ---------- fake for edits: answers that make an edit read as a target ----------
1130
+ /** a node's identity in a layout: part + record */
1131
+ const leafIdentity = (c: any, l: {part: string; params?: any}) => identityKey(c, l.part, l.params)
1132
+
1133
+ /**
1134
+ * The edit's call-1 picks for one candidate plan; null when the target can't be expressed (a
1135
+ * setting a decision model can't change, a part that can't be added, too many records).
1136
+ */
1137
+ function editPicks(c: any, pending: any, want: Leaf[], {moved, swap}: {moved: string[]; swap: boolean}) {
1138
+ const leaves: Leaf[] = pending.leaves, records = pending.records || {}
1139
+ const q = qnames(leaves.map(l => l.key))
1140
+ const picks: any = {swap, simpler: false}
1141
+ const wantBy = new Map(want.map(l => [l.key, l]))
1142
+ let changing = false
1143
+ for (const l of leaves) {
1144
+ const k = q[l.key], to = wantBy.get(l.key)
1145
+ picks[`remove_${k}`] = !to
1146
+ picks[`simpler_${k}`] = false
1147
+ for (const [name, p] of Object.entries<any>(paramsOf(partOf(c, l.part)))) {
1148
+ if (!to) { picks[`set_${k}${sep}${name}`] = 'keep'; continue }
1149
+ const cur = l.params?.[name] ?? p.default, next = to.params?.[name] ?? p.default
1150
+ if (String(cur) === String(next)) { if (askable(p)) picks[`set_${k}${sep}${name}`] = 'keep'; continue }
1151
+ if (!askable(p)) return null
1152
+ changing = true
1153
+ picks[`set_${k}${sep}${name}`] = String(next)
1154
+ }
1155
+ }
1156
+ picks.change = changing
1157
+ // adds: one per plain part, one member per group, up to ADD_SLOTS records per record part
1158
+ const prevKeys = new Set(leaves.map(l => l.key))
1159
+ const adds = want.filter(l => !prevKeys.has(l.key))
1160
+ const add = addableParts(c, leaves, records)
1161
+ const groups: any = {}
1162
+ const per: any = {}
1163
+ for (const l of adds) {
1164
+ if (!add.includes(l.part)) return null
1165
+ const part = partOf(c, l.part)
1166
+ ;(per[l.part] ||= []).push(l)
1167
+ if (part.group != null) { if (groups[part.group] && groups[part.group] !== l.part) return null; groups[part.group] = l.part }
1168
+ }
1169
+ for (const id of add) {
1170
+ const part = partOf(c, id), rp = recordParam(part), mine = per[id] || []
1171
+ if (part.group != null) picks[`add_g_${part.group}`] ??= 'omit'
1172
+ else if (!rp) picks[`add_${id}`] = mine.length ? 'use' : 'omit'
1173
+ if (!rp && mine.length > 1) return null
1174
+ if (rp) {
1175
+ if (mine.length > ADD_SLOTS) return null
1176
+ for (let n = 1; n <= ADD_SLOTS; n++) picks[`rec_${id}${sep}${n}`] = mine[n - 1] ? String(mine[n - 1].params?.[rp]) : 'none'
1177
+ }
1178
+ if (part.group != null && mine.length) picks[`add_g_${part.group}`] = id
1179
+ // params: one answer per part (its added nodes share them)
1180
+ for (const [name, p] of Object.entries<any>(paramsOf(part))) {
1181
+ if (kindOf(p) == 'record') continue
1182
+ const vals = [...new Set(mine.map((l: Leaf) => String(l.params?.[name] ?? p.default)))]
1183
+ if (vals.length > 1) return null
1184
+ if (!askable(p)) { if (vals.length && vals[0] !== String(p.default)) return null; continue }
1185
+ picks[`param_${id}${sep}${name}`] = !vals.length || vals[0] === String(p.default) ? 'not_stated' : vals[0]
1186
+ }
1187
+ }
1188
+ // moves: up to MOVE_SLOTS by the move slots, more by the `placed` gate (every node gets a where)
1189
+ const many = moved.length > MOVE_SLOTS
1190
+ for (let n = 1; n <= MOVE_SLOTS; n++) picks[`moved_${n}`] = !many && moved[n - 1] ? moved[n - 1] : 'none'
1191
+ picks.placed = many
1192
+ return picks
1193
+ }
1194
+
1195
+ /**
1196
+ * Where picks that read as `want`: for each mover, the options that could put it where the target
1197
+ * has it (beside the row-mate before it; below the node before it, above the one after it; top,
1198
+ * bottom, into; not_stated for the nodes meant to stay), tried depth first until the layout reads
1199
+ * as the target (`ok`). null when none does within the budget.
1200
+ */
1201
+ function wherePicks(c: any, pending: any, step: any, wantLayout: any, ok: (picks: any) => boolean, stay: Set<string>, budget = {n: 400}) {
1202
+ const {movers} = editSets(c, pending)
1203
+ const q = moverNames(pending)
1204
+ const opts = optionsAt(step)
1205
+ // the target's places: per leaf, its slot sequence (rows flattened, marked) and row
1206
+ const W = editLeaves(c, wantLayout, {})
1207
+ const byKey = new Map(W.map((l, i) => [l.key, {...l, i}]))
1208
+ // the added movers ↔ the target's new leaves (by identity, in order)
1209
+ const prevKeys = new Set(pending.leaves.map((l: Leaf) => l.key))
1210
+ const fresh = W.filter(l => !prevKeys.has(l.key))
1211
+ const target = (m: any) => {
1212
+ if (m.exists) return byKey.get(m.key)
1213
+ const i = fresh.findIndex(l => leafIdentity(c, l) === leafIdentity(c, m))
1214
+ if (i < 0) return undefined
1215
+ const [l] = fresh.splice(i, 1)
1216
+ return byKey.get(l.key)
1217
+ }
1218
+ const at = movers.map(target)
1219
+ const cands = movers.map((m, i) => {
1220
+ const name = `where_${q[m.key]}`, keys: string[] = opts[name] || []
1221
+ const t = at[i]
1222
+ if (stay.has(m.key) && keys.includes('not_stated')) return ['not_stated']
1223
+ const out: string[] = []
1224
+ const put = (x: string) => { if (keys.includes(x) && !out.includes(x)) out.push(x) }
1225
+ if (m.replaces) put('not_stated')
1226
+ if (t) {
1227
+ const same = W.filter(l => l.at === t.at)
1228
+ const j = same.findIndex(l => l.key === t.key)
1229
+ if (t.row) for (let k = j - 1; k >= 0 && same[k].row === t.row; k--) put(`beside:${same[k].key}`)
1230
+ // a pinned part goes first in its row whichever side it joined
1231
+ if (t.row) for (let k = j + 1; k < same.length && same[k].row === t.row; k++) put(`beside:${same[k].key}`)
1232
+ const n = out.length
1233
+ for (let k = j - 1; k >= 0 && out.length === n; k--) if (same[k].row !== t.row || !t.row) put(`below:${same[k].key}`)
1234
+ const n2 = out.length
1235
+ for (let k = j + 1; k < same.length && out.length === n2; k++) if (same[k].row !== t.row || !t.row) put(`above:${same[k].key}`)
1236
+ if (t.at === `${wantLayout.root}:${ROOT_SLOT}`) { if (j === 0) put('top'); if (j === same.length - 1) put(m.exists ? 'bottom' : 'not_stated') }
1237
+ else for (const x of keys) if (x.startsWith('into:') && x.endsWith(`:${t.at.slice(t.at.lastIndexOf(':') + 1)}`)) put(x)
1238
+ // pins reorder a slot after the edit: the far anchors and the page's ends too
1239
+ for (let k = j - 1; k >= 0; k--) put(`below:${same[k].key}`)
1240
+ for (let k = j + 1; k < same.length; k++) put(`above:${same[k].key}`)
1241
+ put('top')
1242
+ put('bottom')
1243
+ }
1244
+ put('not_stated')
1245
+ return out.slice(0, 10)
1246
+ })
1247
+ const picks: any = {}
1248
+ const dfs = (i: number): boolean => {
1249
+ if (i === movers.length) return budget.n-- > 0 && ok(picks)
1250
+ for (const x of cands[i]) {
1251
+ if (budget.n <= 0) return false
1252
+ picks[`where_${q[movers[i].key]}`] = x
1253
+ if (dfs(i + 1)) return true
1254
+ }
1255
+ return false
1256
+ }
1257
+ return dfs(0) ? spread(step, picks) : null
1258
+ }
1259
+
1260
+ /**
1261
+ * The sets of nodes to move, smallest first (up to 4 nodes), for which the nodes that stay can stay:
1262
+ * each keeps its slot (or its container is gone), stays row-mates with exactly the nodes that stay it
1263
+ * shared a row with, and keeps its order in its slot; then every node.
1264
+ */
1265
+ /** a layout's parts and containers (not rows) in reading order, with the container key + slot they are in */
1266
+ function unitsOf(layout: any) {
1267
+ const out: Array<{key: string; at: string}> = []
1268
+ const nodes = layout?.nodes || {}
1269
+ const visit = (k: string, at: string) => {
1270
+ const n = nodes[k]
1271
+ if (!n) return
1272
+ if (n.part !== ROW) out.push({key: k, at})
1273
+ for (const [s, list] of Object.entries<any>(n.slots || {})) for (const x of list || []) visit(x, n.part === ROW ? at : `${k}:${s}`)
1274
+ }
1275
+ for (const x of nodes[layout?.root]?.slots?.[ROOT_SLOT] || []) visit(x, `${layout.root}:${ROOT_SLOT}`)
1276
+ return out
1277
+ }
1278
+
1279
+ function moveSets(leaves: Leaf[], W: Leaf[], previous: any, want: any) {
1280
+ const wantBy = new Map(W.map(l => [l.key, l]))
1281
+ const surv = leaves.filter(l => wantBy.get(l.key)?.part === l.part)
1282
+ const prevIdx = new Map(leaves.map((l, i) => [l.key, i]))
1283
+ const prevUnit = new Map(unitsOf(previous).map((u, i) => [u.key, i]))
1284
+ const wantUnits = unitsOf(want)
1285
+ const box = (at: string) => at.slice(0, at.lastIndexOf(':'))
1286
+ const boxes = new Set(W.map(t => box(t.at)))
1287
+ const valid = (moved: Set<string>) => {
1288
+ const stay = surv.filter(l => !moved.has(l.key))
1289
+ for (const l of stay) { const t = wantBy.get(l.key)!; if (t.at !== l.at && boxes.has(box(l.at))) return false }
1290
+ for (let i = 0; i < stay.length; i++) for (let j = i + 1; j < stay.length; j++) {
1291
+ const a = stay[i], b = stay[j], ta = wantBy.get(a.key)!, tb = wantBy.get(b.key)!
1292
+ if ((!!a.row && a.row === b.row) !== (!!ta.row && ta.row === tb.row)) return false
1293
+ }
1294
+ // a row of the target needs a node that stays in it (`beside` anchors on a node that doesn't move)
1295
+ const rows = new Set(W.filter(t => t.row).map(t => t.row))
1296
+ for (const r of rows) if (!W.some(t => t.row === r && prevIdx.has(t.key) && !moved.has(t.key))) return false
1297
+ // in every slot, the nodes that stay and the containers that were there keep their order
1298
+ const last = new Map<string, number>()
1299
+ for (const u of wantUnits) {
1300
+ if (!prevUnit.has(u.key) || moved.has(u.key) || (wantBy.has(u.key) && !prevIdx.has(u.key))) continue
1301
+ const i = prevUnit.get(u.key)!
1302
+ if ((last.get(u.at) ?? -1) > i) return false
1303
+ last.set(u.at, i)
1304
+ }
1305
+ return true
1306
+ }
1307
+ const keys = surv.map(l => l.key)
1308
+ const out: string[][] = []
1309
+ const pick = (from: number, k: number, acc: string[]) => {
1310
+ if (out.length >= 24) return
1311
+ if (!k) { if (valid(new Set(acc))) out.push(acc); return }
1312
+ for (let i = from; i <= keys.length - k; i++) pick(i + 1, k - 1, [...acc, keys[i]])
1313
+ }
1314
+ for (let k = 0; k <= Math.min(4, keys.length); k++) pick(0, k, [])
1315
+ if (!out.some(m => m.length === keys.length)) out.push(keys)
1316
+ return out
1317
+ }
1318
+
1319
+ /** the reply for the pending edit step (`edit` or `where`) that reads as `wantLayout`; null when no answers reach it */
1320
+ function fakeEditReply(c: any, state: any, pending: any, wantLayout: any, stepOf: (p: any) => any, read: (p: any, reply: any) => any) {
1321
+ const W = editLeaves(c, wantLayout, state)
1322
+ const same = (l: any) => !!l && show(canon(toTree(l))) === show(canon(toTree(wantLayout)))
1323
+ const budget = {n: 1500}
1324
+ const tryWhere = (p2: any, moved: string[]) => {
1325
+ const step2 = stepOf(p2)
1326
+ const ok = (picks: any) => { const r = read(p2, answersFor(step2.questions, picks)); return !r.error && same(r.layout) }
1327
+ // the movers that are meant to stay answer not_stated (the `placed` gate made every node a mover)
1328
+ const stay = new Set<string>(p2.plan.moved.filter((k: string) => !moved.includes(k)))
1329
+ return wherePicks(c, p2, step2, wantLayout, (picks: any) => ok(spread(step2, picks)), stay, budget)
1330
+ }
1331
+ const sets = moveSets(pending.leaves, W, pending.previous, wantLayout)
1332
+ if (pending.step === 'where') {
1333
+ const step2 = stepOf(pending)
1334
+ for (const moved of sets) {
1335
+ if (!moved.every(k => pending.plan.moved.includes(k))) continue
1336
+ const picks = tryWhere(pending, moved)
1337
+ if (picks) return answersFor(step2.questions, picks)
1338
+ }
1339
+ return null
1340
+ }
1341
+ const step1 = stepOf(pending)
1342
+ // nothing fits (an empty target) or no change: the empty plan (status 'unavailable', the layout kept)
1343
+ if (!W.length || same(pending.previous)) return answersFor(step1.questions, spread(step1, editPicks(c, pending, pending.leaves, {moved: [], swap: false})))
1344
+ const prevKeys = new Set(pending.leaves.map((l: Leaf) => l.key))
1345
+ const swaps = pending.leaves.filter((l: Leaf) => !W.some(t => t.key === l.key)).length === 1 && W.filter(t => !prevKeys.has(t.key)).length === 1 ? [true, false] : [false]
1346
+ for (const moved of sets) {
1347
+ for (const swap of swaps) {
1348
+ if (budget.n <= 0) return null
1349
+ const picks = editPicks(c, pending, W, {moved, swap})
1350
+ if (!picks) return null
1351
+ const reply = answersFor(step1.questions, spread(step1, picks))
1352
+ const out = read(pending, reply)
1353
+ if (out.error) continue
1354
+ if (!out.request) { if (same(out.layout)) return reply; continue }
1355
+ if (tryWhere(JSON.parse(JSON.stringify(out.pending)), moved)) return reply
1356
+ }
1357
+ }
1358
+ return null
1359
+ }
1360
+
1361
+ // ---------- the backend ----------
1362
+ export interface DecisionsOptions {
1363
+ url?: string
1364
+ model?: string
1365
+ key?: string
1366
+ rowMean?: number
1367
+ prefilter?: boolean | 'auto'
1368
+ maxOptions?: number
1369
+ profile?: 'small'
1370
+ maxParts?: number
1371
+ /** passed to every decide() request (D472: a large step goes as parallel sub-requests, one merged reply) */
1372
+ maxQuestions?: number
1373
+ maxPromptTokens?: number
1374
+ split?: boolean
1375
+ }
1376
+
1377
+ /**
1378
+ * B-5: `prefilter: 'auto'` (the default) asks the category step first when the catalog has
1379
+ * categories and the select step would ask about more parts than this (Jev: the ops console's 50
1380
+ * parts compose as well with it, at about half the tokens and one more call, ~+0.2 s)
1381
+ */
1382
+ export const PREFILTER_AT = 60
1383
+ /** the same for profile 'small' (a local model: input tokens are most of its time) */
1384
+ export const PREFILTER_AT_SMALL = 30
1385
+ /** profile 'small': the most parts one composition selects (Jev and others: the catalog's limits.maxParts, 12) */
1386
+ const SMALL_MAX_PARTS = 8
1387
+
1388
+ const isOllama = (url: string) => /:11434\b|ollama/i.test(url)
1389
+
1390
+ export function decisions({url = '/api/decide', model, key, rowMean = 0.25, prefilter = 'auto', maxOptions, profile, maxParts, maxQuestions, maxPromptTokens, split}: DecisionsOptions = {}) {
1391
+ // profile 'small' (a local 9B decision model such as Ollama's nimble, G-755): pairwise order
1392
+ // questions asked in both option orders (it prefers the first option), and near-ties ordered by
1393
+ // where the request names the elements
1394
+ const small = profile === 'small'
1395
+ const debias = small
1396
+ // B-5: profile 'small' selects at most 8 parts (fewer, surer picks); an explicit maxParts wins
1397
+ const partsCap = maxParts ?? (small ? SMALL_MAX_PARTS : undefined)
1398
+ /** whether a fresh composition asks the category step first */
1399
+ const usePrefilter = (catalog: any) => !!categoriesOf(catalog).length && (prefilter === true ||
1400
+ (prefilter === 'auto' && selectable(catalog).length > (small ? PREFILTER_AT_SMALL : PREFILTER_AT)))
1401
+ // a decide() server picks no default model: every request needs one (SYG681, the contract's
1402
+ // backend option problem; compose.llm() doesn't report it: a chat transport has its own default)
1403
+ if (typeof model != 'string' || !model) dev('SYG681', "compose.decisions() needs a `model` (the decision model's id, e.g. compose.decisions({ model: 'jev-latest' })); its requests go without one", 'error')
1404
+ // Ollama's /v1/systemone takes 2–26 candidates per choice, TypeSafe 255; at least 4 (a where's head is 3)
1405
+ const max = Math.max(4, maxOptions ?? (isOllama(url) ? 25 : 255))
1406
+ const request = (names: any, step: any) => decide({
1407
+ url, model, state: step.state, questions: step.questions,
1408
+ key: key ?? names?.key, latest: true, ok: names?.ok, error: names?.error,
1409
+ ...(maxQuestions !== undefined && {maxQuestions}), ...(maxPromptTokens !== undefined && {maxPromptTokens}), ...(split !== undefined && {split}),
1410
+ } as any)
1411
+ const answersOf = (reply: any) => (reply && typeof reply == 'object' ? reply.answers : undefined)
1412
+
1413
+ /** the questions of a pending step (rebuilt from it, as sent) */
1414
+ function stepOf(catalog: any, pending: any) {
1415
+ if (pending.step === 'categories') return categoryStep(catalog, pending.prompt)
1416
+ if (pending.step === 'select') return selectStep(catalog, pending.prompt, {categories: pending.categories, records: pending.records, max})
1417
+ if (pending.step === 'edit') return editStep(catalog, pending, max)
1418
+ if (pending.step === 'where') return whereStep(catalog, pending, max)
1419
+ return layoutStep(catalog, pending.prompt, {items: pending.items, row: pending.row}, max, debias)
1420
+ }
1421
+ function selectPending(catalog: any, state: any, prompt: string, categories: string[] | null, base: any) {
1422
+ const records = prefilterRecords(catalog, state, prompt, categories, max)
1423
+ return {...base, step: 'select', prompt, categories, records}
1424
+ }
1425
+ const done = (catalog: any, state: any, tree: any[], previous: any) => finish(catalog, fromTree(catalog, tree, previous), state)
1426
+
1427
+ /** a reply → the backend's result (pure; fake() reads with it too) */
1428
+ function read({catalog, state, pending, reply, names}: any) {
1429
+ const step = stepOf(catalog, pending)
1430
+ const body = answersOf(reply)
1431
+ // a reply that can't be read: the behavior's status 'error' (contract), not a throw
1432
+ if (!body || typeof body != 'object' || Array.isArray(body)) return {error: 'compose.decisions(): the decision reply has no answers'}
1433
+ const {answers: raw, bad} = validate(step.questions, body)
1434
+ if (bad && !Object.keys(raw).length) return {error: `compose.decisions(): every answer of the ${pending.step} step is outside its question's options (SYG682)`}
1435
+ const decisions = {...(pending.decisions || {}), [pending.step]: raw}
1436
+ const a = unchunk(raw, step.meta)
1437
+ const base = {previous: pending.previous ?? null, decisions}
1438
+ // an edit (B-4): keys come from the previous layout (`keyed`: the behavior doesn't re-key it)
1439
+ if (pending.step === 'edit') {
1440
+ const next = {...base, step: 'where', prompt: pending.prompt, leaves: pending.leaves, records: pending.records, plan: readEdit(catalog, pending, a)}
1441
+ const pl = next.plan
1442
+ // nothing to remove, change, move or add: nothing in the request applies (the layout is kept)
1443
+ if (!pl.removed.length && !pl.moved.length && !pl.added.length && !Object.keys(pl.params).length) return {layout: null, unavailable: true as const, decisions}
1444
+ const now = applyEdit(catalog, next, {}, state)
1445
+ if (!editSets(catalog, next).movers.length) return {layout: now, keyed: true, decisions}
1446
+ return {preview: now, request: request(names, stepOf(catalog, next)), pending: next, keyed: true, decisions}
1447
+ }
1448
+ if (pending.step === 'where') return {layout: applyEdit(catalog, pending, a, state), keyed: true, decisions}
1449
+ if (pending.step === 'categories') {
1450
+ const chosen = categoriesOf(catalog).filter(id => a[`cat_${id}`]?.choice === 'use')
1451
+ const open = partIds(catalog).some(id => !partOf(catalog, id).required && partOf(catalog, id).category == null)
1452
+ if (!chosen.length && !open) return {layout: null, unavailable: true as const, decisions}
1453
+ const next = selectPending(catalog, state, pending.prompt, chosen, base)
1454
+ return {request: request(names, stepOf(catalog, next)), pending: next, decisions}
1455
+ }
1456
+ if (pending.step === 'select') {
1457
+ const sel = readSelect(catalog, a, {categories: pending.categories, records: pending.records, maxParts: partsCap})
1458
+ if (sel.unavailable) return {layout: null, unavailable: true as const, decisions}
1459
+ const p = plan(catalog, sel)
1460
+ if (!needsLayout(p)) return {layout: done(catalog, state, readLayout(catalog, sel, {}, {rowMean, prompt: small ? pending.prompt : null}), pending.previous), decisions}
1461
+ const next = {...base, step: 'layout', prompt: pending.prompt, items: sel.items, row: sel.row}
1462
+ const preview = done(catalog, state, sel.items.map((i: any) => ({part: i.part, ...(i.params && {params: i.params})})), pending.previous)
1463
+ return {preview, request: request(names, stepOf(catalog, next)), pending: next, decisions}
1464
+ }
1465
+ const tree = readLayout(catalog, {items: pending.items, row: pending.row}, a, {rowMean, prompt: small ? pending.prompt : null})
1466
+ return {layout: done(catalog, state, tree, pending.previous), decisions}
1467
+ }
1468
+
1469
+ return {
1470
+ sink: 'HTTP',
1471
+ kind: 'decisions',
1472
+ start({catalog, state, prompt, previous, names}: any) {
1473
+ const base = previous ? {previous} : {}
1474
+ // a layout with parts on it: an edit (2 calls at most); `fresh: true` passes previous = null
1475
+ const leaves = previous ? editLeaves(catalog, previous, state) : []
1476
+ if (leaves.length) {
1477
+ const pending = {...base, step: 'edit', prompt, leaves, records: addRecords(catalog, state, prompt, leaves, max)}
1478
+ return {request: request(names, stepOf(catalog, pending)), pending}
1479
+ }
1480
+ const pending = usePrefilter(catalog)
1481
+ ? {...base, step: 'categories', prompt}
1482
+ : selectPending(catalog, state, prompt, null, base)
1483
+ return {request: request(names, stepOf(catalog, pending)), pending}
1484
+ },
1485
+ reply: read,
1486
+ /** the reply body that makes the pending step read as `target` (for composeWith); throws when it can't */
1487
+ fake({catalog, state, pending, target}: any) {
1488
+ const step = stepOf(catalog, pending)
1489
+ if (pending.step === 'edit' || pending.step === 'where') {
1490
+ const want = finish(catalog, fromTree(catalog, target || [], pending.previous), state)
1491
+ const reply = fakeEditReply(catalog, state, pending, want, (p: any) => stepOf(catalog, p), (p: any, reply: any) => read({catalog, state, pending: p, reply, names: {}}))
1492
+ if (!reply) throw new Error(`[Sygnal] compose.decisions() fake: can't reach the target ${show(toTree(want))} as an edit of ${show(toTree(pending.previous))} (an edit removes, changes, moves up to any number of parts and adds parts beside, above, below or into others; compose it with { fresh: true } instead)`)
1493
+ return reply
1494
+ }
1495
+ const want = toTree(finish(catalog, fromTree(catalog, target || [], pending.previous ?? undefined), state))
1496
+ const picks = fakePicks(catalog, pending, step, want)
1497
+ const reply = answersFor(step.questions, picks)
1498
+ check(catalog, state, pending, want, read({catalog, state, pending, reply, names: {}}))
1499
+ return reply
1500
+ },
1501
+ }
1502
+ }
1503
+
1504
+ // ---------- fake: answers that read as a target tree (structure/evals/selftest.mjs) ----------
1505
+ /** the target's leaves (DFS order) and containers, with their places */
1506
+ function walkTarget(c: any, tree: any[]) {
1507
+ const leaves: any[] = [], containers: any = {}, rows: any[] = []
1508
+ const visit = (list: any[], where: string, row: any) => {
1509
+ for (const n of list || []) {
1510
+ if (n.part === ROW) {
1511
+ const r: any[] = []
1512
+ rows.push(r)
1513
+ visit(Array.isArray(n.children) ? n.children : n.children?.[ROOT_SLOT] || [], where, r)
1514
+ } else if (containerOf(c, n.part)) {
1515
+ containers[n.part] = where
1516
+ if (Array.isArray(n.children)) visit(n.children, `${n.part}:${ROOT_SLOT}`, null)
1517
+ else for (const [s, l] of Object.entries<any>(n.children || {})) visit(l, `${n.part}:${s}`, null)
1518
+ } else {
1519
+ const leaf = {part: n.part, params: n.params || {}, where, row, i: leaves.length}
1520
+ leaves.push(leaf)
1521
+ row?.push(leaf)
1522
+ }
1523
+ }
1524
+ }
1525
+ visit(tree, `${ROOT}:${ROOT_SLOT}`, null)
1526
+ return {leaves, containers, rows}
1527
+ }
1528
+ const one = (choice: string) => ({choice, probabilities: {[choice]: 1}})
1529
+
1530
+ /** the options of a (possibly chunked) question, by its base name */
1531
+ function optionsAt(step: any) {
1532
+ const out: any = {}
1533
+ for (const [name, m] of Object.entries<any>(step.meta)) {
1534
+ const q = step.questions[name]
1535
+ if (q?.type !== 'choice') continue
1536
+ const base = m.base ?? name
1537
+ const have: string[] = out[base] || []
1538
+ out[base] = [...have, ...Object.keys(q.criteria).filter(k => !have.includes(k))]
1539
+ }
1540
+ return out
1541
+ }
1542
+ /** fake answers by base name → picks per question (a chunk without the wanted option answers its head, else a 1% option) */
1543
+ function spread(step: any, wanted: any) {
1544
+ const picks: any = {}
1545
+ for (const [name, m] of Object.entries<any>(step.meta)) {
1546
+ const q = step.questions[name], w = wanted[m.base ?? name]
1547
+ if (w === undefined || !q) continue
1548
+ if (q.type !== 'choice') { picks[name] = w; continue }
1549
+ const keys = Object.keys(q.criteria)
1550
+ if (keys.includes(String(w))) picks[name] = one(String(w))
1551
+ else if (m.head?.length) picks[name] = one(m.head[0])
1552
+ else picks[name] = {choice: keys[0], probabilities: {[keys[0]]: 0.01}}
1553
+ }
1554
+ return picks
1555
+ }
1556
+ /** each question's meta once, by base name */
1557
+ const metasOf = (step: any) => {
1558
+ const seen = new Set<string>()
1559
+ return Object.entries<any>(step.meta).filter(([name, m]) => { const b = m.base ?? name; if (seen.has(b)) return false; seen.add(b); return true }).map(([name, m]) => [m.base ?? name, m] as [string, any])
1560
+ }
1561
+
1562
+ function fakePicks(c: any, pending: any, step: any, want: any[]) {
1563
+ const {leaves, containers} = walkTarget(c, want)
1564
+ const picks: any = {}
1565
+ const opts = optionsAt(step)
1566
+ const has = (id: string) => leaves.filter(l => l.part === id)
1567
+ if (pending.step === 'categories' || pending.step === 'select') {
1568
+ for (const [name, m] of metasOf(step)) {
1569
+ const keys: string[] = opts[name] || []
1570
+ if (m.kind === 'cat') picks[name] = leaves.some(l => partOf(c, l.part)?.category === m.category) ? 'use' : 'omit'
1571
+ else if (m.kind === 'use') picks[name] = has(m.part).length ? 'use' : 'omit'
1572
+ else if (m.kind === 'count') picks[name] = String(Math.min(has(m.part).length, maxUses(partOf(c, m.part))))
1573
+ else if (m.kind === 'pick') picks[name] = m.members.find((id: string) => has(id).length) ?? 'omit'
1574
+ else if (m.kind === 'rec') {
1575
+ const rp = recordParam(partOf(c, m.part))
1576
+ const values = has(m.part).map(l => String(l.params?.[rp as string]))
1577
+ const v = values[m.n - 1]
1578
+ picks[name] = v !== undefined && keys.includes(v) ? v : 'none'
1579
+ } else if (m.kind === 'param') {
1580
+ const v = has(m.part).map(l => l.params?.[m.param]).find(x => x !== undefined)
1581
+ const s = v === undefined ? 'not_stated' : String(v)
1582
+ picks[name] = keys.includes(s) ? s : 'not_stated'
1583
+ } else if (m.kind === 'row') picks[name] = leaves.some(l => l.row)
1584
+ }
1585
+ return spread(step, picks)
1586
+ }
1587
+ // layout: match the selected items to the target's leaves (part + record, in order)
1588
+ const items = pending.items
1589
+ const taken = new Set<any>()
1590
+ const leafOf: any = {}
1591
+ for (const it of items) {
1592
+ const rp = recordParam(partOf(c, it.part))
1593
+ const l = leaves.find(l => !taken.has(l) && l.part === it.part && (!rp || String(l.params?.[rp]) === String(it.params?.[rp])))
1594
+ if (l) { taken.add(l); leafOf[it.key] = l }
1595
+ }
1596
+ const pos = (k: string) => leafOf[k]?.i ?? Infinity
1597
+ for (const [name, m] of metasOf(step)) {
1598
+ const keys: string[] = opts[name] || []
1599
+ if (m.kind === 'parent') {
1600
+ const where = leafOf[m.key]?.where ?? containers[m.key]
1601
+ picks[name] = keys.includes(where) ? where : keys[0]
1602
+ } else if (m.kind === 'beside') {
1603
+ const row = leafOf[m.key]?.row
1604
+ if (!row) { picks[name] = 'none'; continue }
1605
+ // a ring: each member names the next one (the last names the first), so average-linkage keeps
1606
+ // the row together at rowMean 0.25 for up to 6 members
1607
+ const mates = row.map((l: any) => Object.keys(leafOf).find(k => leafOf[k] === l))
1608
+ const next = mates[(mates.indexOf(m.key) + 1) % mates.length]
1609
+ picks[name] = keys.includes(next) ? next : 'none'
1610
+ } else if (m.kind === 'same') picks[name] = !!leafOf[m.a]?.row && leafOf[m.a].row === leafOf[m.b]?.row
1611
+ else if (m.kind === 'left' || m.kind === 'first') picks[name] = pos(m.a) <= pos(m.b) ? m.a : m.b
1612
+ else if (m.kind === 'gap') {
1613
+ const g = leaves.filter(l => l.part === m.part && l.i < pos(m.key)).length
1614
+ picks[name] = String(Math.min(g, keys.length - 1))
1615
+ }
1616
+ }
1617
+ return spread(step, picks)
1618
+ }
1619
+
1620
+ const canon = (x: any): any => Array.isArray(x) ? x.map(canon)
1621
+ : x && typeof x == 'object' ? Object.fromEntries(Object.keys(x).sort().map(k => [k, canon(x[k])])) : x
1622
+ const show = (t: any) => JSON.stringify(t)
1623
+
1624
+ /** throws when reading the fake's answers doesn't give the target (an unreachable target) */
1625
+ function check(c: any, state: any, pending: any, want: any[], out: any) {
1626
+ const fail = (why: string) => { throw new Error(`[Sygnal] compose.decisions() fake: can't reach the target ${show(want)}: ${why}`) }
1627
+ if (pending.step === 'select') {
1628
+ for (const l of walkTarget(c, want).leaves) {
1629
+ const rp = recordParam(partOf(c, l.part))
1630
+ if (rp && !(pending.records?.[l.part] || []).some((r: any) => String(r.value) === String(l.params?.[rp]))) {
1631
+ fail(`the prompt ${JSON.stringify(pending.prompt)} doesn't name the ${l.part} record ${JSON.stringify(l.params?.[rp])} (records are found by name or alias in the prompt)`)
1632
+ }
1633
+ }
1634
+ }
1635
+ if (out.request) {
1636
+ if (pending.step !== 'select') return
1637
+ const got = out.pending.items.map((i: any) => show(canon({part: i.part, params: i.params || {}}))).sort()
1638
+ const exp = walkTarget(c, want).leaves.map(l => show(canon({part: l.part, params: l.params || {}}))).sort()
1639
+ if (show(got) !== show(exp)) fail(`the select step reads as ${got.join(' ')}`)
1640
+ return
1641
+ }
1642
+ if (out.unavailable) {
1643
+ // nothing fits: the target holds nothing but required parts (which finish() adds anyway)
1644
+ if (walkTarget(c, want).leaves.some(l => !partOf(c, l.part)?.required)) fail('the answers read as "nothing fits"')
1645
+ return
1646
+ }
1647
+ const got = toTree(out.layout)
1648
+ if (show(canon(got)) !== show(canon(want))) fail(`the answers read as ${show(got)}`)
1649
+ }