Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 16 additions & 11 deletions docs/design/ltm--loops-that-matter.md
Original file line number Diff line number Diff line change
Expand Up @@ -1067,13 +1067,17 @@ hoisted too: the `Iterated` axis carries the (target, source) dimension
pair, the agg is arrayed over the TARGET dim (`State`), and each source row
is remapped to the slot of its positionally-corresponding target element
(`iterated_axis_slot_elements` -- the preimage of
`mapped_element_correspondence`, so the element-map/positional gate is
inherited). The only reducers *not* hoisted are the dynamic-index carve-out
(`SUM(pop[idx, *])`, `idx` non-literal -- not statically describable,
reclassified `DynamicIndex`) and the mapped sliced reducers the
correspondence declines -- an explicit element-mapped pair (execution
resolves positionally, GH #756) or a reverse-declared mapping (GH #757) --
which keep the conservative cross-product; a bare non-literal index
`positional_correspondence`, which is the right rule here because
`matrix[State, *]` names the dimension the equation ITERATES and execution
folds that to an ordinal; an explicit element map is therefore honoured as a
DECLARED correspondence but not READ, GH #997). The only reducers *not*
hoisted are the dynamic-index carve-out (`SUM(pop[idx, *])`, `idx`
non-literal -- not statically describable, reclassified `DynamicIndex`), a
pair with no declared correspondence at all, and a `MappedRead` axis
(`SUM(matrix[Region, *])` naming a NON-iterated dimension, GH #997: its
executed rule admits a many-to-one correspondence that the one-slot-per-row
remap cannot express, so `compute_read_slice` declines it) -- all of which
keep the conservative cross-product; a bare non-literal index
(`arr[i+1]`) is a dynamic reference, not a reducer, so it stays conservative.
Variable-backed aggs (`total_population = SUM(population[*])`) are already
real nodes -- their edges come from the normal arrayed→scalar /
Expand Down Expand Up @@ -1173,13 +1177,14 @@ slot per `D1` element); `SUM(matrix3d[D1, NYC, *])` over an A2A-`D1` body ⇒
`result_dims = [State]` -- the agg is arrayed over the TARGET's iterated
dim, and the emitters remap each source row to the slot of its
positionally-corresponding target element (`iterated_axis_slot_elements`,
the preimage inversion of `mapped_element_correspondence`, so the
positional-only gate is inherited). The carve-outs (tracked tech debt;
the preimage inversion of `positional_correspondence`, the rule the ITERATED
spelling gets). The carve-outs (tracked tech debt;
the conservative cross-product / coarse link score stays in place) are: a
reducer over a *dynamic index* (`SUM(pop[idx, *])`, `idx` non-literal -- the
IR reclassifies its reference to `DynamicIndex`); a mapped sliced reducer
the correspondence declines -- an explicit element-mapped pair (execution
resolves positionally and ignores the map, GH #756) or a mapping declared
the correspondence declines -- a pair with no declared correspondence, or a
`MappedRead` axis whose executed rule the slot remap cannot invert
(GH #997) -- or a mapping declared
only in the reverse direction (on the source's dimension; GH #757 tracks
that direction's classification); and a multi-source reducer whose arrayed
args read incompatible slices (`combined_read_slice` returns `None` on
Expand Down
19 changes: 10 additions & 9 deletions src/simlin-engine/CLAUDE.md

Large diffs are not rendered by default.

63 changes: 63 additions & 0 deletions src/simlin-engine/examples/layout_probe_models.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
// Copyright 2026 The Simlin Authors. All rights reserved.
// Use of this source code is governed by the Apache License,
// Version 2.0, that can be found in the LICENSE file.

//! One-shot helper: give each vensim-probes/*.mdl a generated diagram so the
//! probes open with a visible view in Vensim, WITHOUT touching the equations.
//!
//! The obvious route -- read, lay out, and re-serialize the whole project
//! through the MDL writer -- rewrites the equation section too, and the writer
//! spells an apply-to-all equation per element. That changes exactly what a
//! probe asks Vensim to parse (an element-pinned left-hand side over a
//! right-hand side naming subscript ranges), so instead the generated output is
//! used only as a donor: the sketch block between the `\\\---///` and
//! `///---\\\` markers is spliced into the original file, whose hand-written
//! equation text stays byte-identical. Sketch entries reference variables by
//! name, so a donor sketch over the original equations is well-formed.

use std::fs;

const SKETCH_START: &str = "\\\\\\---///";
const SKETCH_END: &str = "///---\\\\\\";

fn sketch_block(mdl: &str) -> &str {
let start = mdl.find(SKETCH_START).expect("no sketch start marker");
let end = mdl.find(SKETCH_END).expect("no sketch end marker") + SKETCH_END.len();
&mdl[start..end]
}

fn main() {
for path in [
"vensim-probes/elm_map_computed_source.mdl",
"vensim-probes/elm_map_variable_sources.mdl",
"vensim-probes/repeated_dimension.mdl",
] {
let original = fs::read_to_string(path).unwrap_or_else(|e| panic!("read {path}: {e}"));
let mut project = simlin_engine::compat::open_vensim(&original)
.unwrap_or_else(|e| panic!("open {path}: {e}"));
let model_name = project.models[0].name.clone();
let view = simlin_engine::layout::generate_best_layout(&project, &model_name, None)
.unwrap_or_else(|e| panic!("layout {path}: {e}"));
project.models[0].views = vec![simlin_engine::datamodel::View::StockFlow(view)];
let (rendered, warnings) = simlin_engine::compat::to_mdl_with_warnings(&project)
.unwrap_or_else(|e| panic!("render {path}: {e}"));
for w in &warnings {
eprintln!("warning ({path}): {}", w.message);
}

let donor = sketch_block(&rendered);
let start = original.find(SKETCH_START).expect("no sketch in original");
let end = original
.find(SKETCH_END)
.expect("no sketch end in original")
+ SKETCH_END.len();
let spliced = format!("{}{}{}", &original[..start], donor, &original[end..]);
assert_eq!(
&spliced[..start],
&original[..start],
"equation text must be untouched"
);
fs::write(path, spliced).unwrap_or_else(|e| panic!("write {path}: {e}"));
println!("spliced sketch into {path}");
}
}
106 changes: 106 additions & 0 deletions src/simlin-engine/examples/ltm_declined_edges.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
// Copyright 2026 The Simlin Authors. All rights reserved.
// Use of this source code is governed by the Apache License,
// Version 2.0, that can be found in the LICENSE file.

//! Every LTM link score a model DECLINES to emit, bucketed by the reason the
//! generator gave.
//!
//! `examples/ltm_fragment_failures.rs` counts fragments that fail to COMPILE;
//! this counts the ones never generated at all -- the `PartialEquationError`
//! family (unprojectable dep, rank-like partial, unfreezable partial, bare
//! reducer feeder, parse failure) plus the GH #758 loud skip. Those are
//! invisible to the fragment count precisely because nothing was emitted.
//!
//! Usage:
//! cargo run --release -p simlin-engine --example ltm_declined_edges
//! LTM_DECLINE_MODEL=path/to/model.mdl cargo run --release ... --example ltm_declined_edges

use std::collections::BTreeMap;
use std::path::PathBuf;

use simlin_engine::db::{
SimlinDb, collect_all_diagnostics, set_project_ltm_enabled, sync_from_datamodel_incremental,
};
use simlin_engine::{open_vensim, open_xmile};

/// Which decline this diagnostic reports, keyed off the message's own wording
/// (the messages are the only channel `collect_all_diagnostics` exposes).
fn bucket(msg: &str) -> Option<&'static str> {
if !msg.contains("could not be generated") && !msg.contains("no link score") {
return None;
}
let kinds = [
(
"cannot be projected onto that target element",
"unprojectable-dep",
),
("array-producing", "rank-like-partial"),
("freeze an array slice", "unfreezable-partial"),
("inside an array-reducer argument", "bare-reducer-feeder"),
("did not parse", "parse-failure"),
];
for (needle, name) in kinds {
if msg.contains(needle) {
return Some(name);
}
}
Some("other")
}

fn main() {
let model_path = std::env::var("LTM_DECLINE_MODEL")
.map(PathBuf::from)
.unwrap_or_else(|_| {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../../test/xmutil_test_models/C-LEARN v77 for Vensim.mdl")
});

let contents = std::fs::read_to_string(&model_path).expect("read model");
let datamodel = if model_path.extension().is_some_and(|e| e == "mdl") {
open_vensim(&contents).expect("import vensim model")
} else {
open_xmile(&mut contents.as_bytes()).expect("import xmile model")
};
println!("model: {}", model_path.display());

let mut db = SimlinDb::default();
let sync = sync_from_datamodel_incremental(&mut db, &datamodel, None);
set_project_ltm_enabled(&mut db, sync.project, true);

let diags = collect_all_diagnostics(&db, sync.project);
let mut by_bucket: BTreeMap<&'static str, Vec<String>> = BTreeMap::new();
for d in &diags {
let msg = format!("{:?}", d.error);
if let Some(b) = bucket(&msg) {
// The link-score variable name is the quoted ident right after
// "variable '".
let name = msg
.split_once("variable '")
.and_then(|(_, rest)| rest.split_once('\''))
.map(|(n, _)| n.to_string())
.unwrap_or_else(|| msg.clone());
// The offending dep / equation text, the second quoted run.
let detail = msg
.split_once("dependency '")
.or_else(|| msg.split_once("equation '"))
.and_then(|(_, rest)| rest.split_once('\''))
.map(|(d, _)| d.to_string())
.unwrap_or_default();
by_bucket
.entry(b)
.or_default()
.push(format!("{name} [{detail}]"));
}
}

let total: usize = by_bucket.values().map(Vec::len).sum();
println!("declined link scores: {total}");
for (b, names) in &by_bucket {
println!("\n=== {b}: {} ===", names.len());
let mut names = names.clone();
names.sort();
for n in &names {
println!(" {n}");
}
}
}
67 changes: 67 additions & 0 deletions src/simlin-engine/examples/ltm_slot_width.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
// Copyright 2026 The Simlin Authors. All rights reserved.
// Use of this source code is governed by the Apache License,
// Version 2.0, that can be found in the LICENSE file.

//! The two numbers `simulate::clearn_ltm_var_count_guardrail` pins: a model's
//! emitted LTM variable COUNT and its per-step result-row WIDTH in slots (the
//! GH #654 resource, against the VM's 65,536 u16 slot ceiling).
//!
//! The guard's rustdoc requires re-measuring BOTH whenever the count moves, and
//! the width is not derivable from the count -- an arrayed variable occupies one
//! slot per element. This is the harness that produces them, so the numbers in
//! that rustdoc are regenerable rather than folklore.
//!
//! Usage:
//! cargo run --release -p simlin-engine --example ltm_slot_width
//! LTM_WIDTH_MODEL=path/to/model.mdl cargo run --release ... --example ltm_slot_width

use std::path::PathBuf;

use simlin_engine::db::{
SimlinDb, model_ltm_variables, set_project_ltm_enabled, sync_from_datamodel_incremental,
};
use simlin_engine::queue_compile::compile_sim;
use simlin_engine::{open_vensim, open_xmile};

fn main() {
let model_path = std::env::var("LTM_WIDTH_MODEL")
.map(PathBuf::from)
.unwrap_or_else(|_| {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../../test/xmutil_test_models/C-LEARN v77 for Vensim.mdl")
});

let contents = std::fs::read_to_string(&model_path).expect("read model");
let datamodel = if model_path.extension().is_some_and(|e| e == "mdl") {
open_vensim(&contents).expect("import vensim model")
} else {
open_xmile(&mut contents.as_bytes()).expect("import xmile model")
};
println!("model: {}", model_path.display());

let mut db = SimlinDb::default();
let sync = sync_from_datamodel_incremental(&mut db, &datamodel, None);
set_project_ltm_enabled(&mut db, sync.project, true);

let total: usize = sync
.models
.values()
.map(|m| {
model_ltm_variables(&db, m.source_model, sync.project)
.vars
.len()
})
.sum();
println!("emitted LTM variables: {total}");

let main_name = datamodel
.models
.iter()
.find(|m| m.name == "main")
.map(|m| m.name.clone())
.unwrap_or_else(|| datamodel.models[0].name.clone());
let build = compile_sim(&mut db, sync.project, &datamodel, &main_name).expect("compile");
let width = build.compiled.n_slots();
println!("per-step result-row width: {width} slots");
println!("free against the 65,536-slot ceiling: {}", 65536 - width);
}
43 changes: 43 additions & 0 deletions src/simlin-engine/examples/ltm_var_dump.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
// Copyright 2026 The Simlin Authors. All rights reserved.
// Use of this source code is governed by the Apache License,
// Version 2.0, that can be found in the LICENSE file.

//! Every LTM variable name a model emits, `model<TAB>name`, sorted -- the
//! instrument behind `simulate::clearn_ltm_var_count_guardrail`'s derivation.
//!
//! The guardrail pins a COUNT, which says a number moved but not which names
//! moved or in which direction. Diffing two runs of this does:
//!
//! ```text
//! cargo run --release -p simlin-engine --example ltm_var_dump > after.txt
//! # (revert the change under test)
//! cargo run --release -p simlin-engine --example ltm_var_dump > before.txt
//! comm -13 <(sort before.txt) <(sort after.txt) # added
//! comm -23 <(sort before.txt) <(sort after.txt) # removed
//! ```
//!
//! That is how the MDL apply-to-all import fix was shown to be strictly
//! additive (315 added, 0 removed) rather than a wash of gains and losses.
use simlin_engine::db::{
SimlinDb, model_ltm_variables, set_project_ltm_enabled, sync_from_datamodel_incremental,
};
fn main() {
let path = concat!(
env!("CARGO_MANIFEST_DIR"),
"/../../test/xmutil_test_models/C-LEARN v77 for Vensim.mdl"
);
let contents = std::fs::read_to_string(path).expect("read model");
let datamodel = simlin_engine::open_vensim(&contents).expect("import");
let mut db = SimlinDb::default();
let sync = sync_from_datamodel_incremental(&mut db, &datamodel, None);
set_project_ltm_enabled(&mut db, sync.project, true);
let mut names: Vec<String> = Vec::new();
for (model_name, m) in sync.models.iter() {
let ltm = model_ltm_variables(&db, m.source_model, sync.project);
names.extend(ltm.vars.iter().map(|v| format!("{model_name}\t{}", v.name)));
}
names.sort_unstable();
for n in &names {
println!("{n}");
}
}
71 changes: 71 additions & 0 deletions src/simlin-engine/examples/mdl_compile_census.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
// Copyright 2026 The Simlin Authors. All rights reserved.
// Use of this source code is governed by the Apache License,
// Version 2.0, that can be found in the LICENSE file.

//! Import every `.mdl` under `test/` and report which ones fail, and why.
//!
//! Prints one `IMPORT-FAIL` / `COMPILE-FAIL` line per failing model with its
//! diagnostics, and a summary to stderr. Diffing two runs is how a change to the
//! MDL importer is shown not to regress the corpus: the apply-to-all import fix
//! moved exactly one model (`sdeverywhere/models/vector/vector.mdl`, which had
//! been failing codegen on `y`'s dimension arithmetic) from fail to ok, and
//! moved none the other way, across 262 files.
//!
//! The remaining failures are pre-existing and unrelated -- unimplemented Vensim
//! builtins dominate -- so the summary counts are a ratchet, not a target.
use std::path::{Path, PathBuf};
fn walk(dir: &Path, out: &mut Vec<PathBuf>) {
let Ok(rd) = std::fs::read_dir(dir) else {
return;
};
for e in rd.flatten() {
let p = e.path();
if p.is_dir() {
walk(&p, out);
} else if p.extension().is_some_and(|x| x == "mdl") {
out.push(p);
}
}
}
fn main() {
let root = PathBuf::from(concat!(env!("CARGO_MANIFEST_DIR"), "/../../test"));
let mut files = Vec::new();
walk(&root, &mut files);
files.sort();
let (mut ok, mut import_err, mut compile_err) = (0, 0, 0);
for f in &files {
let rel = f.strip_prefix(&root).unwrap_or(f).display().to_string();
let Ok(contents) = std::fs::read_to_string(f) else {
continue;
};
let dm = match simlin_engine::open_vensim(&contents) {
Ok(d) => d,
Err(e) => {
import_err += 1;
println!("IMPORT-FAIL\t{rel}\t{e}");
continue;
}
};
// Use the production incremental path: sync + collect diagnostics.
let mut db = simlin_engine::db::SimlinDb::default();
let sync = simlin_engine::db::sync_from_datamodel_incremental(&mut db, &dm, None);
let diags = simlin_engine::db::collect_all_diagnostics(&db, sync.project);
let mut msgs: Vec<String> = diags
.iter()
.filter(|d| d.severity == simlin_engine::db::DiagnosticSeverity::Error)
.map(|d| format!("{}:{:?}", d.variable.as_deref().unwrap_or("-"), d.error))
.collect();
msgs.sort();
msgs.dedup();
if msgs.is_empty() {
ok += 1;
} else {
compile_err += 1;
println!("COMPILE-FAIL\t{rel}\t{}", msgs.join(" | "));
}
}
eprintln!(
"total={} ok={ok} import_err={import_err} compile_err={compile_err}",
files.len()
);
}
Loading
Loading