cozyclay 3.1.1 → 3.1.2

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 (110) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/bin/agent/agent-routes.mjs +4 -4
  3. package/bin/agent/main-agent.mjs +21 -7
  4. package/bin/agent/main-prompt.mjs +7 -6
  5. package/bin/agent/script-tool.mjs +2 -1
  6. package/bin/agent/studio-prompt.mjs +1 -1
  7. package/bin/agent/studio-rebase.mjs +5 -4
  8. package/bin/agent/studio-task-routes.mjs +1 -1
  9. package/bin/agent/studio-tasks.mjs +116 -25
  10. package/bin/agent/studio-tools.mjs +3 -2
  11. package/bin/cozyclay.mjs +4 -2
  12. package/bin/live-endpoint.mjs +2 -0
  13. package/dist/app/index.html +6 -6
  14. package/dist/assets/TaskBoard-GR8xZm5c.js +1 -0
  15. package/dist/assets/{app-BlwOpest.css → app-Bhe0TpGz.css} +1 -1
  16. package/dist/assets/app-C4QTcDcb.js +5144 -0
  17. package/dist/assets/compile-worker-oy_P9HHh.js +6 -0
  18. package/dist/assets/motion3-compile-dgGhrvPK.js +1 -0
  19. package/dist/assets/scene-asset-sync-BEjjhNEo.css +1 -0
  20. package/dist/assets/scene-asset-sync-qoxcdrhj.js +13 -0
  21. package/dist/assets/task-board-BqReHzOi.js +15 -0
  22. package/dist/assets/workflow-C87p6E1E.js +179 -0
  23. package/dist/cozyclay-package.json +1 -1
  24. package/dist/index.html +1 -1
  25. package/dist/sitemap.xml +1 -1
  26. package/dist/workflow/index.html +4 -4
  27. package/mcp/README.md +1 -1
  28. package/mcp/live-hub.mjs +5 -3
  29. package/mcp/tool-handlers.mjs +5 -5
  30. package/package.json +1 -1
  31. package/src/App.jsx +42 -64
  32. package/src/bus-task-scopes.js +59 -11
  33. package/src/bus-task-targets.js +35 -9
  34. package/src/bus-tasks.js +23 -10
  35. package/src/command-bus.js +7 -4
  36. package/src/commands/ai.js +1 -1
  37. package/src/commands/export.js +1 -1
  38. package/src/commands/motion3.js +27 -7
  39. package/src/export-workspace.js +244 -0
  40. package/src/keyframe-pack.js +5 -1
  41. package/src/live-control.js +14 -7
  42. package/src/motion3/compile-job.js +15 -3
  43. package/src/motion3/compiler/channels.js +74 -24
  44. package/src/motion3/compiler/compile.js +2 -1
  45. package/src/motion3/compiler/idle.js +2 -0
  46. package/src/motion3/compiler/overlap.js +180 -0
  47. package/src/motion3/compiler/pose.js +6 -4
  48. package/src/motion3/compiler/validate-common.js +1 -0
  49. package/src/motion3/compiler/validate-parts.js +5 -1
  50. package/src/motion3/compiler/validate.js +30 -10
  51. package/src/motion3/correct.js +236 -0
  52. package/src/motion3/docs.js +8 -6
  53. package/src/motion3/face-lean.js +31 -0
  54. package/src/motion3/fix/index.js +5 -1
  55. package/src/motion3/fix/reach-body.js +172 -0
  56. package/src/motion3/hint-check.js +44 -0
  57. package/src/motion3/json-patch.js +120 -12
  58. package/src/motion3/lenient.js +46 -4
  59. package/src/motion3/object-keys.js +17 -5
  60. package/src/motion3/pipeline.js +46 -9
  61. package/src/motion3/qa/arm-checks.js +96 -0
  62. package/src/motion3/qa/asked.js +113 -0
  63. package/src/motion3/qa/goals.js +37 -8
  64. package/src/motion3/qa/hints-goal.js +6 -5
  65. package/src/motion3/qa/index.js +5 -2
  66. package/src/motion3/qa/key-checks.js +4 -3
  67. package/src/motion3/qa/motion-life.js +243 -0
  68. package/src/motion3/qa/spec-checks.js +15 -9
  69. package/src/motion3/qa/verdict.js +50 -0
  70. package/src/motion3/refs.js +38 -14
  71. package/src/motion3/repair-watch.js +132 -0
  72. package/src/motion3/repair.js +52 -19
  73. package/src/motion3/report.js +50 -9
  74. package/src/motion3/schema/topics.js +6 -4
  75. package/src/motion3/schema/types.js +1 -1
  76. package/src/motion3/skills/basic.js +15 -8
  77. package/src/motion3/skills/index.js +52 -18
  78. package/src/motion3/skills/info.js +1 -1
  79. package/src/motion3/skills/stance.js +6 -0
  80. package/src/motion3/skills/walk-fit.js +170 -0
  81. package/src/panels/PoseImagePanel.jsx +3 -2
  82. package/src/panels/SculptPanel.jsx +2 -2
  83. package/src/result-modal.jsx +3 -3
  84. package/src/shell/ExportFolderPreference.jsx +103 -0
  85. package/src/shell/PreferencesDialog.jsx +3 -166
  86. package/src/shell/StatusBar.jsx +1 -1
  87. package/src/shell/preference-controls.jsx +114 -0
  88. package/src/shell/preference-pages.jsx +72 -0
  89. package/src/shell/preferences.css +46 -0
  90. package/src/shot-prompt.js +29 -9
  91. package/src/studio-actions.js +2 -2
  92. package/src/studio-agent-motion.js +19 -9
  93. package/src/studio-agent-protocol.js +1 -1
  94. package/src/studio-app-binding.js +3 -2
  95. package/src/workflow/AgentPanel.jsx +2 -2
  96. package/src/workflow/CozySceneNode.jsx +1 -1
  97. package/src/workflow/WorkflowBuilder.jsx +2 -2
  98. package/src/workflow/keyframe-pack-request.js +3 -10
  99. package/src/workflow/task-board.css +1 -1
  100. package/src/workflow/task-board.js +7 -5
  101. package/tools/loc-baseline.json +1 -2
  102. package/tools/run-tests.mjs +5 -5
  103. package/dist/assets/TaskBoard-ngdqiu7u.js +0 -1
  104. package/dist/assets/app-DmR7jBkz.js +0 -5144
  105. package/dist/assets/compile-worker-D0pKioa0.js +0 -5
  106. package/dist/assets/motion3-compile-BB3_1-EB.js +0 -1
  107. package/dist/assets/scene-asset-sync-CBx7XRti.css +0 -1
  108. package/dist/assets/scene-asset-sync-Cgdtr1Yw.js +0 -13
  109. package/dist/assets/task-board-ErQvfoaz.js +0 -14
  110. package/dist/assets/workflow-zQM-EKW1.js +0 -179
@@ -0,0 +1,236 @@
1
+ // C21. Corrections: slips in a sent spec that have one reading, fixed before the spec is stored, each named
2
+ // in output.adjusted ("corrected: ...") so the compile goes on instead of being refused. A name is corrected
3
+ // only when exactly one known name matches it once case, "_", "-" and spaces are ignored (spinePitch ->
4
+ // spine_pitch, walk_to -> walkTo), when it is a plural or a singular of one (goal -> goals), or by the short
5
+ // alias lists below (arm_swing_l -> aswing_l, pos -> p on a world anchor), and the right name is not there
6
+ // already. Anything with two readings stays for the validator to refuse with every problem listed.
7
+ // spec fields, use[i] skill names and fields, use[i].args names (an arg of the skill written beside args
8
+ // moves into it, args.f0 moves out), key channels and finger presets, object key and fall fields, goal,
9
+ // fit and check entry fields, anchor fields
10
+ // generators[i] with to, arrive, a single heading or type "walkTo" -> a walkTo use entry (the walk that
11
+ // heads for a point), placed in f0 order among the use entries
12
+ // objects.<id> written as { free: {...} } (or carry, hinge, follow) -> the record itself
13
+ // fitWalks: a walkTo with to and steps gets the stride, or the step count and cadence, that ends the steps at
14
+ // to in a natural gait (skills/basic.js fitTo), stored with the spec; one it cannot fit is refused with what the
15
+ // walk would do and needs
16
+ import { SPEC_FIELDS, USE_FIELDS, OBJECT_KEY_FIELDS, FALL_FIELDS, GOAL_FIELDS, FIT_FIELDS, ANCHOR_FIELDS, CHECK_ENTRY_FIELDS, OBJECT_FIELDS } from "./schema/fields.js";
17
+ import { VEC_CHANNELS, NUM_CHANNELS, MAP_CHANNELS, FINGER_PRESETS } from "./compiler/vocab.js";
18
+ import { SKILL_INFO } from "./skills/info.js";
19
+ import { SKILL_NAMES, expandUse } from "./skills/index.js";
20
+ import { customChannels } from "./compiler/validate.js";
21
+
22
+ const plain = v => Boolean(v) && typeof v === "object" && !Array.isArray(v);
23
+ const squash = s => String(s).toLowerCase().replace(/[\s_-]/g, "");
24
+ /** Sided names: left / right / L / R at the end read as _l / _r. */
25
+ const sided = s => String(s).replace(/[_-]?(left|Left|LEFT)$/, "_l").replace(/[_-]?(right|Right|RIGHT)$/, "_r").replace(/[_-]?L$/, "_l").replace(/[_-]?R$/, "_r");
26
+ const CHANNEL_ALIASES = { arm_swing: "aswing", armswing: "aswing", elbow_bend: "abend", arm_bend: "abend", wrist_twist: "wtwist", wrist_flex: "wflex",
27
+ foot_yaw: "fyaw", foot_pitch: "fpitch", arm_weight: "armw", ik_weight: "armw", hips: "root", pelvis: "root", finger: "fingers", hand_pole: "elbow", knee_pole: "knee" };
28
+ const FIELD_ALIASES = {
29
+ objectKey: { position: "pos", rotation: "rot", quat: "rot", quaternion: "rot", frame: "f" },
30
+ anchor: { pos: "p", position: "p", point: "p", id: "object", joint: "bone" },
31
+ use: { frame: "f0", f: "f0" },
32
+ };
33
+
34
+ /** The one name of `names` that `name` stands for, or null: alias, case and separators, plural or singular. */
35
+ function nameFor(name, names, aliases = {}) {
36
+ if (names.includes(name)) return name;
37
+ const alias = aliases[name] ?? aliases[squash(name)];
38
+ if (alias && names.includes(alias)) return alias;
39
+ const hits = new Set(names.filter(n => squash(n) === squash(name) || n === `${name}s` || `${n}s` === name));
40
+ return hits.size === 1 ? [...hits][0] : null;
41
+ }
42
+
43
+ /** obj with each unknown field that stands for exactly one free known field renamed; one note per rename. */
44
+ function renameFields(obj, fields, at, notes, aliases) {
45
+ if (!plain(obj)) return obj;
46
+ let out = obj;
47
+ for (const f of Object.keys(obj)) {
48
+ const to = fields.includes(f) ? null : nameFor(f, fields, aliases);
49
+ if (!to || to in out) continue;
50
+ out = Object.fromEntries(Object.entries(out).map(([k, v]) => (k === f ? [to, v] : [k, v])));
51
+ notes.push(`corrected: ${at}.${f} -> ${at}.${to}`);
52
+ }
53
+ return out;
54
+ }
55
+
56
+ const mapList = (list, fn) => (Array.isArray(list) ? list.map(fn) : list);
57
+
58
+ /** A generator that is a walkTo: it heads for a point, plans to arrive, turns to one heading, or says so. */
59
+ const isWalkTo = g => plain(g) && (g.to !== undefined || g.arrive !== undefined || typeof g.heading === "number" || nameFor(String(g.type), ["walkTo"]) === "walkTo");
60
+
61
+ function moveGenerators(spec, notes) {
62
+ if (!Array.isArray(spec.generators) || !spec.generators.some(isWalkTo)) return spec;
63
+ const use = Array.isArray(spec.use) ? [...spec.use] : [], kept = [];
64
+ spec.generators.forEach((g, i) => {
65
+ if (!isWalkTo(g)) return kept.push(g);
66
+ const { type, f0, ...args } = g, at = use.findIndex(u => plain(u) && Number.isFinite(u.f0) && u.f0 > f0);
67
+ const j = at < 0 ? use.length : at;
68
+ use.splice(j, 0, { skill: "walkTo", f0, args });
69
+ const why = g.to !== undefined ? "to" : g.arrive !== undefined ? "arrive" : typeof g.heading === "number" ? "a single heading" : `type "${type}"`;
70
+ notes.push(`corrected: generators[${i}] (a walk with ${why}) -> use[${j}] walkTo { ${Object.keys(args).join(", ")} }: a generator walks by steps and [h0, h1]; walkTo is the walk that heads for a point`);
71
+ });
72
+ const { generators, ...rest } = spec;
73
+ return { ...rest, ...(kept.length ? { generators: kept } : {}), use };
74
+ }
75
+
76
+ function fixUse(u, i, notes) {
77
+ if (!plain(u)) return u;
78
+ let out = u;
79
+ const skill = SKILL_NAMES.includes(u.skill) ? u.skill : nameFor(String(u.skill), SKILL_NAMES);
80
+ if (skill && skill !== u.skill) { out = { ...out, skill }; notes.push(`corrected: use[${i}].skill "${u.skill}" -> "${skill}"`); }
81
+ const info = SKILL_INFO[out.skill];
82
+ if (!info) return out;
83
+ const known = Object.keys(info.args);
84
+ if (plain(out.args) || out.args === undefined) {
85
+ const given = out.args ?? {};
86
+ let args = renameFields(given, known, `use[${i}].args`, notes);
87
+ // an enum arg written another way: "left" -> "l", "Back" -> "back"
88
+ for (const [a, v] of Object.entries(args)) {
89
+ const choices = info.args[a]?.enum, to = choices && typeof v === "string" && !choices.includes(v) ? nameFor(v, choices, { left: "l", right: "r" }) : null;
90
+ if (to) { args = { ...args, [a]: to }; notes.push(`corrected: use[${i}].args.${a} "${v}" -> "${to}"`); }
91
+ }
92
+ if (args.f0 !== undefined && !known.includes("f0") && out.f0 === undefined) {
93
+ const { f0, ...rest } = args;
94
+ args = rest; out = { ...out, f0 };
95
+ notes.push(`corrected: use[${i}].args.f0 -> use[${i}].f0`);
96
+ }
97
+ // an arg of the skill written beside args goes into args
98
+ for (const f of Object.keys(out)) {
99
+ const a = USE_FIELDS.includes(f) ? null : nameFor(f, known);
100
+ if (!a || a in args) continue;
101
+ args = { ...args, [a]: out[f] };
102
+ out = Object.fromEntries(Object.entries(out).filter(([k]) => k !== f));
103
+ notes.push(`corrected: use[${i}].${f} -> use[${i}].args.${a}`);
104
+ }
105
+ // an anchor a skill defines (reachGrip define) is an anchor like any other
106
+ if (plain(args.define) && ANCHOR_FIELDS[args.define.kind]) {
107
+ const define = renameFields(args.define, ["kind", ...ANCHOR_FIELDS[args.define.kind]], `use[${i}].args.define`, notes, FIELD_ALIASES.anchor);
108
+ if (define !== args.define) args = { ...args, define };
109
+ }
110
+ if (args !== given) out = { ...out, args };
111
+ }
112
+ return renameFields(out, USE_FIELDS, `use[${i}]`, notes, FIELD_ALIASES.use);
113
+ }
114
+
115
+ /** The body's channels and the spec's own (spec.channels, object and check channels, validate.js). */
116
+ const knownChannels = spec => [...VEC_CHANNELS, ...NUM_CHANNELS, ...MAP_CHANNELS, ...customChannels(spec)];
117
+
118
+ function channelFor(ch, known) {
119
+ const s = sided(ch), side = /_[lr]$/.exec(s)?.[0] ?? "", family = side ? s.slice(0, -2) : s;
120
+ const alias = CHANNEL_ALIASES[family] ?? CHANNEL_ALIASES[squash(family)];
121
+ if (alias && known.includes(alias + side)) return alias + side;
122
+ return nameFor(s, known);
123
+ }
124
+
125
+ function fixKey(key, i, known, notes) {
126
+ if (!plain(key) || !plain(key.set)) return key;
127
+ let set = key.set, step = key.step;
128
+ for (const ch of Object.keys(key.set)) {
129
+ if (known.includes(ch) || ch.endsWith("_vis") || ch.startsWith("w_")) continue;
130
+ const to = channelFor(ch, known);
131
+ if (!to || to in set) continue;
132
+ set = Object.fromEntries(Object.entries(set).map(([k, v]) => (k === ch ? [to, v] : [k, v])));
133
+ if (Array.isArray(step)) step = step.map(s => (s === ch ? to : s));
134
+ notes.push(`corrected: keys[${i}] (f${key.f}) channel "${ch}" -> "${to}"`);
135
+ }
136
+ for (const side of ["l", "r"]) {
137
+ const map = set[`fingers_${side}`];
138
+ if (!plain(map)) continue;
139
+ const fixed = renameFields(map, FINGER_PRESETS, `keys[${i}] (f${key.f}) fingers_${side}`, notes);
140
+ if (fixed !== map) set = { ...set, [`fingers_${side}`]: fixed };
141
+ }
142
+ return set === key.set ? key : { ...key, set, ...(step ? { step } : {}) };
143
+ }
144
+
145
+ /** objects.<id>: { free: { keys } } -> { keys }: a kind name used as a wrapper around the record's own fields. */
146
+ function unwrapObject(id, o, notes) {
147
+ if (!plain(o)) return o;
148
+ const wrap = Object.keys(o).filter(k => ["free", "carry", "hinge", "follow", "switch"].includes(k) && plain(o[k]));
149
+ if (wrap.length !== 1) return o;
150
+ const { [wrap[0]]: inner, ...rest } = o;
151
+ if (Object.keys(inner).some(k => k in rest)) return o;
152
+ notes.push(`corrected: objects.${id}.${wrap[0]} { ${Object.keys(inner).join(", ")} } -> objects.${id} { ${Object.keys(inner).join(", ")} }: an entry's kind is read from its fields, not named`);
153
+ return { ...rest, ...inner };
154
+ }
155
+
156
+ function fixObject(id, o, notes) {
157
+ const at = `objects.${id}`, rec = unwrapObject(id, o, notes);
158
+ if (!plain(rec)) return rec;
159
+ const all = [...new Set(Object.values(OBJECT_FIELDS).flat())];
160
+ let out = renameFields(rec, all, at, notes);
161
+ if (Array.isArray(out.keys)) out = { ...out, keys: out.keys.map((k, j) => renameFields(k, OBJECT_KEY_FIELDS, `${at}.keys[${j}]`, notes, FIELD_ALIASES.objectKey)) };
162
+ if (plain(out.fall)) out = { ...out, fall: renameFields(out.fall, FALL_FIELDS, `${at}.fall`, notes) };
163
+ else if (Array.isArray(out.fall)) out = { ...out, fall: out.fall.map((fl, j) => renameFields(fl, FALL_FIELDS, `${at}.fall[${j}]`, notes)) };
164
+ return out;
165
+ }
166
+
167
+ const fixGoal = (g, i, notes) => renameFields(g, GOAL_FIELDS, `goals[${i}]`, notes);
168
+
169
+ function fixAnchors(anchors, notes) {
170
+ if (!plain(anchors)) return anchors;
171
+ return Object.fromEntries(Object.entries(anchors).map(([name, a]) => [name, plain(a) && ANCHOR_FIELDS[a.kind]
172
+ ? renameFields(a, ["kind", ...ANCHOR_FIELDS[a.kind]], `anchors.${name}`, notes, FIELD_ALIASES.anchor) : a]));
173
+ }
174
+
175
+ function fixChecks(checks, notes) {
176
+ if (!plain(checks)) return checks;
177
+ return Object.fromEntries(Object.entries(checks).map(([name, list]) => [name, CHECK_ENTRY_FIELDS[name] ? mapList(list, (k, j) => renameFields(k, CHECK_ENTRY_FIELDS[name], `checks.${name}[${j}]`, notes)) : list]));
178
+ }
179
+
180
+ /**
181
+ * correctSpec(spec, notes) -> the spec with every correction above (a new object when one applied,
182
+ * else spec itself); notes gets one "corrected: ..." line per correction. Run before the spec is stored (pipeline.js).
183
+ */
184
+ function correctSpec(spec, notes = []) {
185
+ if (!plain(spec)) return spec;
186
+ const before = notes.length;
187
+ let s = renameFields(spec, [...SPEC_FIELDS, "probes"], "spec", notes);
188
+ s = moveGenerators(s, notes);
189
+ if (Array.isArray(s.use)) s = { ...s, use: s.use.map((u, i) => fixUse(u, i, notes)) };
190
+ if (s.anchors) s = { ...s, anchors: fixAnchors(s.anchors, notes) };
191
+ if (Array.isArray(s.keys)) { const known = knownChannels(s); s = { ...s, keys: s.keys.map((k, i) => fixKey(k, i, known, notes)) }; }
192
+ if (plain(s.objects)) s = { ...s, objects: Object.fromEntries(Object.entries(s.objects).map(([id, o]) => [id, fixObject(id, o, notes)])) };
193
+ if (Array.isArray(s.goals)) s = { ...s, goals: s.goals.map((g, i) => fixGoal(g, i, notes)) };
194
+ if (Array.isArray(s.fits)) s = { ...s, fits: s.fits.map((f, i) => renameFields(f, FIT_FIELDS, `fits[${i}]`, notes)) };
195
+ if (s.checks) s = { ...s, checks: fixChecks(s.checks, notes) };
196
+ return notes.length > before ? s : spec;
197
+ }
198
+
199
+ /**
200
+ * fitWalks(spec, { sk, scene }, notes, refusals) -> spec with each walkTo's fitted args (skills/basic.js fitTo) written
201
+ * into its use entry, so the stored spec walks where it says and compiles the same again; refusals gets one line per
202
+ * walk it cannot fit (the validator lists them with its own, pipeline.js). A spec that does not expand is left as it is.
203
+ */
204
+ function fitWalks(spec, { sk, scene } = {}, notes = [], refusals = []) {
205
+ if (!plain(spec) || !Array.isArray(spec.use) || !spec.use.some(u => u?.skill === "walkTo" && plain(u.args) && u.args.to !== undefined)) return spec;
206
+ // a walk may end later freely only in a frames "auto" spec with nothing after it, else into frames nothing uses
207
+ // (skills/basic.js fitTo, roomAfter): busy lists the frames of the object keys, grabs and releases, last the take's last frame
208
+ const busy = Object.values(plain(spec.objects) ? spec.objects : {}).flatMap(o => (plain(o) ? [o.grab, o.release, ...(Array.isArray(o.keys) ? o.keys.map(k => k?.f) : [])] : [])).filter(Number.isFinite);
209
+ const expand = noStop => {
210
+ const fit = { refusals: [], stretch: spec.frames === "auto", busy, last: Number.isFinite(spec.frames) ? spec.frames - 1 : Infinity, noStop, stopped: new Set() };
211
+ try { return { used: expandUse({ ...spec, frames: 20000 }, { sk, scene, fit, auto: true }).used, fit }; } catch { return null; }
212
+ };
213
+ // a walk stopped before a body (walk-fit.js) moves where the next one starts: a stop is kept only while the spec has no
214
+ // more refusals than with every walk on its to (bench replay: 3 of 692 specs refused a later walk), the earliest first
215
+ let run = expand(new Set());
216
+ if (!run) return spec;
217
+ if (run.fit.stopped.size) {
218
+ const off = new Set(run.fit.stopped), onTo = expand(off);
219
+ if (onTo && onTo.fit.refusals.length < run.fit.refusals.length) {
220
+ for (const i of [...run.fit.stopped].sort((a, b) => a - b)) {
221
+ off.delete(i);
222
+ const t = expand(off);
223
+ if (!t || t.fit.refusals.length > onTo.fit.refusals.length) off.add(i);
224
+ }
225
+ run = expand(off) ?? onTo;
226
+ }
227
+ }
228
+ const { used, fit } = run;
229
+ refusals.push(...fit.refusals);
230
+ if (!used.some(u => u.adjust)) return spec;
231
+ for (const u of used) if (u.adjust) notes.push(`corrected: ${u.adjust.note}`);
232
+ return { ...spec, use: spec.use.map((u, i) => (used[i]?.adjust ? { ...u, args: { ...u.args, ...used[i].adjust.args } } : u)) };
233
+ }
234
+
235
+ /** prepareSpec(spec, { sk, scene }, notes, refusals): correctSpec, then fitWalks: the spec as it is stored and compiled. */
236
+ export const prepareSpec = (spec, ctx, notes, refusals) => fitWalks(correctSpec(spec, notes), ctx, notes, refusals);
@@ -2,14 +2,16 @@
2
2
  // Lane P owns the wording; the code only reads these constants. The procedure lives in the Studio
3
3
  // system prompt and the field tables in motion.describe, so these strings say only what each action takes and answers.
4
4
  export const MOTION_ACTION_HELP = {
5
- compile: "Bake a spec on the character's rig and run QA. install: true installs (default), false or \"never\" changes nothing, \"ifClean\" only if the final qa.ok; output.installed tells. "
5
+ compile: "Bake a spec on the character's rig and run QA. install: true installs (default), false or \"never\" changes nothing, \"ifClean\" only if the final qa.ok. "
6
6
  + "Send spec, or base (an answered specId) + optional patch (JSON Patch); base alone or patch: [] re-measures (new probes). "
7
- + "spec = { schema: \"cozyclay.motion-spec.v1\", fps: 24, frames (number or \"auto\"), ... } (parts: motion.describe); any number may be { range: [lo, hi] } (searched). "
8
- + "probes (at most 16, request level; spec.probes: measured, not stored): { name, distance: [ref, ref] | position: ref, frames, every? }. "
9
- + "Refs need a prefix (joint: prop: anchor: landmark: object: person:; goals[].moved: a bare id too) and take loose bone names (any case); spec.checks need exact ones. "
7
+ + "Address array items by field, not index: /keys/@f=120/set/hand_l (add creates the key at f120), remove /keys/@f=120, { op: \"move\", from: \"/keys/@f=120\", path: \"/keys/@f=132\" }, "
8
+ + "/use/@skill=reach&f0=40, /goals/@name=<name>, /objects/<id>/keys/@f=40. "
9
+ + "spec = { schema: \"cozyclay.motion-spec.v1\", fps: 24, frames (number or \"auto\"), ... }; any number may be { range: [lo, hi] } (searched). "
10
+ + "probes (at most 16; spec.probes too, never stored): { name, distance: [ref, ref] | position: ref, frames, every? }. "
11
+ + "Refs need a prefix (joint: prop: anchor: landmark: object: person:; goals[].moved: a bare id too) and take loose bone names; spec.checks need exact ones. "
10
12
  + "repair: { rounds } (1-8, default 4) applies group patches while severity drops. "
11
- + "qa judges only what is broken; speed, acceleration, balance, knee bend are observations unless checks.limits names them. "
12
- + "Answers frames, installed, specId, qa { ok, total, goals, groups (GOAL first, then severity; cause, hint, patch?) }, observations, delta (with base), repair, probes, timeline, fixed, search, skills. output.more: motion.report pages the rest.",
13
+ + "qa judges only what is broken; speed, balance and bends are observations unless checks.limits names them. "
14
+ + "Answers brief (qa, misses, top groups: a script returns it as is), frames, installed, stuck (when to stop), specId, qa { ok, total, goals, groups (GOAL first, then severity; cause, hint, repair patch?) }, observations (asked: skill ends, contacts, goal points), delta (with base), repair, probes, timeline, fixed, search, skills. more: motion.report pages the rest.",
13
15
  qa: "Measure the character's installed take again against the scene as it is now (objects may have moved). Optional checks replace the spec's checks and its skills' checks. "
14
16
  + "Answers specId and qa as motion.compile does (goals, ranked groups with cause, hint and patch); output.more pages with motion.report. Changes nothing.",
15
17
  sample: "World positions (metres) of named bones of the installed take at up to 64 frames. Bone names may be loose (any case, a mixamorig: prefix, aliases such as hips or hand_l); an unknown one answers a did-you-mean. "
@@ -0,0 +1,31 @@
1
+ // A held object brought to the face leans by default (lane arm). An aim key at the lips without lean or roll (a cup
2
+ // tipped to the mouth) is held as the aim asks only with the elbow out to the side and over the shoulder: the grip the
3
+ // reach took on the table turns the hand with the cup, and the wrist cannot take the rest (the agents' first compiles of
4
+ // bench S6 and S8: the upper arm past 110 deg from hanging on 31 of 56, the elbow over the head on 17). Leaning the aim
5
+ // toward the hand's side lets the elbow stay down (key-aim.js lean); the agent wrote lean on few of those keys and
6
+ // re-keyed the elbows after QA had passed. So such a key leans FACE_LEAN deg (the most lean takes) unless it gives lean
7
+ // or roll: its top (the rim) within FACE_NEAR m of the mouth at its frame, on the pose without the keys, on a body at
8
+ // most SIP m tall (a cup, a can, a bottle: a bat's knob at the shoulder in a batting stance is not a sip, and a group's
9
+ // height is unknown here); the rim stays where aim put it (leanPos). On the 84 first compiles of bench S6, S8 and S10 the agents wrote, the frames of BODY went
10
+ // from 7231 to 5526, of FACE from 1102 to 839 and of WRIST from 1212 to 1007, and the takes with an elbow over the
11
+ // shoulder (qa/arm-checks.js) from 39 to 31; the mouth goals missed on 694 frames instead of 623 (lean 35 within 0.2 m
12
+ // saved fewer BODY and FACE frames and lost 46 mouth frames).
13
+ import { add, scale, norm, dist } from "./compiler/math.js";
14
+
15
+ export const FACE_LEAN = 45;
16
+ const FACE_NEAR = 0.25, SIP = 0.3;
17
+ /**
18
+ * Entry o of a body `top` m tall (0: a group) with FACE_LEAN on its aim keys (no lean, no roll) whose top (`top` m up
19
+ * the aim from pos) is within FACE_NEAR of the mouth at their frame; mouthAt(f) the mouth on the pose without the
20
+ * keys. o itself when none is, or the body is no sip (SIP).
21
+ */
22
+ export function faceLeaned(o, top, mouthAt) {
23
+ if (!(top > 0 && top <= SIP)) return o;
24
+ let any = false;
25
+ const keys = o.keys.map(k => {
26
+ if (!k.aim || k.lean !== undefined || k.roll !== undefined || !(dist(add(k.pos, scale(norm(k.aim), top)), mouthAt(k.f)) < FACE_NEAR)) return k;
27
+ any = true;
28
+ return { ...k, lean: FACE_LEAN, leaned: true };
29
+ });
30
+ return any ? { ...o, keys } : o;
31
+ }
@@ -4,7 +4,9 @@
4
4
  //
5
5
  // spec.fix = { reach: true, slide: true, penetration: 0.02, self: true, retime: false } (false = all off)
6
6
  // reach wrist targets past the arm's reach go onto it through a soft knee (no snap);
7
- // ankle targets past the leg's reach lower the hips just enough, eased in and out
7
+ // ankle targets past the leg's reach lower the hips just enough, eased in and out; a hand
8
+ // on a pinned target (a world or a scene object's anchor) that the straight arm misses brings
9
+ // the body to it: lean, hip shift, lower hips, the feet kept (reach-body.js, fixed.reachBody)
8
10
  // slide a sole under the floor lifts its ankle onto it (ground.js); a planted heel or ball
9
11
  // that drifts less than 1 cm stays put, and a foot turning on its planted ball (heel
10
12
  // up) or heel pivots about it whatever the drift; a flat planted foot (heel and ball
@@ -19,6 +21,7 @@
19
21
  // grasp a holding hand's fingers close on what it holds and the object rests in the hand (#816);
20
22
  // false keeps the preset grip and the grab-frame offset
21
23
  import { lowerHips, armReach } from "./reach.js";
24
+ import { reachBody } from "./reach-body.js";
22
25
  import { lockFeet } from "./slide.js";
23
26
  import { holdTurns } from "./turn.js";
24
27
  import { liftSoles } from "./ground.js";
@@ -46,6 +49,7 @@ export function applyFixes(out, fix, engine, { whole = true } = {}) {
46
49
  if (fix.retime && whole) put("retime", retime(out, engine));
47
50
  if (fix.slide) put("ground", liftSoles(out, engine));
48
51
  if (fix.reach) put("reachLegs", lowerHips(out, engine));
52
+ if (fix.reach) put("reachBody", reachBody(out, engine));
49
53
  if (fix.slide) put("slide", withTurns(holdTurns(out, engine), lockFeet(out, engine)));
50
54
  // a pivot can hold a foot past the leg's reach: the hips go down for those frames too
51
55
  if (fix.reach && fixed.slide?.pivots) put("reachLegs", mergeReach(fixed.reachLegs, lowerHips(out, engine)));
@@ -0,0 +1,172 @@
1
+ // Reach by the body (spec.fix.reach): a hand on a pinned target (a world anchor, or an anchor on a scene object no
2
+ // hand carries, at half weight or more) that the straight arm misses by more than QA's reach limit brings the body to
3
+ // it instead of leaving REACH to the agent. Over each run of such frames the body leans toward the target (spine_pitch
4
+ // forward, only for a target ahead and not above the shoulder), the hips shift toward it and, for a low target, go
5
+ // down: one amount t in (0, 1] of LEAN deg, SHIFT m and DROP m, the least that brings the hand within GOOD m at the
6
+ // run's worst frame (one the hand is fully on) with the elbow still bent (the target within BENT of the arm's reach),
7
+ // eased in and out over EASE frames and following the weights that take the hand there (armw and the anchor's). The feet stay on their keyed targets (the
8
+ // legs' IK takes the hips; fix.slide holds them after), so a contact keyed elsewhere does not move: a t is refused
9
+ // when it puts a leg past its reach, takes the other hand, on its own target, further off it, or puts the body deeper
10
+ // into the scene's bodies (QA's capsules against the boxes there: a lean into the machine the hand reaches into). The
11
+ // whole move is undone when it adds a one-frame pop (QA's own detector) where it eases in or out, or when the misses
12
+ // over the run do not drop. A run goes on for up to EASE frames while the hand stays short (it takes the object from
13
+ // there). One pass, each run once, a run's amount scaled down by what an earlier run already added at its worst frame
14
+ // (both hands on one box), so it cannot cascade. Reported as fixed.reachBody: per run the side, frames, lean (deg),
15
+ // shift [x, z] (m), drop (m) and the worst miss where the hand is fully on, before -> after (the larger of a fit's
16
+ // effector to the target and the wrist's). Sessions: REACH arm_miss_l 66 times, the agent moving the root by hand.
17
+ import { sub, len, norm, dot } from "../compiler/math.js";
18
+ import { SIDES } from "../compiler/pose.js";
19
+ import { resolveAnchor, armReach } from "../compiler/anchors.js";
20
+ import { round4 } from "./ease.js";
21
+ import { CAPSULES, segPoints, sdBox } from "../qa/geometry.js";
22
+ import { QA_LIMITS } from "../qa/checks.js";
23
+ import { popChecks } from "../qa/style.js";
24
+
25
+ const GOOD = 0.01, BENT = 0.97, LEAN = 25, SHIFT = 0.08, DROP = 0.15, EASE = 10, GAP = 2, SLACK = 0.002, TRIES = [0.25, 0.5, 0.75, 1];
26
+ const smooth = x => x * x * (3 - 2 * x);
27
+
28
+ /** The anchor that leads hand k at frame f when it is pinned (world, or a scene object no hand carries), else null. */
29
+ function pinned(ctx, c, k, f) {
30
+ if (!(c[`armw_${k}`] >= 0.5)) return null;
31
+ let best = null;
32
+ for (const [ch, w] of Object.entries(c)) {
33
+ const m = /^w_(.+)_([lr])$/.exec(ch);
34
+ if (m && m[2] === k && w >= 0.5 && (!best || w > best.w)) best = { w, name: m[1] };
35
+ }
36
+ const a = best && ctx.spec.anchors?.[best.name];
37
+ return a && (a.kind === "world" || (a.kind === "object" && !ctx.objectHand?.(a.object, f))) ? best.name : null;
38
+ }
39
+
40
+ /** How deep the body is in the scene at frame f (the sum over QA's capsules), m; the reaching hand against the object it reaches for is left out. */
41
+ function depthIn(out, f, k, object) {
42
+ const { s } = out.frames[f - out.f0], I = out.ctx.sk.index, scene = out.ctx.scene?.objects ?? {};
43
+ const boxes = Object.entries(out.objectsAt(f - out.f0) ?? {}).filter(([, b]) => b.half && !b.ignore);
44
+ const target = id => object !== undefined && (id === object || scene[id]?.ancestors?.includes(object));
45
+ let sum = 0;
46
+ for (const [part, a, b, r] of CAPSULES) {
47
+ if (I[a] === undefined || I[b] === undefined) continue;
48
+ const pts = segPoints(s.P[I[a]], s.P[I[b]], 5);
49
+ for (const [id, box] of boxes) if (!(part === `hand_${k}` && target(id))) sum += Math.max(0, r - Math.min(...pts.map(p => sdBox(p, box))));
50
+ }
51
+ return sum;
52
+ }
53
+
54
+ /** QA's one-frame pops (qa/style.js popChecks) over a run of frames: their summed excess past the floor. */
55
+ function popsIn(sk, frames, L) {
56
+ let sum = 0;
57
+ popChecks(sk, { frames }, L, (kind, msg, f, value, limit) => { sum += value - limit; });
58
+ return sum;
59
+ }
60
+
61
+ /** The point a fit's effector names: a prop point, or the joints' middle. */
62
+ function effector(sk, s, diag, eff) {
63
+ if (eff?.prop) return diag.props?.[eff.prop]?.[eff.point] ?? null;
64
+ const js = eff?.joints ?? ["RightHandIndex2", "RightHandMiddle2"], ps = js.map(j => s.P[sk.index[j]]).filter(Boolean);
65
+ return ps.length ? ps.reduce((m, p) => m.map((x, i) => x + p[i] / ps.length), [0, 0, 0]) : null;
66
+ }
67
+
68
+ /** Runs [a, b] (absolute frames) where hand k misses a pinned target by more than limit (gaps of GAP frames merge). */
69
+ function runsOf(out, k, limit) {
70
+ const runs = [];
71
+ out.frames.forEach((fr, i) => {
72
+ const f = out.f0 + i;
73
+ if (!(fr.diag[`arm_miss_${k}`] > limit) || fr.diag[`arm_fold_${k}`] || !pinned(out.ctx, fr.c, k, f)) return;
74
+ const last = runs.at(-1);
75
+ if (last && f - last[1] <= GAP + 1) last[1] = f; else runs.push([f, f]);
76
+ });
77
+ // the hand is still short right after (it takes the object from there): the run goes on, up to EASE frames more
78
+ const short = f => f >= out.f0 && f < out.f0 + out.frames.length && out.frames[f - out.f0].diag[`arm_miss_${k}`] > limit && !out.frames[f - out.f0].diag[`arm_fold_${k}`];
79
+ for (const r of runs) for (let i = 0; i < EASE && short(r[1] + 1); i++) r[1] += 1;
80
+ return runs;
81
+ }
82
+
83
+ export function reachBody(out, engine) {
84
+ const { ctx } = out, { sk } = ctx, I = sk.index, n = out.frameCount, baked = out.baked;
85
+ const limit = ctx.spec.checks?.limits?.reach ?? 0.01, at = f => out.frames[f - out.f0];
86
+ const lo = out.f0, hi = out.f0 + out.frames.length - 1, report = [];
87
+ // what this fix added per frame, so overlapping runs (both hands) stay within one full amount
88
+ const added = { pitch: new Float64Array(n), move: new Float64Array(n) }, made = [];
89
+ for (const k of ["r", "l"]) for (const [a, b] of runsOf(out, k, limit)) {
90
+ // the target is the anchor's point (a fit winds the wrist target past it, so the miss does not say where it is);
91
+ // the run's worst frame is where it is farthest from the shoulder
92
+ const sh = f => at(f).s.P[I[`${SIDES[k]}Arm`]], target = f => { const { s, diag, c } = at(f), name = pinned(ctx, c, k, f); return name ? resolveAnchor(ctx, name, s, { ...diag, frame: f }, c) : null; };
93
+ // measured where the hand is fully on it (armw and the anchor's weight at 1) when the run has such frames: before, it is still on its way
94
+ const full = f => at(f).c[`armw_${k}`] >= 0.99 && at(f).c[`w_${pinned(ctx, at(f).c, k, f)}_${k}`] >= 0.99;
95
+ const span = Array.from({ length: b - a + 1 }, (_, i) => a + i), pick = span.some(full) ? span.filter(full) : span;
96
+ let fw = -1, T = null;
97
+ for (const f of pick) { const q = target(f); if (q && (!T || len(sub(q, sh(f))) > len(sub(T, sh(fw))))) { fw = f; T = q; } }
98
+ if (!T) continue;
99
+ const { s, diag, c } = at(fw), other = k === "l" ? "r" : "l";
100
+ const fit = (ctx.spec.fits ?? []).find(ft => ft.side === k && ft.anchor === pinned(ctx, c, k, fw)), eff = fit && effector(sk, s, diag, fit.effector);
101
+ const miss0 = Math.max(eff ? len(sub(eff, T)) : 0, diag[`arm_miss_${k}`] ?? 0);
102
+ // what the arm reaches with: the straight arm, and the effector's offset from the wrist when a fit names one
103
+ const reach = armReach(sk, k) + (eff ? len(sub(eff, s.P[I[`${SIDES[k]}Hand`]])) : 0);
104
+ const S = sh(fw), yaw = (c.yaw ?? 0) * Math.PI / 180, fwd = [Math.sin(yaw), 0, Math.cos(yaw)];
105
+ const flat = [T[0] - S[0], 0, T[2] - S[2]], hd = len(flat) > 1e-3 ? norm(flat) : fwd;
106
+ const lean = dot(hd, fwd) > 0.3 && T[1] < S[1] + 0.1 ? LEAN : 0, drop = DROP * Math.min(1, Math.max(0, (S[1] - T[1] - 0.2) / 0.4));
107
+ const free = Math.max(0, 1 - Math.max(added.pitch[fw] / LEAN, added.move[fw] / SHIFT));
108
+ if (!free) continue;
109
+ if (!baked.root) { baked.root = Array.from({ length: n }, () => sk.restWorldPos[I.Hips].slice()); made.push("root"); }
110
+ if (!baked.spine_pitch) { baked.spine_pitch = new Array(n).fill(0); made.push("spine_pitch"); }
111
+ const root0 = baked.root.map(r => r.slice()), pitch0 = baked.spine_pitch.slice();
112
+ const set = (f, t) => {
113
+ baked.root[f] = [root0[f][0] + hd[0] * SHIFT * t, root0[f][1] - drop * t, root0[f][2] + hd[2] * SHIFT * t];
114
+ baked.spine_pitch[f] = pitch0[f] + lean * t;
115
+ };
116
+ const legOk = (d, d0) => ["l", "r"].every(q => !((d[`leg_miss_${q}`] ?? 0) > Math.max(d0[`leg_miss_${q}`] ?? 0, 5e-4) + SLACK));
117
+ const otherOk = (d, d0) => !(at(fw).c[`armw_${other}`] > 0.5) || (d[`arm_miss_${other}`] ?? 0) <= (d0[`arm_miss_${other}`] ?? 0) + SLACK;
118
+ const object = ctx.spec.anchors[pinned(ctx, c, k, fw)]?.object, saved = at(fw), depth0 = depthIn(out, fw, k, object);
119
+ const trial = t => {
120
+ set(fw, t);
121
+ engine.solve([fw]);
122
+ // enough: the target within BENT of the arm's reach, so the elbow stays a little bent (a straight arm pops as it locks)
123
+ // the miss: a fit's effector to the target (the wrist reaches a soft-clamped target, fix.reach), else the wrist's
124
+ // (and the wrist's own miss too: a fit's target wound past a straight arm is what QA reads as REACH)
125
+ const { s: s1, diag: d } = at(fw), e1 = fit && effector(sk, s1, d, fit.effector), miss = Math.max(e1 ? len(sub(e1, T)) : 0, d[`arm_miss_${k}`] ?? 0);
126
+ const r = { t, miss, ok: legOk(d, diag) && otherOk(d, diag) && depthIn(out, fw, k, object) <= depth0 + SLACK, bent: len(sub(T, sh(fw))) <= BENT * reach };
127
+ baked.root[fw] = root0[fw]; baked.spine_pitch[fw] = pitch0[fw];
128
+ out.frames[fw - out.f0] = saved;
129
+ return r;
130
+ };
131
+ let best = null, prev = 0;
132
+ for (const t of TRIES.map(x => x * free)) {
133
+ const r = trial(t);
134
+ if (!r.ok) break;
135
+ if (!best || r.miss < best.miss) best = r;
136
+ if (r.miss <= GOOD && r.bent) {
137
+ // the least amount that is enough
138
+ let x = prev, y = t;
139
+ for (let it = 0; it < 4; it++) { const m = trial((x + y) / 2); if (m.ok && m.miss <= GOOD && m.bent) { y = m.t; best = m; } else x = m.t; }
140
+ break;
141
+ }
142
+ prev = t;
143
+ }
144
+ if (!best || !(best.miss < miss0 - GOOD)) continue;
145
+ // the body comes as the hand does, by the weights that take it to the target (the reach's own timing: a torso leaning
146
+ // over an arm still hanging swings it into the hips, and one leaning early changes the reach's speed profile)
147
+ const w01 = (ch, f) => Math.min(1, Math.max(0, baked[ch]?.[f] ?? 1)), name = pinned(ctx, c, k, fw);
148
+ const touched = [], weightAt = f => w01(`armw_${k}`, f) * w01(`w_${name}_${k}`, f) * (f >= a && f <= b ? 1 : smooth(1 - (f < a ? a - f : f - b) / (EASE + 1)));
149
+ for (let f = Math.max(lo, a - EASE); f <= Math.min(hi, b + EASE); f++) { set(f, best.t * weightAt(f)); touched.push(f); }
150
+ const before = touched.map(f => at(f)), depthBefore = touched.map(f => depthIn(out, f, k, object));
151
+ engine.solve(touched);
152
+ // kept when QA's reach (the wrist's misses over the run) and the worst miss where the hand is fully on drop, and no leg or other hand lost its target
153
+ const was = f => before[f - touched[0]], gapAt = (fr, f) => { const q = target(f), e = fit && q && effector(sk, fr.s, fr.diag, fit.effector); return Math.max(e ? len(sub(e, q)) : 0, fr.diag[`arm_miss_${k}`] ?? 0); };
154
+ const sumOf = get => span.reduce((m, f) => m + (get(f).diag[`arm_miss_${k}`] ?? 0), 0), worstOf = get => Math.max(...pick.map(f => gapAt(get(f), f)));
155
+ const depths = touched.map(f => depthIn(out, f, k, object)), L = { ...QA_LIMITS, ...ctx.spec.checks?.limits };
156
+ // the frames around the move, as they were and as they are: the move may not add a pop where it eases in or out
157
+ const near = Array.from({ length: Math.min(hi, touched.at(-1) + 3) - Math.max(lo, touched[0] - 3) + 1 }, (_, i) => Math.max(lo, touched[0] - 3) + i);
158
+ const popped = popsIn(sk, near.map(f => at(f)), L) > popsIn(sk, near.map(f => (f >= touched[0] && f <= touched.at(-1) ? before[f - touched[0]] : at(f))), L) + 1e-4;
159
+ const legsHeld = touched.every((f, i) => legOk(at(f).diag, before[i].diag) && otherOk(at(f).diag, before[i].diag) && depths[i] <= depthBefore[i] + SLACK);
160
+ const worst0 = worstOf(was), worst1 = worstOf(at);
161
+ if (!legsHeld || popped || !(sumOf(at) < sumOf(was) - 1e-6) || !(worst1 < worst0 - GOOD / 2)) {
162
+ for (const f of touched) { baked.root[f] = root0[f]; baked.spine_pitch[f] = pitch0[f]; }
163
+ touched.forEach((f, i) => { out.frames[f - out.f0] = before[i]; });
164
+ continue;
165
+ }
166
+ for (const f of touched) { added.pitch[f] += lean * best.t * weightAt(f); added.move[f] += SHIFT * best.t * weightAt(f); }
167
+ report.push({ side: k, frames: [a, b], lean: round4(lean * best.t), shift: [round4(hd[0] * SHIFT * best.t), round4(hd[2] * SHIFT * best.t)], drop: round4(drop * best.t), miss: [round4(worst0), round4(worst1)] });
168
+ }
169
+ // a channel this fix made for a move it undid goes again
170
+ if (!report.length) for (const ch of made) delete baked[ch];
171
+ return report.length ? { runs: report, how: "an out-of-reach hand on a pinned target: the body leaned (spine_pitch deg), shifted its hips (x, z m) and lowered them (m) toward it, eased in and out; the feet kept their targets" } : null;
172
+ }
@@ -0,0 +1,44 @@
1
+ // C6. A hint's patch is offered only when it was compiled and helped. The logs: a patch applied as sent made the take
2
+ // worse 49% of the time (better 40%), and the agent spent compiles finding that out. So the first CHECKED groups that
3
+ // carry a patch (qa.groups order) each get one compile of the spec with their patch, and the patch stays on the group
4
+ // only when its own group gets lower and qa/verdict.js compareQa calls the whole result better (no goal lost, no more
5
+ // problems, nothing that got worse costs more than half of what got better); the group then says what it measured
6
+ // (patchChecked). Otherwise the patch is taken off and patchTried says what it did; the hint's words stay. A patch past
7
+ // the first CHECKED is not compiled and not offered. A spec repair or an earlier check already
8
+ // compiled is not compiled again (`compile` may answer from its cache).
9
+ import { applyPatch } from "./json-patch.js";
10
+ import { groupKey } from "./qa/severity.js";
11
+ import { compareQa, severityIn } from "./qa/verdict.js";
12
+
13
+ const CHECKED = 2;
14
+ const r2 = x => Math.round(x * 100) / 100;
15
+
16
+ /**
17
+ * checkHints(spec, qa, compile) -> qa (its groups changed in place)
18
+ * @param {object} spec the spec the patches address (qa's own)
19
+ * @param {object} qa runMotionQa's answer for spec
20
+ * @param {(spec: object) => object} compile spec -> its qa
21
+ */
22
+ export function checkHints(spec, qa, compile) {
23
+ let n = 0;
24
+ for (const g of qa?.groups ?? []) {
25
+ if (!g.patch) continue;
26
+ if (n >= CHECKED) { delete g.patch; continue; }
27
+ n += 1;
28
+ const key = groupKey(g);
29
+ let next;
30
+ try { next = compile(applyPatch(spec, g.patch)); } catch (error) { next = { error: String(error?.message ?? error).slice(0, 160) }; }
31
+ const tried = g.patch.map(o => `${o.op} ${o.path}`).join(", "), patch = g.patch;
32
+ delete g.patch;
33
+ if (next?.error || !next?.groups) { g.patchTried = `${tried}: does not compile (${next?.error ?? "no QA"})`; continue; }
34
+ const own = [r2(severityIn(qa, key)), r2(severityIn(next, key))], v = compareQa(next, qa);
35
+ if (own[1] < own[0] && v.better) {
36
+ g.patch = patch;
37
+ g.patchChecked = { severity: own, total: v.sev, problems: v.problems };
38
+ continue;
39
+ }
40
+ const why = !(own[1] < own[0]) ? `this group ${own[0]} -> ${own[1]}` : v.why;
41
+ g.patchTried = `${tried}: compiled, did not help (${why}${why.startsWith("problems") ? "" : `; problems ${v.problems[0]} -> ${v.problems[1]}`})`;
42
+ }
43
+ return qa;
44
+ }