You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
This content was written by an AI agent and must be verified by a human developer. After human verification, this alert may be removed.
Summary
Dependency tracking, scheduling, cycle detection, and graphcal graph all work at declaration granularity: an @ reference from a to w is one edge, even when both are indexed and the body only reads @w[t] for the current t. The language already distinguishes static keys (labels, loop variables, key(Fin(N), c), Fin-key arithmetic i + c) from runtime keys, and docs/language/indexes.md ("Evaluation granularity") describes static-key access as keeping per-element edges. This issue proposes making that distinction real as a static, element-level dependency relation that refines the declaration-level graph, exposed through graphcal dump / graphcal graph, without changing evaluation semantics.
Motivation
The driving use case is decision models under uncertainty (XLRM / robust decision making) where a .gcl model is served to Tenax. Such a model has uncertainty inputs w_k, stage-indexed action nodes a[t], and an information structure: the action at stage t may depend on w_k only if w_k has been observed by stage t. That constraint should be checkable from the DAG alone. Today the graph says action → w1 for the whole action declaration, so a checker cannot tell whether action[S0] (before the observation) reads w1.
index Stage = { S0, S1 };
index Mine = { One, Two };
param w1: Dimensionless(min: 0.0, max: 40.0) = 20.0;
param w2: Dimensionless(min: 2.0, max: 34.0) = 18.0;
param threshold: Dimensionless = 18.0;
// w1 is observed after stage 0 and may only influence stage 1.
node action: Key<Mine>[Stage] = for t: Stage {
match t {
Stage#S0 => Mine#One,
Stage#S1 => if @w1 >= @threshold { Mine#One } else { Mine#Two },
}
};
Today: one edge w1 → action. Desired: w1 → action[S1] only, so "no element of action at stage t reads an input revealed after t" is a structural property.
The same granularity gap is the "per-(node, t) dependency granularity decision" listed as untracked in internals/vensim-parity.md (§4.1, A[t] → B[t-1] cross-references). This issue only covers the analysis side; it does not change scheduling or cycle detection.
Current behaviour (as far as I can tell)
ResolvedDagDependencies::runtime_deps (crates/graphcal-compiler/src/tir/typed/model.rs) is HashMap<ResolvedDeclName, BTreeSet<ResolvedDeclName>>: declaration to declarations, no key information.
combined_runtime_order_for (crates/graphcal-eval/src/exec_plan.rs) schedules and detects cycles at that granularity.
graphcal graph documents that "every @ reference becomes a directed edge from the value being read to the declaration reading it" (docs/cli-reference.md).
IndexArg (crates/graphcal-compiler/src/syntax/ast/value.rs) already separates Variant (label), Var (loop variable), and Expr (arbitrary expression), which is the syntactic information the analysis needs.
The "Evaluation granularity" note in docs/language/indexes.md (added with Add first-class index keys: Key<I>, argmax/argmin, and key formers #1070) says static-key access keeps fine-grained per-element edges. I did not find a data structure that records such edges; if the note describes evaluator-internal behaviour, please point to it. Either way, nothing element-level is exposed to tooling today.
Where declaration-level edges lose information
Three distinct situations, which need different treatment:
Static keys.@w[Stage#S1], @w[t] under for t: Stage, @v[i + 1] under for i: Fin(N), key(Fin(N), c). The accessed element is a known function of the enclosing loop binders. Element-level edges are computable by a symbolic key map. This is the main opportunity.
Runtime keys.@x[@k] where k comes from argmax, fin_key, nearest_key, or a stored Key node. The element is data-dependent, so the edge is genuinely a gather over the whole declaration. This stays All unless a later extension bounds the key's possible values.
Conditional dependence.for t: TimeStep { if coord(t) >= @t_obs { f(@w1) } else { g() } } references @w1 in every element; whether it is live for a given t depends on a runtime predicate. A dataflow edge cannot express this. The language-level answer is to make the stage structure static: match t { Stage#S0 => ..., Stage#S1 => ... } over a named axis gives exact per-arm dependencies, and stage-specific dag blocks with explicit param lists (Strict Isolation) make the information set an interface. The analysis should treat match on a loop key as per-element, and leave if on runtime predicates as a union.
Proposal (incremental)
Step 1: element-level dependency relation in the compiler
For every edge reader → source in runtime_deps where at least one side is indexed, record a key map from the reader's axis keys to the set of source keys read. A small algebra is enough for the static forms:
Reader expression
Key map (per axis)
@src[I#L]
Const(L)
@src[t] with t a for binder over the same axis
Identity
@src[i + c] (Fin-key arithmetic)
Shift(c)
@src[key(Fin(N), c)]
Const(c)
@src[@k] with runtime k
All
sum(for m: M { @src[m] }) and other aggregations
All on the aggregated axis (a scalar reads every element)
match t { I#A => e_A, I#B => e_B } under for t: I
per-arm: element A gets deps(e_A), element B gets deps(e_B)
if p { e1 } else { e2 } with runtime p
union of both branches
scan / unfold over axis T
element t depends on source elements <= t (Prefix); state reads through prev are Prefix, reads of other indexed nodes at t are Identity
anything else
All
Multi-axis access is the product of per-axis maps. Non-indexed readers with a constant key get a single-element edge. All reproduces today's declaration-level edge exactly, so the relation is a refinement: projecting every key map to All must give back runtime_deps.
Scheduling, cycle detection, and evaluation are unchanged. The relation is analysis-only data.
Step 2: expose the relation
graphcal dump includes the key maps next to the existing dependency information.
graphcal graph gets an opt-in flag (name to be decided, for example --edges element) that prints one edge per (reader element, source element) pair for static maps and keeps a single labelled All edge for gathers. Output stays deterministic (sorted).
Step 3 (later, separate issue): consumers
A check that a stage-indexed declaration does not read inputs before their declared observation stage (the decision-model use case).
No change to evaluation order, scheduling, or which programs compile.
No attempt to bound runtime keys (argmax, fin_key, nearest_key); they stay All.
No new surface syntax. If a way to declare an information structure is wanted, that is a follow-up.
Open questions
Representation: is a per-axis map algebra (Const, Identity, Shift, Prefix, All) sufficient, or should the relation be materialised as explicit key sets for concrete axes (bounded by the existing 1,000,000-element limit)?
Should param defaults participate the same way as node bodies? They are ordinary graph expressions, so presumably yes.
Include boundaries: an include binding values: @displacement passes the whole value; inside the DAG the analysis continues per element. Is the projection across the boundary Identity on the bound axis?
Should the docs/language/indexes.md "Evaluation granularity" note be reworded until the relation exists, so the docs do not promise per-element edges the tooling cannot show?
Acceptance criteria
The compiler computes the element-level relation for static keys (label, loop variable, key(Fin(N), c), Fin-key i + c), per-arm match on loop keys, aggregation (All on the aggregated axis), and scan/unfold (Prefix), and falls back to All for runtime keys and runtime if.
Projecting every key map to All reproduces runtime_deps exactly (property test).
graphcal dump and graphcal graph (opt-in flag) expose the relation deterministically.
Fixtures: one per key form; one two-stage decision-model fixture (as in the example above) whose exported element-level graph shows w1 → action[S1] and no edge into action[S0]; one unfold fixture showing the Prefix relation.
Warning
This content was written by an AI agent and must be verified by a human developer. After human verification, this alert may be removed.
Summary
Dependency tracking, scheduling, cycle detection, and
graphcal graphall work at declaration granularity: an@reference fromatowis one edge, even when both are indexed and the body only reads@w[t]for the currentt. The language already distinguishes static keys (labels, loop variables,key(Fin(N), c), Fin-key arithmetici + c) from runtime keys, anddocs/language/indexes.md("Evaluation granularity") describes static-key access as keeping per-element edges. This issue proposes making that distinction real as a static, element-level dependency relation that refines the declaration-level graph, exposed throughgraphcal dump/graphcal graph, without changing evaluation semantics.Motivation
The driving use case is decision models under uncertainty (XLRM / robust decision making) where a
.gclmodel is served to Tenax. Such a model has uncertainty inputsw_k, stage-indexed action nodesa[t], and an information structure: the action at stagetmay depend onw_konly ifw_khas been observed by staget. That constraint should be checkable from the DAG alone. Today the graph saysaction → w1for the wholeactiondeclaration, so a checker cannot tell whetheraction[S0](before the observation) readsw1.Today: one edge
w1 → action. Desired:w1 → action[S1]only, so "no element ofactionat stagetreads an input revealed aftert" is a structural property.The same granularity gap is the "per-
(node, t)dependency granularity decision" listed as untracked ininternals/vensim-parity.md(§4.1,A[t] → B[t-1]cross-references). This issue only covers the analysis side; it does not change scheduling or cycle detection.Current behaviour (as far as I can tell)
ResolvedDagDependencies::runtime_deps(crates/graphcal-compiler/src/tir/typed/model.rs) isHashMap<ResolvedDeclName, BTreeSet<ResolvedDeclName>>: declaration to declarations, no key information.combined_runtime_order_for(crates/graphcal-eval/src/exec_plan.rs) schedules and detects cycles at that granularity.graphcal graphdocuments that "every@reference becomes a directed edge from the value being read to the declaration reading it" (docs/cli-reference.md).IndexArg(crates/graphcal-compiler/src/syntax/ast/value.rs) already separatesVariant(label),Var(loop variable), andExpr(arbitrary expression), which is the syntactic information the analysis needs.docs/language/indexes.md(added with Add first-class index keys: Key<I>, argmax/argmin, and key formers #1070) says static-key access keeps fine-grained per-element edges. I did not find a data structure that records such edges; if the note describes evaluator-internal behaviour, please point to it. Either way, nothing element-level is exposed to tooling today.Where declaration-level edges lose information
Three distinct situations, which need different treatment:
@w[Stage#S1],@w[t]underfor t: Stage,@v[i + 1]underfor i: Fin(N),key(Fin(N), c). The accessed element is a known function of the enclosing loop binders. Element-level edges are computable by a symbolic key map. This is the main opportunity.@x[@k]wherekcomes fromargmax,fin_key,nearest_key, or a storedKeynode. The element is data-dependent, so the edge is genuinely a gather over the whole declaration. This staysAllunless a later extension bounds the key's possible values.for t: TimeStep { if coord(t) >= @t_obs { f(@w1) } else { g() } }references@w1in every element; whether it is live for a giventdepends on a runtime predicate. A dataflow edge cannot express this. The language-level answer is to make the stage structure static:match t { Stage#S0 => ..., Stage#S1 => ... }over a named axis gives exact per-arm dependencies, and stage-specificdagblocks with explicitparamlists (Strict Isolation) make the information set an interface. The analysis should treatmatchon a loop key as per-element, and leaveifon runtime predicates as a union.Proposal (incremental)
Step 1: element-level dependency relation in the compiler
For every edge
reader → sourceinruntime_depswhere at least one side is indexed, record a key map from the reader's axis keys to the set of source keys read. A small algebra is enough for the static forms:@src[I#L]Const(L)@src[t]withtaforbinder over the same axisIdentity@src[i + c](Fin-key arithmetic)Shift(c)@src[key(Fin(N), c)]Const(c)@src[@k]with runtimekAllsum(for m: M { @src[m] })and other aggregationsAllon the aggregated axis (a scalar reads every element)match t { I#A => e_A, I#B => e_B }underfor t: IAgetsdeps(e_A), elementBgetsdeps(e_B)if p { e1 } else { e2 }with runtimepscan/unfoldover axisTtdepends on source elements<= t(Prefix); state reads throughprevarePrefix, reads of other indexed nodes attareIdentityAllMulti-axis access is the product of per-axis maps. Non-indexed readers with a constant key get a single-element edge.
Allreproduces today's declaration-level edge exactly, so the relation is a refinement: projecting every key map toAllmust give backruntime_deps.Scheduling, cycle detection, and evaluation are unchanged. The relation is analysis-only data.
Step 2: expose the relation
graphcal dumpincludes the key maps next to the existing dependency information.graphcal graphgets an opt-in flag (name to be decided, for example--edges element) that prints one edge per(reader element, source element)pair for static maps and keeps a single labelledAlledge for gathers. Output stays deterministic (sorted).Step 3 (later, separate issue): consumers
(node, t)cycle detection so thatA[t] → B[t-1]becomes legal (internals/vensim-parity.md§4.1, related to Design: DAG-style closure syntax for unfold/scan bodies #349 and feat: allow indexed state in unfold/scan #889). This is a semantic change and is explicitly out of scope here.Non-goals
argmax,fin_key,nearest_key); they stayAll.Open questions
Const,Identity,Shift,Prefix,All) sufficient, or should the relation be materialised as explicit key sets for concrete axes (bounded by the existing 1,000,000-element limit)?paramdefaults participate the same way asnodebodies? They are ordinary graph expressions, so presumably yes.includebindingvalues: @displacementpasses the whole value; inside the DAG the analysis continues per element. Is the projection across the boundaryIdentityon the bound axis?docs/language/indexes.md"Evaluation granularity" note be reworded until the relation exists, so the docs do not promise per-element edges the tooling cannot show?Acceptance criteria
key(Fin(N), c), Fin-keyi + c), per-armmatchon loop keys, aggregation (Allon the aggregated axis), andscan/unfold(Prefix), and falls back toAllfor runtime keys and runtimeif.Allreproducesruntime_depsexactly (property test).graphcal dumpandgraphcal graph(opt-in flag) expose the relation deterministically.w1 → action[S1]and no edge intoaction[S0]; oneunfoldfixture showing thePrefixrelation.indexes.mdgranularity note andcli-reference.md(graphcal graph,graphcal dump) updated.Related
unfold/scanbody structure and indexed state; per-(node, t)granularity discussion ininternals/vensim-parity.md)