Skip to content

Resolve UNRESOLVED.initConc and UNRESOLVED.Size in SBML import - #1985

Merged
jcschaff merged 3 commits into
masterfrom
fix/803-inline-constant-app-scoped-values
Aug 18, 2026
Merged

Resolve UNRESOLVED.initConc and UNRESOLVED.Size in SBML import#1985
jcschaff merged 3 commits into
masterfrom
fix/803-inline-constant-app-scoped-values

Conversation

@jcschaff

@jcschaff jcschaff commented Aug 17, 2026

Copy link
Copy Markdown
Member

Resolves #803both halves. initConc by hoisting into a global parameter, Size by using the model-level StructureSize.

The problem

SBML has one flat namespace; VCell separates physiology from application. An SBML global parameter becomes a Model parameter, while a species' initial concentration is a SpeciesContextSpec parameter under the SimulationContext. Those two name scopes are unrelated roots — ModelNameScope.getParent() and SimulationContextNameScope.getParent() both return null, and neither is the other's peer — so a Model parameter cannot name an initial concentration, and getRelativeScopePrefix yields the UNRESOLVED. marker. The import then dies much later with:

Error binding global parameter 'beta' to model: 'UNRESOLVED.initConc' is either not found in your model or is not allowed to be used in the current context.

31 curated BioModels fail this way. #803 has been open since 2023.

The fix: invert the dependency, don't drop it

For SBML beta = c1/(N1*s4) where s4 is a species:

before   s4.initConc = 250000              beta = c1/(N1 * UNRESOLVED.initConc)   [broken]
after    s4_initConc = 250000   (global)   s4.initConc = s4_initConc
                                           beta = c1/(N1 * s4_initConc)           [exact]

Verified on the imported model rather than from the log:

s4_initConc = 250000.0          beta        = (c1 / (N1 * s4_initConc))
s2_initConc = 48000.0           s4.initConc = s4_initConc
                                s2.initConc = s2_initConc

Nothing is lost. The relationship stays symbolic, so scanning s4_initConc moves the initial condition and beta together — which is what the SBML meant, and is the VCell idiom for varying an initial condition. Hoisting happens once per species, so N dependents produce one parameter, not N.

Everything stays a global parameter, which matters: global parameters already round-trip through SBMLExporter, whereas SimulationContextParameter does not (#1984). This fix therefore does not wait on that work.

One implementation detail worth knowing

The reference is written as a plain name, deliberately. A species' initial condition resolves it through SimulationContext.getLocalEntry(), which falls through to getModel().getLocalEntry(). Writing it as new Expression(ste, namescope) would ask the scope machinery for a prefix and get UNRESOLVED. straight back. I probed that both directions bind before building on it.

An earlier attempt, rejected

I first inlined the constant value (beta = 1.539e-7). Rejected on review — it freezes the dependency, so the imported model reads as a magic number and an export no longer reproduces the source.

It was also strictly weaker: it could only act when the initial condition was a literal, so model 632 — whose species initial condition is itself computed — stayed broken. Hoisting handles it because it moves the expression, not the value.

Compartment Size: fixed, not deferred

A compartment size has two representations: the StructureMapping Size parameter, which belongs to the application, and Structure.StructureSize, a ModelQuantity that belongs to the physiology and that ModelNameScope names directly.

adjustExpression already swapped to the latter when the target was a species initial condition — it just didn't when the target was in Model scope. Making that swap unconditional fixes the 7 UNRESOLVED.Size models. No hoisting needed, and the size stays a constant, so StructureSizeSolver, GeometryContext and SBMLExporter — all of which call evaluateConstant() on it — are unaffected.

BIOMD0000000457 now imports. 429 and 1027 still fail, on a compartment with constant="false" — a different unsupported feature this failure was hiding.

No reporting left

An earlier revision reported unresolved references instead of fixing them. Resolving them is better, and with both classes handled no UNRESOLVED. marker survives anywhere in the 27-model import suite — the report was dead code and is gone, along with the collector list that fed the initial-condition message.

What remains is one logger.error inside adjustExpression for a scope mismatch we have not seen, since the eventual binding failure names only the marker and not what produced it. No collector, no call-site plumbing, and it covers all twelve call sites rather than two.

Verification

model before after
872 UNRESOLVED.initConc imports
599, 705, 696 UNRESOLVED.initConc import
632 UNRESOLVED.initConc imports — the case inlining could not do
627 UNRESOLVED.initConc still fails, on a reaction-rate reference (#1983) that was hidden behind this
457 UNRESOLVED.Size imports
429, 1027 UNRESOLVED.Size still fail, on constant="false" compartments — a different feature this was hiding
suite result
SBMLTestSuiteTest — computed results vs reference CSVs, within tolerance 1382 tests, 0 failures
BMDB_SBMLImportTest (SBML_IT) 27 tests, 0 failures — 696 removed from the fault table, it now passes
vcell-core Fast, CI parallel flags 547 tests, 1 error (VCellDataTest poetry noise, environmental)
vcell-math Fast 6545 tests, 0 failures

The first row is the one that matters. "It imports" says nothing about whether the math is still right; the SBML Test Suite compares simulated output against reference results, so it is the check that these scope changes preserve semantics rather than just parseability.

AbstractNameScope gains a named constant for the "UNRESOLVED." literal, so callers that can do better on that path can test for it rather than matching a magic string.

Refs #803, #1984

🤖 Generated with Claude Code

…sing them

SBML has one flat namespace; VCell separates physiology from application. An SBML global
parameter becomes a Model parameter, while a species' initial concentration is a
SpeciesContextSpec parameter under the SimulationContext. Those two name scopes are unrelated
roots -- ModelNameScope.getParent() and SimulationContextNameScope.getParent() both return
null, and neither is the other's peer -- so a Model parameter cannot name an initial
concentration, getRelativeScopePrefix yields the UNRESOLVED. marker, and the import dies much
later with

    Error binding global parameter 'beta' to model: 'UNRESOLVED.initConc' is either not found
    in your model or is not allowed to be used in the current context.

31 curated BioModels fail this way (issue #803, open since 2023).

Rather than dropping the dependency or freezing it as a number, invert it. For SBML
beta = c1/(N1*s4) where s4 is a species:

    before   s4.initConc = 250000              beta = c1/(N1 * UNRESOLVED.initConc)  [broken]
    after    s4_initConc = 250000   (global)   s4.initConc = s4_initConc
                                               beta = c1/(N1 * s4_initConc)          [exact]

Nothing is lost. The relationship stays symbolic, so scanning s4_initConc moves the initial
condition and beta together, which is what the SBML meant. Everything stays a global
parameter, which matters: global parameters already round-trip through SBMLExporter, whereas
SimulationContextParameter does not (#1984). Hoisting once per species, so N dependents
produce one parameter, not N.

The reference is written as a PLAIN NAME on purpose. A species' initial condition resolves it
through SimulationContext.getLocalEntry(), which falls through to getModel().getLocalEntry();
writing it as new Expression(ste, namescope) would ask the scope machinery for a prefix and
get UNRESOLVED. straight back. Verified both directions bind before building on it.

An earlier attempt inlined the constant value instead. Rejected: it freezes the dependency, so
the imported model reads as a magic number and an export no longer reproduces the source. It
was also strictly weaker -- it could only act when the initial condition was a literal, so
model 632, whose species initial condition is itself computed, stayed broken. Hoisting handles
it because it moves the expression, not the value.

Compartment sizes are deliberately NOT hoisted. A StructureMapping size must remain constant:
StructureSizeSolver (775, 786, 789), GeometryContext (419) and SBMLExporter (373, 387) all
call evaluateConstant() on it, so a symbol there would break the size solver and export. Those
models still fail, now with an explanation rather than a leaked UNRESOLVED marker.

Verified against the models: 599, 632, 705, 872 import (632 is the one inlining could not
do); 627 still fails, on a reaction-rate reference (#1983) that was hidden behind this one.
BMDB_SBMLImportTest 27 tests 0 failures, with 696 removed from the fault table because it now
passes. vcell-core Fast 547 tests, 1 error (VCellDataTest poetry noise, environmental).

AbstractNameScope gains a named constant for the "UNRESOLVED." literal so callers that can do
better on that path can test for it.

Refs #803, #1984

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
jcschaff and others added 2 commits August 17, 2026 21:25
The List<String> threaded through adjustExpression existed solely so one LowPriority vcLogger
message could name the hoisted initial conditions. It had no functional role -- hoisting
happens inside hoistInitialConditionToGlobalParameter whether or not a collector is passed.

Removing it because it was the wrong shape three ways:

  - inconsistent: wired into 2 of adjustExpression's ~12 call sites, so hoists reached from any
    other path reported nothing, with no principle behind which ones did;
  - duplicative: SBMLSymbolMapping already records SBase->STE provenance for both initial and
    runtime contexts, and the hoisted parameter carries a description explaining itself;
  - expensive for what it was: an extra overload and parameter on a method called from a dozen
    places, to produce a log line.

The logger.info inside the hoist itself stays, and is strictly better -- it fires for every
hoist from every call site rather than two. Naming conflicts never depended on the list either;
uniqueGlobalParameterName checks getModelParameter and getLocalEntry directly.

adjustExpression returns to a single five-argument method. Behaviour is unchanged: 599, 632,
705 and 872 still import, 627 still fails on its reaction-rate reference, BMDB_SBMLImportTest
27 tests 0 failures.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…tion

A compartment's size has two representations in VCell: the StructureMapping Size parameter,
which belongs to the application, and Structure.StructureSize, a ModelQuantity that belongs to
the physiology and that ModelNameScope names directly. adjustExpression already swapped to the
latter when the target was a species initial condition; it did not when the target was in Model
scope, so a global parameter referencing a compartment size resolved to UNRESOLVED.Size and
failed at binding. Seven of the 31 models in #803 fail that way.

Fixed by making the swap unconditional -- anything outside the application needs the model-level
quantity. BIOMD0000000457 now imports. 429 and 1027 still fail, on a compartment with
constant="false", which is a different unsupported feature that this failure was hiding.

This replaces the reporting added earlier for the same case. Reporting an unresolved reference
was the wrong shape: better to resolve it. With initial conditions hoisted and sizes using
StructureSize, no UNRESOLVED. marker survives anywhere in the 27-model import suite, so the
report was dead code.

What remains is a single logger.error inside adjustExpression for a scope mismatch we have not
seen, since the eventual binding failure names only the marker and not what produced it. No
collector, no call-site plumbing, and it covers all twelve call sites rather than two.

Verified with the check that actually tests semantics rather than parseability: the SBML Test
Suite, which compares computed results against reference CSVs within tolerance, passes 1382
tests 0 failures. BMDB_SBMLImportTest 27 tests 0 failures.

Refs #803

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@jcschaff jcschaff changed the title Hoist species initial conditions into global parameters instead of losing them Resolve UNRESOLVED.initConc and UNRESOLVED.Size in SBML import Aug 18, 2026
@jcschaff
jcschaff merged commit ac5774d into master Aug 18, 2026
9 checks passed
@jcschaff
jcschaff deleted the fix/803-inline-constant-app-scoped-values branch August 18, 2026 02:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

31 BMDB models fail to import: SBML global parameters reaching application-scoped initConc/Size (UNRESOLVED.)

1 participant