@hatiolab/figure-model 0.2.13 → 0.2.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/graph.js CHANGED
@@ -643,6 +643,22 @@ for (const [name, op] of Object.entries(ops))
643
643
  const IMPURE_OPERATORS = [];
644
644
  for (const [name, op] of Object.entries(ops))
645
645
  op.pure = !IMPURE_OPERATORS.includes(name);
646
+ /*
647
+ The operators a pose step may run (ADR-0108 decision 3): arithmetic, transforms, and what carries a pose -- placing,
648
+ assembling, repeating, selecting. Not a shape, a layout, a pitch or a feature of a shape: an input that reaches one
649
+ changes what is drawn, not only where. An operator added without a form here is left out of the fast path, and the
650
+ test that holds \`FIGURE_POSE_EXCLUDED_OPERATORS\` still says so.
651
+ */
652
+ const POSE_FORMS = [
653
+ 'add@1', 'mul@1', 'div@1', 'sin@1', 'geomean@1', 'curve@1', 'at-least@1', 'max@1', 'min@1', 'affine-bounds@1', 'between@1',
654
+ 'assembly@1', 'repeat@1', 'select-item@1', 'rigid@1', 'axis-turn@1', 'axis-slide@1', 'compose@1', 'attach@1',
655
+ 'point@1', 'map-point@1', 'distance@1', 'place@1', 'member@1'
656
+ ];
657
+ for (const [name, op] of Object.entries(ops))
658
+ if (op.pure && POSE_FORMS.includes(name))
659
+ op.poseForm = 'run';
660
+ /** The operators a pose step does not run: an input whose reach holds one goes by an ordinary evaluation. */
661
+ export const FIGURE_POSE_EXCLUDED_OPERATORS = Object.freeze(Object.keys(ops).filter(k => !ops[k].poseForm));
646
662
  /** Every operator's state policy, by name, for the guard test and for readers of the contract. */
647
663
  export const FIGURE_OPERATOR_STATE_POLICY = Object.freeze(Object.fromEntries(Object.entries(ops).map(([k, op]) => [k, op.state])));
648
664
  /** Every operator's purity, by name, for the guard test and for readers of the evaluator. */
@@ -675,9 +691,64 @@ function checkedValue(value, type, path) {
675
691
  fail('COUNT', path, 'nonnegative safe integer required');
676
692
  return value;
677
693
  }
678
- finite(value, path);
694
+ /*
695
+ By the value's type, where it says where the numbers are: a transform's are its r and t; a placed thing's are its
696
+ pose, its shape being the very object its shape node produced and this check already passed. The same numbers in
697
+ the same order as the walk below, without its bookkeeping for the two kinds a moving figure makes most of.
698
+ */
699
+ if (type.kind === 'transform' && isPose(value))
700
+ finitePose(value, path);
701
+ else if (type.kind === 'placed' && keysAre(value, 'shape', 'pose') && finiteObjects.has(value.shape) && isPose(value.pose))
702
+ finitePose(value.pose, path);
703
+ else {
704
+ finite(value, path);
705
+ /* A shape is what copies and placed things carry again and again: it is remembered, nothing else is. */
706
+ if (type.kind === 'shape' && value && typeof value === 'object')
707
+ finiteObjects.add(value);
708
+ }
679
709
  return value;
680
710
  }
711
+ /** Whether a value's own keys are exactly these two, in this order -- without making the list of them. */
712
+ const keysAre = (v, a, b) => {
713
+ if (!v || typeof v !== 'object' || Array.isArray(v))
714
+ return false;
715
+ let n = 0;
716
+ for (const k in v) {
717
+ if (!Object.hasOwn(v, k))
718
+ continue;
719
+ if (n > 1 || k !== (n === 0 ? a : b))
720
+ return false;
721
+ n++;
722
+ }
723
+ return n === 2;
724
+ };
725
+ const isPose = (v) => keysAre(v, 'r', 't') && Array.isArray(v.r) && Array.isArray(v.t);
726
+ /** A list's numbers in order, the first one not finite refused; anything else in it walked as \`finite\` walks it. */
727
+ function finiteList(list, path) {
728
+ for (let i = 0; i < list.length; i++) {
729
+ if (!(i in list))
730
+ continue;
731
+ const x = list[i];
732
+ if (typeof x === 'number') {
733
+ if (!Number.isFinite(x))
734
+ fail('NON_FINITE', path, String(x));
735
+ }
736
+ else
737
+ finite(x, path);
738
+ }
739
+ }
740
+ function finitePose(p, path) {
741
+ for (let i = 0; i < p.r.length; i++) {
742
+ if (!(i in p.r))
743
+ continue;
744
+ const row = p.r[i];
745
+ if (Array.isArray(row))
746
+ finiteList(row, path);
747
+ else
748
+ finite(row, path);
749
+ }
750
+ finiteList(p.t, path);
751
+ }
681
752
  /*
682
753
  Every number in a value is finite: the first one that is not, depth first in key order, is refused. A plain loop:
683
754
  a repeat's output holds every copy's pose, and the walk with a closure and forEach per level was most of what
@@ -695,10 +766,25 @@ function finite(v, path) {
695
766
  }
696
767
  else if (v && typeof v === 'object') {
697
768
  const keys = Object.keys(v);
769
+ /* A pose -- two lists of numbers -- is walked: remembering it costs more than walking it. */
770
+ const small = keys.length <= 2 && Array.isArray(v[keys[0]]) && (keys.length < 2 || Array.isArray(v[keys[1]]));
771
+ if (!small && finiteObjects.has(v))
772
+ return;
698
773
  for (let i = 0; i < keys.length; i++)
699
774
  finite(v[keys[i]], path);
700
775
  }
701
776
  }
777
+ /*
778
+ Shapes already found finite. A computed value is never written after it is made (the evaluator hands unchanged
779
+ values on as the same objects), so one found finite stays so: a row's twenty-four copies carry the one shape of the
780
+ member they copy, and that shape -- most of the numbers -- was walked again for every copy at every turn (51% of a
781
+ turning roller row, 2026-10-03). Only shapes are remembered: remembering every object kept a table the size of the
782
+ busiest moment (4 MB after 500 conveyors were made and dropped).
783
+ */
784
+ const finiteObjects = new WeakSet();
785
+ /** 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
+ const POSE_KINDS = ['transform', 'placed', 'assembly', 'collection', 'point'];
787
+ const notPose = (t) => (Array.isArray(t) || POSE_KINDS.includes(t?.kind) ? undefined : String(t?.kind ?? 'value'));
702
788
  export function compileFigureGraph(source) {
703
789
  return compileInternal(source);
704
790
  }
@@ -794,6 +880,29 @@ function compileInternal(source) {
794
880
  visit(name);
795
881
  /** An instance evaluator's walk, made on first use (`walkOf`). */
796
882
  let walk;
883
+ /** The pose analyses made so far, by what reads the graph (`poseAnalysisOf`). */
884
+ const analyses = new Map();
885
+ /*
886
+ The one check of an input record (ADR-0108 C1): a whole evaluation, an instance's next evaluation and a pose step
887
+ all take their inputs through these two, so a value refused one way is refused every way.
888
+ */
889
+ const inputIds = new Set(m.inputs.map(p => p.id));
890
+ function inputFields(input) {
891
+ if (!input || typeof input !== 'object' || Array.isArray(input))
892
+ fail('SCHEMA', 'input values', 'object required');
893
+ for (const p of m.inputs)
894
+ if (!Object.hasOwn(input, p.id))
895
+ fail('SCHEMA', 'input values', `missing ${p.id}`);
896
+ for (const k of Object.keys(input))
897
+ if (!inputIds.has(k))
898
+ fail('SCHEMA', 'input values', `unknown ${k}`);
899
+ }
900
+ function inputValue(p, given) {
901
+ const raw = num(given, p.id);
902
+ if (raw < p.min || raw > p.max)
903
+ fail('INPUT_RANGE', p.id, `${raw} outside [${p.min},${p.max}]`);
904
+ return checkedValue(raw * unit(p.unit, p.id)[1], types.get(p.id), p.id);
905
+ }
797
906
  /*
798
907
  One evaluation. Given the last one, only what the changed inputs reach runs again (user's decision 2026-09-26: a
799
908
  figure whose controls move re-evaluated every body every frame -- 2.2 us a body, 26.7 ms for sixty turning
@@ -802,14 +911,11 @@ function compileInternal(source) {
802
911
  tests compare the two over random sequences of inputs.
803
912
  */
804
913
  function run(input, previous) {
805
- fields(input, m.inputs.map(p => p.id), [], 'input values');
914
+ inputFields(input);
806
915
  const values = new Map(), geometry = [], bodies = new Map(), changed = new Set();
807
916
  for (const p of m.inputs) {
808
- const raw = num(input[p.id], p.id);
809
- if (raw < p.min || raw > p.max)
810
- fail('INPUT_RANGE', p.id, `${raw} outside [${p.min},${p.max}]`);
811
- values.set(p.id, checkedValue(raw * unit(p.unit, p.id)[1], types.get(p.id), p.id));
812
- if (!previous || previous.input[p.id] !== raw)
917
+ values.set(p.id, inputValue(p, input[p.id]));
918
+ if (!previous || previous.input[p.id] !== input[p.id])
813
919
  changed.add(p.id);
814
920
  }
815
921
  for (const p of m.constants)
@@ -853,20 +959,61 @@ function compileInternal(source) {
853
959
  * (chief architect's ruling 2026-09-26: the compiled graph is shared, the last values belong to the instance -- sixty
854
960
  * conveyors of one type sharing one cache would break it at every call). A throw drops what it kept.
855
961
  */
856
- evaluator() {
962
+ evaluator(options = {}) {
857
963
  let kept;
964
+ /*
965
+ The generation (ADR-0108): while it stands, body slot n is the same body -- the same id at the same place in the
966
+ geometry. It moves on with a whole evaluation (the first, and the one after a refusal) and with an evaluation
967
+ whose bodies differ from the last one's.
968
+ */
969
+ let generation = 0;
970
+ let ids = [];
971
+ /* What reads this graph does not change under an evaluator: its analysis is looked up once. */
972
+ 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]))
976
+ generation++;
977
+ ids = now;
978
+ return result;
979
+ };
980
+ const evaluate = (input) => {
981
+ const previous = kept;
982
+ kept = undefined;
983
+ if (!previous) {
984
+ const whole = run(input, undefined);
985
+ kept = hold(whole.kept, whole.result);
986
+ return evaluated(whole.result, true);
987
+ }
988
+ const result = step(input, previous);
989
+ kept = previous;
990
+ return evaluated(result, false);
991
+ };
858
992
  return {
859
- evaluate(input) {
860
- const previous = kept;
861
- kept = undefined;
862
- if (!previous) {
863
- const whole = run(input, undefined);
864
- kept = hold(whole.kept, whole.result);
865
- return whole.result;
993
+ evaluate,
994
+ /**
995
+ * One step (ADR-0108): poses alone when every changed input takes the fast path, an evaluation otherwise --
996
+ * with why. The inputs are checked as an evaluation checks them; a refusal drops what was kept, as it does there.
997
+ * \`evaluate\` (\`ordinary\` names why) takes no fast path.
998
+ */
999
+ advance(input, ordinary) {
1000
+ if (kept && !ordinary) {
1001
+ const previous = kept;
1002
+ kept = undefined;
1003
+ const step = poseStep(input, previous, (analysis ??= poseAnalysisOf(options.reads)));
1004
+ kept = previous;
1005
+ if ('result' in step)
1006
+ return { ...step.result, generation };
1007
+ const result = evaluate(input);
1008
+ return { kind: 'evaluation', result, generation, why: step.why };
866
1009
  }
867
- const result = step(input, previous);
868
- kept = previous;
869
- return result;
1010
+ const bound = !!kept;
1011
+ const result = evaluate(input);
1012
+ return { kind: 'evaluation', result, generation, why: { code: bound ? ordinary : 'NOT_BOUND' } };
1013
+ },
1014
+ /** Which inputs take the fast path for what reads this graph, and why the others do not. */
1015
+ poseInputs() {
1016
+ return poseAnalysisOf(options.reads).inputs.map(a => ({ ...a }));
870
1017
  }
871
1018
  };
872
1019
  }
@@ -920,13 +1067,12 @@ function compileInternal(source) {
920
1067
  const always = [...new Set([...impure.map(node => node.index), ...reachOf(impure.flatMap(node => node.outs))])].sort((a, b) => a - b);
921
1068
  walk = {
922
1069
  keys,
1070
+ slot,
923
1071
  nodes,
924
- ids: new Set(m.inputs.map(p => p.id)),
1072
+ readers,
925
1073
  reach: m.inputs.map((_, i) => reachOf([i])),
926
1074
  always,
927
- shapes: nodes.filter(node => node.op.geometry).map(node => node.index),
928
- scale: m.inputs.map(p => unit(p.unit, p.id)[1]),
929
- inputTypes: m.inputs.map(p => types.get(p.id))
1075
+ shapes: nodes.filter(node => node.op.geometry).map(node => node.index)
930
1076
  };
931
1077
  return walk;
932
1078
  }
@@ -951,16 +1097,198 @@ function compileInternal(source) {
951
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
952
1098
  caller does not change when the evaluator moves on, and a caller writing into one does not change the next.
953
1099
  */
1100
+ /*
1101
+ Which inputs take the fast path (ADR-0108 decision 2), decided from everything each one reaches: every node it
1102
+ reaches has a pose form, every value it changes is a number or carries a pose, and nothing outside the graph that
1103
+ may not change reads one. Made once per plan and what reads it.
1104
+ */
1105
+ function poseAnalysisOf(reads = {}) {
1106
+ const key = JSON.stringify([reads.forbidden ?? {}, [...(reads.external ?? [])].sort()]);
1107
+ const held = analyses.get(key);
1108
+ if (held)
1109
+ return held;
1110
+ const w = walkOf();
1111
+ const forbidden = reads.forbidden ?? {};
1112
+ const external = new Set(reads.external ?? []);
1113
+ const inputs = m.inputs.map((p, i) => {
1114
+ const kind = notPose(types.get(p.id));
1115
+ if (kind)
1116
+ return { fast: false, code: 'INPUT_NOT_POSE', input: p.id, node: p.id, reaches: kind };
1117
+ if (Object.hasOwn(forbidden, p.id))
1118
+ return { fast: false, code: 'INPUT_NOT_POSE', input: p.id, node: p.id, reaches: forbidden[p.id] };
1119
+ for (const index of w.reach[i]) {
1120
+ const node = w.nodes[index];
1121
+ if (!node.op.poseForm)
1122
+ return { fast: false, code: 'UNSUPPORTED_OPERATOR', input: p.id, node: node.n.id, op: node.n.op };
1123
+ for (const [k, s] of node.outs.entries()) {
1124
+ const kind = notPose(node.types[k]);
1125
+ if (kind)
1126
+ return { fast: false, code: 'INPUT_NOT_POSE', input: p.id, node: node.n.id, reaches: kind };
1127
+ const ref = w.keys[s];
1128
+ if (Object.hasOwn(forbidden, ref))
1129
+ return { fast: false, code: 'INPUT_NOT_POSE', input: p.id, node: node.n.id, reaches: forbidden[ref] };
1130
+ }
1131
+ }
1132
+ return { fast: true };
1133
+ });
1134
+ /* A row whose copies nothing reads -- no node, nothing outside -- is written by its poses alone. */
1135
+ const terminal = w.nodes.map(node => node.n.op === 'repeat@1' && !node.n.params?.within && node.outs.every(s => !w.readers[s].length && !external.has(w.keys[s])));
1136
+ const made = { inputs, terminal, visits: new Map() };
1137
+ analyses.set(key, made);
1138
+ return made;
1139
+ }
1140
+ /* The nodes a set of changed inputs reaches, once each, in order -- made once per set and kept with the analysis. */
1141
+ function visitOf(moved, analysis) {
1142
+ const w = walkOf();
1143
+ if (moved.length === 1)
1144
+ return w.reach[moved[0]];
1145
+ const key = moved.join();
1146
+ let visit = analysis.visits.get(key);
1147
+ if (!visit) {
1148
+ const seen = new Set();
1149
+ for (const i of moved)
1150
+ for (const n of w.reach[i])
1151
+ seen.add(n);
1152
+ visit = [...seen].sort((a, b) => a - b);
1153
+ if (analysis.visits.size < 256)
1154
+ analysis.visits.set(key, visit);
1155
+ }
1156
+ return visit;
1157
+ }
1158
+ function bindFast(held) {
1159
+ const w = walkOf();
1160
+ const start = new Int32Array(w.nodes.length).fill(-1);
1161
+ let at = 0;
1162
+ for (const index of w.shapes) {
1163
+ start[index] = at;
1164
+ at += held.bodies[index]?.length ?? 0;
1165
+ }
1166
+ return { raw: held.raw.slice(), slots: held.slots.slice(), mark: new Int32Array(w.keys.length), epoch: 0, start, bodyCount: at, prefix: new Map() };
1167
+ }
1168
+ /*
1169
+ A pose step (ADR-0108): every changed input takes the fast path, so only poses move. The reach of the changed inputs
1170
+ runs once, in order, each node by its own run as an evaluation runs it -- the same operations in the same order, so
1171
+ the poses are an evaluation's to the bit (same engine). A row nothing reads has its copies composed straight into the
1172
+ pose list: compose(compose(pose, offset), member), the offset part made once while the row's pose stands.
1173
+ Returns undefined, having changed nothing, when an input does not take the fast path.
1174
+ */
1175
+ function poseStep(input, held, analysis) {
1176
+ const w = walkOf();
1177
+ inputFields(input);
1178
+ const fast = (held.fast ??= bindFast(held));
1179
+ const moved = [];
1180
+ const values = [];
1181
+ for (let i = 0; i < m.inputs.length; i++) {
1182
+ const p = m.inputs[i];
1183
+ const given = input[p.id];
1184
+ if (given === fast.raw[i])
1185
+ continue;
1186
+ values.push(inputValue(p, given));
1187
+ moved.push(i);
1188
+ }
1189
+ for (const i of moved)
1190
+ if (!analysis.inputs[i].fast)
1191
+ return { why: analysis.inputs[i] };
1192
+ const epoch = ++fast.epoch;
1193
+ moved.forEach((i, k) => {
1194
+ fast.raw[i] = input[m.inputs[i].id];
1195
+ fast.slots[i] = values[k];
1196
+ fast.mark[i] = epoch;
1197
+ });
1198
+ const visit = visitOf(moved, analysis);
1199
+ const bodies = new Int32Array(fast.bodyCount);
1200
+ const poses = new Float64Array(fast.bodyCount * 12);
1201
+ let written = 0;
1202
+ const write = (slot, pose) => {
1203
+ const r = pose.r, t = pose.t, o = written * 12;
1204
+ bodies[written++] = slot;
1205
+ poses[o] = r[0][0];
1206
+ poses[o + 1] = r[0][1];
1207
+ poses[o + 2] = r[0][2];
1208
+ poses[o + 3] = r[1][0];
1209
+ poses[o + 4] = r[1][1];
1210
+ poses[o + 5] = r[1][2];
1211
+ poses[o + 6] = r[2][0];
1212
+ poses[o + 7] = r[2][1];
1213
+ poses[o + 8] = r[2][2];
1214
+ poses[o + 9] = t[0];
1215
+ poses[o + 10] = t[1];
1216
+ poses[o + 11] = t[2];
1217
+ };
1218
+ for (const index of visit) {
1219
+ const node = w.nodes[index];
1220
+ let reached = false;
1221
+ for (const s of node.args)
1222
+ if (fast.mark[s] === epoch) {
1223
+ reached = true;
1224
+ break;
1225
+ }
1226
+ if (!reached)
1227
+ continue;
1228
+ const args = new Array(node.args.length);
1229
+ for (let k = 0; k < node.args.length; k++)
1230
+ args[k] = fast.slots[node.args[k]];
1231
+ if (analysis.terminal[index]) {
1232
+ const [layout, assembly, pose, rise] = args;
1233
+ const axis = ['x', 'y', 'z'].indexOf(node.n.params.axis), up = ['x', 'y', 'z'].indexOf(node.n.params.riseAxis);
1234
+ /* The offsets stand while the row's own pose and rise do (its layout is never a pose step's to change). */
1235
+ const steady = fast.mark[node.args[2]] !== epoch && (node.args[3] === undefined || fast.mark[node.args[3]] !== epoch);
1236
+ let prefix = steady ? fast.prefix.get(index) : undefined;
1237
+ if (!prefix) {
1238
+ prefix = [];
1239
+ for (let i = 0; i < layout.count; i++) {
1240
+ const t = [0, 0, 0];
1241
+ t[axis] = layout.centers[i];
1242
+ if (rise !== undefined)
1243
+ t[up] = i * rise;
1244
+ prefix.push(compose(pose, { r: identity(), t }));
1245
+ }
1246
+ fast.prefix.set(index, prefix);
1247
+ }
1248
+ let slot = fast.start[index];
1249
+ for (let i = 0; i < layout.count; i++)
1250
+ for (const member of assembly.members) {
1251
+ const p = compose(prefix[i], member.pose);
1252
+ finite(p, node.n.id);
1253
+ write(slot++, p);
1254
+ }
1255
+ continue;
1256
+ }
1257
+ const out = node.op.run(args, node.n.id, node.n);
1258
+ node.outs.forEach((s, i) => {
1259
+ fast.slots[s] = checkedValue(out[i], node.types[i], node.n.id);
1260
+ fast.mark[s] = epoch;
1261
+ });
1262
+ if (node.op.geometry) {
1263
+ const g = node.op.geometry(args, out, node.n);
1264
+ const own = Array.isArray(g) ? g : [g];
1265
+ let slot = fast.start[index];
1266
+ for (const body of own)
1267
+ write(slot++, body.pose);
1268
+ }
1269
+ }
1270
+ const result = {
1271
+ kind: 'poses',
1272
+ generation: 0,
1273
+ bodies: bodies.slice(0, written),
1274
+ poses: poses.slice(0, written * 12),
1275
+ read: (ref) => {
1276
+ const s = w.slot.get(ref);
1277
+ if (s === undefined)
1278
+ fail('TARGET_ABSENT', ref, 'no such value');
1279
+ return fast.slots[s];
1280
+ },
1281
+ changed: (ref) => {
1282
+ const s = w.slot.get(ref);
1283
+ return s !== undefined && fast.mark[s] === epoch;
1284
+ }
1285
+ };
1286
+ return { result };
1287
+ }
954
1288
  function step(input, held) {
955
1289
  const w = walkOf();
956
- if (!input || typeof input !== 'object' || Array.isArray(input))
957
- fail('SCHEMA', 'input values', 'object required');
958
- for (const p of m.inputs)
959
- if (!Object.hasOwn(input, p.id))
960
- fail('SCHEMA', 'input values', `missing ${p.id}`);
961
- for (const k of Object.keys(input))
962
- if (!w.ids.has(k))
963
- fail('SCHEMA', 'input values', `unknown ${k}`);
1290
+ inputFields(input);
1291
+ held.fast = undefined;
964
1292
  const epoch = ++held.epoch;
965
1293
  const touched = [];
966
1294
  let moved = -1;
@@ -970,11 +1298,8 @@ function compileInternal(source) {
970
1298
  /* The same number as last time passed every check then. */
971
1299
  if (given === held.raw[i])
972
1300
  return;
973
- const raw = num(given, p.id);
974
- if (raw < p.min || raw > p.max)
975
- fail('INPUT_RANGE', p.id, `${raw} outside [${p.min},${p.max}]`);
976
- held.slots[i] = checkedValue(raw * w.scale[i], w.inputTypes[i], p.id);
977
- held.raw[i] = raw;
1301
+ held.slots[i] = inputValue(p, given);
1302
+ held.raw[i] = given;
978
1303
  held.mark[i] = epoch;
979
1304
  touched.push(i);
980
1305
  if (moved >= 0)