@metamesh/modeling 0.11.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # @metamesh/modeling
2
2
 
3
+ ## 0.12.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 45d2617: Replace named computed bindings with an optional dependency array as the third argument:
8
+ `computed(id, callback, dependencies?)`. Automatic parameter and computation tracking remains
9
+ enabled; the array supplements it for values captured outside the callback, including destructured
10
+ parameters. Update existing named-binding calls to close over those values and list their dependencies.
11
+
12
+ Compare dependency entries by identity, preserve computed reader chaining and cache verification,
13
+ and reject mutable object or array dependencies without adding deep comparisons to generation.
14
+
3
15
  ## 0.11.0
4
16
 
5
17
  ### Minor Changes
package/README.md CHANGED
@@ -51,12 +51,26 @@ const model = generate<{ height: number; gap: number }>(({ params, computed, par
51
51
  });
52
52
  ```
53
53
 
54
- Changing `gap` reuses the body's validated geometry. Changing `height` recomputes it. Computations
55
- are pure and synchronous: bind changing locals explicitly with `computed(id, { value }, inputs => ...)`.
54
+ Changing `gap` reuses the body's validated geometry. Changing `height` recomputes it. Read parameters
55
+ and computed readers inside the callback; locals calculated there keep those reads tracked.
56
+ For changing values prepared outside, add a dependency array:
57
+
58
+ ```ts
59
+ const { height } = params;
60
+ const body = computed("body", () => box({ size: [10, 10, height] }), [height]);
61
+ ```
62
+
63
+ Both forms track synchronous parameter and reader calls inside the callback. An earlier parameter
64
+ value or computed result carries no dependency into a later callback unless listed explicitly.
65
+ The array supplements automatic tracking; even `[]` still tracks reads inside the callback.
66
+ Entries compare with `Object.is`, without deep comparisons. Objects and arrays must be deeply
67
+ immutable; parameter snapshots and computed results already satisfy this contract. Fresh objects
68
+ still cause cache misses, so prefer scalar inputs and create temporary options inside the callback.
69
+ Computations are pure and synchronous; ordinary generation without caching remains supported.
56
70
 
57
71
  Capture cached numeric arrays with `immutableBuffer(values)`: read `length` or `at(index)`, and
58
72
  call `copy()` for an owned mutable typed array. Capture cached meshes with `immutableMesh(mesh)`
59
- and pass their readers directly to `part`. Computed results and bindings reject raw typed arrays;
73
+ and pass their readers directly to `part`. Computed results and dependencies reject raw typed arrays;
60
74
  ordinary transient mesh inputs, snapshots, and Worker output still use normal typed arrays.
61
75
 
62
76
  The optional session setting `verifyCache: true` reruns callbacks on cache hits and compares bounded
@@ -464,11 +464,12 @@ export declare interface GenerateContext<P, S = Record<never, never>> {
464
464
  readonly params: Readonly<P>;
465
465
  /** Host-prepared readonly synchronous capabilities, fixed for the session. */
466
466
  readonly services: Readonly<S>;
467
- /** Registers a lazy pure calculation; capture changing locals through explicit bindings. */
468
- readonly computed: {
469
- <T>(id: string, callback: () => T): Computed<T>;
470
- <B extends Readonly<Record<string, unknown>>, T>(id: string, bindings: B, callback: (bindings: B) => T): Computed<T>;
471
- };
467
+ /**
468
+ * Registers a lazy pure calculation and automatically tracks parameter and computed-reader reads.
469
+ * Optional dependencies add changing outer captures; entries compare with Object.is. Objects and
470
+ * arrays must be deeply immutable. An empty list still tracks reads inside the callback.
471
+ */
472
+ readonly computed: <T>(id: string, callback: () => T, dependencies?: readonly unknown[]) => Computed<T>;
472
473
  /** Declares a semantic part with identity placement, or explicit placement and appearance. */
473
474
  readonly part: {
474
475
  (id: string, geometry: ComputedValue<PartGeometry>): GeneratedPart;
@@ -1,7 +1,7 @@
1
1
  import { GeometryError } from "../geometry/errors.js";
2
2
  import { adoptImmutableBuffer, immutableBuffer, immutableBufferView, isImmutableBuffer, } from "../model/immutable-buffer.js";
3
3
  import { geometryRecipe, isGeometryRecipe } from "./geometry-recipes.js";
4
- const mutableBufferMessage = "Generation data cannot contain mutable typed arrays or raw ArrayBuffers. Use immutableBuffer(array) or immutableMesh(mesh) before caching or binding them.";
4
+ const mutableBufferMessage = "Generation data cannot contain mutable typed arrays or raw ArrayBuffers. Use immutableBuffer(array) or immutableMesh(mesh) before caching them or declaring dependencies.";
5
5
  /** Iterates plain fields; recipe capture may read each enumerable accessor once. */
6
6
  export function* generationDataEntries(value, captureAccessors = false) {
7
7
  const prototype = Object.getPrototypeOf(value);
@@ -144,15 +144,18 @@ export function compareGenerationValues(left, right) {
144
144
  };
145
145
  return compare(left, right);
146
146
  }
147
- function captureValue(value, recipeInputs, readerAllowed = () => false) {
147
+ function captureValue(value, mode, readerAllowed = () => false, validated) {
148
+ const recipeInputs = mode === "recipe";
148
149
  const seen = new Map();
149
150
  const copy = (input) => {
150
151
  if (typeof input === "function" && !recipeInputs && !readerAllowed(input))
151
- throw new GeometryError("INVALID_ARGUMENT", "Computed values and bindings cannot contain arbitrary function closures.", { operation: "computed" });
152
+ throw new GeometryError("INVALID_ARGUMENT", "Computed values and dependencies cannot contain arbitrary function closures.", { operation: "computed" });
152
153
  if (typeof input !== "object" || input === null || isGeometryRecipe(input))
153
154
  return input;
154
155
  if (isImmutableBuffer(input))
155
156
  return adoptImmutableBuffer(input);
157
+ if (mode === "dependency" && validated?.has(input))
158
+ return input;
156
159
  const old = seen.get(input);
157
160
  if (old !== undefined)
158
161
  return old;
@@ -166,6 +169,14 @@ function captureValue(value, recipeInputs, readerAllowed = () => false) {
166
169
  if (input instanceof ArrayBuffer)
167
170
  throw new TypeError(mutableBufferMessage);
168
171
  const entries = generationDataEntries(input, recipeInputs);
172
+ if (mode === "dependency") {
173
+ if (!Object.isFrozen(input))
174
+ throw new GeometryError("INVALID_ARGUMENT", "Computed dependencies require deeply immutable objects and arrays. Read params inside the callback, use a computed value, or depend on individual scalar values.", { operation: "computed" });
175
+ seen.set(input, input);
176
+ for (const [, field] of entries)
177
+ copy(field);
178
+ return input;
179
+ }
169
180
  const result = Array.isArray(input)
170
181
  ? new Array(input.length)
171
182
  : Object.create(Object.getPrototypeOf(input) === null ? null : Object.prototype);
@@ -174,13 +185,32 @@ function captureValue(value, recipeInputs, readerAllowed = () => false) {
174
185
  Object.defineProperty(result, key, { value: copy(field), enumerable: true });
175
186
  return Object.freeze(result);
176
187
  };
177
- return copy(value);
188
+ const result = copy(value);
189
+ // Cache validation only after the complete immutable graph succeeds, including cycles.
190
+ if (mode === "dependency")
191
+ for (const input of seen.keys())
192
+ validated?.add(input);
193
+ return result;
178
194
  }
179
195
  /** Validates while capturing immutable data; only explicitly allowed session readers may be functions. */
180
196
  export function captureGenerationValue(value, readerAllowed) {
181
- return captureValue(value, false, readerAllowed);
197
+ return captureValue(value, "data", readerAllowed);
198
+ }
199
+ /** Snapshots the list, preserving immutable entry identities and validating each object only once. */
200
+ export function captureGenerationDependencies(values, readerAllowed, validated) {
201
+ const inputs = [];
202
+ for (const [key, value] of generationDataEntries(values)) {
203
+ if (key !== String(inputs.length))
204
+ throw new TypeError("Computed dependencies must be a dense array without extra fields.");
205
+ inputs.push(value);
206
+ }
207
+ if (inputs.length !== values.length)
208
+ throw new TypeError("Computed dependencies must be a dense array without extra fields.");
209
+ const result = Object.freeze(inputs);
210
+ captureValue(result, "dependency", readerAllowed, validated);
211
+ return result;
182
212
  }
183
213
  /** Captures ordinary inputs once, including accessor values and mutable numeric arrays. */
184
214
  export function captureRecipeInputs(values) {
185
- return captureValue(values, true);
215
+ return captureValue(values, "recipe");
186
216
  }
@@ -1,6 +1,6 @@
1
1
  import { GeometryError } from "../geometry/errors.js";
2
2
  import { GenerationError, } from "../model/generation.js";
3
- import { captureGenerationValue, compareGenerationValues, generationDataEntries, } from "./immutable-generation-data.js";
3
+ import { captureGenerationDependencies, captureGenerationValue, compareGenerationValues, generationDataEntries, } from "./immutable-generation-data.js";
4
4
  import { createModelDependencies } from "./model-dependencies.js";
5
5
  import { assertSynchronousScopeResult } from "./native/ownership.js";
6
6
  function reusableEntry(node) {
@@ -11,17 +11,13 @@ function reusableEntry(node) {
11
11
  return undefined;
12
12
  }
13
13
  const parameterKeys = Symbol("parameter-keys");
14
- const emptyBindings = Object.freeze({});
14
+ const emptyInputs = Object.freeze([]);
15
15
  const isArray = (value) => Array.isArray(value);
16
16
  const canonicalId = (id) => typeof id === "string" && id.length <= 64 && /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(id);
17
17
  function invalid(message) {
18
18
  throw new GeometryError("INVALID_ARGUMENT", message, { operation: "generate" });
19
19
  }
20
- const sameBindings = (left, right) => {
21
- const keys = Object.keys(left);
22
- return (keys.length === Object.keys(right).length &&
23
- keys.every((key) => Object.hasOwn(right, key) && Object.is(left[key], right[key])));
24
- };
20
+ const sameInputs = (left, right) => left.length === right.length && left.every((value, index) => Object.is(value, right[index]));
25
21
  function evaluateComputed(callback) {
26
22
  const result = callback();
27
23
  assertSynchronousScopeResult(result, () => new GeometryError("INVALID_ARGUMENT", "Computed callbacks must be synchronous.", {
@@ -46,16 +42,18 @@ class ComputationRevision {
46
42
  parameterSnapshot;
47
43
  committed;
48
44
  readerIds;
45
+ validatedInputs;
49
46
  assertActive;
50
47
  assertNoFailedAssertion;
51
48
  nodes = new Map();
52
49
  keySignature;
53
50
  stack = [];
54
- constructor(options, parameterSnapshot, committed, readerIds, assertActive, assertNoFailedAssertion) {
51
+ constructor(options, parameterSnapshot, committed, readerIds, validatedInputs, assertActive, assertNoFailedAssertion) {
55
52
  this.options = options;
56
53
  this.parameterSnapshot = parameterSnapshot;
57
54
  this.committed = committed;
58
55
  this.readerIds = readerIds;
56
+ this.validatedInputs = validatedInputs;
59
57
  this.assertActive = assertActive;
60
58
  this.assertNoFailedAssertion = assertNoFailedAssertion;
61
59
  // Structural reads share one unambiguous signature, even when parameter names contain NUL.
@@ -77,27 +75,22 @@ class ComputationRevision {
77
75
  this.nodes.clear();
78
76
  this.committed = new Map();
79
77
  }
80
- register(id, bindingsOrCallback, callback) {
78
+ register(id, callback, dependencies = emptyInputs) {
81
79
  if (!canonicalId(id) || this.nodes.has(id))
82
80
  invalid("Computed IDs must be unique canonical lowercase identifiers of at most 64 characters.");
83
81
  if (this.nodes.size >= 100_000)
84
82
  throw new GeometryError("COMPLEXITY_LIMIT", "Too many declared computations.", {
85
83
  operation: "computed",
86
84
  });
87
- const bindings = typeof bindingsOrCallback === "function"
88
- ? emptyBindings
89
- : Object.freeze(Object.fromEntries(generationDataEntries(bindingsOrCallback)));
85
+ if (typeof callback !== "function" || !Array.isArray(dependencies))
86
+ invalid("Use computed(id, callback, dependencies?) with an optional dependency array.");
87
+ const inputs = dependencies === emptyInputs
88
+ ? emptyInputs
89
+ : captureGenerationDependencies(dependencies, (value) => this.readerIds.has(value), this.validatedInputs);
90
90
  const previous = this.committed.get(id);
91
- let boundInputs;
92
- if (previous !== undefined && sameBindings(previous.bindings, bindings))
93
- boundInputs = previous.boundInputs;
94
- else
95
- boundInputs = captureGenerationValue(bindings, (value) => this.readerIds.has(value));
96
- const invoke = typeof bindingsOrCallback === "function" ? bindingsOrCallback : () => callback(boundInputs);
97
91
  this.nodes.set(id, {
98
- callback: invoke,
99
- bindings,
100
- boundInputs,
92
+ callback,
93
+ inputs,
101
94
  previous,
102
95
  state: { kind: "pending" },
103
96
  });
@@ -162,7 +155,7 @@ class ComputationRevision {
162
155
  }
163
156
  }
164
157
  canReuse(node, previous) {
165
- if (this.options.cache === false || node.boundInputs !== previous.boundInputs)
158
+ if (this.options.cache === false || !sameInputs(node.inputs, previous.inputs))
166
159
  return false;
167
160
  for (const [key, value] of previous.parameters) {
168
161
  const current = key === parameterKeys ? this.keySignature : Reflect.get(this.parameterSnapshot, key);
@@ -191,6 +184,18 @@ class ComputationRevision {
191
184
  evaluation.parameters.clear();
192
185
  evaluation.dependencies.clear();
193
186
  evaluation.cacheable = true;
187
+ // Declared readers depend on their output, not just their stable callable identity.
188
+ for (const input of node.inputs) {
189
+ const dependency = typeof input === "function" ? this.readerIds.get(input) : undefined;
190
+ if (dependency !== undefined) {
191
+ try {
192
+ this.read(dependency);
193
+ }
194
+ catch {
195
+ // Failed reads already prevent reuse; the callback owns propagation or fallback.
196
+ }
197
+ }
198
+ }
194
199
  const result = evaluateComputed(node.callback);
195
200
  this.assertNoFailedAssertion();
196
201
  const previous = node.previous;
@@ -198,14 +203,13 @@ class ComputationRevision {
198
203
  if (reusable && previous !== undefined) {
199
204
  const comparison = compareGenerationValues(previous.value, result);
200
205
  if (comparison === "different")
201
- invalid(`Cache verification for '${id}' found a changed result without a tracked dependency. Bind changing local values explicitly.`);
206
+ invalid(`Cache verification for '${id}' found a changed result without a tracked dependency. Add changing outer values to the dependency array.`);
202
207
  if (comparison === "unsupported")
203
208
  invalid(`Cache verification for '${id}' cannot compare this result within its bounded data/recipe probe. Use cache: false for a full output equivalence check.`);
204
209
  value = previous.value;
205
210
  }
206
211
  return Object.freeze({
207
- bindings: node.bindings,
208
- boundInputs: node.boundInputs,
212
+ inputs: node.inputs,
209
213
  parameters: evaluation.parameters,
210
214
  dependencies: evaluation.dependencies,
211
215
  value,
@@ -221,11 +225,12 @@ export function createGenerationGraph(options, maxParts) {
221
225
  let committed = new Map();
222
226
  const readers = new Map();
223
227
  const readerIds = new WeakMap();
228
+ const validatedInputs = new WeakSet();
224
229
  let activeRead;
225
230
  let previousInput = {};
226
231
  let previousParameterSnapshot = {};
227
232
  // Build stable readers outside registration's lexical scope so they cannot retain a revision's
228
- // callback, captured locals, or copied bindings after that revision closes.
233
+ // callback, captured locals, or dependency inputs after that revision closes.
229
234
  const createReader = (id) => (() => {
230
235
  if (activeRead === undefined)
231
236
  return invalid("Computed readers can only be used during their active model session revision.");
@@ -251,7 +256,7 @@ export function createGenerationGraph(options, maxParts) {
251
256
  if (assertionFailure !== undefined)
252
257
  throw assertionFailure;
253
258
  };
254
- const computations = new ComputationRevision(options, parameterSnapshot, committed, readerIds, assertActive, assertNoFailedAssertion);
259
+ const computations = new ComputationRevision(options, parameterSnapshot, committed, readerIds, validatedInputs, assertActive, assertNoFailedAssertion);
255
260
  const params = new Proxy(parameterSnapshot, {
256
261
  get: (target, key) => {
257
262
  assertActive();
@@ -300,11 +305,11 @@ export function createGenerationGraph(options, maxParts) {
300
305
  },
301
306
  });
302
307
  activeRead = computations.read;
303
- const computed = ((id, bindingsOrCallback, callback) => {
308
+ const computed = ((id, callback, dependencies) => {
304
309
  assertActive();
305
310
  if (!declaring || computations.evaluating)
306
311
  invalid("Declare computations only in the outer generation callback.");
307
- computations.register(id, bindingsOrCallback, callback);
312
+ computations.register(id, callback, dependencies);
308
313
  let reader = readers.get(id);
309
314
  if (reader === undefined) {
310
315
  reader = createReader(id);
@@ -403,7 +408,7 @@ export function createGenerationGraph(options, maxParts) {
403
408
  for (const node of computations.nodes.values()) {
404
409
  const entry = reusableEntry(node);
405
410
  if (entry !== undefined)
406
- retained.push(entry.value, entry.bindings, entry.boundInputs, ...entry.parameters.values());
411
+ retained.push(entry.value, entry.inputs, ...entry.parameters.values());
407
412
  }
408
413
  return [...retained, parameterSnapshot, inputValues];
409
414
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@metamesh/modeling",
3
- "version": "0.11.0",
3
+ "version": "0.12.0",
4
4
  "description": "Scoped solid modeling and pure geometry foundations for Node and browsers.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {