Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
5505dac
engine: record unscoreable edges on ltm partial-equation skips
bpowers Jun 12, 2026
f451f1f
engine: record unscoreable edges at the agg-half partial-equation skips
bpowers Jun 12, 2026
807cdf0
engine: warn once per unscoreable edge on pinned-pass re-visits
bpowers Jun 12, 2026
ef91194
engine: decline bare-spelled feeder of un-hoisted reducer loudly
bpowers Jun 12, 2026
ef07218
engine: correct gh779 prose and name the bare-feeder decline reason
bpowers Jun 12, 2026
6c26ce1
engine: golden-pin square-source and projection-feeder element edges
bpowers Jun 12, 2026
bb21b10
engine: unify agg-routed row derivation under read_slice_row_parts (I4)
bpowers Jun 12, 2026
d35bbd2
engine: decline degenerate square-source reducers in ltm (gh778, gh785)
bpowers Jun 12, 2026
72b97a9
engine: square-source follow-up: doc scoping, message text, test hard…
bpowers Jun 12, 2026
0fcfba5
engine: score arrayed-owner broadcast reduce slices in ltm (gh777)
bpowers Jun 12, 2026
c250eaf
engine: refresh comments stale after the gh777 broadcast admission
bpowers Jun 12, 2026
c1a16df
engine: decline strict-slice cartesian reduce scores in ltm (gh791)
bpowers Jun 12, 2026
98b8454
engine: review follow-ups for the gh791 strict-slice decline
bpowers Jun 12, 2026
0cae644
engine: route scalar feeder of whole-rhs reduce through agg arm (gh790)
bpowers Jun 12, 2026
d15e8e9
engine: review follow-ups for the gh790 scalar-feeder routing
bpowers Jun 12, 2026
177da89
engine: project discovery a2a from-node onto source dims (gh754)
bpowers Jun 12, 2026
2fc2f2e
engine: add slot-attribution oracle for discovery a2a expansion
bpowers Jun 12, 2026
676b4e9
engine: decline per-element-owner strict-slice reduce scores in ltm (…
bpowers Jun 12, 2026
00b6bc0
engine: decline every per-element-owner reducer read in ltm (gh792)
bpowers Jun 12, 2026
436e2af
engine: close ltm phase one residuals
bpowers Jun 12, 2026
dce79ed
engine: scope bare reducer declines by term
bpowers Jun 12, 2026
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: 8 additions & 19 deletions src/libsimlin/tests/integration/errors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -583,24 +583,13 @@ fn test_get_errors_no_ltm_diagnostics_when_ltm_never_requested() {
/// projection-feeder acceptance, so the reducer is not hoisted, the element
/// graph keeps the conservative cross-product, and the cross-row circuits'
/// loop scores reference per-`(row, slot)` link-score names the cartesian
/// emitters never produce -- those fragments genuinely fail to compile (a
/// real, non-injected failure; pinned engine-side by
/// emitters never produce. The engine catches that before fragment
/// compilation now, warning that the loop score was not emitted rather than
/// letting the fragment compiler create a zero stub (pinned engine-side by
/// `non_projection_feeder_co_source_closure_stays_loud`). The previous
/// fixtures here were the GH #759 pinned-index repro (retired when #759's
/// fix made its helpers compile), the GH #742 RANK-hoisted agg (retired
/// when the GH #771 de-hoist stopped minting the ill-shaped agg), the
/// GH #765 Pinned-bearing variable-backed mixed reduce (retired when T3 of
/// the shape-expressiveness design made its read-slice scores compile
/// cleanly), and the GH #743 iterated-dim-feeder closure (retired when
/// T5's I1 feeder clause hoisted it -- GH #767); the assertion matches the
/// shared "failed to compile" wording rather than one diagnostic
/// sub-class, so the test keeps covering the FFI harvest channel as
/// individual shapes get fixed. (The engine-side guard-injected
/// `test_model_ltm_fragment_diagnostics_covers_implicit_helpers` covers
/// the implicit-helper diagnostic leg independently of any real-shape
/// lifetime; if a future task fixes this shape too and no organic failure
/// shape remains, the engine-`pub(crate)` `LtmFragmentFailureGuard`
/// injection hook will need re-exporting for FFI tests.)
/// fixtures here were organic fragment-failure shapes that have since been
/// fixed; this one keeps covering the FFI diagnostic harvest channel for the
/// current loud-safe degradation.
#[test]
fn test_get_errors_surfaces_ltm_fragment_failure_after_ltm_sim() {
let datamodel = TestProject::new("get_errors_fragment_fail")
Expand Down Expand Up @@ -640,8 +629,8 @@ fn test_get_errors_surfaces_ltm_fragment_failure_after_ltm_sim() {
"get_errors must return the fragment-failure warning"
);
assert!(
any_detail_message_contains(all_errors, "failed to compile"),
"the LTM fragment compile-failure warning must reach get_errors after an LTM sim"
any_detail_message_contains(all_errors, "was not emitted"),
"the LTM missing-name loop-score warning must reach get_errors after an LTM sim"
);

simlin_error_free(all_errors);
Expand Down
8 changes: 4 additions & 4 deletions src/simlin-engine/CLAUDE.md

Large diffs are not rendered by default.

6 changes: 6 additions & 0 deletions src/simlin-engine/examples/clearn_discover.rs
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,10 @@ fn main() {
.collect();
let ltm_vars = model_ltm_variables(&db, source_model, source_project);
let dm_dims = project_datamodel_dims(&db, source_project);
// Per-variable declared dims + dimension-mapping context for the A2A
// from-node projection (GH #754), built through the production decision.
let expansion =
simlin_engine::analysis::build_link_expansion_context(&db, source_model, source_project);
println!(
" element-graph stocks: {}, ltm synthetic vars: {}",
stocks.len(),
Expand All @@ -265,6 +269,7 @@ fn main() {
&stocks,
&ltm_vars.vars,
dm_dims,
&expansion,
&sample_steps,
)
});
Expand Down Expand Up @@ -361,6 +366,7 @@ fn main() {
&stocks,
&ltm_vars.vars,
dm_dims,
&expansion,
&sub_model_ports,
None,
)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# Seeds for failure cases proptest has generated in the past. It is
# automatically read and these particular cases re-run before any
# novel cases are generated.
#
# It is recommended to check this file in to source control so that
# everyone who runs the test benefits from these saved cases.
#
# Provenance: all four seeds below were captured during the GH #783 mutation
# experiment (widening Reduced.subset to the full extent inside the shared
# read_slice_row_parts derivation) -- they fail instantly under that
# I4-divergence mutation class, so they guard the shared-derivation contract,
# not historical production bugs.
cc fccd22302688b0453d459384a9e6860d29f6a5ad4edae0d87e9827410d8bb207 # shrinks to spec = ProjectSpec { dim_size: 2, var_specs: [ArrayedVarSpec { pattern: Constant }, ArrayedVarSpec { pattern: Constant }], include_scalar: false, scalar_reducer_target: Some(ScalarReducerTarget { var_idx: 0, with_scalar_feeder: false, with_subset: true }), subset_size: 1, mixed_ref_target: None }
cc 19b02d0301bd3ac87854013e59d14febf49e4035d7efd5c0902c26e3931b57a0 # shrinks to spec = ProjectSpec { dim_size: 2, var_specs: [ArrayedVarSpec { pattern: Constant }, ArrayedVarSpec { pattern: InlinedSubsetReducer { var_idx: 0 } }], include_scalar: false, scalar_reducer_target: None, subset_size: 1, mixed_ref_target: None }
cc 5ce7efc1c4db6019707f53092814935c593e1964bf0f0d05964c883ac46810bb # shrinks to spec = ProjectSpec { dim_size: 2, var_specs: [ArrayedVarSpec { pattern: Constant }, ArrayedVarSpec { pattern: Constant }, ArrayedVarSpec { pattern: InlinedSubsetReducer { var_idx: 0 } }, ArrayedVarSpec { pattern: WildcardReducer { var_idx: 1 } }], include_scalar: true, scalar_reducer_target: Some(ScalarReducerTarget { var_idx: 1, with_scalar_feeder: false, with_subset: false }), subset_size: 1, mixed_ref_target: None }
cc 409488a2682a942f1fae2a0b8046d29e64953670e788abcfee8cd6311dfd3160 # shrinks to spec = ProjectSpec { dim_size: 2, var_specs: [ArrayedVarSpec { pattern: Constant }, ArrayedVarSpec { pattern: Constant }, ArrayedVarSpec { pattern: Constant }, ArrayedVarSpec { pattern: WildcardReducer { var_idx: 1 } }], include_scalar: true, scalar_reducer_target: Some(ScalarReducerTarget { var_idx: 1, with_scalar_feeder: true, with_subset: true }), subset_size: 1, mixed_ref_target: Some(MixedRefTarget { var_idx: 1, age_idx: 1, broadcast: true, age_first: true }) }
43 changes: 43 additions & 0 deletions src/simlin-engine/src/analysis.rs
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,41 @@ pub fn build_sub_model_output_ports(
.collect()
}

/// Build the `LinkExpansionContext` the discovery A2A expansion needs to spell
/// each Bare link score's per-element from-node in lockstep with the element
/// graph (GH #754).
///
/// `declared_dims` maps every variable in `model` (by canonical name) to its
/// declared dimensions via the salsa-cached `variable_dimensions` -- the SAME
/// query `model_element_causal_edges` reads, so the from-side projection and
/// the element graph derive dims identically. `dim_ctx` is the project's
/// dimension-mapping correspondence (the GH #527 positional-mapping diagonal).
///
/// Public so the engine's LTM discovery tests can drive
/// `discover_loops_with_graph` through the exact production decision rather
/// than reconstructing it.
pub fn build_link_expansion_context(
db: &dyn crate::db::Db,
source_model: crate::db::SourceModel,
source_project: SourceProject,
) -> crate::ltm_finding::LinkExpansionContext {
let declared_dims = source_model
.variables(db)
.iter()
.map(|(name, var)| {
(
crate::common::Ident::new(name.as_str()),
crate::db::variable_dimensions(db, *var, source_project).clone(),
)
})
.collect();
let dim_ctx = crate::db::project_dimensions_context(db, source_project).clone();
crate::ltm_finding::LinkExpansionContext {
declared_dims,
dim_ctx,
}
}

/// The loop-bearing half of a successful `run_ltm_pipeline` run: the time
/// array, the discovered loop summaries, the dominant-period intervals, and
/// whether discovery was truncated by the time budget. Named (rather than a
Expand Down Expand Up @@ -331,6 +366,13 @@ fn run_ltm_pipeline(
let ltm_vars = crate::db::model_ltm_variables(db, source_model, source_project);
let dm_dims = crate::db::project_datamodel_dims(db, source_project);

// Per-variable declared dims + the dimension-mapping context let the A2A
// expansion project each Bare score's from-node onto the source's OWN
// dims (bare for a scalar feeder, the diagonal/broadcast/mapped form for
// an arrayed one) so the discovery search graph's node names match the
// element graph the discovery runs on (GH #754).
let expansion = build_link_expansion_context(db, source_model, source_project);

let sub_model_output_ports = build_sub_model_output_ports(db, source_project);

let discovery = crate::ltm_finding::discover_loops_with_graph(
Expand All @@ -339,6 +381,7 @@ fn run_ltm_pipeline(
&stocks,
&ltm_vars.vars,
dm_dims,
&expansion,
&sub_model_output_ports,
budget,
);
Expand Down
214 changes: 192 additions & 22 deletions src/simlin-engine/src/ast/expr3.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1044,37 +1044,41 @@ impl<'a> Pass1Context<'a> {
return (transformed, has_a2a);
}

// Get the array dimensions and names from the expression bounds
let (dims, dim_names) = match transformed.get_array_bounds() {
Some(bounds) => (
bounds.dims().to_vec(),
bounds.dim_names().map(|n| n.to_vec()),
),
let view = match Self::decomposed_array_view(&transformed) {
Some(view) => view,
None => {
// No array bounds - might be a scalar or already decomposed
// Check for array view
if let Some(view) = transformed.get_array_view() {
(view.dims.clone(), Some(view.dim_names.clone()))
} else {
// Scalar expression - no decomposition needed
// Fallback to the original type-checker bounds when the
// resolved expression shape cannot be derived locally.
let (dims, dim_names) = match transformed.get_array_bounds() {
Some(bounds) => (
bounds.dims().to_vec(),
bounds.dim_names().map(|n| n.to_vec()),
),
None => {
if let Some(view) = transformed.get_array_view() {
(view.dims.clone(), Some(view.dim_names.clone()))
} else {
// Scalar expression - no decomposition needed
return (transformed, has_a2a);
}
}
};
if dims.is_empty() {
return (transformed, has_a2a);
}
if let Some(names) = dim_names {
ArrayView::contiguous_with_names(dims, names)
} else {
ArrayView::contiguous(dims)
}
}
};

// Edge case: empty dimensions means 0-dimensional array (scalar-like)
// No decomposition needed for this case
if dims.is_empty() {
// Edge case: empty dimensions means 0-dimensional array (scalar-like).
if view.dims.is_empty() {
return (transformed, has_a2a);
}

// Create the view for the temp array, preserving dimension names for broadcasting
let view = if let Some(names) = dim_names {
ArrayView::contiguous_with_names(dims, names)
} else {
ArrayView::contiguous(dims)
};

// Allocate a temp ID and create the decomposition
let temp_id = self.allocate_temp_id();
let loc = transformed.get_loc();
Expand Down Expand Up @@ -1116,6 +1120,172 @@ impl<'a> Pass1Context<'a> {
Expr3::AssignTemp(_, _, _) => false,
}
}

/// Derive the temp view from the already-transformed expression tree.
///
/// Type-checker bounds are computed before Pass 1 resolves active A2A
/// dimension references. For reducer bodies such as
/// `SUM(matrix[D1,*] * frac[D1])`, the original `Op2` bounds can still
/// mention `D1` even though both references have since collapsed that axis
/// to the active element. Using the resolved tree prevents the temp
/// iterator from reintroducing active dimensions that the reducer should
/// not iterate.
fn decomposed_array_view(expr: &Expr3) -> Option<ArrayView> {
match expr {
Expr3::StaticSubscript(_, view, _, _)
| Expr3::TempArray(_, view, _)
| Expr3::TempArrayElement(_, view, _, _) => Some(view.clone()),
Expr3::Subscript(_, indices, bounds, _) => {
let bounds = bounds.as_ref()?;
Self::subscript_view_from_bounds(indices, bounds)
}
Expr3::Op1(_, inner, _, _) => Self::decomposed_array_view(inner),
Expr3::Op2(_, left, right, _, _) => {
let left_view = Self::decomposed_array_view(left);
let right_view = Self::decomposed_array_view(right);
Self::combine_views(left_view, right_view)
}
Expr3::If(_, then_expr, else_expr, _, _) => {
let then_view = Self::decomposed_array_view(then_expr);
let else_view = Self::decomposed_array_view(else_expr);
Self::combine_views(then_view, else_view)
}
Expr3::App(_, bounds, _) | Expr3::Var(_, bounds, _) => {
bounds.as_ref().map(Self::array_view_from_bounds)
}
Expr3::Const(_, _, _) | Expr3::AssignTemp(_, _, _) => None,
}
}

fn array_view_from_bounds(bounds: &ArrayBounds) -> ArrayView {
let dims = bounds.dims().to_vec();
if let Some(names) = bounds.dim_names() {
ArrayView::contiguous_with_names(dims, names.to_vec())
} else {
ArrayView::contiguous(dims)
}
}

fn subscript_view_from_bounds(
indices: &[IndexExpr3],
bounds: &ArrayBounds,
) -> Option<ArrayView> {
let bound_dims = bounds.dims();
let bound_names = bounds.dim_names();
let mut dims = Vec::new();
let mut names = Vec::new();
let mut next_result_dim = 0usize;

for (index_pos, index) in indices.iter().enumerate() {
let preserve = match index {
IndexExpr3::StarRange(name, _) => Some(Some(name.as_str())),
IndexExpr3::StaticRange(start, end, _) => {
let (_, name) = Self::bound_dim_for_preserved_index(
bound_dims,
bound_names,
indices.len(),
index_pos,
None,
&mut next_result_dim,
)?;
dims.push(end.saturating_sub(*start));
names.push(name);
continue;
}
IndexExpr3::Range(_, _, _) => Some(None),
IndexExpr3::DimPosition(_, _)
| IndexExpr3::Dimension(_, _)
| IndexExpr3::Expr(_) => None,
};

let Some(preferred_name) = preserve else {
continue;
};

let (dim, name) = Self::bound_dim_for_preserved_index(
bound_dims,
bound_names,
indices.len(),
index_pos,
preferred_name,
&mut next_result_dim,
)?;
dims.push(dim);
names.push(name);
}

Some(ArrayView::contiguous_with_names(dims, names))
}

fn bound_dim_for_preserved_index(
bound_dims: &[usize],
bound_names: Option<&[String]>,
source_index_count: usize,
index_pos: usize,
preferred_name: Option<&str>,
next_result_dim: &mut usize,
) -> Option<(usize, String)> {
if let (Some(names), Some(name)) = (bound_names, preferred_name)
&& let Some(pos) = names.iter().position(|candidate| candidate == name)
{
return Some((bound_dims[pos], names[pos].clone()));
}

if bound_dims.len() == source_index_count && bound_dims.len() > index_pos {
let name = Self::bound_name_for_index(bound_names, index_pos, preferred_name)
.unwrap_or_default();
return Some((bound_dims[index_pos], name));
}

if *next_result_dim < bound_dims.len() {
let pos = *next_result_dim;
*next_result_dim += 1;
let name =
Self::bound_name_for_index(bound_names, pos, preferred_name).unwrap_or_default();
return Some((bound_dims[pos], name));
}

None
}

fn bound_name_for_index(
bound_names: Option<&[String]>,
index_pos: usize,
fallback: Option<&str>,
) -> Option<String> {
bound_names
.and_then(|names| names.get(index_pos))
.cloned()
.or_else(|| fallback.map(str::to_string))
}

fn combine_views(left: Option<ArrayView>, right: Option<ArrayView>) -> Option<ArrayView> {
match (left, right) {
(None, None) => None,
(Some(view), None) | (None, Some(view)) => Some(view),
(Some(left), Some(right)) => {
if left.dims == right.dims && left.dim_names == right.dim_names {
return Some(left);
}

if left.dim_names.iter().any(|name| name.is_empty())
|| right.dim_names.iter().any(|name| name.is_empty())
{
return None;
}

let mut dims = left.dims;
let mut names = left.dim_names;
for (dim, name) in right.dims.into_iter().zip(right.dim_names) {
if !names.iter().any(|existing| existing == &name) {
dims.push(dim);
names.push(name);
}
}
Some(ArrayView::contiguous_with_names(dims, names))
}
}
}
}

/// If `builtin` is a graphical-function lookup whose table base produces a
Expand Down
Loading
Loading