@things-factory/figure-service 10.1.167 → 10.1.169

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 (34) hide show
  1. package/dist-server/service/figure/figure-context.d.ts +108 -0
  2. package/dist-server/service/figure/figure-context.js +368 -0
  3. package/dist-server/service/figure/figure-context.js.map +1 -0
  4. package/dist-server/service/figure/figure-format.js +10 -16
  5. package/dist-server/service/figure/figure-format.js.map +1 -1
  6. package/dist-server/service/figure/figure-mutation.js +2 -2
  7. package/dist-server/service/figure/figure-mutation.js.map +1 -1
  8. package/dist-server/service/figure/figure-proposal.js +4 -2
  9. package/dist-server/service/figure/figure-proposal.js.map +1 -1
  10. package/dist-server/service/figure/figure-propose-steps.d.ts +34 -6
  11. package/dist-server/service/figure/figure-propose-steps.js +128 -17
  12. package/dist-server/service/figure/figure-propose-steps.js.map +1 -1
  13. package/dist-server/service/figure/figure-tools.js +197 -49
  14. package/dist-server/service/figure/figure-tools.js.map +1 -1
  15. package/dist-server/tsconfig.tsbuildinfo +1 -1
  16. package/package.json +4 -4
  17. package/server/service/figure/figure-context.test.ts +288 -0
  18. package/server/service/figure/figure-context.ts +447 -0
  19. package/server/service/figure/figure-format.test.ts +18 -1
  20. package/server/service/figure/figure-format.ts +10 -16
  21. package/server/service/figure/figure-llm-smoke.test.ts +82 -13
  22. package/server/service/figure/figure-mutation.ts +3 -3
  23. package/server/service/figure/figure-proposal.ts +4 -2
  24. package/server/service/figure/figure-propose-steps.test.ts +146 -6
  25. package/server/service/figure/figure-propose-steps.ts +145 -26
  26. package/server/service/figure/figure-tools-door-db.test.ts +227 -0
  27. package/server/service/figure/figure-tools.test.ts +382 -22
  28. package/server/service/figure/figure-tools.ts +218 -50
  29. package/translations/en.json +11 -9
  30. package/translations/es.json +11 -9
  31. package/translations/ja.json +11 -9
  32. package/translations/ko.json +11 -9
  33. package/translations/ms.json +11 -9
  34. package/translations/zh.json +11 -9
@@ -0,0 +1,447 @@
1
+ /**
2
+ * What of a figure the model is shown (ADR-0061 2026-10-16 decision 4).
3
+ *
4
+ * The kernel keeps a figure up to 1 MiB (`FIGURE_JSON_MAX_BYTES`); that is whether the figure is valid. How much of it the
5
+ * model reads at once is another question with its own number. A figure that fits is shown whole. One that does not is
6
+ * shown as an outline -- its sections, and every part by name, shape and what it is fastened to -- and in full only the
7
+ * parts and sections asked for, while they fit. A section too large for the outline is left out, named by its JSON
8
+ * Pointer with the entries inside it, and read by pointer. The full source stays on the server: a proposal is applied
9
+ * and judged on it, on the version the request was made on.
10
+ *
11
+ * A proposal on a figure shown in part changes only what it was shown: no part not read in full, and nothing inside a
12
+ * section left out and not read. Read is what the server put before the writer, never what a model says it read.
13
+ */
14
+ import type { FigureAsset, FigureProposalStep } from '@hatiolab/figure-model'
15
+
16
+ /** The most of a figure, serialized, the model is shown in one read. Not a validity limit: that is the kernel's. */
17
+ export const FIGURE_CONTEXT_BUDGET = 100_000
18
+
19
+ /** Of the budget, what the outline's sections may take; the rest is for the parts and sections read beside it. */
20
+ const OUTLINE_SHARE = FIGURE_CONTEXT_BUDGET / 2
21
+
22
+ /** How many entries of a section left out, or pointers in a reason, are named: the largest first. */
23
+ const ENTRIES_NAMED = 20
24
+
25
+ type Dynamic = any
26
+
27
+ export interface FigureOutlinePart {
28
+ id: string
29
+ /** The operation that makes its shape (`box@1`, `extrude@1`, ...). */
30
+ shape?: string
31
+ /** What it is fastened to: `<part>.<face>`, `mounting-plane`, or nothing when unfastened. */
32
+ on?: string
33
+ joint?: { id: string; type: string }
34
+ material?: string
35
+ /** How long its part read is: what reading it costs. */
36
+ chars: number
37
+ }
38
+
39
+ export interface FigureOmittedSection {
40
+ /** The JSON Pointer of the section left out. */
41
+ section: string
42
+ chars: number
43
+ /** Its largest entries, by pointer: what to read when the whole does not fit. */
44
+ entries?: { section: string; chars: number }[]
45
+ }
46
+
47
+ export interface FigureOutline {
48
+ /** The whole source's length, and the budget it does not fit. */
49
+ chars: number
50
+ budget: number
51
+ parts: FigureOutlinePart[]
52
+ /** Parts the outline had no room to list, past the ones listed. */
53
+ partsNotListed?: number
54
+ /** Sections left out because they would crowd the outline. */
55
+ omitted?: FigureOmittedSection[]
56
+ [section: string]: unknown
57
+ }
58
+
59
+ export interface FigurePartRead {
60
+ appearance: Dynamic
61
+ /** The fastenings that name it: its own (body1) and its children's (body0). */
62
+ joints: Dynamic[]
63
+ /** The graph nodes and constants under its name: `<part>` and `<part>.*` (a part id has no dot). */
64
+ nodes: Dynamic[]
65
+ constants: Dynamic[]
66
+ }
67
+
68
+ export interface FigureReadRequest {
69
+ parts?: readonly string[]
70
+ /** JSON Pointers (RFC 6901) into the source, such as `/document/capabilities/bindings/0`. */
71
+ sections?: readonly string[]
72
+ }
73
+
74
+ export interface FigureView {
75
+ whole: boolean
76
+ /** Whole: the source as it is. */
77
+ source?: FigureAsset
78
+ outline?: FigureOutline
79
+ /** The parts shown in full, by id, in the order asked. */
80
+ parts?: Record<string, FigurePartRead>
81
+ shown?: string[]
82
+ /** The sections read, by pointer. */
83
+ sections?: Record<string, unknown>
84
+ /** What was asked for and not shown, with why and what to read instead. */
85
+ notRead?: ({ part: string; reason: string } | { section: string; reason: string })[]
86
+ }
87
+
88
+ const length = (value: unknown) => JSON.stringify(value)?.length ?? 0
89
+ const under = (part: string) => (id: unknown) => id === part || (typeof id === 'string' && id.startsWith(`${part}.`))
90
+
91
+ /* ── JSON Pointer (RFC 6901) ─────────────────────────────────────────────────────────── */
92
+
93
+ const escapeToken = (token: string) => token.replace(/~/g, '~0').replace(/\//g, '~1')
94
+ const pointerOf = (tokens: readonly (string | number)[]) => tokens.map(t => `/${escapeToken(String(t))}`).join('')
95
+ function tokensOf(pointer: string): string[] | undefined {
96
+ if (typeof pointer !== 'string' || !pointer.startsWith('/')) return undefined
97
+ return pointer.slice(1).split('/').map(t => t.replace(/~1/g, '/').replace(/~0/g, '~'))
98
+ }
99
+ function valueAt(root: unknown, tokens: readonly string[]): { found: boolean; value?: unknown } {
100
+ let at: Dynamic = root
101
+ for (const token of tokens) {
102
+ if (at === null || typeof at !== 'object' || !Object.prototype.hasOwnProperty.call(at, token)) return { found: false }
103
+ at = at[token]
104
+ }
105
+ return { found: true, value: at }
106
+ }
107
+ /** `inner` is `outer` or inside it. */
108
+ const within = (inner: string, outer: string) => inner === outer || inner.startsWith(`${outer}/`)
109
+
110
+ /** A value's entries by pointer, largest first, the first ENTRIES_NAMED of them. */
111
+ function entriesOf(pointer: string, value: unknown): { section: string; chars: number }[] {
112
+ if (value === null || typeof value !== 'object') return []
113
+ const tokens = tokensOf(pointer)!
114
+ return Object.entries(value as object)
115
+ .map(([key, entry]) => ({ section: pointerOf([...tokens, key]), chars: length(entry) }))
116
+ .sort((a, b) => b.chars - a.chars)
117
+ .slice(0, ENTRIES_NAMED)
118
+ }
119
+
120
+ /* ── Parts ───────────────────────────────────────────────────────────────────────────── */
121
+
122
+ export function figurePartReadOf(asset: FigureAsset, part: string): FigurePartRead | undefined {
123
+ const appearance = asset.appearance.find(a => a.target === part)
124
+ if (!appearance) return undefined
125
+ const model = asset.document.model as Dynamic
126
+ const own = under(part)
127
+ return {
128
+ appearance,
129
+ joints: ((asset as Dynamic).joints ?? []).filter((j: Dynamic) => j?.body1?.part === part || j?.body0?.part === part),
130
+ nodes: model.nodes.filter((n: Dynamic) => own(n.id)),
131
+ constants: model.constants.filter((c: Dynamic) => own(c.id))
132
+ }
133
+ }
134
+
135
+ /** Where a part's records are, by pointer: for a part too large to read as one. */
136
+ function partPointers(asset: FigureAsset, part: string): string[] {
137
+ const model = asset.document.model as Dynamic
138
+ const own = under(part)
139
+ return [
140
+ ...asset.appearance.flatMap((a, i) => (a.target === part ? [pointerOf(['appearance', i])] : [])),
141
+ ...((asset as Dynamic).joints ?? []).flatMap((j: Dynamic, i: number) => (j?.body1?.part === part || j?.body0?.part === part ? [pointerOf(['joints', i])] : [])),
142
+ ...model.nodes.flatMap((n: Dynamic, i: number) => (own(n.id) ? [pointerOf(['document', 'model', 'nodes', i])] : [])),
143
+ ...model.constants.flatMap((c: Dynamic, i: number) => (own(c.id) ? [pointerOf(['document', 'model', 'constants', i])] : []))
144
+ ]
145
+ }
146
+
147
+ /* ── The outline ─────────────────────────────────────────────────────────────────────── */
148
+
149
+ /*
150
+ The sections of an outline: every top-level field but the parts' records, the document's fields but its model, and the
151
+ model's fields but its graph. Each may be left out by its pointer.
152
+ */
153
+ const PART_RECORDS = ['/document', '/appearance', '/joints', '/document/model', '/document/model/nodes', '/document/model/constants']
154
+ function sectionsOf(asset: FigureAsset): string[] {
155
+ const out: string[] = []
156
+ const walk = (tokens: string[]) => {
157
+ const { value } = valueAt(asset, tokens)
158
+ for (const key of Object.keys(value as object)) {
159
+ const pointer = pointerOf([...tokens, key])
160
+ if (pointer === '/document' || pointer === '/document/model') walk([...tokens, key])
161
+ else if (!PART_RECORDS.includes(pointer)) out.push(pointer)
162
+ }
163
+ }
164
+ walk([])
165
+ return out
166
+ }
167
+
168
+ function outlinePartsOf(asset: FigureAsset): FigureOutlinePart[] {
169
+ const nodes = (asset.document.model as Dynamic).nodes
170
+ const joints = (asset as Dynamic).joints ?? []
171
+ return asset.appearance.map((look: Dynamic) => {
172
+ const id = look.target
173
+ const joint = joints.find((j: Dynamic) => j?.body1?.part === id)
174
+ const body0 = joint?.body0
175
+ const on = !joint ? undefined : body0?.part ? `${body0.part}.${body0.face}` : (body0?.plane ?? 'mounting-plane')
176
+ const shape = nodes.find((n: Dynamic) => n.id === `${id}.shape`)?.op
177
+ return {
178
+ id,
179
+ ...(shape ? { shape } : {}),
180
+ ...(on ? { on } : {}),
181
+ ...(joint ? { joint: { id: joint.id, type: joint.type } } : {}),
182
+ ...(look.material?.token ? { material: look.material.token } : {}),
183
+ chars: length(figurePartReadOf(asset, id))
184
+ }
185
+ })
186
+ }
187
+
188
+ /** Writes a section into the outline at its pointer, making the objects on the way. */
189
+ function place(outline: Dynamic, tokens: readonly string[], value: unknown) {
190
+ let at = outline
191
+ tokens.slice(0, -1).forEach(token => (at = at[token] ??= {}))
192
+ at[tokens[tokens.length - 1]!] = value
193
+ }
194
+
195
+ /**
196
+ * The outline: within OUTLINE_SHARE for its sections, the largest left out first; and, every field counted, within the
197
+ * budget -- a figure of very many parts lists them by id only, then as many as fit.
198
+ */
199
+ export function figureOutlineOf(asset: FigureAsset, chars = length(asset)): FigureOutline {
200
+ const model = asset.document.model as Dynamic
201
+ const sections = sectionsOf(asset).map(pointer => ({ pointer, value: valueAt(asset, tokensOf(pointer)!).value }))
202
+ const sized = sections.map(s => ({ ...s, chars: length(s.value) })).sort((a, b) => b.chars - a.chars)
203
+
204
+ let total = sized.reduce((sum, s) => sum + s.chars, 0)
205
+ const omitted: FigureOmittedSection[] = []
206
+ const kept = new Set(sized.map(s => s.pointer))
207
+ for (const s of sized) {
208
+ if (total <= OUTLINE_SHARE) break
209
+ kept.delete(s.pointer)
210
+ total -= s.chars
211
+ const entries = entriesOf(s.pointer, s.value)
212
+ omitted.push({ section: s.pointer, chars: s.chars, ...(entries.length ? { entries } : {}) })
213
+ }
214
+
215
+ const outline: Dynamic = { chars, budget: FIGURE_CONTEXT_BUDGET }
216
+ place(outline, ['document', 'model', 'nodes'], model.nodes.length)
217
+ place(outline, ['document', 'model', 'constants'], model.constants.length)
218
+ for (const s of sections) if (kept.has(s.pointer)) place(outline, tokensOf(s.pointer)!, s.value)
219
+ if (omitted.length) outline.omitted = omitted
220
+
221
+ let parts: Dynamic[] = outlinePartsOf(asset)
222
+ outline.parts = parts
223
+ if (length(outline) > FIGURE_CONTEXT_BUDGET) outline.parts = parts = parts.map(p => ({ id: p.id }))
224
+ if (length(outline) > FIGURE_CONTEXT_BUDGET) {
225
+ /* As many as fit, in part order, with how many more there are. */
226
+ let room = FIGURE_CONTEXT_BUDGET - length({ ...outline, parts: [], partsNotListed: parts.length })
227
+ const listed: Dynamic[] = []
228
+ for (const p of parts) {
229
+ room -= length(p) + 1
230
+ if (room < 0) break
231
+ listed.push(p)
232
+ }
233
+ outline.parts = listed
234
+ outline.partsNotListed = parts.length - listed.length
235
+ }
236
+ return outline as FigureOutline
237
+ }
238
+
239
+ /* ── The view ────────────────────────────────────────────────────────────────────────── */
240
+
241
+ /**
242
+ * The view of a figure the model is shown: whole when it fits, else the outline and what was asked for -- parts, then
243
+ * sections, each in the order asked -- while it fits beside it. Every field counted, the view is within the budget.
244
+ */
245
+ export function figureViewOf(asset: FigureAsset, request: FigureReadRequest = {}): FigureView {
246
+ const chars = length(asset)
247
+ if (chars <= FIGURE_CONTEXT_BUDGET) return { whole: true, source: asset }
248
+
249
+ const outline = figureOutlineOf(asset, chars)
250
+ const view: FigureView = { whole: false, outline, parts: {}, shown: [], sections: {}, notRead: [] }
251
+ const room = () => FIGURE_CONTEXT_BUDGET - length(view)
252
+ const refuse = (one: { part: string; reason: string } | { section: string; reason: string }) => {
253
+ if (length(one) + 1 <= room()) view.notRead!.push(one)
254
+ }
255
+
256
+ for (const part of [...new Set(request.parts ?? [])]) {
257
+ const one = figurePartReadOf(asset, part)
258
+ if (!one) {
259
+ refuse({ part, reason: `${part} is not a part of this figure; the outline lists its parts` })
260
+ continue
261
+ }
262
+ const cost = length(one) + length(part) + length(part) + 3
263
+ view.parts![part] = one
264
+ view.shown!.push(part)
265
+ if (room() >= 0) continue
266
+ delete view.parts![part]
267
+ view.shown!.pop()
268
+ if (cost <= FIGURE_CONTEXT_BUDGET - length(outline)) {
269
+ refuse({ part, reason: `${part} (${cost} characters) does not fit beside what is already read; read it in another call with fewer parts or sections` })
270
+ } else {
271
+ const pointers = partPointers(asset, part)
272
+ refuse({
273
+ part,
274
+ reason:
275
+ `${part} (${cost} characters) is too large to show beside the outline; read its records as sections: ` +
276
+ `${pointers.slice(0, ENTRIES_NAMED).join(', ')}${pointers.length > ENTRIES_NAMED ? ` and ${pointers.length - ENTRIES_NAMED} more` : ''}. ` +
277
+ 'Parts read as sections are not counted as read in full: they cannot be changed'
278
+ })
279
+ }
280
+ }
281
+
282
+ for (const section of [...new Set(request.sections ?? [])]) {
283
+ const tokens = tokensOf(section)
284
+ const at = tokens && tokens.length ? valueAt(asset, tokens) : { found: false }
285
+ if (!at.found) {
286
+ refuse({ section, reason: `there is no section ${section}; a section is a JSON Pointer such as /document/capabilities/bindings/0` })
287
+ continue
288
+ }
289
+ const cost = length(at.value) + length(section) + 2
290
+ view.sections![section] = at.value
291
+ if (room() >= 0) continue
292
+ delete view.sections![section]
293
+ const entries = entriesOf(section, at.value)
294
+ refuse({
295
+ section,
296
+ reason: entries.length
297
+ ? `${section} (${cost} characters) is too large to show at once here; read its entries: ${entries.map(e => `${e.section} (${e.chars})`).join(', ')}`
298
+ : `${section} is one value of ${length(at.value)} characters, larger than can be shown here; it cannot be read or changed by a proposal`
299
+ })
300
+ }
301
+
302
+ if (!view.notRead!.length) delete view.notRead
303
+ return view
304
+ }
305
+
306
+ /* ── What a proposal may change ──────────────────────────────────────────────────────── */
307
+
308
+ type StepOf<K extends FigureProposalStep['kind']> = Extract<FigureProposalStep, { kind: K }>
309
+ type Subject = string | undefined
310
+
311
+ /**
312
+ * The part each kind of step changes, read from the field the kernel reads for that kind (audit D4-R1: a `part` beside a
313
+ * `set-material`'s `target` is not read by the kernel, so it may not stand in for it). Every kind of the kernel's step
314
+ * type is here, or this does not compile. A step that changes no one part says so with undefined: what it does to the
315
+ * figure's sections is held by `unreadSectionEdits`.
316
+ */
317
+ const SUBJECT: { [K in FigureProposalStep['kind']]: (step: StepOf<K>, base: FigureAsset) => Subject } = {
318
+ 'add-part': () => undefined,
319
+ 'fit-room': () => undefined,
320
+ 'set-section-corner': step => step.part,
321
+ 'set-cavity': step => step.part,
322
+ 'set-section': step => step.part,
323
+ attach: step => step.part,
324
+ 'link-dimension': step => step.part,
325
+ 'follow-size': step => step.part,
326
+ 'unlink-dimension': step => step.part,
327
+ 'scale-position': step => step.part,
328
+ 'fix-position': step => step.part,
329
+ 'nudge-part': step => step.part,
330
+ detach: step => step.part,
331
+ 'make-repeat': step => step.part,
332
+ 'set-repeat': step => step.part,
333
+ 'remove-repeat': step => step.part,
334
+ 'set-mounting-face': step => step.part,
335
+ 'add-binding': step => step.part,
336
+ 'set-joint': step => step.joint?.body1?.part,
337
+ 'remove-joint': (step, base) => ((base as Dynamic).joints ?? []).find((j: Dynamic) => j?.id === step.id)?.body1?.part,
338
+ 'set-material': step => step.target,
339
+ 'set-segments': step => step.target,
340
+ 'rename-part': step => step.target,
341
+ 'set-visibility': step => step.target,
342
+ 'remove-part': step => step.target,
343
+ 'duplicate-part': step => step.target,
344
+ 'move-part': step => step.target,
345
+ 'set-placement': () => undefined,
346
+ 'declare-occupancy': () => undefined,
347
+ 'declare-instance-size': () => undefined,
348
+ 'clear-mounting-face': () => undefined,
349
+ 'scale-uniformly': () => undefined,
350
+ 'follow-size-in-proportion': () => undefined,
351
+ 'set-detail-level': () => undefined,
352
+ 'add-design-input': () => undefined,
353
+ 'set-input-range': () => undefined,
354
+ 'publish-input': () => undefined,
355
+ 'unpublish-input': () => undefined,
356
+ 'remove-binding': () => undefined,
357
+ 'set-slot-layout': () => undefined,
358
+ 'remove-slot-layout': () => undefined,
359
+ 'set-extension': () => undefined,
360
+ rename: () => undefined,
361
+ 'set-capabilities': () => undefined,
362
+ 'set-state-input': () => undefined,
363
+ 'set-state-defaults': () => undefined,
364
+ 'set-driver': () => undefined,
365
+ 'remove-driver': () => undefined,
366
+ 'set-clip': () => undefined,
367
+ 'set-motion-group': () => undefined,
368
+ 'remove-motion-group': () => undefined,
369
+ 'set-data-schema': () => undefined,
370
+ 'rename-data-field': () => undefined,
371
+ 'set-data-binding': () => undefined,
372
+ 'remove-data-binding': () => undefined,
373
+ 'set-status-port': () => undefined,
374
+ 'remove-status-port': () => undefined
375
+ }
376
+
377
+ /**
378
+ * The steps that change a part of the base not shown in full, as reasons said back to the writer. A part may still be
379
+ * named to fasten to or to follow -- the kernel works from it, nothing about it is guessed -- and a new part is the
380
+ * steps' own. A kind this table does not know is refused: it is not let through unjudged.
381
+ */
382
+ export function unreadPartEdits(base: FigureAsset, steps: readonly FigureProposalStep[], view: FigureView): string[] {
383
+ const existing = new Set(base.appearance.map(a => a.target))
384
+ const shown = new Set(view.shown ?? [])
385
+ const allowed = [...shown].join(', ') || 'none'
386
+ return steps.flatMap((step, i) => {
387
+ const kind = (step as Dynamic)?.kind
388
+ if (typeof kind !== 'string') return []
389
+ const judge = (SUBJECT as Record<string, (step: Dynamic, base: FigureAsset) => Subject>)[kind]
390
+ if (!Object.prototype.hasOwnProperty.call(SUBJECT, kind) || !judge)
391
+ return [`UNKNOWN_STEP: steps[${i}] has the kind "${kind}", which is not one of the commands; use only the commands taught.`]
392
+ const part = judge(step, base)
393
+ if (typeof part !== 'string' || !existing.has(part) || shown.has(part)) return []
394
+ return [
395
+ `UNREAD_PART: steps[${i}] (${kind}) changes "${part}", which was not shown in full, so its source is not known here. ` +
396
+ `Change only the parts shown in full (${allowed}) or parts the steps add. To change ${part}, read it with getFigureSource parts and pass it in parts to proposeFigureCandidate.`
397
+ ]
398
+ })
399
+ }
400
+
401
+ /** Entries of a value, keyed by `id` when every entry of a list has a distinct one, else by place. */
402
+ function keyedEntries(value: unknown): Map<string, { token: string; value: unknown }> {
403
+ const out = new Map<string, { token: string; value: unknown }>()
404
+ if (Array.isArray(value)) {
405
+ const ids = value.map(v => (v && typeof v === 'object' && typeof (v as Dynamic).id === 'string' ? (v as Dynamic).id : undefined))
406
+ const byId = ids.every(Boolean) && new Set(ids).size === ids.length
407
+ value.forEach((v, i) => out.set(byId ? `id:${ids[i]}` : `at:${i}`, { token: String(i), value: v }))
408
+ } else if (value && typeof value === 'object') {
409
+ for (const [key, v] of Object.entries(value)) out.set(`key:${key}`, { token: key, value: v })
410
+ }
411
+ return out
412
+ }
413
+
414
+ /**
415
+ * What of the sections left out of the outline and not read the steps changed, as reasons said back to the writer. Judged
416
+ * on what the steps made, not on what they say: nothing that was there may be changed or taken away. A new entry -- a
417
+ * record in a list, a key in a map -- is the steps' own.
418
+ */
419
+ export function unreadSectionEdits(base: FigureAsset, after: FigureAsset, view: FigureView): string[] {
420
+ const read = Object.keys(view.sections ?? {})
421
+ const reasons: string[] = []
422
+ const say = (pointer: string, what: string) =>
423
+ reasons.push(
424
+ `UNREAD_SECTION: the steps ${what} ${pointer}, which was left out of the outline and not read, so it is not known here. ` +
425
+ `Leave it as it is; to change it, read it with getFigureSource sections and pass it in sections to proposeFigureCandidate.`
426
+ )
427
+ const check = (pointer: string, before: unknown, now: unknown) => {
428
+ if (reasons.length >= ENTRIES_NAMED || read.some(r => within(pointer, r))) return
429
+ const records = (v: unknown) => v !== null && typeof v === 'object'
430
+ if (!records(before) || !records(now) || Array.isArray(before) !== Array.isArray(now)) {
431
+ if (JSON.stringify(before) !== JSON.stringify(now)) say(pointer, 'change')
432
+ return
433
+ }
434
+ const later = keyedEntries(now)
435
+ for (const [key, entry] of keyedEntries(before)) {
436
+ const child = `${pointer}/${escapeToken(entry.token)}`
437
+ const kept = later.get(key)
438
+ if (kept) check(child, entry.value, kept.value)
439
+ else if (!read.some(r => within(child, r))) say(child, 'remove')
440
+ }
441
+ }
442
+ for (const omitted of view.outline?.omitted ?? []) {
443
+ const tokens = tokensOf(omitted.section)!
444
+ check(omitted.section, valueAt(base, tokens).value, valueAt(after, tokens).value)
445
+ }
446
+ return reasons
447
+ }
@@ -185,6 +185,23 @@ describe('figure-format — 판을 알아보는 문', () => {
185
185
  expect(`${finding.message}${finding.why}${finding.how}`).not.toMatch(/declared volume|leaves/)
186
186
  })
187
187
 
188
+ it('확장 속성의 키가 없어지면 발행을 막고 키 이름으로 말한다; 값이 바뀌면 알리기만 한다 (ADR-0123)', () => {
189
+ const carrier = { type: 'ROLL', dimensions: { width: 1000, length: 600, height: 200 } }
190
+ const withKey = applyFigureAuthoring(figureAsset(), { kind: 'set-extension', key: 'fmsim/carrier', value: carrier })
191
+ const released = serializeFigureAsset(withKey)
192
+
193
+ const gone = inspectSource(serializeFigureAsset(figureAsset()), released, translatorOf(KO)).findings.find(f => f.code === 'extension-gone')!
194
+ expect(gone.blocking).toBe(true)
195
+ expect(gone.message).toBe("'fmsim/carrier' 확장 속성이 없어졌습니다.")
196
+
197
+ const changed = applyFigureAuthoring(withKey, { kind: 'set-extension', key: 'fmsim/carrier', value: { ...carrier, type: 'ROLL2' } })
198
+ const said = inspectSource(serializeFigureAsset(changed), released, translatorOf(KO))
199
+ const finding = said.findings.find(f => f.code === 'extension-changed')!
200
+ expect(finding.blocking).toBe(false)
201
+ expect(finding.message).toBe("'fmsim/carrier' 확장 속성의 값이 바뀌었습니다.")
202
+ expect(said.findings.some(f => f.code === 'extension-gone')).toBe(false)
203
+ })
204
+
188
205
  it('공간을 선언하지 않은 도형은 무엇을 누르면 되는지 말한다', () => {
189
206
  const finding = inspectSource(bare(), null, translatorOf(KO)).findings.find(f => f.code === 'occupancy-undeclared')!
190
207
 
@@ -350,7 +367,7 @@ describe('figure-format — 판을 알아보는 문', () => {
350
367
  it('every new change has its sentence in both languages', () => {
351
368
  const KO = require('../../../translations/ko.json') as Record<string, string>
352
369
  const EN = require('../../../translations/en.json') as Record<string, string>
353
- for (const code of ['occupancy-changed', 'contact-face-changed', 'public-input-gone', 'public-input-range-narrowed', 'public-input-added', 'state-input-joints-changed', 'joint-axis-changed', 'joint-motion-unit-changed', 'joint-child-changed', 'joint-list-unread', 'carrier-gone', 'carrier-type-changed', 'carrier-dimensions-changed', 'carrier-added'])
370
+ for (const code of ['occupancy-changed', 'contact-face-changed', 'public-input-gone', 'public-input-range-narrowed', 'public-input-added', 'state-input-joints-changed', 'joint-axis-changed', 'joint-motion-unit-changed', 'joint-child-changed', 'joint-list-unread', 'extension-gone', 'extension-changed', 'extension-added'])
354
371
  for (const dict of [KO, EN]) for (const field of ['message', 'why']) expect(dict[`figure.gate.contract.${code}.${field}`]).toBeTruthy()
355
372
  })
356
373
  it('the data changes have their sentences under figure-model 0.6.6\'s codes, in all six languages', () => {
@@ -478,28 +478,22 @@ function figureContractWording(change: FigureContractChange, say: Say): Pick<Gat
478
478
  message: say(key('message'), { ...v, input: change.subject ?? '' }, `'${change.subject}' can now be set on a board.`),
479
479
  why: say(key('why'), {}, 'Placed instances that give no value stand at its default.')
480
480
  }
481
- /* What live data names a carrier by, and the size a holder fits it by (ADR-0123). */
482
- case 'carrier-gone':
481
+ /* A value another product reads by its key (ADR-0123): only a key that goes refuses; what a change means is its owner's. */
482
+ case 'extension-gone':
483
483
  return {
484
- message: say(key('message'), v, `The figure is no longer carrier type ${v.type}.`),
485
- why: say(key('why'), {}, 'Live data names carriers by this code. After this release carriers of this type find no figure.'),
484
+ message: say(key('message'), v, `The extension '${v.key}' is gone.`),
485
+ why: say(key('why'), {}, 'Another product reads this value by its key. After this release it finds none.'),
486
486
  ...gone
487
487
  }
488
- case 'carrier-type-changed':
488
+ case 'extension-changed':
489
489
  return {
490
- message: say(key('message'), v, `The carrier type changed from ${v.before} to ${v.after}.`),
491
- why: say(key('why'), {}, 'Live data names carriers by this code. After this release carriers of the old type find no figure.'),
492
- ...gone
493
- }
494
- case 'carrier-dimensions-changed':
495
- return {
496
- message: say(key('message'), v, `The carrier size changed from ${v.before} to ${v.after} mm.`),
497
- why: say(key('why'), {}, 'Holders fit and stack carriers by this size. Where they stand and what fits is judged again once the map takes this release.')
490
+ message: say(key('message'), v, `The value of the extension '${v.key}' changed.`),
491
+ why: say(key('why'), {}, 'The product that owns this key reads the new value and judges the change.')
498
492
  }
499
- case 'carrier-added':
493
+ case 'extension-added':
500
494
  return {
501
- message: say(key('message'), v, `The figure is now carrier type ${v.type}.`),
502
- why: say(key('why'), {}, 'Live data with this code finds this figure once the type map takes this release.')
495
+ message: say(key('message'), v, `The extension '${v.key}' is new.`),
496
+ why: say(key('why'), {}, 'The product that owns this key reads this value.')
503
497
  }
504
498
  /* What a consumer's data mapping and a board's motion selection lean on (ADR-0051 §2.2, §3.2). */
505
499
  case 'data-field-gone':
@@ -11,9 +11,9 @@
11
11
  */
12
12
  import { createAIClient, type AIClient } from '@things-factory/ai-client-base'
13
13
 
14
- import { applyFigureProposal, createFigureAsset, inspectFigureAsset } from '@hatiolab/figure-model'
14
+ import { applyFigureProposal, compileFigureAsset, createFigureAsset, figureShapeBoundsOf, inspectFigureAsset, type FigureAsset } from '@hatiolab/figure-model'
15
15
 
16
- import { proposeFigureSteps } from './figure-propose-steps'
16
+ import { judgeFigureAnswer, proposeFigureSteps } from './figure-propose-steps'
17
17
 
18
18
  // Provider modules register themselves on import. Keep this separate from the deterministic tests.
19
19
  import '@things-factory/ai-client-base'
@@ -35,6 +35,83 @@ function provider(): { provider: Provider; apiKey: string } | undefined {
35
35
  return undefined
36
36
  }
37
37
 
38
+ /** A mixer: a body standing on the floor, a port fastened to its front. */
39
+ function mixerDraft(): FigureAsset {
40
+ return applyFigureProposal(createFigureAsset('Mixer'), [
41
+ { kind: 'add-part', part: { id: 'body', kind: 'cylinder', dimensions: { radius: 140, length: 320 } } },
42
+ { kind: 'add-part', part: { id: 'port', kind: 'box', dimensions: { width: 80, height: 60, depth: 30 } } },
43
+ { kind: 'attach', part: 'body', face: 'bottom', to: { plane: 'mounting-plane' } },
44
+ { kind: 'attach', part: 'port', face: 'back', to: { part: 'body', face: 'front' } },
45
+ { kind: 'fit-room', placement: 'floor' }
46
+ ] as any)
47
+ }
48
+
49
+ /**
50
+ * Each part's reach along its own axes and the colour it is drawn in, at the figure's default size. The colour is the
51
+ * palette's value for the part's token: two tokens of one colour draw the same.
52
+ */
53
+ function drawn(asset: FigureAsset): Record<string, { size: Record<'x' | 'y' | 'z', number>; colour: string }> {
54
+ const geometry = compileFigureAsset(asset).evaluate({}).geometry as any[]
55
+ return Object.fromEntries(
56
+ geometry.map(body => {
57
+ const bounds = figureShapeBoundsOf(body.shape)
58
+ const size = { x: bounds.x.max - bounds.x.min, y: bounds.y.max - bounds.y.min, z: bounds.z.max - bounds.z.min }
59
+ /* The kernel refuses a token the palette has no colour for, so every body has one. */
60
+ return [body.id, { size, colour: String(asset.palette![body.material.token]).toLowerCase() }]
61
+ })
62
+ )
63
+ }
64
+
65
+ /**
66
+ * What "make the port twice as wide and give it an accent colour" asks of the mixer, said as what is missing: the port
67
+ * twice as wide (X), its height and depth kept, a colour it did not have, and the body left as it was. Nothing missing:
68
+ * an empty list. Checking only that the parts still exist let a revision that changed nothing pass.
69
+ */
70
+ function portRevisionMisses(before: FigureAsset, after: FigureAsset): string[] {
71
+ const was = drawn(before),
72
+ now = drawn(after)
73
+ const misses: string[] = []
74
+ const near = (a: number, b: number) => Math.abs(a - b) <= 0.5
75
+ if (!now.port) return ['the port is gone']
76
+ if (!near(now.port.size.x, was.port.size.x * 2)) misses.push(`port width ${now.port.size.x}, asked ${was.port.size.x * 2}`)
77
+ for (const axis of ['y', 'z'] as const) if (!near(now.port.size[axis], was.port.size[axis])) misses.push(`port ${axis} changed to ${now.port.size[axis]}`)
78
+ if (now.port.colour === was.port.colour) misses.push(`port colour still ${was.port.colour}`)
79
+ if (!now.body) misses.push('the body is gone')
80
+ else for (const axis of ['x', 'y', 'z'] as const) if (!near(now.body.size[axis], was.body.size[axis])) misses.push(`body ${axis} changed to ${now.body.size[axis]}`)
81
+ if (now.body && now.body.colour !== was.body.colour) misses.push(`body colour changed to ${now.body.colour}`)
82
+ return misses
83
+ }
84
+
85
+ /* The check above, held to answers whose verdict is known - so it is seen to fail a wrong revision without a provider. */
86
+ describe('the revision check of the smoke test', () => {
87
+ const draft = mixerDraft()
88
+ const answer = (steps: unknown[]) => {
89
+ const judged = judgeFigureAnswer(draft, { steps }, true)
90
+ if (!judged.source) throw new Error(judged.reasons.join('\n'))
91
+ return judged.source
92
+ }
93
+ const accent = { kind: 'set-material', target: 'port', material: { token: 'palette.accent' }, palette: { 'palette.accent': '#d08a2c' } }
94
+
95
+ it('passes the revision asked for', () => {
96
+ expect(portRevisionMisses(draft, answer([{ kind: 'set-section', part: 'port', across: { x: 160 } }, accent]))).toEqual([])
97
+ })
98
+
99
+ it('fails a revision that left the width, or widened the body instead, or kept the colour', () => {
100
+ expect(portRevisionMisses(draft, answer([accent]))).toEqual(['port width 80, asked 160'])
101
+ expect(portRevisionMisses(draft, answer([{ kind: 'set-section', part: 'port', across: { x: 160 } }, { kind: 'set-section', part: 'body', across: { x: 320 } }, accent]))).toEqual(
102
+ expect.arrayContaining(['body x changed to 320'])
103
+ )
104
+ expect(portRevisionMisses(draft, answer([{ kind: 'set-section', part: 'port', across: { x: 160 } }]))).toEqual([`port colour still ${draft.palette!['palette.primary']}`])
105
+ })
106
+
107
+ /* A new token is not a new colour: an alias of the colour the port already had changes nothing on screen. */
108
+ it('fails a new token that names the colour the port already had', () => {
109
+ const same = draft.palette!['palette.primary']!
110
+ const alias = { kind: 'set-material', target: 'port', material: { token: 'palette.accent' }, palette: { 'palette.accent': same.toUpperCase() } }
111
+ expect(portRevisionMisses(draft, answer([{ kind: 'set-section', part: 'port', across: { x: 160 } }, alias]))).toEqual([`port colour still ${same}`])
112
+ })
113
+ })
114
+
38
115
  const picked = process.env.RUN_FIGURE_LLM_SMOKE === '1' ? provider() : undefined
39
116
  const describeIf = picked ? describe : describe.skip
40
117
 
@@ -66,20 +143,12 @@ describeIf('Figure 실제 LLM smoke', () => {
66
143
  )
67
144
 
68
145
  test(
69
- '고치는 제안은 건드리지 않은 부품을 그대로 둔다',
146
+ '고치는 제안은 요청한 것만 바꾼다 — 포트 폭 두 배, 새 색, 몸통은 그대로',
70
147
  async () => {
71
- const draft = applyFigureProposal(createFigureAsset('Mixer'), [
72
- { kind: 'add-part', part: { id: 'body', kind: 'cylinder', dimensions: { radius: 140, length: 320 } } },
73
- { kind: 'add-part', part: { id: 'port', kind: 'box', dimensions: { width: 80, height: 60, depth: 30 } } },
74
- { kind: 'attach', part: 'body', face: 'bottom', to: { plane: 'mounting-plane' } },
75
- { kind: 'attach', part: 'port', face: 'back', to: { part: 'body', face: 'front' } },
76
- { kind: 'fit-room', placement: 'floor' }
77
- ] as any)
148
+ const draft = mixerDraft()
78
149
  const result = await proposeFigureSteps({ client, prompt: 'make the port twice as wide and give it an accent colour', base: draft })
79
150
 
80
- const ids = result.source.document.model.nodes.map((n: any) => n.id)
81
- expect(ids.some((id: string) => id.includes('body'))).toBe(true)
82
- expect(ids.some((id: string) => id.includes('port'))).toBe(true)
151
+ expect(portRevisionMisses(draft, result.source)).toEqual([])
83
152
  // eslint-disable-next-line no-console
84
153
  console.log(`[figure-llm-smoke] revise: ${JSON.stringify(result.steps)}`)
85
154
  },