circuitjson-toolkit 1.4.0 → 1.4.1

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/README.md CHANGED
@@ -8,8 +8,9 @@ SPDX-License-Identifier: CC-BY-SA-4.0
8
8
 
9
9
  CircuitJSON Toolkit is the dependency-free common runtime for the ECAD toolkit
10
10
  family. It provides the same parser, project, rendering, interaction, query,
11
- manufacturing, simulation, 3D scene, capability, error, and worker contracts
12
- used by `gerber-toolkit`, `altium-toolkit`, and `kicad-toolkit`.
11
+ manufacturing, simulation, 3D scene, self-adjusting computation, capability,
12
+ error, and worker contracts used by `gerber-toolkit`, `altium-toolkit`, and
13
+ `kicad-toolkit`.
13
14
 
14
15
  CircuitJSON is the shared immutable model. Source-format packages keep their
15
16
  native decoders and fidelity data in explicit extension namespaces while
@@ -77,6 +78,11 @@ of defensively copying them again. The resulting model, extensions,
77
78
  parameters, and `DocumentResult` shape are unchanged. See the
78
79
  [1.4.0 release notes](docs/release-notes-v1.4.0.md).
79
80
 
81
+ Version 1.4.1 adds the canonical `SelfAdjustingComputation` runtime with
82
+ dynamic data/control dependencies, changed-root reader lists, stale-trace
83
+ replacement, explicit reclamation, and from-scratch consistency coverage. See
84
+ the [1.4.1 release notes](docs/release-notes-v1.4.1.md).
85
+
80
86
  Before 1.1.0:
81
87
 
82
88
  ```js
@@ -386,6 +392,7 @@ copy while keeping sync, direct async, and worker results mutation-isolated.
386
392
  - [1.2.1 release notes](docs/release-notes-v1.2.1.md)
387
393
  - [1.3.0 release notes](docs/release-notes-v1.3.0.md)
388
394
  - [1.4.0 release notes](docs/release-notes-v1.4.0.md)
395
+ - [1.4.1 release notes](docs/release-notes-v1.4.1.md)
389
396
  - [Library scope](spec/library-scope.md)
390
397
 
391
398
  ## Package scope
package/docs/api.md CHANGED
@@ -16,6 +16,38 @@ this document for new code. Thirty-seven previous CircuitJSON-specific classes
16
16
  remain under `circuitjson-toolkit/extensions`; the three documented viewer
17
17
  compatibility classes remain on the root. See [migration.md](migration.md).
18
18
 
19
+ ### `SelfAdjustingComputation`
20
+
21
+ The root API exports the shared synchronous change-propagation runtime used by
22
+ ECAD Forge and reusable toolkit consumers. It records dynamic property reads
23
+ for stable named computations, maintains reverse reader lists, validates only
24
+ the traces reached from explicit changed roots, and replaces stale control-flow
25
+ dependencies after re-execution.
26
+
27
+ ```js
28
+ import { SelfAdjustingComputation } from 'circuitjson-toolkit'
29
+
30
+ const runtime = new SelfAdjustingComputation()
31
+ const results = runtime.propagate(
32
+ { locale: 'en', status: 'ready' },
33
+ [['status']],
34
+ [
35
+ {
36
+ name: 'status-label',
37
+ computation: (state) => state.locale + ':' + state.status
38
+ }
39
+ ]
40
+ )
41
+ ```
42
+
43
+ Computations must be synchronous and treat their tracked input as read-only.
44
+ `forget(name)` reclaims one trace, `clear()` reclaims the graph, and
45
+ `getStatistics()` exposes bounded trace counts. Plain objects and arrays are
46
+ traversed; non-plain objects are atomic identity dependencies unless the
47
+ constructor's `isAtomic(value, path)` option selects an earlier boundary.
48
+ Callers must supply conservative changed paths and test propagated results
49
+ against a fresh execution for the same input.
50
+
19
51
  ## Common conventions
20
52
 
21
53
  ### Document input
@@ -171,7 +203,7 @@ adoption or `prepare()` for arbitrary caller-owned graphs.
171
203
 
172
204
  ## Root entrypoint
173
205
 
174
- `circuitjson-toolkit` has an exact 17-class root. The 14 canonical classes are:
206
+ `circuitjson-toolkit` has an exact 18-class root. The 15 canonical classes are:
175
207
 
176
208
  - `Parser`
177
209
  - `ProjectLoader`
@@ -185,6 +217,7 @@ adoption or `prepare()` for arbitrary caller-owned graphs.
185
217
  - `SimulationService`
186
218
  - `PcbScene3dBuilder`
187
219
  - `PcbScene3dPreparator`
220
+ - `SelfAdjustingComputation`
188
221
  - `ToolkitCapabilities`
189
222
  - `ToolkitError`
190
223
 
@@ -0,0 +1,27 @@
1
+ # circuitjson-toolkit 1.4.1
2
+
3
+ This patch release adds a shared self-adjusting-computation runtime for
4
+ persistent toolkit and application state. It implements dynamic dependency
5
+ tracing and ordered change propagation without coupling the common package to
6
+ DOM, Three.js, or source-format parser state.
7
+
8
+ ## Self-adjusting computation
9
+
10
+ - `SelfAdjustingComputation` is available from the package root and is shared
11
+ by identity through the Gerber, Altium, KiCad, and PCB Scene3D packages.
12
+ - Named synchronous computations record the data and control-flow paths they
13
+ observe. Explicit changed roots start propagation from reverse reader lists.
14
+ - Potentially affected computations compare their previous observations and
15
+ reuse successful results when values, presence, key structure, and selected
16
+ atomic identities remain unchanged.
17
+ - Re-execution replaces the previous trace and its abandoned reader edges.
18
+ `forget()` and `clear()` reclaim trace storage explicitly.
19
+ - Tracked snapshots reject mutation and asynchronous trace escape. Callers can
20
+ choose an atomic boundary for immutable documents and native objects.
21
+
22
+ ## Verification
23
+
24
+ The unit suite covers nested and structural reads, control-flow replacement,
25
+ stale reader removal, atomic document identity, failed and asynchronous
26
+ computations, write rejection, explicit trace reclamation, and equality with a
27
+ fresh runtime after each propagated change.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "circuitjson-toolkit",
3
- "version": "1.4.0",
3
+ "version": "1.4.1",
4
4
  "description": "Canonical CircuitJSON parsing, project, rendering, query, manufacturing, simulation, and scene contracts",
5
5
  "keywords": [
6
6
  "circuitjson",
@@ -57,6 +57,7 @@
57
57
  "docs/release-notes-v1.2.1.md",
58
58
  "docs/release-notes-v1.3.0.md",
59
59
  "docs/release-notes-v1.4.0.md",
60
+ "docs/release-notes-v1.4.1.md",
60
61
  "docs/testing.md",
61
62
  "spec",
62
63
  "LICENSE",
@@ -0,0 +1,610 @@
1
+ /**
2
+ * Memoizes named computations and dynamically tracks their observed input paths.
3
+ */
4
+ export class SelfAdjustingComputation {
5
+ /** @type {Map<string, { dependencies: object[], value: any, readerRoots: Set<PropertyKey>, readsWholeInput: boolean }>} */
6
+ #computations
7
+
8
+ /** @type {Map<PropertyKey, Set<string>>} */
9
+ #readersByRoot
10
+
11
+ /** @type {Set<string>} */
12
+ #wholeInputReaders
13
+
14
+ /** @type {object | null} */
15
+ #propagationInput
16
+
17
+ /** @type {Set<string> | null} */
18
+ #affectedComputations
19
+
20
+ /** @type {(value: object, path: PropertyKey[]) => boolean} */
21
+ #isAtomic
22
+
23
+ /** @type {Map<symbol, number>} */
24
+ #symbolIds
25
+
26
+ /** @type {number} */
27
+ #nextSymbolId
28
+
29
+ /**
30
+ * @param {{ isAtomic?: (value: object, path: PropertyKey[]) => boolean }} [options] Dependency-tracking options.
31
+ */
32
+ constructor(options = {}) {
33
+ this.#computations = new Map()
34
+ this.#readersByRoot = new Map()
35
+ this.#wholeInputReaders = new Set()
36
+ this.#propagationInput = null
37
+ this.#affectedComputations = null
38
+ this.#isAtomic =
39
+ typeof options.isAtomic === 'function'
40
+ ? options.isAtomic
41
+ : () => false
42
+ this.#symbolIds = new Map()
43
+ this.#nextSymbolId = 1
44
+ }
45
+
46
+ /**
47
+ * Propagates changed modifiable roots through named computations in order.
48
+ * A null change set performs dependency validation for every existing trace.
49
+ * @param {object} input Current input snapshot.
50
+ * @param {PropertyKey[][] | null} changedPaths Changed input paths, or null when unknown.
51
+ * @param {{ name: string, computation: (trackedInput: object) => any }[]} computations Ordered computations.
52
+ * @returns {Map<string, { value: any, recomputed: boolean }>} Results by computation name.
53
+ */
54
+ propagate(input, changedPaths, computations) {
55
+ this.#validateInput(input)
56
+ if (!Array.isArray(computations)) {
57
+ throw new TypeError(
58
+ 'Self-adjusting propagation requires an ordered computation array.'
59
+ )
60
+ }
61
+
62
+ this.#propagationInput = input
63
+ this.#affectedComputations =
64
+ changedPaths === null
65
+ ? null
66
+ : this.#findAffectedComputations(changedPaths)
67
+ const results = new Map()
68
+ try {
69
+ for (const entry of computations) {
70
+ if (!entry || typeof entry !== 'object') {
71
+ throw new TypeError(
72
+ 'Self-adjusting propagation entries must be objects.'
73
+ )
74
+ }
75
+ const key = String(entry.name)
76
+ results.set(key, this.evaluate(key, input, entry.computation))
77
+ }
78
+ return results
79
+ } finally {
80
+ this.#propagationInput = null
81
+ this.#affectedComputations = null
82
+ }
83
+ }
84
+
85
+ /**
86
+ * Evaluates one named computation or reuses its last successful result.
87
+ * @param {string} name Stable computation name.
88
+ * @param {object} input Current input snapshot.
89
+ * @param {(trackedInput: object) => any} computation Synchronous computation.
90
+ * @returns {{ value: any, recomputed: boolean }} Evaluation result.
91
+ */
92
+ evaluate(name, input, computation) {
93
+ this.#validateInput(input)
94
+ if (typeof computation !== 'function') {
95
+ throw new TypeError(
96
+ 'Self-adjusting computation requires a computation function.'
97
+ )
98
+ }
99
+
100
+ const key = String(name)
101
+ const previous = this.#computations.get(key)
102
+ if (previous && !this.#isPotentiallyAffected(key, input)) {
103
+ return { value: previous.value, recomputed: false }
104
+ }
105
+ if (previous && this.#dependenciesMatch(previous.dependencies, input)) {
106
+ return { value: previous.value, recomputed: false }
107
+ }
108
+
109
+ const trace = this.#trace(input, computation)
110
+ this.#replaceTrace(key, trace)
111
+ return { value: trace.value, recomputed: true }
112
+ }
113
+
114
+ /**
115
+ * Removes one named trace and its reader-list entries.
116
+ * @param {string} name Stable computation name.
117
+ * @returns {boolean} Whether a trace was removed.
118
+ */
119
+ forget(name) {
120
+ const key = String(name)
121
+ if (!this.#computations.has(key)) return false
122
+ this.#removeTrace(key)
123
+ return true
124
+ }
125
+
126
+ /**
127
+ * Removes all traces and reader-list entries.
128
+ * @returns {void}
129
+ */
130
+ clear() {
131
+ this.#computations.clear()
132
+ this.#readersByRoot.clear()
133
+ this.#wholeInputReaders.clear()
134
+ this.#propagationInput = null
135
+ this.#affectedComputations = null
136
+ }
137
+
138
+ /**
139
+ * Returns bounded trace-storage counts for diagnostics and tests.
140
+ * @returns {{ computations: number, dependencies: number, readerEdges: number }} Trace statistics.
141
+ */
142
+ getStatistics() {
143
+ return {
144
+ computations: this.#computations.size,
145
+ dependencies: [...this.#computations.values()].reduce(
146
+ (total, trace) => total + trace.dependencies.length,
147
+ 0
148
+ ),
149
+ readerEdges:
150
+ [...this.#readersByRoot.values()].reduce(
151
+ (total, readers) => total + readers.size,
152
+ 0
153
+ ) + this.#wholeInputReaders.size
154
+ }
155
+ }
156
+
157
+ /**
158
+ * Validates one root input snapshot.
159
+ * @param {unknown} input Candidate input.
160
+ * @returns {void}
161
+ */
162
+ #validateInput(input) {
163
+ if (!input || typeof input !== 'object') {
164
+ throw new TypeError(
165
+ 'Self-adjusting computation input must be an object.'
166
+ )
167
+ }
168
+ }
169
+
170
+ /**
171
+ * Finds prior computations that read a changed root.
172
+ * @param {PropertyKey[][]} changedPaths Changed input paths.
173
+ * @returns {Set<string>} Potentially affected computation names.
174
+ */
175
+ #findAffectedComputations(changedPaths) {
176
+ if (!Array.isArray(changedPaths)) {
177
+ throw new TypeError(
178
+ 'Self-adjusting change sets must be arrays of property paths.'
179
+ )
180
+ }
181
+
182
+ const affected = new Set(this.#wholeInputReaders)
183
+ for (const path of changedPaths) {
184
+ if (!Array.isArray(path)) {
185
+ throw new TypeError(
186
+ 'Self-adjusting change-set entries must be property paths.'
187
+ )
188
+ }
189
+ if (path.length === 0) {
190
+ return new Set(this.#computations.keys())
191
+ }
192
+ const readers = this.#readersByRoot.get(path[0])
193
+ readers?.forEach((name) => affected.add(name))
194
+ }
195
+ return affected
196
+ }
197
+
198
+ /**
199
+ * Returns whether a prior trace must be checked in the active propagation.
200
+ * @param {string} name Computation name.
201
+ * @param {object} input Current input snapshot.
202
+ * @returns {boolean} Whether dependency validation is required.
203
+ */
204
+ #isPotentiallyAffected(name, input) {
205
+ return !(
206
+ this.#propagationInput === input &&
207
+ this.#affectedComputations instanceof Set &&
208
+ !this.#affectedComputations.has(name)
209
+ )
210
+ }
211
+
212
+ /**
213
+ * Replaces a trace and its dynamic reader-list entries atomically.
214
+ * @param {string} name Computation name.
215
+ * @param {{ dependencies: object[], value: any }} trace New successful trace.
216
+ * @returns {void}
217
+ */
218
+ #replaceTrace(name, trace) {
219
+ this.#removeTrace(name)
220
+ const readerRoots = new Set()
221
+ let readsWholeInput = false
222
+ for (const dependency of trace.dependencies) {
223
+ if (dependency.path.length === 0) {
224
+ readsWholeInput = true
225
+ } else {
226
+ readerRoots.add(dependency.path[0])
227
+ }
228
+ }
229
+ const storedTrace = {
230
+ ...trace,
231
+ readerRoots,
232
+ readsWholeInput
233
+ }
234
+ this.#computations.set(name, storedTrace)
235
+ readerRoots.forEach((root) => {
236
+ const readers = this.#readersByRoot.get(root) || new Set()
237
+ readers.add(name)
238
+ this.#readersByRoot.set(root, readers)
239
+ })
240
+ if (readsWholeInput) this.#wholeInputReaders.add(name)
241
+ }
242
+
243
+ /**
244
+ * Removes one stored trace and all of its reverse dependency edges.
245
+ * @param {string} name Computation name.
246
+ * @returns {void}
247
+ */
248
+ #removeTrace(name) {
249
+ const previous = this.#computations.get(name)
250
+ if (!previous) return
251
+ previous.readerRoots.forEach((root) => {
252
+ const readers = this.#readersByRoot.get(root)
253
+ readers?.delete(name)
254
+ if (readers?.size === 0) this.#readersByRoot.delete(root)
255
+ })
256
+ if (previous.readsWholeInput) this.#wholeInputReaders.delete(name)
257
+ this.#computations.delete(name)
258
+ }
259
+
260
+ /**
261
+ * Executes one computation while collecting its dynamic dependencies.
262
+ * @param {object} input Raw input snapshot.
263
+ * @param {(trackedInput: object) => any} computation Computation callback.
264
+ * @returns {{ dependencies: object[], value: any }} Successful trace.
265
+ */
266
+ #trace(input, computation) {
267
+ const dependencies = new Map()
268
+ const proxyCache = new WeakMap()
269
+ const proxyMetadata = new WeakMap()
270
+ const trackedInput = this.#createProxy({
271
+ target: input,
272
+ path: [],
273
+ dependencies,
274
+ proxyCache,
275
+ proxyMetadata
276
+ })
277
+ const trackedValue = computation(trackedInput)
278
+ if (SelfAdjustingComputation.#isPromiseLike(trackedValue)) {
279
+ throw new TypeError(
280
+ 'Self-adjusting computations must be synchronous so dependency tracing cannot escape.'
281
+ )
282
+ }
283
+ const metadata =
284
+ trackedValue &&
285
+ (typeof trackedValue === 'object' ||
286
+ typeof trackedValue === 'function')
287
+ ? proxyMetadata.get(trackedValue)
288
+ : null
289
+ if (metadata) {
290
+ this.#record(dependencies, {
291
+ type: 'value',
292
+ path: metadata.path,
293
+ expected: metadata.target
294
+ })
295
+ }
296
+
297
+ return {
298
+ dependencies: [...dependencies.values()],
299
+ value: metadata?.target ?? trackedValue
300
+ }
301
+ }
302
+
303
+ /**
304
+ * Creates a read-tracking proxy for one traversable input container.
305
+ * @param {{ target: object, path: PropertyKey[], dependencies: Map<string, object>, proxyCache: WeakMap<object, Map<string, object>>, proxyMetadata: WeakMap<object, { target: object, path: PropertyKey[] }> }} context Tracking context.
306
+ * @returns {object} Read-tracking proxy.
307
+ */
308
+ #createProxy(context) {
309
+ const pathKey = this.#pathKey(context.path)
310
+ const cachedByPath = context.proxyCache.get(context.target)
311
+ if (cachedByPath?.has(pathKey)) {
312
+ return cachedByPath.get(pathKey)
313
+ }
314
+
315
+ const proxy = new Proxy(context.target, {
316
+ get: (target, property) =>
317
+ this.#readProperty(context, target, property),
318
+ has: (target, property) =>
319
+ this.#readPresence(context, target, property),
320
+ ownKeys: (target) => this.#readKeys(context, target),
321
+ getOwnPropertyDescriptor: (target, property) =>
322
+ this.#readDescriptor(context, target, property),
323
+ set: () => this.#rejectWrite(),
324
+ defineProperty: () => this.#rejectWrite(),
325
+ deleteProperty: () => this.#rejectWrite(),
326
+ setPrototypeOf: () => this.#rejectWrite(),
327
+ preventExtensions: () => this.#rejectWrite()
328
+ })
329
+ const nextCachedByPath = cachedByPath || new Map()
330
+ nextCachedByPath.set(pathKey, proxy)
331
+ if (!cachedByPath) {
332
+ context.proxyCache.set(context.target, nextCachedByPath)
333
+ }
334
+ context.proxyMetadata.set(proxy, {
335
+ target: context.target,
336
+ path: [...context.path]
337
+ })
338
+ return proxy
339
+ }
340
+
341
+ /**
342
+ * Reads and records one property dependency.
343
+ * @param {object} context Tracking context.
344
+ * @param {object} target Current raw target.
345
+ * @param {PropertyKey} property Requested property.
346
+ * @returns {any} Raw atomic value or tracked container.
347
+ */
348
+ #readProperty(context, target, property) {
349
+ const value = Reflect.get(target, property, target)
350
+ const path = [...context.path, property]
351
+ if (this.#isTraversable(value, path)) {
352
+ this.#record(context.dependencies, {
353
+ type: 'kind',
354
+ path,
355
+ expected: SelfAdjustingComputation.#valueKind(value)
356
+ })
357
+ return this.#createProxy({ ...context, target: value, path })
358
+ }
359
+
360
+ this.#record(context.dependencies, {
361
+ type: 'value',
362
+ path,
363
+ expected: value
364
+ })
365
+ return value
366
+ }
367
+
368
+ /**
369
+ * Reads and records a property-existence dependency.
370
+ * @param {object} context Tracking context.
371
+ * @param {object} target Current raw target.
372
+ * @param {PropertyKey} property Requested property.
373
+ * @returns {boolean} Whether the property exists.
374
+ */
375
+ #readPresence(context, target, property) {
376
+ const path = [...context.path, property]
377
+ const expected = Reflect.has(target, property)
378
+ this.#record(context.dependencies, {
379
+ type: 'has',
380
+ path,
381
+ expected
382
+ })
383
+ return expected
384
+ }
385
+
386
+ /**
387
+ * Reads and records the ordered own-key set for one container.
388
+ * @param {object} context Tracking context.
389
+ * @param {object} target Current raw target.
390
+ * @returns {PropertyKey[]} Own keys.
391
+ */
392
+ #readKeys(context, target) {
393
+ const expected = Reflect.ownKeys(target)
394
+ this.#record(context.dependencies, {
395
+ type: 'keys',
396
+ path: [...context.path],
397
+ expected
398
+ })
399
+ return expected
400
+ }
401
+
402
+ /**
403
+ * Reads and records whether one own property is enumerable.
404
+ * @param {object} context Tracking context.
405
+ * @param {object} target Current raw target.
406
+ * @param {PropertyKey} property Requested property.
407
+ * @returns {PropertyDescriptor | undefined} Raw property descriptor.
408
+ */
409
+ #readDescriptor(context, target, property) {
410
+ const descriptor = Reflect.getOwnPropertyDescriptor(target, property)
411
+ this.#record(context.dependencies, {
412
+ type: 'descriptor',
413
+ path: [...context.path, property],
414
+ expected: SelfAdjustingComputation.#descriptorState(descriptor)
415
+ })
416
+ return descriptor
417
+ }
418
+
419
+ /**
420
+ * Returns whether a value should expose nested dependency reads.
421
+ * @param {any} value Candidate value.
422
+ * @param {PropertyKey[]} path Input path.
423
+ * @returns {boolean} Whether the value is a traversable container.
424
+ */
425
+ #isTraversable(value, path) {
426
+ if (!value || typeof value !== 'object') return false
427
+ if (this.#isAtomic(value, [...path])) return false
428
+ if (Array.isArray(value)) return true
429
+ const prototype = Reflect.getPrototypeOf(value)
430
+ return prototype === Object.prototype || prototype === null
431
+ }
432
+
433
+ /**
434
+ * Stores or replaces one dependency by type and input path.
435
+ * @param {Map<string, object>} dependencies Dependency registry.
436
+ * @param {{ type: string, path: PropertyKey[], expected: any }} dependency Dependency record.
437
+ * @returns {void}
438
+ */
439
+ #record(dependencies, dependency) {
440
+ dependencies.set(
441
+ dependency.type + ':' + this.#pathKey(dependency.path),
442
+ dependency
443
+ )
444
+ }
445
+
446
+ /**
447
+ * Returns whether every dependency still matches a new input.
448
+ * @param {object[]} dependencies Previous successful dependencies.
449
+ * @param {object} input Current raw input.
450
+ * @returns {boolean} Whether the computation can be reused.
451
+ */
452
+ #dependenciesMatch(dependencies, input) {
453
+ return dependencies.every((dependency) =>
454
+ this.#dependencyMatches(dependency, input)
455
+ )
456
+ }
457
+
458
+ /**
459
+ * Compares one recorded dependency against a new input.
460
+ * @param {{ type: string, path: PropertyKey[], expected: any }} dependency Recorded dependency.
461
+ * @param {object} input Current raw input.
462
+ * @returns {boolean} Whether the dependency is unchanged.
463
+ */
464
+ #dependencyMatches(dependency, input) {
465
+ if (dependency.type === 'has') {
466
+ const parent = this.#readPath(input, dependency.path.slice(0, -1))
467
+ return (
468
+ parent.found &&
469
+ Reflect.has(Object(parent.value), dependency.path.at(-1)) ===
470
+ dependency.expected
471
+ )
472
+ }
473
+
474
+ const current = this.#readPath(input, dependency.path)
475
+ if (dependency.type === 'value') {
476
+ return (
477
+ current.found && Object.is(current.value, dependency.expected)
478
+ )
479
+ }
480
+ if (dependency.type === 'kind') {
481
+ return (
482
+ current.found &&
483
+ SelfAdjustingComputation.#valueKind(current.value) ===
484
+ dependency.expected
485
+ )
486
+ }
487
+ if (dependency.type === 'keys') {
488
+ return (
489
+ current.found &&
490
+ current.value !== null &&
491
+ typeof current.value === 'object' &&
492
+ SelfAdjustingComputation.#sameKeys(
493
+ Reflect.ownKeys(current.value),
494
+ dependency.expected
495
+ )
496
+ )
497
+ }
498
+ if (dependency.type === 'descriptor') {
499
+ const parent = this.#readPath(input, dependency.path.slice(0, -1))
500
+ if (!parent.found || parent.value === null) return false
501
+ const descriptor = Reflect.getOwnPropertyDescriptor(
502
+ Object(parent.value),
503
+ dependency.path.at(-1)
504
+ )
505
+ return (
506
+ SelfAdjustingComputation.#descriptorState(descriptor) ===
507
+ dependency.expected
508
+ )
509
+ }
510
+ return false
511
+ }
512
+
513
+ /**
514
+ * Resolves one raw input path without invoking dependency tracking.
515
+ * @param {object} input Root input.
516
+ * @param {PropertyKey[]} path Input path.
517
+ * @returns {{ found: boolean, value: any }} Resolved value.
518
+ */
519
+ #readPath(input, path) {
520
+ let value = input
521
+ for (const property of path) {
522
+ if (
523
+ value === null ||
524
+ (typeof value !== 'object' && typeof value !== 'function')
525
+ ) {
526
+ return { found: false, value: undefined }
527
+ }
528
+ value = Reflect.get(value, property, value)
529
+ }
530
+ return { found: true, value }
531
+ }
532
+
533
+ /**
534
+ * Creates a collision-free string key for a property path.
535
+ * @param {PropertyKey[]} path Input path.
536
+ * @returns {string} Registry key.
537
+ */
538
+ #pathKey(path) {
539
+ return path
540
+ .map((property) => {
541
+ if (typeof property === 'symbol') {
542
+ if (!this.#symbolIds.has(property)) {
543
+ this.#symbolIds.set(property, this.#nextSymbolId)
544
+ this.#nextSymbolId += 1
545
+ }
546
+ return 'y' + this.#symbolIds.get(property)
547
+ }
548
+ const text = String(property)
549
+ return 's' + text.length + ':' + text
550
+ })
551
+ .join('|')
552
+ }
553
+
554
+ /**
555
+ * Rejects mutation through a tracked snapshot.
556
+ * @returns {never}
557
+ */
558
+ #rejectWrite() {
559
+ throw new TypeError(
560
+ 'Self-adjusting computation inputs are read-only while tracked.'
561
+ )
562
+ }
563
+
564
+ /**
565
+ * Returns the comparison category for one container value.
566
+ * @param {any} value Candidate value.
567
+ * @returns {string} Value category.
568
+ */
569
+ static #valueKind(value) {
570
+ if (value === null) return 'null'
571
+ if (Array.isArray(value)) return 'array'
572
+ return typeof value
573
+ }
574
+
575
+ /**
576
+ * Returns the dependency-relevant state of a property descriptor.
577
+ * @param {PropertyDescriptor | undefined} descriptor Property descriptor.
578
+ * @returns {string} Descriptor state.
579
+ */
580
+ static #descriptorState(descriptor) {
581
+ if (!descriptor) return 'missing'
582
+ return descriptor.enumerable ? 'enumerable' : 'non-enumerable'
583
+ }
584
+
585
+ /**
586
+ * Compares two ordered property-key arrays.
587
+ * @param {PropertyKey[]} left Current keys.
588
+ * @param {PropertyKey[]} right Recorded keys.
589
+ * @returns {boolean} Whether both key sets and orders match.
590
+ */
591
+ static #sameKeys(left, right) {
592
+ return (
593
+ left.length === right.length &&
594
+ left.every((key, index) => Object.is(key, right[index]))
595
+ )
596
+ }
597
+
598
+ /**
599
+ * Returns whether a computation result is a promise or thenable.
600
+ * @param {unknown} value Candidate result.
601
+ * @returns {boolean} Whether the result is asynchronous.
602
+ */
603
+ static #isPromiseLike(value) {
604
+ return Boolean(
605
+ value &&
606
+ (typeof value === 'object' || typeof value === 'function') &&
607
+ typeof value.then === 'function'
608
+ )
609
+ }
610
+ }
package/src/index.mjs CHANGED
@@ -1,6 +1,7 @@
1
1
  export { CircuitJsonDocument } from './core/CircuitJsonDocument.mjs'
2
2
  export { CircuitJsonIndexer } from './core/CircuitJsonIndexer.mjs'
3
3
  export { CircuitJsonUnits } from './core/CircuitJsonUnits.mjs'
4
+ export { SelfAdjustingComputation } from './core/SelfAdjustingComputation.mjs'
4
5
  export { Parser } from './core/Parser.mjs'
5
6
  export { ProjectLoader } from './core/ProjectLoader.mjs'
6
7
  export { CircuitJsonDocumentContext } from './core/context/CircuitJsonDocumentContext.mjs'
@@ -11,6 +11,7 @@ const CANONICAL_CLASS_NAMES = Object.freeze([
11
11
  'SimulationService',
12
12
  'PcbScene3dBuilder',
13
13
  'PcbScene3dPreparator',
14
+ 'SelfAdjustingComputation',
14
15
  'ToolkitCapabilities',
15
16
  'ToolkitError'
16
17
  ])