dynamic state dependencies

- collect from ValueRef/DataRef params
- optional .getDependencies()
- cycle detection
- suspend/resume state tree evaluation
This commit is contained in:
Alexander Rose
2026-05-23 09:03:54 -07:00
parent 27f251e8e4
commit 60a7cab28f
8 changed files with 694 additions and 14 deletions

View File

@@ -1,5 +1,5 @@
/**
* Copyright (c) 2018-2024 mol* contributors, licensed under MIT, See LICENSE file for more info.
* Copyright (c) 2018-2026 mol* contributors, licensed under MIT, See LICENSE file for more info.
*
* @author David Sehnal <david.sehnal@gmail.com>
* @author Alexander Rose <alexander.rose@weirdbyte.de>
@@ -436,12 +436,12 @@ export const LoadTrajectory = StateAction.build({
//
const dependsOn = [model.ref, coordinates.ref];
// dependsOn is auto-derived from the `getDependencies` hook on TrajectoryFromModelAndCoordinates
const traj = state.build().toRoot()
.apply(TrajectoryFromModelAndCoordinates, {
modelRef: model.ref,
coordinatesRef: coordinates.ref
}, { dependsOn })
})
.apply(StateTransforms.Model.ModelFromTrajectory, { modelIndex: 0 });
await state.updateTree(traj).runInContext(taskCtx);

View File

@@ -22,7 +22,7 @@ import { PluginContext } from '../../mol-plugin/context';
import { MolScriptBuilder } from '../../mol-script/language/builder';
import { Expression } from '../../mol-script/language/expression';
import { Script } from '../../mol-script/script';
import { StateObject, StateTransformer } from '../../mol-state';
import { StateObject, StateTransform, StateTransformer } from '../../mol-state';
import { RuntimeContext, Task } from '../../mol-task';
import { deepEqual } from '../../mol-util';
import { ParamDefinition as PD } from '../../mol-util/param-definition';
@@ -247,6 +247,12 @@ const TrajectoryFromModelAndCoordinates = PluginStateTransform.BuiltIn({
coordinatesRef: PD.Text('', { isHidden: true }),
}
})({
getDependencies: ({ modelRef, coordinatesRef }: { modelRef: string, coordinatesRef: string }) => {
const deps: StateTransform.Ref[] = [];
if (modelRef) deps.push(modelRef as StateTransform.Ref);
if (coordinatesRef) deps.push(coordinatesRef as StateTransform.Ref);
return deps;
},
apply({ params, dependencies }) {
return Task.create('Create trajectory from model/topology and coordinates', async ctx => {
const coordinates = dependencies![params.coordinatesRef].data as Coordinates;

View File

@@ -18,7 +18,7 @@ import { volumeFromCube } from '../../mol-model-formats/volume/cube';
import { volumeFromDx } from '../../mol-model-formats/volume/dx';
import { Grid, Volume } from '../../mol-model/volume';
import { PluginContext } from '../../mol-plugin/context';
import { StateSelection, StateTransformer } from '../../mol-state';
import { StateSelection, StateTransform, StateTransformer } from '../../mol-state';
import { volumeFromSegmentationData } from '../../mol-model-formats/volume/segmentation';
import { getTransformFromParams, TransformParam, transformParamsNeedCentroid } from './helpers';
@@ -233,7 +233,8 @@ const AssignColorVolume = PluginStateTransform.BuiltIn({
const props = { label: a.label, description: 'Volume + Colors' };
return new SO.Volume.Data(volume, props);
});
}
},
getDependencies: ({ ref }) => ref ? [ref as StateTransform.Ref] : []
});
type VolumeTransform = typeof VolumeTransform;

View File

@@ -0,0 +1,324 @@
/**
* Copyright (c) 2026 mol* contributors, licensed under MIT, See LICENSE file for more info.
*
* @author Alexander Rose <alexander.rose@weirdbyte.de>
*/
import { State, StateObject, StateObjectCell, StateTransform, StateTransformer, StateTreeCycleError } from '../../mol-state';
import { Task } from '../../mol-task';
import { ParamDefinition as PD } from '../../mol-util/param-definition';
interface TypeInfo { name: string; typeClass: 'Root' | 'Data' }
const Create = StateObject.factory<TypeInfo>();
class Root extends Create({ name: 'Root', typeClass: 'Root' }) { }
class Leaf extends Create<{ value: number }>({ name: 'Leaf', typeClass: 'Data' }) { }
const NS = 'state-deps-spec';
let counter = 0;
const uniq = (s: string) => `${s}-${counter++}`;
function newState() {
return State.create(new Root({}), { runTask: <T>(t: Task<T>) => t.run() });
}
/** Plain leaf created from Root with a number param. */
function constLeaf() {
return StateTransformer.create<Root, Leaf, { value: number }>(NS, {
name: uniq('const-leaf'),
from: [Root],
to: [Leaf],
display: { name: 'Const Leaf' },
params: () => ({ value: PD.Numeric(0) }) as any,
apply({ params }) { return new Leaf({ value: params.value }); },
update({ oldParams, newParams }) {
return oldParams.value === newParams.value
? StateTransformer.UpdateResult.Unchanged
: StateTransformer.UpdateResult.Recreate;
}
});
}
/** Leaf whose value is read from a single explicit dependsOn ref. */
function deriveFromDep(depRef: string) {
return StateTransformer.create<Root, Leaf, {}>(NS, {
name: uniq('derive-from-dep'),
from: [Root],
to: [Leaf],
display: { name: 'Derive From Dep' },
params: () => ({}) as any,
apply({ dependencies }) {
const dep = dependencies?.[depRef] as Leaf;
if (!dep) throw new Error('missing dep');
return new Leaf({ value: dep.data.value + 100 });
},
update({ b, dependencies }) {
const dep = dependencies?.[depRef] as Leaf;
if (!dep) throw new Error('missing dep');
(b.data as { value: number }).value = dep.data.value + 100;
return StateTransformer.UpdateResult.Updated;
}
});
}
describe('State dependencies - linking', () => {
it('explicit dependsOn establishes an edge and passes the dep object to apply', async () => {
const state = newState();
const A = constLeaf();
const B = deriveFromDep('leaf-a');
const builder = state.build();
builder.toRoot<Root>().apply(A as any, { value: 7 }, { ref: 'leaf-a' });
builder.toRoot<Root>().apply(B as any, {}, { ref: 'leaf-b', dependsOn: ['leaf-a'] });
await state.runTask(state.updateTree(builder));
const b = state.cells.get('leaf-b')!;
expect(b.dependencies.dependsOn.map(c => c.transform.ref)).toEqual(['leaf-a']);
expect((b.obj as Leaf).data.value).toBe(107);
const a = state.cells.get('leaf-a')!;
expect(a.dependencies.dependentBy.map(c => c.transform.ref)).toEqual(['leaf-b']);
});
it('re-evaluates dependents when the source updates', async () => {
const state = newState();
const A = constLeaf();
const B = deriveFromDep('leaf-a');
const builder1 = state.build();
builder1.toRoot<Root>().apply(A as any, { value: 1 }, { ref: 'leaf-a' });
builder1.toRoot<Root>().apply(B as any, {}, { ref: 'leaf-b', dependsOn: ['leaf-a'] });
await state.runTask(state.updateTree(builder1));
expect((state.cells.get('leaf-b')!.obj as Leaf).data.value).toBe(101);
const builder2 = state.build();
builder2.to('leaf-a').update({ value: 5 });
await state.runTask(state.updateTree(builder2));
expect((state.cells.get('leaf-b')!.obj as Leaf).data.value).toBe(105);
});
it('throws when an explicit dependsOn references a non-existent transform', async () => {
const state = newState();
const B = deriveFromDep('missing-ref');
const builder = state.build();
builder.toRoot<Root>().apply(B as any, {}, { ref: 'leaf-b', dependsOn: ['missing-ref'] });
await expect(state.runTask(state.updateTree(builder))).rejects.toThrow(/non-existent transform/);
});
it('honors getDependencies(params) and relinks when params change', async () => {
const state = newState();
const A = constLeaf();
const A2 = constLeaf();
const PickViaParams = StateTransformer.create<Root, Leaf, { which: string }>(NS, {
name: uniq('pick-via-params'),
from: [Root],
to: [Leaf],
display: { name: 'Pick' },
params: () => ({ which: PD.Text('leaf-a') }) as any,
getDependencies(params) { return params.which ? [params.which as StateTransform.Ref] : []; },
apply({ params, dependencies }) {
const dep = dependencies?.[params.which] as Leaf;
return new Leaf({ value: dep ? dep.data.value : -1 });
},
update({ b, newParams, dependencies }) {
const dep = dependencies?.[newParams.which] as Leaf;
(b.data as { value: number }).value = dep ? dep.data.value : -1;
return StateTransformer.UpdateResult.Updated;
}
});
const builder = state.build();
builder.toRoot<Root>().apply(A as any, { value: 11 }, { ref: 'leaf-a' });
builder.toRoot<Root>().apply(A2 as any, { value: 22 }, { ref: 'leaf-a2' });
builder.toRoot<Root>().apply(PickViaParams as any, { which: 'leaf-a' }, { ref: 'pick' });
await state.runTask(state.updateTree(builder));
const pick = state.cells.get('pick')!;
expect(pick.dependencies.dependsOn.map(c => c.transform.ref)).toEqual(['leaf-a']);
expect((pick.obj as Leaf).data.value).toBe(11);
const update = state.build();
update.to('pick').update({ which: 'leaf-a2' });
await state.runTask(state.updateTree(update));
const pick2 = state.cells.get('pick')!;
expect(pick2.dependencies.dependsOn.map(c => c.transform.ref)).toEqual(['leaf-a2']);
expect((pick2.obj as Leaf).data.value).toBe(22);
// Old source no longer reverse-linked.
expect(state.cells.get('leaf-a')!.dependencies.dependentBy.length).toBe(0);
expect(state.cells.get('leaf-a2')!.dependencies.dependentBy.map(c => c.transform.ref)).toEqual(['pick']);
});
it('auto-collects refs from PD.ValueRef parameter values', async () => {
const state = newState();
const A = constLeaf();
const ViaValueRef = StateTransformer.create<Root, Leaf, { target: { ref: string, getValue: () => Leaf } }>(NS, {
name: uniq('via-value-ref'),
from: [Root],
to: [Leaf],
display: { name: 'Via ValueRef' },
params: () => ({
target: PD.ValueRef<Leaf>(() => [], (ref, getData) => getData(ref))
}) as any,
apply({ params, dependencies }) {
const dep = dependencies?.[params.target.ref] as Leaf;
return new Leaf({ value: dep ? dep.data.value * 2 : -1 });
}
});
const builder = state.build();
builder.toRoot<Root>().apply(A as any, { value: 9 }, { ref: 'leaf-a' });
builder.toRoot<Root>().apply(ViaValueRef as any, {
target: { ref: 'leaf-a', getValue: () => null as any }
}, { ref: 'vr' });
await state.runTask(state.updateTree(builder));
const vr = state.cells.get('vr')!;
expect(vr.dependencies.dependsOn.map(c => c.transform.ref)).toEqual(['leaf-a']);
expect((vr.obj as Leaf).data.value).toBe(18);
});
it('falls back to a structural scan when the schema is unavailable', async () => {
const state = newState();
const A = constLeaf();
// No `def.params` - params normalization will drop unknown fields at
// evaluation time, but link-time collection (via the structural
// fallback) still happens against the original transform.params.
const Structural = StateTransformer.create<Root, Leaf, any>(NS, {
name: uniq('structural'),
from: [Root],
to: [Leaf],
display: { name: 'Structural' },
apply({ dependencies }) {
const ref = dependencies ? Object.keys(dependencies)[0] : undefined;
const dep = ref ? dependencies![ref] as Leaf : undefined;
return new Leaf({ value: dep ? dep.data.value + 1000 : -1 });
}
});
const builder = state.build();
builder.toRoot<Root>().apply(A as any, { value: 3 }, { ref: 'leaf-a' });
builder.toRoot<Root>().apply(Structural as any, {
link: { ref: 'leaf-a', getValue: () => null }
}, { ref: 'struct' });
await state.runTask(state.updateTree(builder));
const s = state.cells.get('struct')!;
expect(s.dependencies.dependsOn.map(c => c.transform.ref)).toEqual(['leaf-a']);
expect((s.obj as Leaf).data.value).toBe(1003);
});
it('filters out self and root refs from getDependencies', async () => {
const state = newState();
const SelfRef = StateTransformer.create<Root, Leaf, {}>(NS, {
name: uniq('self-ref'),
from: [Root],
to: [Leaf],
display: { name: 'Self Ref' },
params: () => ({}) as any,
getDependencies() { return ['self', StateTransform.RootRef as any]; },
apply() { return new Leaf({ value: 42 }); }
});
const builder = state.build();
builder.toRoot<Root>().apply(SelfRef as any, {}, { ref: 'self' });
await state.runTask(state.updateTree(builder));
const cell = state.cells.get('self')!;
expect(cell.dependencies.dependsOn.length).toBe(0);
expect((cell.obj as Leaf).data.value).toBe(42);
});
});
describe('State dependencies - cycle detection', () => {
it('throws StateTreeCycleError for a direct A → B → A cycle', async () => {
const state = newState();
// Two transformers, each declaring a getDependencies pointing at the other.
const A = StateTransformer.create<Root, Leaf, {}>(NS, {
name: uniq('cycle-a'),
from: [Root],
to: [Leaf],
display: { name: 'Cycle A' },
params: () => ({}) as any,
getDependencies() { return ['cyc-b' as any]; },
apply() { return new Leaf({ value: 0 }); }
});
const B = StateTransformer.create<Root, Leaf, {}>(NS, {
name: uniq('cycle-b'),
from: [Root],
to: [Leaf],
display: { name: 'Cycle B' },
params: () => ({}) as any,
getDependencies() { return ['cyc-a' as any]; },
apply() { return new Leaf({ value: 0 }); }
});
const builder = state.build();
builder.toRoot<Root>().apply(A as any, {}, { ref: 'cyc-a' });
builder.toRoot<Root>().apply(B as any, {}, { ref: 'cyc-b' });
let caught: unknown;
try {
await state.runTask(state.updateTree(builder));
} catch (e) { caught = e; }
expect(caught).toBeInstanceOf(StateTreeCycleError);
const cycle = (caught as StateTreeCycleError).cycle;
expect(cycle[0]).toBe(cycle[cycle.length - 1]);
expect(cycle).toEqual(expect.arrayContaining(['cyc-a', 'cyc-b']));
});
});
describe('State dependencies - deferred resolution', () => {
/** Force evaluation order: place dependent subtree first under root so
* tree pre-order visits it before its dependency. */
it('resolves cross-subtree deps even when the dependent is scheduled first', async () => {
const state = newState();
const A = constLeaf();
const B = deriveFromDep('leaf-a');
const builder = state.build();
// B added FIRST - so its subtree comes before A's in tree pre-order.
builder.toRoot<Root>().apply(B as any, {}, { ref: 'leaf-b', dependsOn: ['leaf-a'] });
builder.toRoot<Root>().apply(A as any, { value: 4 }, { ref: 'leaf-a' });
await state.runTask(state.updateTree(builder));
expect((state.cells.get('leaf-a')!.obj as Leaf).data.value).toBe(4);
expect((state.cells.get('leaf-b')!.obj as Leaf).data.value).toBe(104);
});
it('propagates a clear error when a dep has errored and cannot resolve', async () => {
const state = newState();
const Boom = StateTransformer.create<Root, Leaf, {}>(NS, {
name: uniq('boom'),
from: [Root],
to: [Leaf],
display: { name: 'Boom' },
params: () => ({}) as any,
apply() { throw new Error('intentional'); }
});
const B = deriveFromDep('boom');
const builder = state.build();
builder.toRoot<Root>().apply(Boom as any, {}, { ref: 'boom' });
builder.toRoot<Root>().apply(B as any, {}, { ref: 'leaf-b', dependsOn: ['boom'] });
// The state surfaces transform errors via console.error; suppress the noise.
const err = jest.spyOn(console, 'error').mockImplementation(() => {});
try {
await state.runTask(state.updateTree(builder));
} finally {
err.mockRestore();
}
const b: StateObjectCell = state.cells.get('leaf-b')!;
expect(b.status).toBe('error');
expect(b.errorText).toMatch(/Unresolved dependency|missing dep|intentional/);
});
});

View File

@@ -1,7 +1,8 @@
/**
* Copyright (c) 2018-2025 mol* contributors, licensed under MIT, See LICENSE file for more info.
* Copyright (c) 2018-2026 mol* contributors, licensed under MIT, See LICENSE file for more info.
*
* @author David Sehnal <david.sehnal@gmail.com>
* @author Alexander Rose <alexander.rose@weirdbyte.de>
*/
import { StateObject, StateObjectCell, StateObjectSelector } from './object';
@@ -24,7 +25,22 @@ import { arraySetAdd, arraySetRemove } from '../mol-util/array';
import { UniqueArray } from '../mol-data/generic/unique-array';
import { assignIfUndefined } from '../mol-util/object';
export { State };
export { State, StateTreeCycleError };
/**
* Thrown when a cycle is detected in the state-tree dependency graph
* (the cross-edges defined by `transform.dependsOn` and effective
* dependencies derived from params). `cycle` holds the closed path,
* e.g. `['A', 'B', 'A']`.
*/
class StateTreeCycleError extends Error {
readonly cycle: StateTransform.Ref[];
constructor(cycle: StateTransform.Ref[]) {
super(`Cyclic state-tree dependency detected: ${cycle.join(' -> ')}`);
this.name = 'StateTreeCycleError';
this.cycle = cycle;
}
}
class State {
private _tree: TransientTree;
@@ -494,6 +510,21 @@ interface UpdateContext {
wasAborted: boolean,
newCurrent?: Ref,
/**
* Refs that are scheduled to be (re-)evaluated in this update pass:
* the union of every `roots[i]` and its descendants. Used to distinguish
* "dep not yet produced this pass" (defer + retry) from "dep can never
* be produced this pass" (throw).
*/
scheduled?: Set<Ref>,
/**
* Subtree roots that were skipped because at least one of their
* dependencies hadn't been evaluated yet. Drained after the main loop
* makes a fixpoint pass.
*/
deferred?: Ref[],
getCellData: (ref: string) => any
}
@@ -573,11 +604,45 @@ async function update(ctx: UpdateContext) {
// Set status of cells that will be updated to 'pending'.
initCellStatus(ctx, roots);
// Build the set of refs that will be (re-)evaluated this pass so that
// `updateSubtree` can distinguish "dep not produced yet" (defer + retry)
// from "dep can never be produced" (throw via resolveDependencies).
ctx.scheduled = collectScheduled(ctx, roots);
// Sequentially update all the subtrees.
for (const root of roots) {
await updateSubtree(ctx, root);
}
// Drain the deferred queue: nodes whose `dependsOn` cells weren't yet
// evaluated when first visited get retried until either all succeed or
// a full pass makes no forward progress (true deadlock / unresolvable).
if (ctx.deferred && ctx.deferred.length > 0) {
while (ctx.deferred.length > 0) {
const pending = ctx.deferred;
ctx.deferred = [];
let progress = false;
for (const ref of pending) {
const before = ctx.deferred.length;
await updateSubtree(ctx, ref);
// Forward progress = this attempt did not re-defer the same ref.
const reDeferred = ctx.deferred.length > before
&& ctx.deferred[ctx.deferred.length - 1] === ref;
if (!reDeferred) progress = true;
}
if (!progress) {
const stuck = ctx.deferred.map(r => {
const c = ctx.cells.get(r);
if (!c) return r;
const blockers = pendingBlockers(ctx, c);
return `${r} (waiting on: ${blockers.length ? blockers.join(', ') : 'unknown'})`;
});
ctx.deferred = [];
throw new Error(`Unresolved dependency: ${stuck.join('; ')}`);
}
}
}
// Sync cell states
if (!ctx.editInfo) {
syncNewStates(ctx);
@@ -663,7 +728,13 @@ function setCellStatus(ctx: UpdateContext, ref: Ref, status: StateObjectCell.Sta
}
function initCellStatusVisitor(t: StateTransform, _: any, ctx: UpdateContext) {
ctx.cells.get(t.ref)!.transform = t;
const cell = ctx.cells.get(t.ref)!;
cell.transform = t;
if (relinkCells(cell, ctx)) {
// Edges changed (e.g. param update added/removed dependencies) —
// re-verify there is no cycle.
checkDependenciesCycle(cell);
}
setCellStatus(ctx, t.ref, 'pending');
}
@@ -707,9 +778,10 @@ function addCellsVisitor(transform: StateTransform, _: any, { ctx, added, visite
// type LinkCellsCtx = { ctx: UpdateContext, visited: Set<Ref>, dependent: UniqueArray<Ref, StateObjectCell> }
function linkCells(target: StateObjectCell, ctx: UpdateContext) {
if (!target.transform.dependsOn) return;
const effective = StateTransform.getEffectiveDependsOn(target.transform, ctx.parent.globalContext);
if (effective.length === 0) return;
for (const ref of target.transform.dependsOn) {
for (const ref of effective) {
const t = ctx.tree.transforms.get(ref);
if (!t) {
throw new Error(`Cannot depend on a non-existent transform.`);
@@ -721,6 +793,103 @@ function linkCells(target: StateObjectCell, ctx: UpdateContext) {
}
}
/**
* Diff the current outgoing dependency edges of `target` against the effective
* set derived from its (possibly updated) transform. Unlinks stale edges and
* links new ones so the dependency graph reflects param changes. Idempotent
* when nothing has changed. Returns `true` if any edge was added or removed.
*/
function relinkCells(target: StateObjectCell, ctx: UpdateContext): boolean {
const effective = StateTransform.getEffectiveDependsOn(target.transform, ctx.parent.globalContext);
const current = target.dependencies.dependsOn;
// Fast path: same number and all current refs are still in effective.
if (current.length === effective.length) {
let same = true;
for (const c of current) {
if (effective.indexOf(c.transform.ref) < 0) { same = false; break; }
}
if (same) return false;
}
const desired = new Set(effective);
let changed = false;
// Remove stale outgoing edges.
for (let i = current.length - 1; i >= 0; i--) {
const dep = current[i];
if (!desired.has(dep.transform.ref)) {
current.splice(i, 1);
arraySetRemove(dep.dependencies.dependentBy, target);
changed = true;
}
}
// Add new outgoing edges.
const have = new Set(current.map(c => c.transform.ref));
for (const ref of effective) {
if (have.has(ref)) continue;
const t = ctx.tree.transforms.get(ref);
if (!t) {
throw new Error(`Cannot depend on a non-existent transform.`);
}
const cell = ctx.cells.get(ref)!;
arraySetAdd(target.dependencies.dependsOn, cell);
arraySetAdd(cell.dependencies.dependentBy, target);
changed = true;
}
return changed;
}
/**
* Detect a cycle in the `dependsOn` graph reachable from `start`.
*
* Iterative DFS, returning the closed cycle path (first occurrence of the
* repeated ref appended at the end) or `undefined` if none. Operates on the
* already-linked cell graph; safe to call after `linkCells` / `relinkCells`.
*/
function detectDependenciesCycle(start: StateObjectCell): StateTransform.Ref[] | undefined {
if (start.dependencies.dependsOn.length === 0) return void 0;
const stack: { cell: StateObjectCell, idx: number }[] = [{ cell: start, idx: 0 }];
const onPath = new Set<StateTransform.Ref>([start.transform.ref]);
const fully = new Set<StateTransform.Ref>();
while (stack.length > 0) {
const top = stack[stack.length - 1];
const deps = top.cell.dependencies.dependsOn;
if (top.idx >= deps.length) {
onPath.delete(top.cell.transform.ref);
fully.add(top.cell.transform.ref);
stack.pop();
continue;
}
const next = deps[top.idx++];
const ref = next.transform.ref;
if (fully.has(ref)) continue;
if (onPath.has(ref)) {
const path: StateTransform.Ref[] = [];
let started = false;
for (const s of stack) {
if (started || s.cell.transform.ref === ref) {
started = true;
path.push(s.cell.transform.ref);
}
}
path.push(ref);
return path;
}
onPath.add(ref);
stack.push({ cell: next, idx: 0 });
}
return void 0;
}
function checkDependenciesCycle(cell: StateObjectCell) {
const cycle = detectDependenciesCycle(cell);
if (cycle) throw new StateTreeCycleError(cycle);
}
function initCells(ctx: UpdateContext, roots: Ref[]) {
const initCtx: InitCellsCtx = { ctx, visited: new Set(), added: [] };
@@ -734,6 +903,12 @@ function initCells(ctx: UpdateContext, roots: Ref[]) {
linkCells(cell, ctx);
}
// Cycle detection over the dependency cross-edges of newly added cells.
// Parent/child is already a tree, so cycles can only enter via dependsOn.
for (const cell of initCtx.added) {
checkDependenciesCycle(cell);
}
let dependent: UniqueArray<Ref, StateObjectCell>;
// Find dependent cells
@@ -834,7 +1009,54 @@ type UpdateNodeResult =
const ParentNullErrorText = 'Parent is null';
function collectScheduledVisitor(t: StateTransform, _: any, s: Set<Ref>) {
s.add(t.ref);
}
function collectScheduled(ctx: UpdateContext, roots: Ref[]): Set<Ref> {
const out = new Set<Ref>();
for (const root of roots) {
const node = ctx.tree.transforms.get(root);
if (!node) continue;
StateTree.doPreOrder(ctx.tree, node, out, collectScheduledVisitor);
}
return out;
}
/**
* Refs that `cell` depends on which are scheduled for evaluation in this
* pass but haven't produced an object yet (and aren't in an error state).
* An empty result means deps are either ready, in error, or out-of-scope —
* in any of those cases the cell should proceed (and may throw at
* `resolveDependencies` time if the dep is genuinely missing).
*/
function pendingBlockers(ctx: UpdateContext, cell: StateObjectCell): Ref[] {
const blockers: Ref[] = [];
const scheduled = ctx.scheduled;
if (!scheduled) return blockers;
for (const dep of cell.dependencies.dependsOn) {
if (dep.obj) continue;
if (dep.status === 'error') continue;
if (!scheduled.has(dep.transform.ref)) continue;
blockers.push(dep.transform.ref);
}
return blockers;
}
async function updateSubtree(ctx: UpdateContext, root: Ref) {
const cell = ctx.cells.get(root);
if (cell && cell.dependencies.dependsOn.length > 0) {
const blockers = pendingBlockers(ctx, cell);
if (blockers.length > 0) {
// A dependency hasn't been produced yet this pass — defer this
// subtree and retry after other roots have run. Children will be
// visited when the deferred re-attempt succeeds.
if (!ctx.deferred) ctx.deferred = [];
ctx.deferred.push(root);
return;
}
}
setCellStatus(ctx, root, 'processing');
let isNull = false;

View File

@@ -1,12 +1,14 @@
/**
* Copyright (c) 2018-2025 mol* contributors, licensed under MIT, See LICENSE file for more info.
* Copyright (c) 2018-2026 mol* contributors, licensed under MIT, See LICENSE file for more info.
*
* @author David Sehnal <david.sehnal@gmail.com>
* @author Alexander Rose <alexander.rose@weirdbyte.de>
*/
import { StateTransformer } from './transformer';
import { UUID } from '../mol-util';
import { hashMurmur128o } from '../mol-data/util/hash-functions';
import { ParamDefinition as PD } from '../mol-util/param-definition';
export { Transform as StateTransform };
@@ -170,6 +172,81 @@ namespace Transform {
return true;
}
/**
* Compute the effective set of sibling-like dependencies for a transform.
*
* Combines (in order, de-duplicated):
* 1. Explicit `t.dependsOn` (back-compat / non-param refs).
* 2. Refs from `transformer.definition.getDependencies(params)` if defined.
* 3. Refs collected from `PD.ValueRef` / `PD.DataRef` parameter values.
*
* Self-references and the root ref are filtered out. `globalCtx` is forwarded
* to `params(undefined, globalCtx)` for schema acquisition. If the schema
* can't be obtained (no `params` function or it throws), auto-derivation
* falls back to a structural scan of parameter values for `{ ref, getValue }`
* shaped objects.
*/
export function getEffectiveDependsOn(t: Transform, globalCtx?: unknown): Ref[] {
const out: Ref[] = [];
const seen = new Set<string>();
const add = (ref: string | undefined) => {
if (!ref || ref === t.ref || ref === RootRef) return;
if (seen.has(ref)) return;
seen.add(ref);
out.push(ref as Ref);
};
if (t.dependsOn) {
for (const r of t.dependsOn) add(r);
}
const def = t.transformer.definition;
const params = t.params as any;
if (def.getDependencies && params) {
try {
const extra = def.getDependencies(params);
if (extra) for (const r of extra) add(r);
} catch {
// Keep reconciliation robust if a user hook misbehaves.
}
}
if (params) {
let schema: PD.Params | undefined = void 0;
if (def.params) {
try {
schema = def.params(undefined as any, globalCtx) as PD.Params;
} catch {
schema = void 0;
}
}
if (schema) {
const refs = PD.collectRefs(schema, params);
refs.forEach(r => add(r));
} else {
collectStructuralRefs(params, add);
}
}
return out;
}
function collectStructuralRefs(value: any, add: (ref: string) => void, depth = 0) {
if (!value || typeof value !== 'object' || depth > 6) return;
if (Array.isArray(value)) {
for (const v of value) collectStructuralRefs(v, add, depth + 1);
return;
}
if (typeof (value as any).ref === 'string' && typeof (value as any).getValue === 'function') {
add((value as any).ref);
return;
}
for (const k of Object.keys(value)) {
collectStructuralRefs(value[k], add, depth + 1);
}
}
const _emptyParams = {};
/** Updates the version of the transform to be computed as hash of the parameters */
export function setParamsHashVersion(t: Transform) {

View File

@@ -1,7 +1,8 @@
/**
* Copyright (c) 2018-2025 mol* contributors, licensed under MIT, See LICENSE file for more info.
* Copyright (c) 2018-2026 mol* contributors, licensed under MIT, See LICENSE file for more info.
*
* @author David Sehnal <david.sehnal@gmail.com>
* @author Alexander Rose <alexander.rose@weirdbyte.de>
*/
import { Task } from '../mol-task';
@@ -118,6 +119,16 @@ namespace Transformer {
/** Custom conversion to and from JSON */
readonly customSerialization?: { toJSON(params: P, obj?: B): any, fromJSON(data: any): P }
/**
* Derive sibling-like state-tree dependencies (other cells' refs) from the
* current parameter values. Returned refs are merged with explicit
* `dependsOn` and any refs auto-collected from `PD.ValueRef` / `PD.DataRef`
* parameters to form the effective dependency set used by reconciliation.
*
* Return an empty array or undefined to opt out.
*/
getDependencies?(params: P): StateTransform.Ref[] | undefined
}
export interface Definition<A extends StateObject = StateObject, B extends StateObject = StateObject, P extends {} = {}> extends DefinitionBase<A, B, P> {

View File

@@ -1,5 +1,5 @@
/**
* Copyright (c) 2018-2024 mol* contributors, licensed under MIT, See LICENSE file for more info.
* Copyright (c) 2018-2026 mol* contributors, licensed under MIT, See LICENSE file for more info.
*
* @author Alexander Rose <alexander.rose@weirdbyte.de>
* @author David Sehnal <david.sehnal@gmail.com>
@@ -445,6 +445,45 @@ export namespace ParamDefinition {
}
}
function collectRefValue(p: Any, value: any, out: Set<string>) {
if (value === undefined || value === null) return;
if (p.type === 'value-ref' || p.type === 'data-ref') {
const v = value as ValueRef['defaultValue'];
if (v && typeof v.ref === 'string' && v.ref) out.add(v.ref);
} else if (p.type === 'group') {
collectRefsImpl(p.params, value, out);
} else if (p.type === 'mapped') {
const v = value as NamedParams;
if (!v) return;
const param = p.map(v.name);
collectRefValue(param, v.params, out);
} else if (p.type === 'object-list') {
if (!hasValueRef(p.element)) return;
for (const e of value) {
collectRefsImpl(p.element, e, out);
}
}
}
function collectRefsImpl(params: Params, values: any, out: Set<string>) {
for (const n of Object.keys(params)) {
collectRefValue(params[n], values?.[n], out);
}
}
/**
* Collect all non-empty `ref` strings of `value-ref` and `data-ref` parameter
* values into a set. Used by `mol-state` to derive transform dependencies from
* parameter values.
*/
export function collectRefs(params: Params, values: any, out?: Set<string>): Set<string> {
const result = out ?? new Set<string>();
if (!params || !values) return result;
collectRefsImpl(params, values, result);
return result;
}
export function setDefaultValues<T extends Params>(params: T, defaultValues: Values<T>) {
for (const k of Object.keys(params)) {
if (params[k].isOptional) continue;