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
2 changes: 1 addition & 1 deletion src/simlin-engine/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,7 @@ Unit checking is **opt-in by declaring units**: a model that declares units on N
## Special features

- **`src/analysis.rs`** - High-level model analysis API: `analyze_model(project, db, source_project, model_name)` bundles compilation, LTM loop discovery, and dominant-period calculation into a single `ModelAnalysis` result. The caller provides a `SimlinDb` and `SourceProject` (already synced); all compilation and structural analysis use the incremental salsa path. Uses an element-level causal graph (`model_element_causal_edges` + `causal_graph_from_element_edges_with_modules`) so arrayed models get per-element loop detection AND the graph carries the module sub-graphs + variable map the discovery-mode per-exit-port pathway recompute needs (GH #698 -- the bare `causal_graph_from_element_edges` leaves both empty, which silently disabled the recompute on this production path). Passes `LtmSyntheticVar` metadata and datamodel dimensions to `discover_loops_with_graph` for A2A link score expansion, a `LinkExpansionContext` (built by the public `build_link_expansion_context`: per-variable declared dims + the dimension-mapping context) so the discovery from-node projection matches the element graph (GH #754), plus an emission-derived `SubModelOutputPorts` map built by the public `build_sub_model_output_ports` (one `db::ltm::sub_model_output_ports` call per project model) so the discovery per-exit-port recompute enumerates pathway indices against the IDENTICAL project-wide port set the sub-model emitted against -- not a parent-scoped re-derivation that shifts when another project model reads an extra output port (GH #698 / PR #705 r3353097150). A model that cannot be *compiled* for LTM analysis (a malformed equation, an unresolved reference, or the GH #486 non-Euler hard-fail) still returns `Ok` with the model snapshot intact and empty loop fields, but the actionable compile-error message is carried out in `ModelAnalysis::analysis_error` rather than swallowed by an `.ok()?` -- so "could not analyse" is distinguishable from "no loops" (GH #660), and the MCP `read_model`/`edit_model` surfaces re-expose it as `analysisError`. A non-compile structural edge case (model absent from the causal graph, post-simulation discovery bail) degrades gracefully to empty loops with `analysis_error == None`.
- **`src/ltm/`** - Loops That Matter: core data types and causal graph construction, organised as a directory module. `mod.rs` re-exports the public API at `crate::ltm::*` (so external callers compile unchanged) and defines `MAX_LTM_SCC_NODES` plus the top-level `detect_loops` helper. Submodules:
- **`src/ltm/`** - Loops That Matter: core data types and causal graph construction, organised as a directory module. `mod.rs` re-exports the public API at `crate::ltm::*` (so external callers compile unchanged) and defines `MAX_LTM_SCC_NODES`. Submodules:
- `types.rs` - public LTM vocabulary: `LinkPolarity`, `Link`, `Loop`, `LoopPolarity`, `TruncatedByBudget`, plus the `normalize_module_ref` / `is_synthetic_node_name` helpers (`is_synthetic_node_name` = "name carries the reserved `$⁚` synthetic prefix `SYNTHETIC_NODE_PREFIX`"; the broad generalization of `ltm_agg::is_synthetic_agg_name`, used by `collapse_synthetic_links`). `LoopPolarity` is determined by counting negative links in a cycle. The per-reference shape distinction is encoded in `Link.from` / `Link.to` strings, not as a separate field: a cross-dimensional FixedIndex / full-reduce edge carries an element subscript on `from` (`"pop[nyc]"`); a cross-element edge that visits one slot of an A2A target carries it on `to` (`"mp[boston]"`); a loop through an inlined reducer traverses `from[d] → $⁚ltm⁚agg⁚{n} → to[e]` (the agg name is subscript-free, and is trimmed from the reported loop).
- `partitions.rs` - `CyclePartitions` groups loops into stock-to-stock SCCs over the parent-level stock graph; bundles the generic Tarjan SCC used by `compute_cycle_partitions`.
- `polarity.rs` - static polarity analysis on `Expr2` ASTs: `analyze_link_polarity` plus the small expression predicates it leans on (`flip_polarity`, `expr_references_var`, `literal_sign`/`provable_value_sign` -- both seeing through unary negation, since the lexer takes no leading sign and a parsed `-5` is `Op1(Negative, Const(5))` -- `analyze_graphical_function_polarity`, etc.). The Mul one-side arm applies the SD **positive-value labeling convention** to a bare named co-factor (`cofactor_value_sign`): `net_growth = population * fractional_growth` labels `population -> net_growth` Positive, the reading every CLD gives it, and the same convention the Div arm has always applied to `share = pop over total`. A provable co-factor sign (a literal, or a variable whose whole equation is one) beats the convention; a COMPOUND co-factor (`1 - pop over K`) stays Unknown -- its value sign is derived, not conventional, and the single-equation logistic class genuinely flips mid-run. The convention is a labeling decision, not a proof: runtime loop-score reclassification remains the ground truth and overrides it (pinned by the Rux fixtures, which construct a bare co-factor whose value really does flip sign). Per-element graphical functions (#502): when an arrayed source feeds an arrayed graphical-function target, the per-element `tables` list on `Variable::Var` is folded into one link polarity (`fold_per_element_table_polarity` over each element's `Table`); the multi-dim case stays conservatively `Unknown`. The strict-monotonicity check classifies each segment by its **slope** (dy over dx) against a tolerance of `1e-6 * (y_max - y_min) / avg_dx` (floored at `1e-12`; GH #536), where `avg_dx` is the average x-spacing (`x_span` divided by the number of segments). The per-segment noise threshold is `tolerance * dx = 1e-6 * (y_max - y_min) * dx / avg_dx`: on uniformly-spaced tables every `dx == avg_dx` so the threshold reduces EXACTLY to `1e-6 * (y_max - y_min)` -- the same y-range-relative dy epsilon #492 used, preserving import-noise tolerance for finely-sampled tables; for non-uniform tables the threshold scales proportionally with segment width so a narrow steep segment is still caught. A degenerate vertical segment (`x[i] == x[i-1]`) is skipped if it is a redundant duplicate point and bails to `Unknown` if it is a genuine two-outputs-for-one-input step. Numeric-import noise on a near-flat lookup arm still does not flip a monotone table to `Unknown`.
Expand Down
40 changes: 16 additions & 24 deletions src/simlin-engine/src/db/analysis.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2837,42 +2837,34 @@ fn detected_loop_from_loop(l: &crate::ltm::Loop, pin_name: &str) -> DetectedLoop
/// (model-only) surface. The structural FFI takes no `Results` and reports R/B
/// at confidence 1.0 / U at 0.0; the runtime FFI builds the same exhaustive
/// loop set and calls this helper over the completed sim's `loop_score`
/// series, so the exhaustive surface can finally report `Mostly*` (Rux/Bux) or
/// a runtime sign flip. (Now that GH #495 surfaces all five polarity variants
/// along with the confidence verbatim across the FFI, there is no longer a
/// coalescing/confidence-drop at the boundary.) The pysimlin `Run.loops`
/// surface still reclassifies via its own Python `LoopPolarity.from_runtime_scores`
/// mirror (slot-0 only -- see the A2A note below); pysimlin exposes the
/// all-slots engine path separately as `Run.loops_runtime`. The engine
/// series, so the exhaustive surface can report `Mostly*` (Rux/Bux) or a
/// runtime sign flip. (GH #495 surfaces all five polarity variants along with
/// the confidence verbatim across the FFI, so there is no
/// coalescing/confidence-drop at the boundary.) pysimlin's `Run.loops` rides
/// this same helper -- bound as `Sim.get_loops_runtime` -- so Python performs
/// no reclassification of its own and the classification rules live only in
/// [`crate::ltm::LoopPolarity::from_runtime_scores`]. The engine
/// `analyze_model` / MCP surface is discovery-based and reclassifies through
/// the `FoundLoop` path.
///
/// # A2A semantics differ across the three reclassification sites
/// # A2A semantics differ between the two reclassification sites
///
/// `loop_partitions` is the per-loop slot->partition map carried on
/// `LtmVariablesResult::loop_partitions`; its slot-vector length is the
/// `loop_score` series' slot count. For an A2A (per-element) loop this helper
/// **concatenates every element slot's series into one sample set** and
/// classifies the mixed result: if any element of the loop is balancing while
/// another is reinforcing the loop classifies `Undetermined` (a deliberate
/// "the loop's sign is not uniform across the array" reading). This is NOT the
/// same input construction the other two sites use, so do not claim they
/// agree:
/// - **pysimlin `Run.loops`** reads `get_series("$⁚ltm⁚loop_score⁚{id}")`,
/// which resolves to **slot 0 only** (the dominant/first element), so an
/// A2A loop is classified from a single element's series.
/// - **discovery** (`ltm_finding`) classifies each `FoundLoop` from its own
/// single strongest-path scalar score series.
/// "the loop's sign is not uniform across the array" reading). This is NOT
/// the input construction discovery uses, so do not claim they agree:
/// **discovery** (`ltm_finding`) classifies each `FoundLoop` from its own
/// single strongest-path scalar score series.
///
/// All three share the *scalar* semantics (`from_runtime_scores`'s NaN/zero
/// Both sites share the *scalar* semantics (`from_runtime_scores`'s NaN/zero
/// filter; all-positive -> Reinforcing, all-negative -> Balancing, mixed
/// dominant >= threshold -> Mostly*, otherwise Undetermined) and agree exactly
/// on a scalar loop; they diverge only in how an A2A loop's multiple element
/// slots are reduced to one classification. The sim-bearing FFI consumer
/// (`simlin_analyze_get_loops_runtime`, GH #679) deliberately exposes THIS
/// all-slots reading -- pysimlin surfaces it as `Run.loops_runtime`, keeping
/// the slot-0 `Run.loops` path unchanged -- so the two A2A readings now coexist
/// rather than one being reconciled into the other.
/// dominant >= threshold -> Mostly*, otherwise Undetermined) and agree
/// exactly on a scalar loop; they diverge only in how an A2A loop's multiple
/// element slots are reduced to one classification.
pub fn reclassify_loops_from_results(
loops: &mut [DetectedLoop],
results: &crate::Results,
Expand Down
16 changes: 8 additions & 8 deletions src/simlin-mcp/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -135,12 +135,11 @@ mod tests {
// setuptools-scm derives it from the `pysimlin-v*` tag itself (see
// `tag_regex` in src/pysimlin/pyproject.toml) -- so the tag is the only
// thing this can be checked against, and the check needs the tags to be
// present locally. CI checks out with `actions/checkout@v4` and no
// `fetch-depth`, i.e. a shallow clone with no tags, so this guard runs
// only on a developer's full clone and is a no-op in CI. That is a
// deliberate limitation, not an oversight: making it fail on an empty tag
// list would break every CI run. The skip is announced rather than silent
// so a run that unexpectedly finds no tags is attributable.
// present locally. CI's Build-job checkout sets `fetch-tags: true`
// specifically so this guard runs there. A clone without the tags (e.g. a
// developer's fresh shallow clone) skips rather than fails -- announced
// rather than silent, so a run that unexpectedly finds no tags is
// attributable.
#[test]
fn pysimlin_version_matches_latest_tag() {
let output = std::process::Command::new("git")
Expand All @@ -151,8 +150,9 @@ mod tests {
if !output.status.success() || tags.trim().is_empty() {
eprintln!(
"SKIPPING pysimlin_version_matches_latest_tag: no pysimlin-v* tags are \
visible (a shallow clone, as CI produces, fetches no tags). Run \
`git fetch --tags --unshallow` to exercise this guard."
visible. CI fetches tags (fetch-tags: true in ci.yaml), so this skip is \
expected only on a local clone without them; run `git fetch --tags` to \
Comment on lines +153 to +154

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 Badge Limit the no-tag diagnostic to the Build job

In the Code Coverage job, .github/workflows/ci.yaml:116-117 uses the default checkout without fetch-tags, while .github/workflows/ci.yaml:161-163 runs every workspace test, including this one. That job will therefore legitimately take this branch in CI, so saying the skip is expected only on a local clone misattributes a normal coverage run. Either fetch tags in the coverage checkout too or clarify that only the Build job guarantees them.

Useful? React with 👍 / 👎.

exercise this guard."
);
return;
}
Expand Down
Loading