@hatiolab/figure-model 0.3.2 → 0.3.4

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 (71) hide show
  1. package/README.md +15 -1
  2. package/dist/asset-types.d.ts +47 -6
  3. package/dist/asset-types.d.ts.map +1 -1
  4. package/dist/asset.d.ts +11 -0
  5. package/dist/asset.d.ts.map +1 -1
  6. package/dist/asset.js +24 -10
  7. package/dist/asset.js.map +1 -1
  8. package/dist/authoring-actions.d.ts.map +1 -1
  9. package/dist/authoring-actions.js +8 -4
  10. package/dist/authoring-actions.js.map +1 -1
  11. package/dist/capabilities.js +28 -4
  12. package/dist/capabilities.js.map +1 -1
  13. package/dist/gate.d.ts +14 -2
  14. package/dist/gate.d.ts.map +1 -1
  15. package/dist/gate.js +45 -25
  16. package/dist/gate.js.map +1 -1
  17. package/dist/graph-types.d.ts +93 -7
  18. package/dist/graph-types.d.ts.map +1 -1
  19. package/dist/graph.d.ts +7 -0
  20. package/dist/graph.d.ts.map +1 -1
  21. package/dist/graph.js +380 -66
  22. package/dist/graph.js.map +1 -1
  23. package/dist/index.d.ts +2 -2
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +1 -1
  26. package/dist/index.js.map +1 -1
  27. package/dist/joint-list.d.ts.map +1 -1
  28. package/dist/joint-list.js +5 -3
  29. package/dist/joint-list.js.map +1 -1
  30. package/dist/kernel-version.d.ts +1 -1
  31. package/dist/kernel-version.js +1 -1
  32. package/docs/assembly-constraints.md +3 -3
  33. package/docs/asset-persistence.md +8 -8
  34. package/docs/authoring-transactions.md +2 -2
  35. package/docs/capability-bindings.md +1 -1
  36. package/docs/common-kernel-experiment.md +2 -2
  37. package/docs/completeness-review-2026-10-04.md +405 -0
  38. package/docs/core-semantics.md +2 -2
  39. package/docs/cutover.md +4 -4
  40. package/docs/document-contract.md +3 -3
  41. package/docs/dryroom-profile-validation.md +1 -1
  42. package/docs/editor-integration.md +4 -4
  43. package/docs/evaluation-changes-design.md +304 -0
  44. package/docs/evaluation-performance-summary-2026-10-05.md +218 -0
  45. package/docs/full-conveyor-validation.md +8 -8
  46. package/docs/full-dryroom-validation.md +5 -5
  47. package/docs/full-forklift-validation.md +5 -5
  48. package/docs/full-robot-validation.md +4 -4
  49. package/docs/layout-system.md +2 -2
  50. package/docs/legacy-risk-audit-2026-09-23.md +14 -14
  51. package/docs/minimal-model.md +3 -3
  52. package/docs/openusd-review.md +14 -14
  53. package/docs/operator-contracts.md +4 -4
  54. package/docs/prototypes/README.md +2 -2
  55. package/docs/prototypes/asset.schema.json +1 -1
  56. package/docs/prototypes/document.schema.json +1 -1
  57. package/docs/prototypes/editor-browser.mjs +2 -2
  58. package/docs/prototypes/linear-comparison.py +1 -1
  59. package/docs/prototypes/render-dryroom.py +1 -1
  60. package/docs/prototypes/render-full-dryroom.mjs +12 -12
  61. package/docs/prototypes/runtime-browser.mjs +3 -3
  62. package/docs/research-review.md +1 -1
  63. package/docs/review-2026-09-22.md +19 -19
  64. package/docs/runtime-extraction.md +10 -10
  65. package/docs/sample-validation-expanded.md +5 -5
  66. package/docs/sample-validation.md +5 -5
  67. package/docs/shape-dimension-contract.md +1 -1
  68. package/docs/spatial-contracts.md +2 -2
  69. package/docs/status.md +12 -1
  70. package/docs/storage-integration.md +5 -5
  71. package/package.json +1 -1
package/dist/graph.js CHANGED
@@ -708,6 +708,62 @@ function checkedValue(value, type, path) {
708
708
  }
709
709
  return value;
710
710
  }
711
+ /*
712
+ Who owns what an instance's evaluator returns (completeness review 2026-10-04, F1). Its slots hold the values it runs
713
+ on, and an unchanged one is handed on, as the same object, to the next result and stays in the results given before.
714
+ A caller's write into one used to reach the next evaluation (a shape's size.x written as 999 came back as 999).
715
+
716
+ Shapes are frozen where they are: they are made when a design value changes, and a renderer keeps its plan while a
717
+ body's shape is the same object. Everything else a result holds -- poses, placed things, rows, bodies -- is a frozen
718
+ copy of the slot's value, made when the value changes and handed on unchanged after that; the slots themselves stay
719
+ as they were made. Freezing the slots in place cost more than the copies: a frozen list of numbers holds them boxed,
720
+ so a pose step reading frozen poses ran 50% slower, and the 6-axis robot's evaluation with every joint moved took
721
+ 35 us instead of 12 (2026-10-04, Node 24). A whole evaluation keeps nothing, and all of it is the caller's.
722
+ */
723
+ function frozen(v) {
724
+ if (!v || typeof v !== 'object' || Object.isFrozen(v))
725
+ return v;
726
+ Object.freeze(v);
727
+ if (Array.isArray(v))
728
+ for (let i = 0; i < v.length; i++)
729
+ frozen(v[i]);
730
+ else
731
+ for (const k in v)
732
+ frozen(v[k]);
733
+ return v;
734
+ }
735
+ /**
736
+ * The frozen copy of a value a result holds. Something already frozen -- a shape, or a copy made before -- is itself;
737
+ * \`made\` holds the copies made in this call, so a pose a body and a value both carry is copied once.
738
+ */
739
+ function publicOf(v, made) {
740
+ if (!v || typeof v !== 'object' || Object.isFrozen(v))
741
+ return v;
742
+ let p = made.get(v);
743
+ if (p)
744
+ return p;
745
+ if (isPose(v)) {
746
+ const r = new Array(v.r.length);
747
+ for (let i = 0; i < r.length; i++)
748
+ r[i] = Object.freeze(v.r[i].slice());
749
+ p = { r: Object.freeze(r), t: Object.freeze(v.t.slice()) };
750
+ }
751
+ else if (Array.isArray(v)) {
752
+ p = new Array(v.length);
753
+ for (let i = 0; i < v.length; i++)
754
+ p[i] = publicOf(v[i], made);
755
+ }
756
+ else {
757
+ p = {};
758
+ for (const k in v)
759
+ p[k] = publicOf(v[k], made);
760
+ }
761
+ Object.freeze(p);
762
+ made.set(v, p);
763
+ return p;
764
+ }
765
+ /** A geometry node's bodies: a placement's one, a row's many. */
766
+ const bodiesOf = (g, id) => (Array.isArray(g) ? g : [{ id, ...g }]);
711
767
  /** Whether a value's own keys are exactly these two, in this order -- without making the list of them. */
712
768
  const keysAre = (v, a, b) => {
713
769
  if (!v || typeof v !== 'object' || Array.isArray(v))
@@ -785,6 +841,13 @@ const finiteObjects = new WeakSet();
785
841
  /** What a pose step may change: numbers, transforms, and what carries a pose. A shape, a layout, a count or a flag may not. */
786
842
  const POSE_KINDS = ['transform', 'placed', 'assembly', 'collection', 'point'];
787
843
  const notPose = (t) => (Array.isArray(t) || POSE_KINDS.includes(t?.kind) ? undefined : String(t?.kind ?? 'value'));
844
+ /**
845
+ * Compiles a graph whatever order its nodes are written in, and however deep its chains (the walk keeps its own stack).
846
+ * It holds no size limit of its own: the byte and node limits -- 1 MiB of JSON, nesting 64, \`FIGURE_DOCUMENT_NODE_LIMIT\`
847
+ * nodes -- are the document's and the asset's (\`compileFigureDocument\`, \`compileFigureAsset\`), the entrances for what
848
+ * arrives from outside. A caller compiling a graph directly answers for its size, and its compile and evaluation take
849
+ * time and memory in proportion to it.
850
+ */
788
851
  export function compileFigureGraph(source) {
789
852
  return compileInternal(source);
790
853
  }
@@ -843,41 +906,62 @@ function compileInternal(source) {
843
906
  assign(output, n.id);
844
907
  nodes.set(n.id, n);
845
908
  }
909
+ /*
910
+ Depth first, in declaration order: a node is typed once every node it reads is (completeness review 2026-10-04, F3).
911
+ The walk keeps its own stack -- a chain of 2,000 nodes written last-first overflowed the engine's -- and gives the
912
+ order, the refusals and the cycle path the recursive walk gave: a cycle is named from where the walk entered it.
913
+ */
846
914
  const visited = new Set();
847
- function visit(name) {
848
- if (visited.has(name))
849
- return;
850
- if (active.includes(name))
915
+ const onPath = new Set();
916
+ const path = [];
917
+ const enter = (name) => {
918
+ if (onPath.has(name))
851
919
  fail('CYCLE', name, [...active, name].join(' -> '));
852
920
  active.push(name);
853
- const n = nodes.get(name), op = ops[n.op];
854
- const inputTypes = n.args.map(ref => {
855
- if (!writers.has(ref))
856
- fail('UNKNOWN_REFERENCE', n.id, ref);
857
- if (!types.has(ref))
858
- visit(writers.get(ref));
859
- return types.get(ref);
860
- });
861
- const resultTypes = op.types(inputTypes, n.id, n);
862
- const depends = new Set();
863
- for (const ref of n.args)
864
- for (const s of dependence.get(ref) ?? [])
865
- depends.add(s);
866
- if (depends.size) {
867
- if (op.state === undefined)
868
- fail('STATE_POLICY', n.id, `${n.op} declares no policy for a state-dependent argument (${[...depends].join(', ')})`);
869
- if (op.state === 'refuses')
870
- fail('TOPOLOGY_STATE', n.id, `${n.op} must depend on design inputs only; it depends on ${[...depends].join(', ')}`);
921
+ onPath.add(name);
922
+ path.push({ n: nodes.get(name), at: 0, inputTypes: [] });
923
+ };
924
+ for (const root of nodes.keys()) {
925
+ if (visited.has(root))
926
+ continue;
927
+ enter(root);
928
+ while (path.length) {
929
+ const frame = path[path.length - 1];
930
+ const n = frame.n;
931
+ if (frame.at < n.args.length) {
932
+ const ref = n.args[frame.at];
933
+ if (!writers.has(ref))
934
+ fail('UNKNOWN_REFERENCE', n.id, ref);
935
+ if (!types.has(ref)) {
936
+ enter(writers.get(ref));
937
+ continue;
938
+ }
939
+ frame.inputTypes.push(types.get(ref));
940
+ frame.at++;
941
+ continue;
942
+ }
943
+ const op = ops[n.op];
944
+ const resultTypes = op.types(frame.inputTypes, n.id, n);
945
+ const depends = new Set();
946
+ for (const ref of n.args)
947
+ for (const s of dependence.get(ref) ?? [])
948
+ depends.add(s);
949
+ if (depends.size) {
950
+ if (op.state === undefined)
951
+ fail('STATE_POLICY', n.id, `${n.op} declares no policy for a state-dependent argument (${[...depends].join(', ')})`);
952
+ if (op.state === 'refuses')
953
+ fail('TOPOLOGY_STATE', n.id, `${n.op} must depend on design inputs only; it depends on ${[...depends].join(', ')}`);
954
+ }
955
+ op.ports.forEach((p, i) => types.set(n.outputs[p], resultTypes[i]));
956
+ if (depends.size)
957
+ Object.values(n.outputs).forEach(ref => dependence.set(ref, new Set(depends)));
958
+ path.pop();
959
+ active.pop();
960
+ onPath.delete(n.id);
961
+ visited.add(n.id);
962
+ order.push(n);
871
963
  }
872
- op.ports.forEach((p, i) => types.set(n.outputs[p], resultTypes[i]));
873
- if (depends.size)
874
- Object.values(n.outputs).forEach(ref => dependence.set(ref, new Set(depends)));
875
- active.pop();
876
- visited.add(name);
877
- order.push(n);
878
964
  }
879
- for (const name of nodes.keys())
880
- visit(name);
881
965
  /** An instance evaluator's walk, made on first use (`walkOf`). */
882
966
  let walk;
883
967
  /** The pose analyses made so far, by what reads the graph (`poseAnalysisOf`). */
@@ -938,8 +1022,7 @@ function compileInternal(source) {
938
1022
  changed.add(n.outputs[p]);
939
1023
  });
940
1024
  if (op.geometry) {
941
- const g = op.geometry(args, out, n);
942
- const own = Array.isArray(g) ? g : [{ id: n.id, ...g }];
1025
+ const own = bodiesOf(op.geometry(args, out, n), n.id);
943
1026
  bodies.set(n.id, own);
944
1027
  geometry.push(...own);
945
1028
  }
@@ -970,25 +1053,126 @@ function compileInternal(source) {
970
1053
  let ids = [];
971
1054
  /* What reads this graph does not change under an evaluator: its analysis is looked up once. */
972
1055
  let analysis;
973
- const evaluated = (result, whole) => {
974
- const now = result.geometry.map((g) => g.id);
975
- if (whole || now.length !== ids.length || now.some((id, i) => id !== ids[i]))
1056
+ /* Every call, refused or not; a pose step's \`read\` and \`changed\` answer while it is still the last one. */
1057
+ let calls = 0;
1058
+ /* Whether the bodies are others than the last ones (a whole evaluation always is): the generation moves on. */
1059
+ const moved = (now, whole) => {
1060
+ const other = whole || now.length !== ids.length || now.some((id, i) => id !== ids[i]);
1061
+ if (other)
976
1062
  generation++;
977
1063
  ids = now;
1064
+ return other;
1065
+ };
1066
+ const evaluated = (result, whole) => {
1067
+ moved(result.geometry.map((g) => g.id), whole);
978
1068
  return result;
979
1069
  };
980
1070
  const evaluate = (input) => {
1071
+ calls++;
981
1072
  const previous = kept;
982
1073
  kept = undefined;
983
1074
  if (!previous) {
984
- const whole = run(input, undefined);
985
- kept = hold(whole.kept, whole.result);
986
- return evaluated(whole.result, true);
1075
+ kept = hold(run(input, undefined).kept);
1076
+ return evaluated({ values: { ...kept.values }, geometry: kept.geometry.slice() }, true);
987
1077
  }
988
- const result = step(input, previous);
1078
+ const result = step(input, previous, true);
989
1079
  kept = previous;
990
1080
  return evaluated(result, false);
991
1081
  };
1082
+ /*
1083
+ An evaluation told as its changes (\`FigureGraphChanges\`, chosen with \`changes: true\`). It runs what an evaluation
1084
+ runs, in the same order; what a snapshot would publish waits for the next \`evaluate\` (\`Held.pending\`). Told: the
1085
+ bodies that ran, by slot -- every slot when the bodies are others than the last ones, with the new ids and keys.
1086
+ */
1087
+ const changesOf = (input, why) => {
1088
+ const call = ++calls;
1089
+ const previous = kept;
1090
+ kept = undefined;
1091
+ /* What pose steps moved since the last evaluation: an evaluation drops their state, so it is taken first. */
1092
+ const posed = previous?.fast;
1093
+ let ran;
1094
+ if (!previous) {
1095
+ kept = hold(run(input, undefined).kept, false);
1096
+ ran = w().shapes;
1097
+ }
1098
+ else {
1099
+ ran = step(input, previous, false);
1100
+ kept = previous;
1101
+ }
1102
+ const held = kept;
1103
+ const now = [], keys = [], paths = [], indexes = [];
1104
+ const start = new Int32Array(w().nodes.length);
1105
+ for (const index of w().shapes) {
1106
+ start[index] = now.length;
1107
+ for (const b of held.bodies[index] ?? []) {
1108
+ now.push(b.id);
1109
+ keys.push(b.key);
1110
+ /* A row's copy keeps the path through its rows: a frozen copy, told with the structure only. */
1111
+ paths.push(b.path ? Object.freeze(b.path.slice()) : undefined);
1112
+ indexes.push(b.index);
1113
+ }
1114
+ }
1115
+ const structure = moved(now, !previous);
1116
+ const told = structure ? w().shapes : posed?.wrote.size ? [...new Set([...ran, ...posed.wrote])].sort((a, b) => a - b) : ran;
1117
+ let count = 0;
1118
+ for (const index of told)
1119
+ count += held.bodies[index]?.length ?? 0;
1120
+ const bodies = new Int32Array(count), poses = new Float64Array(count * 12), shapes = new Array(count);
1121
+ let k = 0;
1122
+ for (const index of told)
1123
+ (held.bodies[index] ?? []).forEach((b, j) => {
1124
+ bodies[k] = start[index] + j;
1125
+ shapes[k] = b.shape ?? null;
1126
+ const o = k * 12;
1127
+ /* A body with no pose (a scalar provider) has no numbers: NaN, never a pose made up for it. */
1128
+ if (b.pose) {
1129
+ const r = b.pose.r, t = b.pose.t;
1130
+ poses[o] = r[0][0];
1131
+ poses[o + 1] = r[0][1];
1132
+ poses[o + 2] = r[0][2];
1133
+ poses[o + 3] = r[1][0];
1134
+ poses[o + 4] = r[1][1];
1135
+ poses[o + 5] = r[1][2];
1136
+ poses[o + 6] = r[2][0];
1137
+ poses[o + 7] = r[2][1];
1138
+ poses[o + 8] = r[2][2];
1139
+ poses[o + 9] = t[0];
1140
+ poses[o + 10] = t[1];
1141
+ poses[o + 11] = t[2];
1142
+ }
1143
+ else
1144
+ poses.fill(NaN, o, o + 12);
1145
+ k++;
1146
+ });
1147
+ const epoch = held.epoch;
1148
+ const live = () => {
1149
+ if (calls !== call)
1150
+ fail('STEP_EXPIRED', 'advance', 'read a step\'s changes before the next call on its evaluator');
1151
+ };
1152
+ return {
1153
+ kind: 'changes',
1154
+ generation,
1155
+ why,
1156
+ ...(structure ? { structure: { ids: now, keys, paths, indexes } } : {}),
1157
+ bodies,
1158
+ poses,
1159
+ shapes,
1160
+ /* A number or a flag as it is; anything else a frozen copy made for this read -- never the evaluator's own. */
1161
+ read: (ref) => {
1162
+ live();
1163
+ const s = w().slot.get(ref);
1164
+ if (s === undefined)
1165
+ fail('TARGET_ABSENT', ref, 'no such value');
1166
+ return publicOf(held.slots[s], new Map());
1167
+ },
1168
+ changed: (ref) => {
1169
+ live();
1170
+ const s = w().slot.get(ref);
1171
+ return s !== undefined && (structure || held.mark[s] === epoch || !!posed?.moved.has(s));
1172
+ }
1173
+ };
1174
+ };
1175
+ const w = () => walkOf();
992
1176
  return {
993
1177
  evaluate,
994
1178
  /**
@@ -998,22 +1182,69 @@ function compileInternal(source) {
998
1182
  */
999
1183
  advance(input, ordinary) {
1000
1184
  if (kept && !ordinary) {
1185
+ const call = ++calls;
1001
1186
  const previous = kept;
1002
1187
  kept = undefined;
1003
1188
  const step = poseStep(input, previous, (analysis ??= poseAnalysisOf(options.reads)));
1004
1189
  kept = previous;
1005
- if ('result' in step)
1006
- return { ...step.result, generation };
1190
+ if ('result' in step) {
1191
+ /*
1192
+ A pose step is not a snapshot: \`read\` and \`changed\` look into the evaluator as this step left it, and
1193
+ the next call moves it on. Past that they are refused rather than answering with a later step's values.
1194
+ */
1195
+ const { read, changed } = step.result;
1196
+ const live = () => {
1197
+ if (calls !== call)
1198
+ fail('STEP_EXPIRED', 'advance', 'read a pose step before the next call on its evaluator');
1199
+ };
1200
+ return {
1201
+ ...step.result,
1202
+ generation,
1203
+ read: (ref) => (live(), read(ref)),
1204
+ changed: (ref) => (live(), changed(ref))
1205
+ };
1206
+ }
1207
+ if (options.changes)
1208
+ return changesOf(input, step.why);
1007
1209
  const result = evaluate(input);
1008
1210
  return { kind: 'evaluation', result, generation, why: step.why };
1009
1211
  }
1010
1212
  const bound = !!kept;
1213
+ const why = { code: bound ? ordinary : 'NOT_BOUND' };
1214
+ if (options.changes)
1215
+ return changesOf(input, why);
1011
1216
  const result = evaluate(input);
1012
- return { kind: 'evaluation', result, generation, why: { code: bound ? ordinary : 'NOT_BOUND' } };
1217
+ return { kind: 'evaluation', result, generation, why };
1013
1218
  },
1014
1219
  /** Which inputs take the fast path for what reads this graph, and why the others do not. */
1015
1220
  poseInputs() {
1016
1221
  return poseAnalysisOf(options.reads).inputs.map(a => ({ ...a }));
1222
+ },
1223
+ /**
1224
+ * One value as the evaluator stands now -- after the last evaluation and the pose steps since -- without a
1225
+ * snapshot: a number or a flag as it is, anything else a frozen copy made for this read, the caller's to keep.
1226
+ * Refused (NOT_BOUND) before the first evaluation and after a refusal.
1227
+ */
1228
+ value(ref) {
1229
+ if (!kept)
1230
+ fail('NOT_BOUND', ref, 'evaluate before reading a value');
1231
+ const s = w().slot.get(ref);
1232
+ if (s === undefined)
1233
+ fail('TARGET_ABSENT', ref, 'no such value');
1234
+ /* The pose steps' slots start as the evaluation's and move on with each step: they are the latest. */
1235
+ const fast = kept.fast;
1236
+ if (!fast)
1237
+ return publicOf(kept.slots[s], new Map());
1238
+ /*
1239
+ Except a row nothing reads, whose copies a pose step writes as poses alone (its slot stays the evaluation's):
1240
+ asked for, it is run from the steps' slots, as an evaluation would run it.
1241
+ */
1242
+ const writer = w().nodes.find(node => node.outs.includes(s));
1243
+ if (writer && fast.wrote.has(writer.index) && !fast.moved.has(s)) {
1244
+ const out = writer.op.run(writer.args.map(a => fast.slots[a]), writer.n.id, writer.n);
1245
+ return publicOf(out[writer.outs.indexOf(s)], new Map());
1246
+ }
1247
+ return publicOf(fast.slots[s], new Map());
1017
1248
  }
1018
1249
  };
1019
1250
  }
@@ -1072,30 +1303,68 @@ function compileInternal(source) {
1072
1303
  readers,
1073
1304
  reach: m.inputs.map((_, i) => reachOf([i])),
1074
1305
  always,
1075
- shapes: nodes.filter(node => node.op.geometry).map(node => node.index)
1306
+ shapes: nodes.filter(node => node.op.geometry).map(node => node.index),
1307
+ shapeSlot: keys.map(k => types.get(k)?.kind === 'shape')
1076
1308
  };
1077
1309
  return walk;
1078
1310
  }
1079
- /** What an evaluator keeps between calls: its slots, its bodies by node, and its own copy of the last result. */
1080
- function hold(whole, result) {
1311
+ /**
1312
+ * What an evaluator keeps between calls: its slots, its bodies by node, and the last result as a caller is given it --
1313
+ * frozen copies of the slots' values and bodies (\`publicOf\`), shapes frozen where they are.
1314
+ */
1315
+ function hold(whole, publish = true) {
1081
1316
  const w = walkOf();
1317
+ const slots = w.keys.map(k => whole.values.get(k));
1318
+ slots.forEach((v, s) => w.shapeSlot[s] && frozen(v));
1319
+ const bodies = w.nodes.map(node => whole.bodies.get(node.n.id));
1320
+ /*
1321
+ An evaluator told as changes publishes nothing until a snapshot is asked for: everything is pending, and the record
1322
+ holds its keys in the order a snapshot gives them.
1323
+ */
1324
+ if (!publish) {
1325
+ const values = {};
1326
+ for (const k of w.keys)
1327
+ values[k] = undefined;
1328
+ return {
1329
+ raw: m.inputs.map(p => whole.input[p.id]),
1330
+ slots,
1331
+ bodies,
1332
+ shown: bodies.map(() => undefined),
1333
+ values,
1334
+ geometry: [],
1335
+ mark: new Int32Array(w.keys.length),
1336
+ ran: new Int32Array(w.nodes.length),
1337
+ epoch: 0,
1338
+ pending: { nodes: new Set(w.shapes.filter(index => bodies[index])), slots: new Set(w.keys.keys()) }
1339
+ };
1340
+ }
1341
+ const made = new Map();
1342
+ const shown = bodies.map(own => own?.map(b => publicOf(b, made)));
1343
+ const values = {};
1344
+ w.keys.forEach((k, s) => (values[k] = publicOf(slots[s], made)));
1345
+ const geometry = [];
1346
+ for (const index of w.shapes)
1347
+ if (shown[index])
1348
+ geometry.push(...shown[index]);
1082
1349
  return {
1083
1350
  raw: m.inputs.map(p => whole.input[p.id]),
1084
- slots: w.keys.map(k => whole.values.get(k)),
1085
- bodies: w.nodes.map(node => whole.bodies.get(node.n.id)),
1086
- /* Copies: the caller owns what it was given, and what it does with it never reaches the next evaluation. */
1087
- values: { ...result.values },
1088
- geometry: result.geometry.slice(),
1351
+ slots,
1352
+ bodies,
1353
+ shown,
1354
+ values,
1355
+ geometry,
1089
1356
  mark: new Int32Array(w.keys.length),
1090
1357
  ran: new Int32Array(w.nodes.length),
1091
- epoch: 0
1358
+ epoch: 0,
1359
+ pending: { nodes: new Set(), slots: new Set() }
1092
1360
  };
1093
1361
  }
1094
1362
  /*
1095
1363
  One evaluation after the first. The result is the one a whole evaluation gives: the same refusals for the same
1096
1364
  inputs, the same values in the same order, an unchanged body as the same object. What is returned is a snapshot
1097
- of its own -- a new values record and a new geometry list, copied from the evaluator's own -- so a result kept by a
1098
- caller does not change when the evaluator moves on, and a caller writing into one does not change the next.
1365
+ of its own -- a new values record and a new geometry list, copied from the evaluator's own, holding frozen values and
1366
+ bodies -- so a result kept by a caller does not change when the evaluator moves on, and a caller writing into one
1367
+ does not change the next.
1099
1368
  */
1100
1369
  /*
1101
1370
  Which inputs take the fast path (ADR-0108 decision 2), decided from everything each one reaches: every node it
@@ -1163,7 +1432,7 @@ function compileInternal(source) {
1163
1432
  start[index] = at;
1164
1433
  at += held.bodies[index]?.length ?? 0;
1165
1434
  }
1166
- return { raw: held.raw.slice(), slots: held.slots.slice(), mark: new Int32Array(w.keys.length), epoch: 0, start, bodyCount: at, prefix: new Map() };
1435
+ return { raw: held.raw.slice(), slots: held.slots.slice(), mark: new Int32Array(w.keys.length), epoch: 0, start, bodyCount: at, prefix: new Map(), wrote: new Set(), moved: new Set() };
1167
1436
  }
1168
1437
  /*
1169
1438
  A pose step (ADR-0108): every changed input takes the fast path, so only poses move. The reach of the changed inputs
@@ -1175,13 +1444,18 @@ function compileInternal(source) {
1175
1444
  function poseStep(input, held, analysis) {
1176
1445
  const w = walkOf();
1177
1446
  inputFields(input);
1178
- const fast = (held.fast ??= bindFast(held));
1447
+ /*
1448
+ What moved is told against the pose steps' own inputs, or the evaluation's while no pose step has been bound since:
1449
+ an input that takes no fast path is found before the steps' state is made (it was made, and dropped, at every
1450
+ evaluation frame of a resize -- 2026-10-05).
1451
+ */
1452
+ const raw = held.fast?.raw ?? held.raw;
1179
1453
  const moved = [];
1180
1454
  const values = [];
1181
1455
  for (let i = 0; i < m.inputs.length; i++) {
1182
1456
  const p = m.inputs[i];
1183
1457
  const given = input[p.id];
1184
- if (given === fast.raw[i])
1458
+ if (given === raw[i])
1185
1459
  continue;
1186
1460
  values.push(inputValue(p, given));
1187
1461
  moved.push(i);
@@ -1189,6 +1463,7 @@ function compileInternal(source) {
1189
1463
  for (const i of moved)
1190
1464
  if (!analysis.inputs[i].fast)
1191
1465
  return { why: analysis.inputs[i] };
1466
+ const fast = (held.fast ??= bindFast(held));
1192
1467
  const epoch = ++fast.epoch;
1193
1468
  moved.forEach((i, k) => {
1194
1469
  fast.raw[i] = input[m.inputs[i].id];
@@ -1252,12 +1527,14 @@ function compileInternal(source) {
1252
1527
  finite(p, node.n.id);
1253
1528
  write(slot++, p);
1254
1529
  }
1530
+ fast.wrote.add(index);
1255
1531
  continue;
1256
1532
  }
1257
1533
  const out = node.op.run(args, node.n.id, node.n);
1258
1534
  node.outs.forEach((s, i) => {
1259
1535
  fast.slots[s] = checkedValue(out[i], node.types[i], node.n.id);
1260
1536
  fast.mark[s] = epoch;
1537
+ fast.moved.add(s);
1261
1538
  });
1262
1539
  if (node.op.geometry) {
1263
1540
  const g = node.op.geometry(args, out, node.n);
@@ -1265,6 +1542,7 @@ function compileInternal(source) {
1265
1542
  let slot = fast.start[index];
1266
1543
  for (const body of own)
1267
1544
  write(slot++, body.pose);
1545
+ fast.wrote.add(index);
1268
1546
  }
1269
1547
  }
1270
1548
  const result = {
@@ -1272,11 +1550,12 @@ function compileInternal(source) {
1272
1550
  generation: 0,
1273
1551
  bodies: bodies.slice(0, written),
1274
1552
  poses: poses.slice(0, written * 12),
1553
+ /* What it hands out is a frozen copy, as an evaluation's values are. */
1275
1554
  read: (ref) => {
1276
1555
  const s = w.slot.get(ref);
1277
1556
  if (s === undefined)
1278
1557
  fail('TARGET_ABSENT', ref, 'no such value');
1279
- return fast.slots[s];
1558
+ return publicOf(fast.slots[s], new Map());
1280
1559
  },
1281
1560
  changed: (ref) => {
1282
1561
  const s = w.slot.get(ref);
@@ -1285,7 +1564,7 @@ function compileInternal(source) {
1285
1564
  };
1286
1565
  return { result };
1287
1566
  }
1288
- function step(input, held) {
1567
+ function step(input, held, publish) {
1289
1568
  const w = walkOf();
1290
1569
  inputFields(input);
1291
1570
  held.fast = undefined;
@@ -1320,7 +1599,7 @@ function compileInternal(source) {
1320
1599
  }
1321
1600
  visit = all.sort((a, b) => a - b);
1322
1601
  }
1323
- let reshaped = false;
1602
+ const ran = [];
1324
1603
  for (const index of visit) {
1325
1604
  const node = w.nodes[index];
1326
1605
  if (node.op.pure && !node.args.some(s => held.mark[s] === epoch))
@@ -1329,27 +1608,62 @@ function compileInternal(source) {
1329
1608
  const out = node.op.run(args, node.n.id, node.n);
1330
1609
  node.outs.forEach((s, i) => {
1331
1610
  held.slots[s] = checkedValue(out[i], node.types[i], node.n.id);
1611
+ if (w.shapeSlot[s])
1612
+ frozen(held.slots[s]);
1332
1613
  held.mark[s] = epoch;
1333
1614
  touched.push(s);
1334
1615
  });
1335
1616
  if (node.op.geometry) {
1336
- const g = node.op.geometry(args, out, node.n);
1337
- held.bodies[index] = Array.isArray(g) ? g : [{ id: node.n.id, ...g }];
1338
- reshaped = true;
1617
+ held.bodies[index] = bodiesOf(node.op.geometry(args, out, node.n), node.n.id);
1618
+ held.pending.nodes.add(index);
1619
+ ran.push(index);
1339
1620
  }
1340
1621
  }
1341
1622
  for (const s of touched)
1342
- held.values[w.keys[s]] = held.slots[s];
1343
- if (reshaped) {
1623
+ held.pending.slots.add(s);
1624
+ if (!publish) {
1625
+ /*
1626
+ What ran is published again at the next snapshot: the copies made of it before are let go here, so a snapshot read
1627
+ once does not keep its changed bodies and values alive through the changes after it. A snapshot a caller kept is
1628
+ not touched; what did not run keeps its copies, the same objects in the next snapshot.
1629
+ */
1630
+ for (const index of ran)
1631
+ held.shown[index] = undefined;
1632
+ if (ran.length)
1633
+ held.geometry = [];
1634
+ for (const s of touched)
1635
+ held.values[w.keys[s]] = undefined;
1636
+ return ran;
1637
+ }
1638
+ publishPending(held);
1639
+ return { values: { ...held.values }, geometry: held.geometry.slice() };
1640
+ }
1641
+ /*
1642
+ What a snapshot shows of what ran since the last one (\`Held.pending\`): frozen copies of the bodies and values made
1643
+ then, once each however many evaluations ran them, in the order a snapshot made them before -- bodies, then values,
1644
+ one copy of a pose both carry. What did not run keeps the copies the earlier snapshots hold.
1645
+ */
1646
+ function publishPending(held) {
1647
+ const w = walkOf();
1648
+ const { nodes, slots } = held.pending;
1649
+ if (!nodes.size && !slots.size)
1650
+ return;
1651
+ const made = new Map();
1652
+ for (const index of [...nodes].sort((a, b) => a - b))
1653
+ held.shown[index] = held.bodies[index].map(b => publicOf(b, made));
1654
+ for (const s of slots)
1655
+ held.values[w.keys[s]] = publicOf(held.slots[s], made);
1656
+ if (nodes.size) {
1344
1657
  const geometry = [];
1345
1658
  for (const index of w.shapes) {
1346
- const own = held.bodies[index];
1659
+ const own = held.shown[index];
1347
1660
  if (own)
1348
1661
  geometry.push(...own);
1349
1662
  }
1350
1663
  held.geometry = geometry;
1351
1664
  }
1352
- return { values: { ...held.values }, geometry: held.geometry.slice() };
1665
+ nodes.clear();
1666
+ slots.clear();
1353
1667
  }
1354
1668
  }
1355
1669
  //# sourceMappingURL=graph.js.map