diff --git a/src/simlin-engine/CLAUDE.md b/src/simlin-engine/CLAUDE.md index fabf0c71d..5c06e5bac 100644 --- a/src/simlin-engine/CLAUDE.md +++ b/src/simlin-engine/CLAUDE.md @@ -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`. diff --git a/src/simlin-engine/src/db/analysis.rs b/src/simlin-engine/src/db/analysis.rs index 017b0af48..88bc8e55d 100644 --- a/src/simlin-engine/src/db/analysis.rs +++ b/src/simlin-engine/src/db/analysis.rs @@ -2837,17 +2837,17 @@ 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 @@ -2855,24 +2855,16 @@ fn detected_loop_from_loop(l: &crate::ltm::Loop, pin_name: &str) -> DetectedLoop /// **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, diff --git a/src/simlin-mcp/src/main.rs b/src/simlin-mcp/src/main.rs index 92a00c07a..d301b865e 100644 --- a/src/simlin-mcp/src/main.rs +++ b/src/simlin-mcp/src/main.rs @@ -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") @@ -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 \ + exercise this guard." ); return; }