@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 +12 -0
- package/README.md +17 -3
- package/dist/api/index.d.ts +6 -5
- package/dist/engine/immutable-generation-data.js +36 -6
- package/dist/engine/session-graph.js +35 -30
- package/package.json +1 -1
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.
|
|
55
|
-
|
|
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
|
|
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
|
package/dist/api/index.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
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
|
|
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,
|
|
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
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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
|
|
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
|
|
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,
|
|
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
|
-
|
|
88
|
-
?
|
|
89
|
-
|
|
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
|
|
99
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
|
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,
|
|
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,
|
|
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.
|
|
411
|
+
retained.push(entry.value, entry.inputs, ...entry.parameters.values());
|
|
407
412
|
}
|
|
408
413
|
return [...retained, parameterSnapshot, inputValues];
|
|
409
414
|
},
|