Skip to content

ltm: support per-element graphical functions in static link polarity analysis #502

@bpowers

Description

@bpowers

Summary

analyze_link_polarity in src/simlin-engine/src/ltm/polarity.rs (Lookup arm, lines 134-170) analyzes the polarity of Lookup builtins by combining the argument's polarity with the table's monotonicity (via analyze_graphical_function_polarity). The table is found by matching the lookup's table_expr against Expr2::Var(name):

let table_name = match table_expr.as_ref() {
    Expr2::Var(name, _, _) => Some(name.as_str()),
    _ => None,
};

Per-element graphical functions (where the table is itself an arrayed variable, referenced as arr[D]) appear as Expr2::Subscript(name, indices, ...) rather than Expr2::Var(name). The current code has an explicit TODO at line 148 acknowledging this:

// TODO: support Expr2::Subscript for subscripted lookup tables (per-element gf)

When this case fires, polarity analysis falls through to LinkPolarity::Unknown, which conservatively classifies any loop containing such a link as Undetermined.

Why this matters

Models that use per-element graphical functions (e.g., a region-specific dose-response curve effect[Region] = LOOKUP(curve[Region], dose)) lose static polarity detection on every link through the lookup. Loop polarity falls back to runtime classification via LoopPolarity::from_runtime_scores, which works but loses information when the simulation never actually exercises the polarity (a parameter never crosses an inflection, etc.).

Static polarity is preferred over runtime polarity wherever possible -- static covers the entire feedback structure (including loops the simulation never activates), feeds into the structural classification of loop ID assignment (r1, b1, u1), and is used by downstream consumers like the simplified-CLD layer in src/diagram. An Unknown static polarity forces a u-prefixed loop ID even when runtime classification could later refine it.

Proposal

  1. Extend the match table_expr.as_ref() to handle Expr2::Subscript(name, indices, _, _).
  2. For each subscript shape:
    • FixedIndex (e.g., LOOKUP(curve[NYC], x)): resolve the specific element's table at compile time. The table for that element is in the variable's tables Vec.
    • Bare A2A (e.g., inside effect[D] = LOOKUP(curve[D], dose[D])): determine that all elements' tables share the same monotonicity (or return Unknown if they differ), reusing the existing arrayed-equation aggregation pattern from analyze_link_polarity's Ast::Arrayed arm.
    • Wildcard / DynamicIndex: conservatively return Unknown (current behavior).
  3. Add tests covering: same-monotonicity per-element tables, mixed-monotonicity per-element tables, fixed-index references, A2A references.

File references

  • TODO marker: src/simlin-engine/src/ltm/polarity.rs:148
  • Lookup polarity logic: src/simlin-engine/src/ltm/polarity.rs:134-170
  • Graphical function monotonicity helper: analyze_graphical_function_polarity in same file
  • Variable tables storage: src/simlin-engine/src/variable.rs Variable::Var.tables

Component(s) affected

simlin-engine (LTM polarity analysis); downstream effect on loop ID classification and the simplified-CLD layer in src/diagram.

Discovery context

Identified during a deep design review of LTM completeness. Distinct from #480 (which covers array reducers SUM/MEAN/etc. returning Unknown) and #492 (GF strict-monotonicity EPSILON precision); this entry is specifically about Expr2::Subscript handling in the Lookup arm of analyze_link_polarity. Tech-debt.md item 21 mirrors #480 and does not cover this case.

Labels / epic

Labels: ltm, polarity-analysis, completeness
Epic: #488 (LTM)

Metadata

Metadata

Assignees

No one assigned

    Labels

    ltmLoops that Matter (LTM) analysis subsystem

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions