The design and the Vensim format notes of the native Rust Vensim MDL reader and writer (src/simlin-engine/src/mdl/).
For current status and agent guidance, see src/simlin-engine/src/mdl/CLAUDE.md.
The reader is native Rust for two reasons:
- Build complexity: it needs no parser generator, no C++ toolchain, and no cross-compilation setup beyond the engine's own.
- WASM compatibility: it builds for wasm with the rest of the engine, so the browser imports
.mdlfiles directly.
Its algorithms are ported from xmutil, Bob Eberlein's C++ Vensim-to-XMILE converter (https://github.com/bobeberlein/xmutil).
Where a comment cites an xmutil function or a File.cpp:line, it names the code a rule was ported from, under that repository's src/ directory.
What a model computes is settled by Vensim's own output (see "What Holds the Reader to Ground Truth"), not by xmutil.
Vensim MDL file
|
Rust lexer (mdl/lexer.rs)
|
Hand-written recursive descent parser (mdl/parser.rs)
|
minimal internal representations private to this package
|
simlin_engine::datamodel::Project <-- target output
|
(optional) xmile::project_to_xmile() <-- "free" XMILE export
We deliberately skip the XMILE intermediate representation.
By targeting datamodel directly:
- We leverage existing XMILE conversion functions for free
- We avoid double-parsing (MDL -> XMILE XML string -> parse XMILE -> datamodel)
- We can extend the datamodel if needed for Vensim-specific features
Some Vensim concepts require intermediate representations before conversion to datamodel:
-
View/Diagram data: Vensim's sketch format uses element indices, relative positioning, and multiple views that must be resolved before converting to absolute positions in a single view in the datamodel.
-
Symbol table: During parsing, we need to track variable definitions, subscript ranges, and macros before final resolution.
The feature set is xmutil's. This section documents it, organized by implementation phase.
- Numbers: integers, floats, scientific notation (e.g.,
1e-6,1.5E+3) - Strings/Symbols: variable names (can contain spaces, underscores)
- Quoted strings with escape sequences (
\"inside quotes) - Operators:
+ - * / ^ < > = ( ) [ ] , ; : | - Compound operators:
:=(data equals),<=,>=,<> - Keywords:
:AND:,:OR:,:NOT:,:NA: - Special keywords:
:MACRO:,:END OF MACRO: - Interpolation modes:
:INTERPOLATE:,:RAW:,:HOLD BACKWARD:,:LOOK FORWARD: - Exception keyword:
:EXCEPT: - Equivalence:
<-> - Map arrow:
-> - Comment terminators:
~and| - Bang subscript modifier:
! - End token:
\\\\\\---///(end of equations section) - Group markers:
{**name**}and***name***|formats viaGroupStartoken
- Track position for error messages
- Handle multi-line tokens (line continuation with
\at EOL) - Skip whitespace appropriately
- Handle nested comments (
{ { nested } }) - Comment extraction handled by EquationReader (text between second
~and|)
Expr::Const(f64, Loc)- numeric literalsExpr::Var(name, subscripts, Loc)- variable referencesExpr::Op2(BinaryOp, ...)for binary,Expr::Op1(UnaryOp, ...)for unary operatorsBinaryOp::And,BinaryOp::Or,UnaryOp::Not- logical operationsExpr::App(name, subscripts, args, CallKind, Loc)- function callsExpr::Literal(Cow<str>, Loc)- string literalsExpr::Paren(Box<Expr>, Loc)- parenthesized expressionsEquation::TabbedArrayandEquation::NumberList- number tables
Equation::Regular(Lhs, Expr)- standard equationEquation::Lookup(Lhs, LookupTable)- lookup definitionEquation::WithLookup(Lhs, Box<Expr>, LookupTable)- WITH LOOKUPEquation::Data(Lhs, Option<Expr>)- data equationEquation::SubscriptDef(name, SubscriptDef)- dimension definitionEquation::Equivalence(name1, name2, Loc)- dimension equivalence
The table mdl::parser implements.
Its binary levels disagree with Vensim and XMILE, which put +/- above the comparisons and :AND: below them (GH #914); the importer emits operands flat so the XMILE grammar re-establishes the intended grouping, and the writer groups by the XMILE/Vensim table (ast::BinaryOp::precedence).
- +(addition/subtraction):OR:= < > <= >= <>:AND:* /:NOT:, unary+,-^(right-associative)
Function recognition via is_builtin() using to_lower_space() canonicalization.
Categories:
- Mathematical: ABS, EXP, SQRT, LN, LOG, SIN, COS, TAN, MIN, MAX, INTEGER, MODULO, QUANTUM
- Conditional: IF THEN ELSE, ZIDZ, XIDZ
- Time: PULSE, PULSE TRAIN, STEP, RAMP
- Delay/Smooth: SMOOTH, SMOOTH3, DELAY1, DELAY3, DELAY FIXED, DELAY N, TREND, FORECAST
- Lookup: WITH LOOKUP, LOOKUP INVERT/AREA/EXTRAPOLATE/FORWARD/BACKWARD, TABXL, GET DATA AT TIME
- Array: SUM, PROD, VMAX, VMIN, ELMCOUNT, VECTOR SELECT/ELM MAP/SORT ORDER/REORDER/LOOKUP
- Integration: INTEG, ACTIVE INITIAL, INITIAL, SAMPLE IF TRUE
- Random: RANDOM 0 1, RANDOM UNIFORM/NORMAL/POISSON/PINK NOISE
- Special: NA, A FUNCTION OF, GAME, GET DIRECT DATA, GET XLS/VDF functions
What some of these mean, as the importer translates them:
- PULSE(start, width) is 1 while
Time + TIME STEP/2is strictly betweenstartandstart + width, and a width of 0 is one TIME STEP (vensim.com/documentation/fn_pulse.html). - PULSE TRAIN(start, width, interval, end) is PULSE repeated every
intervalup toend, inclusive, each pulse at least one TIME STEP long (fn_pulse_train.html, which gives no equation; the inclusive end follows Vensim's own run intest/sdeverywhere/models/pulsetrain/pulsetrain.dat). - INTEGER truncates toward zero and MODULO is the remainder of that truncated division,
A - QUANTUM(A, B)(fn_integer.html, fn_modulo.html;test/test-models/tests/rounding/output.tab), so they import as the engine'sTRUNCandREM, not XMILE's flooringINTandMOD. Vensim's function reference has no INT or MOD; a two-argumentMODcall of a name the model does not define is read as the floored modulus, the writer's spelling of it. - QUANTUM(A, B) returns A for B at or below zero (fn_quantum.html).
- A FUNCTION OF "is not intended for use in writing equations, and precludes simulation" (fn_a_function_of.html); it imports as
NAN, an equation that reads but does not simulate.
- Variable type detection: stocks via top-level INTEG(), flows from rate expressions, auxiliaries for everything else
- Flow linking via is_all_plus_minus algorithm with synthetic net flow generation
- Sim specs extraction from control variables (INITIAL TIME, FINAL TIME, TIME STEP, SAVEPER)
- Dimension building with range expansion and equivalence handling
- A variable's dimensions are those of the elements its equations define: per axis, a dimension a left-hand side names that holds exactly the defined elements, else the smallest declared dimension holding them all, the first declared of several that size (GH #1059).
Vensim runs a variable whose equations define only some elements of a dimension over those elements (
test/test-models/tests/except_subranges); which of several dimensions holding them is the variable's is Simlin's rule, since Vensim keeps no dimension per variable. - Element keys are canonical (
CanonicalElementName::from_subscript), as every reader of a datamodel stores them; an element's spelling is its dimension's. - An import is a function of the file: no decision reads a
HashMap's iteration order (import_determinism_tests). - XMILE-compatible expression formatting (
xmile_compat.rs) - Group building with hierarchy and conflict resolution
- Sketch section parsing after
\\\\\\---///marker - Element types: variable (10), valve (11), comment/cloud (12), connector (1)
- Ghost/alias detection for duplicate variable appearances
- Coordinate transformation and multi-view composition
- Flow point computation with stock inflow/outflow detection
- Arc angle calculation for curved connectors
- Integration type parsing (type 15): Euler/RK2/RK4 method detection
- Unit equivalence parsing (type 22)
writer.rs writes a datamodel::Project as MDL.
Its contract is that a save reads back as the datamodel it saved, up to what MDL cannot hold (views, which a save lays out anew, and an equation's spelling): save_roundtrip_tests holds every corpus .mdl to it, and writer_proptest holds generated models to it and to the save being a fixed point.
What MDL cannot spell is written as close as it can be, with an ExportWarning.
The format constrains the writer in ways worth stating:
- An element equation names no dimension, so an arrayed variable is written under left-hand sides that name its dimensions wherever its equations allow (
writer_arrayed.rs): a number list, a group of elements agreeing across a dimension, or an:EXCEPT:equation over its dimensions or a subrange its default names. Otherwise the reader infers the dimensions from the elements, and of several dimensions holding the same elements it takes the first declared; the writer warns. - Every equation written holds Vensim's rule for a subscript range on a right-hand side: "When you use a Subscript Range in an equation it must appear on the left hand side" (vensim.com/documentation/ref_subscripts.html), unless it is mapped onto one there (ref_subscript_mapping.html).
An
:EXCEPT:default no left-hand side can hold that way, or whose equation would except every element it names, is written as the equations of its elements, with a warning that the default is not kept. - A function Vensim does not have (ROUND, INT, MOD) is written by name: Simlin reads it back, Vensim does not. Lowering one to Vensim primitives would repeat its argument, wrong for a stochastic one.
- A display newline in a name is the
\nescape inside a quoted name, as Vensim writes one, and a reference is spelled as its definition is, since the reader matches names as written.
- No panics across FFI/WASM boundaries: Use
Resultfor all invariant failures in production paths. - Invalid MDL input: Collect and report multiple errors rather than failing fast.
- Preserve xmutil permissive fallbacks: Keep xmutil-compatible behavior (atoi semantics, empty-equation shims, implicit defaults) and document each fallback. Long-term goal is full-fidelity translation.
The production paths return errors rather than panicking:
- Tabbed array parsing (
normalizer.rs): returnsResult - Number parsing helper (
parser_helpers.rs): returnsResult - View parsing (
view/mod.rs): usesok_or(ViewError::UnexpectedEndOfInput) - Normalizer invariant (
normalizer.rs): returnsOk(None)instead of panicking - Invariant unwraps in conversion/view processing: all use
match/continue/Option
Vensim's own output does.
The MDL corpus tests in tests/integration/simulate.rs (simulate_mdl_path and its variants) import a corpus .mdl that has a Vensim run beside it (output.csv, output.tab, or the model's .dat), simulate it, and compare the result with that run.
A change to how a file imports is right when every such test still passes.
The census in tests/integration/mdl_vensim_truth.rs asks the same question of every corpus .mdl with a Vensim run beside it, and pins, for each file that differs, the series that differ (mdl_vensim_truth_series.tsv).
It is a gate: scripts/gates.sh mdl_vensim_truth.
The gates that read a .mdl and compare Simlin with Simlin (the save fixed point, the save-meaning check) cannot stand in for them: a reader that is wrong is wrong on both sides of those.
C-LEARN (test/xmutil_test_models/C-LEARN v77 for Vensim.mdl) exercises subscripts, subranges, bang notation, and element-specific equations extensively.
It is held to its own Vensim reference run by simulates_clearn and its companions in tests/integration/simulate.rs.
ContextInfo.cpp:7-60(GetLHSSpecific): Per-element dimension reference substitutionSymbolList.cpp:29-50(SetOwner): Ownership assignment for subrange detectionSymbolList.cpp:52-113(OutputComputable): Bang subscript output logicXMILEGenerator.cpp:420-543(generateEquation): Multi-equation expansionVariable.cpp:326-349(OutputComputable): Non-bang subscript resolution
- Test-driven correctness: Maintain passing tests throughout migration
- Don't change semantics during translation: Behavior changes come in separate commits
The current merge_views approach combines all views into a single StockFlow view with group wrappers.
Enhancement needed for module/level-structured models:
vele->Ghost(adds)parameter determines cross-level references- Cross-level connector handling in
XMILEGenerator.cpp:910-960