From 5225fcb82ffcda283b0b084695dfec43088ded46 Mon Sep 17 00:00:00 2001 From: Bobby Powers Date: Wed, 9 Sep 2026 22:32:05 -0700 Subject: [PATCH 1/5] engine: preserve LTM snapshot and dominance semantics Freeze lagged co-inputs at their prior evaluation and use exact zero-delta guards so attribution remains consistent across time and unit scales. Require reported loops to reach half of their normalization mass before labeling a dominant period, including when the report cap omits loops. Add production-pipeline regressions and align the LTM references and always-on design constraints with the audited semantics. --- docs/README.md | 1 + .../2026-09-04-link-scores-from-fragments.md | 123 +++++---- .../2026-09-07-ltm-single-mode.md | 2 +- docs/design/ltm-always-on.md | 228 +++++++++++++++++ docs/reference/ltm--loops-that-matter.md | 20 +- ...0.1-seamlessly-integrating-ltm--summary.md | 6 +- .../ltm_loop_discovery.txt | 200 ++++++++------- .../ltm_loop_exhaustive.txt | 76 +++--- .../db/ltm_char_golden/agg_nested_reducer.txt | 10 +- .../ltm_char_golden/agg_to_arrayed_target.txt | 4 +- .../ltm_char_golden/agg_to_scalar_target.txt | 2 +- .../already_lagged_other_dep.txt | 2 +- .../ltm_char_golden/arrayed_agg_to_target.txt | 14 +- .../arrayed_target_no_default_slot_scores.txt | 8 +- .../arrayed_target_slot_scores.txt | 12 +- .../per_element_ambiguous_pin.txt | 10 +- .../per_element_dynamic_index.txt | 6 +- .../per_element_index_nested_occurrence.txt | 4 +- .../per_element_link_scores.txt | 4 +- .../per_element_mapped_occurrence.txt | 10 +- .../per_element_mixed_occurrences.txt | 10 +- .../per_element_other_deps.txt | 8 +- .../reducer_index_nested_freeze.txt | 2 +- .../scalar_feeder_bare_in_hoisted_reducer.txt | 4 +- .../unquotable_source_name.txt | 4 +- src/simlin-engine/src/db/ltm_char_tests.rs | 11 +- .../src/db/ltm_value_gate_tests.rs | 55 +--- src/simlin-engine/src/ltm_augment.rs | 62 +++-- .../src/ltm_augment_occurrence.rs | 4 +- .../src/ltm_augment_pin_tests.rs | 5 +- src/simlin-engine/src/ltm_augment_tests.rs | 93 +++---- .../src/ltm_augment_zero_slot.rs | 40 ++- src/simlin-engine/src/ltm_dominance.rs | 98 +++++-- src/simlin-engine/src/ltm_finding.rs | 46 ++-- src/simlin-engine/src/ltm_finding_enum.rs | 15 +- src/simlin-engine/src/ltm_finding_tests.rs | 66 +++++ .../tests/integration/ltm_array_agg.rs | 54 ++-- .../tests/integration/ltm_snapshot_inputs.rs | 242 ++++++++++++++++++ src/simlin-engine/tests/integration/main.rs | 1 + 39 files changed, 1074 insertions(+), 488 deletions(-) create mode 100644 docs/design/ltm-always-on.md create mode 100644 src/simlin-engine/tests/integration/ltm_snapshot_inputs.rs diff --git a/docs/README.md b/docs/README.md index 2d2e6fd9f..8c6eb514e 100644 --- a/docs/README.md +++ b/docs/README.md @@ -7,6 +7,7 @@ - [design/conveyors.md](design/conveyors.md) -- XMILE conveyor support: complete specification of syntax, per-DT simulation semantics, leakage/initialization formulas, spread inputs, arrays, and engine integration - [design/engine-performance.md](design/engine-performance.md) -- Engine compile/simulate profile (C-LEARN), how to measure a change (the instruction, LTM, artifact, sweep and dump channels), implemented optimizations, and remaining proposals - [design/ltm--loops-that-matter.md](design/ltm--loops-that-matter.md) -- LTM implementation design: data structures, synthetic variables, module handling, array/element-level support, and post-simulation loop discovery (candidate generation, retention against the loop universe, ranking and the coverage-aware cap) +- [design/ltm-always-on.md](design/ltm-always-on.md) -- Proposed always-on LTM constraints: faithful counterfactuals, immutable run analysis, completeness and numerical validity, cheaper instrumentation and storage, bounded discovery, and acceptance evidence - [design/mdl-parser.md](design/mdl-parser.md) -- Vensim MDL parser design history and implementation notes - [design/queues.md](design/queues.md) -- XMILE queue support: complete specification of queue stocks, FIFO discipline, conveyor/queue coupling, overflow flows, and engine integration - [design/vdf.md](design/vdf.md) -- VDF binary format specification and parser design diff --git a/docs/design-plans/2026-09-04-link-scores-from-fragments.md b/docs/design-plans/2026-09-04-link-scores-from-fragments.md index f4685948e..28bb16a0c 100644 --- a/docs/design-plans/2026-09-04-link-scores-from-fragments.md +++ b/docs/design-plans/2026-09-04-link-scores-from-fragments.md @@ -9,8 +9,8 @@ model alongside the modeler's own variables. Today those programs are built as equation text. For a link `x -> z` the generator prints `z`'s equation back out with every read except `x` wrapped in `PREVIOUS(..)`, prints a guard around it, parses the result, and pushes that parse through the entire -compiler a second time -- once per link, 6,155 times on C-LEARN. The numbers -that come out are right. The construction is what costs: every compiler tier +compiler a second time -- once per link, 6,155 times on C-LEARN. The construction +has both a correctness obligation and a cost: every compiler tier of every link is retained (a 222 MiB database against 29 MiB without LTM), and because all of the generated equations live in one whole-model value, editing any variable invalidates all of them, so one edit recompiles thousands @@ -20,12 +20,13 @@ This plan builds each link score's program from the target's already-compiled fragment instead. A fragment is one variable's equation lowered to symbolic bytecode, the same artifact the plain simulation assembles, and the "hold everything else at the previous step" partial a link score needs is a -mechanical rewrite of that opcode stream: reads outside the source's live slot -range become previous-step reads, the final assignment is redirected to the -score's own slot, and every other opcode is copied. It is a pure function over -bytecode -- no printing, no parsing, no re-lowering, and no synthesized helper -variables. Because the rewrite is keyed per link on the target and its live -range, the whole-model list becomes metadata (which links exist, their names +rewrite of that opcode stream under an explicit selection of live reads: +ordinary co-input reads become previous-step reads, temporal and effectful +operations follow their own rules, and the final assignment is redirected to +the score's own slot. It is a pure transformation over bytecode -- no printing, +parsing, or re-lowering. Captured history remains where a temporal expression +requires it. Because the rewrite is keyed per link on the target and its read +selection, the whole-model list becomes metadata (which links exist, their names and dimensions) while each link's program lives in its own memo, so an edit invalidates only the links that touch the edited variable. The families that are not partials (black-box, aggregate, module composite and loop scores) @@ -36,9 +37,10 @@ of its own. Two run-time consequences follow from having a single place that emits a score: the 37-opcode guard scaffold collapses to one `LinkScore` opcode reading per-variable deltas computed once per step, and, on native and separably, the -LTM program moves out of the integration loop into a post-pass over saved -states. Six phases, each gated on bit-identical score series against the text -generators, which are held as a retained oracle until Phase 4 deletes them. +LTM program may move out of the integration loop into a post-pass over retained +integration-step evidence. Six phases are gated on independently derived +semantic tests as well as parity against the text generators; parity preserves +validated behavior but cannot establish correctness of a shared assumption. ## Definition of Done @@ -74,7 +76,7 @@ command or test that shows it. cargo run --release --example clearn_profile` reports the database under 40 MiB after an LTM compile (222 MiB today) and no `Expr0` family among the LTM residency rows. -5. **One opcode per link score at run time.** The 37-opcode guard scaffold is +5. **One opcode for the link-score guard at run time.** The 37-opcode guard scaffold is one `LinkScore` opcode over per-variable delta slots computed once per step, in both backends (`vm.rs` and `wasmgen`); the C-LEARN LTM flow program is under 250 K opcodes per step (855 K today) and @@ -92,7 +94,7 @@ from them. ### link-scores-from-fragments.AC1: A link score's program is a rewrite of the target's fragment - **link-scores-from-fragments.AC1.1 Success:** For a scalar auxiliary-to-auxiliary link, `freeze_reads` over the target's flows-phase fragment with the source's slot as the live range yields a program whose resolved bytecode, run in the VM, produces the same score series bit for bit as the text path's program on every model of the LTM corpus. - **link-scores-from-fragments.AC1.2 Success:** The same holds for a stock-to-flow link (the source is a stock, read at the current step). -- **link-scores-from-fragments.AC1.3 Success:** A `SymLoadPrev` already present in the target's fragment is left unchanged by the rewrite, and a target that reads `TIME` scores identically under both paths. +- **link-scores-from-fragments.AC1.3 Success:** The rewrite preserves the selected source's temporal-read policy and freezes every independent co-input at the counterfactual baseline. Tests derive expectations from the partial definition for current, `PREVIOUS`, and `INIT` reads, mixed current/lagged occurrences, and the clock (`TIME`, `STEP`, `RAMP`, `PULSE`). In particular, freezing a `PREVIOUS(y)` co-input requires the previous value of that lagged expression; leaving its load unchanged is not a general freeze. Agreement with the text generator alone is insufficient evidence. - **link-scores-from-fragments.AC1.4 Success:** A target that reads a sub-model variable (`m·x`) scores identically under both paths; the frozen read is a previous-step read of the module block's slot. - **link-scores-from-fragments.AC1.5 Success:** After Phase 4, `rg "link_score_guard_form|LtmArm|LtmEquation" src/simlin-engine/src` is empty and `LtmSyntheticVar` carries a `kind`, not an equation. @@ -100,8 +102,8 @@ from them. - **link-scores-from-fragments.AC2.1 Success:** Every model under `test/test-models/**/*ltm*` and every `sdeverywhere` model with a detected loop produces bit-identical link-score, loop-score and relative-loop-score series before and after each phase (`tests/integration/ltm_synthesis_parity.rs` while the generators exist, the golden outputs after). - **link-scores-from-fragments.AC2.2 Success:** C-LEARN v77 under LTM produces bit-identical series (an `--ignored` release test, run and recorded in the ledger per phase). - **link-scores-from-fragments.AC2.3 Failure:** A target whose fragment does not compile yields no link score and one `Warning` naming the target and the source, as the `PartialEquationError` path does today; no score reads a constant 0 without a diagnostic. -- **link-scores-from-fragments.AC2.4 Success:** Every arrayed reference shape (`FixedIndex`, `Wildcard`, `DynamicIndex`, `PerElement`, mapped and iterated axes) is a live range, and the arrayed corpus (`arrayed_population_ltm`, `cross_element_ltm`, `cross_agg_ltm`, `tests/integration/ltm_array_agg.rs`) is bit-identical. -- **link-scores-from-fragments.AC2.5 Failure:** An edge the generators decline today (a dynamic-pinned slice, a repeated-dimension target, an unfreezable partial) is declined with the same `Warning` under the new path; a shape that cannot be expressed as a live range is a loud decline, never a silent score. +- **link-scores-from-fragments.AC2.4 Success:** Every admitted arrayed reference shape (`FixedIndex`, `Wildcard`, `DynamicIndex`, `PerElement`, mapped and iterated axes) has an explicit live-read selection. Non-contiguous views, repeated reads of one slot through different occurrences, and dynamic indices are tested against a scalar expansion. A single contiguous range is an optimization for shapes that admit it, not the semantic definition. The arrayed corpus (`arrayed_population_ltm`, `cross_element_ltm`, `cross_agg_ltm`, `tests/integration/ltm_array_agg.rs`) preserves every independently validated score. +- **link-scores-from-fragments.AC2.5 Failure:** An edge the generators decline today (a dynamic-pinned slice, a repeated-dimension target, an unfreezable partial) is declined with the same `Warning` until its semantics are independently established; a shape without an admitted read-selection rule is a loud decline, never a silent score. Non-contiguous selection alone is not a reason for rejection. ### link-scores-from-fragments.AC3: Incremental by construction - **link-scores-from-fragments.AC3.1 Success:** Under the overlay, editing one variable's literal re-executes that variable's fragment and the link-score memos whose target or source it is, and no other `link_score_fragment` or `compile_var_fragment` body (`db::exec_probe::ProbedDb` counts, on a model with two independent loops). @@ -109,7 +111,7 @@ from them. ### link-scores-from-fragments.AC4: Nothing whole-model holds a per-link program - **link-scores-from-fragments.AC4.1 Success:** `model_ltm_variables` holds no `Expr0` and no equation text; `CLEARN_LTM=1 CLEARN_RESIDENCY=1` reports the database under 40 MiB after an LTM compile. -- **link-scores-from-fragments.AC4.2 Success:** `model_ltm_implicit_var_info` and layout section 3b are gone, and the results-offset map of every corpus model is unchanged (every synthetic variable keeps its name and its slot; `ltm_post` and the FFI are untouched). +- **link-scores-from-fragments.AC4.2 Success:** Ordinary instantaneous partials need no parse-generated expression captures. Required temporal captures have one typed owner and retain their history semantics. Public score names and dimensions remain compatible with the readers in `ltm_post` and the FFI; temporary slot placement is an implementation detail. ### link-scores-from-fragments.AC5: One opcode per link score at run time - **link-scores-from-fragments.AC5.1 Success:** The C-LEARN LTM flow program is under 250 K opcodes per step and its series are unchanged. @@ -117,7 +119,7 @@ from them. - **link-scores-from-fragments.AC5.3 Edge:** The first-step and zero-delta guards produce exactly the 0 the scaffold produced, including when `Δz` is 0 while `Δx` is not. ### link-scores-from-fragments.AC6: Post-pass (native) -- **link-scores-from-fragments.AC6.1 Success:** The post-pass over saved states produces series identical to the in-loop form, on the LTM corpus and C-LEARN, with `save_step > dt` still scoring every dt. +- **link-scores-from-fragments.AC6.1 Success:** The post-pass receives the actual current and previous integration-step inputs, initial snapshots, and any additional captured lagged-expression values each admitted score needs. With `save_step > dt`, it agrees with the in-loop form on a nonlinear model where differencing adjacent saved rows gives a different answer. Scoring every dt requires retaining the unsaved evidence too; adjacent public result rows cannot reconstruct it. Peak memory is measured alongside runtime before adopting this optional pass. - **link-scores-from-fragments.AC6.2 Success:** The C-LEARN LTM run on native is within 3x of the plain run; the wasm bundle keeps the in-loop form and its series are unchanged. ## Glossary @@ -137,10 +139,10 @@ from them. source advanced to the current step and every other read held at the previous step. It is the numerator of a link score, and the thing this plan synthesizes by rewriting bytecode instead of printing text. -- **Live range**: the new key of the rewrite. The source's slots that stay - current under the freeze -- a whole variable block for a bare reference, a - single element slot for an indexed one. Every reference shape becomes a - choice of live range. +- **Live read selection**: the key of the rewrite, describing which source + occurrences and element slots stay live under the freeze and their temporal + policy. A **live range** is its contiguous-slot optimization; it does not + describe every mapped, indexed, or occurrence-sensitive read. - **Reference shape (`RefShape`)**: how a target's equation reads its source (`Bare`, `FixedIndex`, `Wildcard`, `DynamicIndex`, `PerElement`, mapped and iterated axes). Classified once per variable in `db/ltm_ir.rs`; it decides @@ -257,35 +259,42 @@ shares a variable recomputes that variable's delta The target's flows-phase symbolic fragment (`VarFragmentResult` from `db::compile_var_fragment`, the same memo assembly reads) already IS the target's equation, lowered. The ceteris-paribus freeze is a rewrite over that -opcode stream, keyed by the LIVE SLOT RANGE (the source's slots that stay -current): +opcode stream, keyed by the live-read selection. For an ordinary contiguous +selection, the basic opcode rules are: | opcode in the target's fragment | outside the live range becomes | inside it | |---|---|---| | `LoadVar { var }` | `SymLoadPrev { var }` (the previous-step snapshot, what `PREVIOUS()` reads) | unchanged | | `PushStaticView` with base `SymStaticViewBase::Var(v)` | base `PrevVar(v)` (`ViewStorage::Prev` exists) | unchanged | | `PushVarViewDirect { var, .. }` | a previous-storage twin of the direct view (new symbolic row, resolves to `ViewStorage::Prev`) | unchanged | -| an original `SymLoadPrev`, `SymLoadInitial`, `LoadGlobalVar` | unchanged: a lagged read is not lagged twice, a snapshot is a snapshot, and the implicit globals follow the text generators' rule (settled by the parity test in Phase 1) | unchanged | +| an original `SymLoadPrev` | the previous value of this lagged co-input expression, through captured history; a single previous-state buffer cannot in general supply it | preserve the explicitly selected temporal occurrence | +| an original `SymLoadInitial` | unchanged for an immutable initial snapshot; preserve any index-selection semantics explicitly | unchanged | +| `LoadGlobalVar` | freeze changing globals such as `TIME`; keep run constants such as `DT` unchanged | only unchanged when the global is itself the selected input | | `AssignCurr { var: target }` | `AssignCurr { var: }` | -- | -| everything else | copied | copied | - -Freezing READS is the freeze of the expression: a pure expression over -previous-step values is the previous-step value of that expression, so the -`PREVIOUS()` helper slots the text path synthesizes for -non-atomic wrapped subtrees (`model_ltm_implicit_var_info`, 738 helpers on -C-LEARN, layout section 3b) are not needed -- every frozen read is a direct -`LoadPrev` of a variable slot. The rewrite is a pure function -`freeze_reads(&SymbolicByteCode, &LiveRange) -> SymbolicByteCode` in a new +| pure arithmetic and control flow over admitted operands | copied, with fragment-local storage and branch targets remapped as needed | copied | +| other opcodes | require an explicit effect/temporal rule or decline with a diagnostic | require an explicit rule | + +For a pure expression whose only changing inputs are current-state reads, +freezing those reads reproduces its previous-step value. This permits +eliminating capture helpers for that admitted subset. Lagged reads, dynamic +index selection, implicit clock dependencies and stateful calls need their +own temporal/effect rules; a blanket opcode copy does not establish those +rules. Some expressions require captured history even when their arithmetic +can be reused. The rewrite is a pure function +`freeze_reads(&SymbolicByteCode, &LiveReadSelection) -> Result` in a new module `db/ltm/synthesize.rs`, with no text, no parse and no lowering. -Per-element attribution is a choice of live range. A `Bare` source is its +For shapes representable by one contiguous range, a `Bare` source is its whole slot block; a `FixedIndex`/`PerElement` occurrence is one element slot; an arrayed target's per-element link score is the rewrite of that element's segment of the fragment (`assemble::segment_member_by_element` already cuts a fragment per element for the SCC path). The reference-shape classification that decides which occurrences of the source are live (`db::analysis::RefShape`, `ltm_ir`) is unchanged; only what it drives -changes: a slot range instead of a text rewrite. +changes: a read selection instead of a text rewrite. Preserve occurrence and +view provenance where distinct reference sites resolve to overlapping slots; +slot membership alone cannot distinguish those sites. The per-link memo key +must contain all facts that affect this selection. The guard is emitted by a typed builder over the partial's result: the comparisons, the `SAFEDIV`, the `SIGN`. In Phase 2 it is the same opcode @@ -294,9 +303,9 @@ it with one `LinkScore` opcode. ### Keying: nothing whole-model between an equation and a link's program - link_score_fragment(db, target: SourceVariable, live: LiveRange, model, project) -> Arc + link_score_fragment(db, target: SourceVariable, live: LiveReadSelection, model, project) -> Arc -reads `compile_var_fragment(target)` and the live range. `model_ltm_variables` +reads `compile_var_fragment(target)` and the live-read selection. `model_ltm_variables` keeps deciding WHICH links exist (the causal edges, the loops, the partitions) and becomes metadata: `LtmSyntheticVar { name, dimensions, kind: LinkScore { target, live } | LoopScore { .. } | .. }` with no equation. @@ -319,7 +328,7 @@ typed builder with no text: | module composites (`m·$⁚ltm⁚composite⁚port`) | text over pathway products | builder over the pathway link scores' slots | | loop scores (products of link scores) | text | builder | -### Run time: one opcode per link, deltas shared +### Run time: one guard opcode per link, deltas shared `LinkScore { partial, target, source }` reads three slots -- the partial's result, the target's, the source's -- and the per-variable deltas @@ -332,10 +341,15 @@ twin, `wasmgen/lower.rs` the lowering. ### Post-pass (native) -A link score reads only current and previous state. With every-dt states -saved (41 MB for C-LEARN), the LTM program is a post-pass over saved states -that is parallel across steps; the wasm bundle keeps the in-loop form. This -is Phase 6 and is separable from the rest. +A score over ordinary instantaneous inputs reads current and previous dt +state. A general score can also need the initial snapshot and captured +lagged-expression values. A post-pass must retain those inputs before the +simulation discards them; `save_step > dt` makes the public results slab +insufficient. Once each step's evidence is complete and immutable, independent +step evaluations can run in parallel. Compare this extra retention with a +streaming analysis pass that consumes the same evidence before it is discarded, +saving only score columns. The native post-pass is optional Phase 6; the wasm +bundle keeps the in-loop form. ## Existing Patterns @@ -380,9 +394,9 @@ the program, not its spelling). path on scalar targets. **Components:** -- `db/ltm/synthesize.rs` -- `LiveRange` (a variable and a slot range within - it), `freeze_reads(&SymbolicByteCode, &LiveRange) -> SymbolicByteCode`, the - guard builder emitting today's opcode sequence. +- `db/ltm/synthesize.rs` -- `LiveReadSelection` (with a contiguous-range fast + path), `freeze_reads`, the explicit temporal/effect rules and the guard + builder emitting today's opcode sequence. - `compiler/symbolic.rs` -- the previous-storage direct-view row in `symbolic_opcode_table!`, resolving to `ViewStorage::Prev`. - `tests/integration/ltm_synthesis_parity.rs` -- for a scalar @@ -424,16 +438,16 @@ edit recompiles no scalar link score; `AC2.1` to `AC2.3`, `AC3.1`. ### Phase 3: Arrayed and per-element attribution -**Goal:** every reference shape (`FixedIndex`, `Wildcard`, `DynamicIndex`, -`PerElement`, mapped and iterated axes) is a live range over the target's -fragment or one of its per-element segments. +**Goal:** every admitted reference shape (`FixedIndex`, `Wildcard`, +`DynamicIndex`, `PerElement`, mapped and iterated axes) has a validated +live-read selection over the target's fragment or its per-element segments. **Components:** -- `db/ltm/synthesize.rs` -- live ranges from `RefShape` and the occurrence IR +- `db/ltm/synthesize.rs` -- live-read selections from `RefShape` and the occurrence IR (`ltm_ir`), per-element segments via `segment_member_by_element`. - `db/ltm/link_scores.rs` -- the arrayed emitters (`emit_per_shape_link_scores`, the `PerElement` row pinning, the array-freeze helpers) produce live - ranges; `ltm_augment_array_freeze.rs`, `ltm_augment_index.rs`, + selections; `ltm_augment_array_freeze.rs`, `ltm_augment_index.rs`, `ltm_augment_post_transform.rs` stop producing text for these shapes. - `tests/integration/ltm_synthesis_parity.rs` -- every arrayed fixture (`arrayed_population_ltm`, `cross_element_ltm`, `cross_agg_ltm`, @@ -447,8 +461,8 @@ fragment or one of its per-element segments. ### Phase 4: The formula families, and no text anywhere **Goal:** the net-flow auxes, black-box, aggregate, composite and loop scores -are typed builders; the text generators, `LtmArm`, `LtmEquation` and -`model_ltm_implicit_var_info` are gone. +are typed builders; the text generators, `LtmArm` and `LtmEquation` are gone. +Required temporal captures are produced by the typed history owner. **Components:** - `db/ltm/synthesize.rs` -- builders for each family, over slot reads. @@ -456,7 +470,8 @@ are typed builders; the text generators, `LtmArm`, `LtmEquation` and emitter produces metadata plus a builder call. - `ltm_augment.rs` and its `ltm_augment_*.rs` siblings -- deleted, with their tests moved to the synthesized-program goldens. -- `db/layout.rs` -- section 3b (LTM implicit helpers) removed. +- `db/layout.rs` -- only required temporal captures receive persistent history; + scratch partials do not require parse-generated helper variables. - `db/ltm_char_tests.rs` -- goldens over opcode streams. - `docs/design/ltm--loops-that-matter.md` -- the derivation described as a rewrite. @@ -468,7 +483,7 @@ are typed builders; the text generators, `LtmArm`, `LtmEquation` and ### Phase 5: The `LinkScore` opcode and shared deltas -**Goal:** one opcode per link score at run time, deltas once per variable +**Goal:** one opcode per link-score guard at run time, deltas once per variable per step, in both backends. **Components:** diff --git a/docs/design-plans/2026-09-07-ltm-single-mode.md b/docs/design-plans/2026-09-07-ltm-single-mode.md index 090441474..18609e091 100644 --- a/docs/design-plans/2026-09-07-ltm-single-mode.md +++ b/docs/design-plans/2026-09-07-ltm-single-mode.md @@ -78,7 +78,7 @@ Drafted from the Definition of Done; to be validated with the owner before an im - **LTM (Loops That Matter)**: The feedback-loop dominance method Simlin implements (Schoenberg, Eberlein et al.). It quantifies, at each timestep, how much of a model's observed behavior each feedback loop accounts for. Full write-up in `docs/reference/ltm--loops-that-matter.md`. - **Link score**: A dimensionless per-timestep measure of one causal link's contribution to the change in its target variable, carrying both a magnitude and a sign. Computed by re-evaluating the target's equation with every input except the one under test held at its previous value. -- **Loop score**: The product of the link scores around a closed loop. Its sign is the loop's polarity; its magnitude is the "force" the loop exerts on the stocks it touches. A loop alone on its stocks always scores exactly plus or minus 1. +- **Loop score**: The product of the link scores around a closed loop. Its sign is the loop's polarity. An isolated active loop with no changing exogenous inputs has the unit-magnitude cancellation described in the papers; a raw loop score need not have unit magnitude under exogenous forcing. Its partition-relative score is plus or minus 1 when it is the only nonzero member of the partition, regardless of its raw magnitude. At equilibrium, the scores are zero. - **Relative loop score**: A loop score normalized by the sum of absolute loop scores of all loops in its cycle partition. Lands in [-1, 1], and the absolute values within a partition sum to 1. This is what consumers plot, because raw scores diverge toward infinity near dominance shifts where competing loops cancel. - **Polarity (reinforcing / balancing / undetermined)**: An even number of negative links makes a loop reinforcing; an odd number makes it balancing; a link of unknown sign makes it undetermined. Static polarity is derived from the equation AST at compile time; runtime polarity is classified from the sign of the recorded score series, with a confidence value. This plan deletes static polarity. - **Cycle partition**: A group of stocks connected to each other through feedback, computed as a strongly connected component of the stock-to-stock reachability graph. Relative scores are only meaningful within a partition. diff --git a/docs/design/ltm-always-on.md b/docs/design/ltm-always-on.md new file mode 100644 index 000000000..640920e2c --- /dev/null +++ b/docs/design/ltm-always-on.md @@ -0,0 +1,228 @@ +# Always-on Loops That Matter: correctness and cost constraints + +The objective is to make useful feedback analysis available without asking a +modeler to select an instrumentation mode. This requires faithful scoring, +bounded interactive work, and results that distinguish an absent signal from +missing evidence. It does not require enumerating every loop before a run can +finish. + +The product priorities are both fast interactive edits in the browser and +complete, trustworthy explanations for large models. Treat these as two +latencies: returning the updated simulation and completing its analysis. +Background execution can separate them, but cannot replace the second +requirement with a permanently sampled answer. Preserve resumable analysis +state for a run that the user wants to finish, and keep partial results clearly +identified until all relevant completeness conditions hold. + +This is a proposed direction and a set of acceptance constraints, not a +description of implemented behavior. The [implementation guide](ltm--loops-that-matter.md) +describes the current engine. The [single-mode plan](../design-plans/2026-09-07-ltm-single-mode.md) +and [fragment-synthesis plan](../design-plans/2026-09-04-link-scores-from-fragments.md) +contain candidate implementation packages; their optional product decisions +need separate justification from consolidating score computation. + +## The semantic boundary + +The instantaneous score uses the finite counterfactual +`f(x_current, other_inputs_previous) - target_previous`, with the zero-change +guards in Eq. 1 of [Schoenberg et al. (2020)](../reference/papers/schoenberg2020-loops-that-matter.pdf). +The flow-to-stock score uses the change in the flow relative to the change in +the stock's net flow, following [Schoenberg et al. (2023)](../reference/papers/schoenberg2023-improving-loops-that-matter.pdf). +These are finite differences. Replacing them with derivatives or automatic +differentiation changes the method on nonlinear and discontinuous equations. + +Zero-change guards must not apply an absolute numerical tolerance: changing +the units of a stock must not erase a dimensionless score. Identify the initial +evaluation by simulation phase rather than approximate equality between the +clock and the start time. Test both variable-magnitude scaling and time scaling. + +A counterfactual input is a read with an element selection and a temporal +meaning, not merely a variable name or a contiguous slot range. A changing +`PREVIOUS(g)` co-input must be held at its previous *evaluated* value; +`INIT(g)` is an initial snapshot. The clock is an input too. An already-lagged +load is therefore not automatically frozen. Selected lagged sources need an +explicit temporal policy: grouping current and past occurrences under one +source name can make the current source delta disagree in sign with the +delta of the value actually read. + +Counterfactual tests must also distinguish source occurrences that resolve to +the same element through different reference shapes. Refactoring an equation +into a named reducer or expanding an array into scalar variables must have a +specified effect on the causal graph. A named reducer and an inline reducer +represented as a real analysis node can share elementary-cycle semantics; +contracting that node and inventing cycles through repeated visits is a +different graph transformation, not just presentation. + +Even correct finite counterfactuals need careful interpretation. For +`z = x*y`, moving `(x,y)` from `(2,3)` to `(4,5)` gives changed-first partials +6 and 4 against a total change of 14. Their shares sum to 5/7 because the +remaining interaction is not assigned to either first-change partial. An +incoming-link normalization conceals that residual by construction. Likewise, +a loop alone in its partition has relative magnitude 1 whenever active even +if exogenous forcing accounts for most of the target's motion. Relative loop +importance compares the measured feedback in that partition; it is not a +general decomposition of all observed change into additive causal percentages. + +At equilibrium the published zero-change rule produces zero scores. Preserve +the distinction between structural feedback and observed active feedback; +deleting structural polarity is not a prerequisite for one runtime scoring +pipeline. A future perturbation or sensitivity view should identify itself as +a different analysis, rather than silently changing the LTM result. + +## One analysis attached to one run + +Use one post-simulation loop-scoring owner over recorded link evidence for +every consumer. Bind that evidence, loop identity, partitions, names, +simulation specifications and parameter overrides to the same immutable model +revision. A later edit must not relabel an old score column with a newly +numbered loop. Layout, Python, TypeScript, MCP and the CLI should consume this +same run analysis instead of simulating independently or selecting different +loop populations. + +Separate the analysis into three lifetimes: + +1. **Model structure:** dependency/read provenance, admitted score programs, + causal graph, cycle partitions and stable circuit identity. Cache these + across parameter sweeps where their dependencies are unchanged. +2. **Run evidence:** observed values needed for finite counterfactuals, compact + link-score series and activity bitsets. Recompute after a trajectory change. +3. **Explanation:** discovered cycles, normalization, ranking, display limits + and dominant periods. Reuse evidence when only the display request changes. + +An active graph from one run is not a safe replacement for the structural +graph of another run. A parameter change can activate a previously unused +branch or reverse a link's sign. + +## Completeness has several independent meanings + +`enumeration_complete` describes whether search exhausted its admitted +runtime graph. It cannot establish that all model dependencies were scored, +that every dt was retained, or that every discovered loop is displayed. +Carry these facts alongside the result: + +| Dimension | Required distinction | +|---|---| +| Scoring | Exact admitted partial, documented approximation, or declined edge with reason | +| Time | Every dt or the saved-step sample, with the actual sampling interval | +| Search | Complete universe, partial enumeration, or sampled candidates | +| Numerics | Finite normalized value, zero-change guard, or unresolved nonfinite computation | +| Presentation | Full report or omitted loops, with per-step unreported mass where known | +| Provenance | Model revision, parameters, integration method and run identity | + +For a complete universe, let `c(t)` be the sum of absolute relative scores of +the displayed loops in a partition. Omitted mass is `1-c(t)` at an active +finite step. Reporting that mass keeps a short explanation honest without +requiring the user to inspect every loop. If the universe is incomplete, +normalizing its candidates only produces shares *within that candidate set*; +the true missing mass is unknown. + +A named dominant set must reach the paper's 50% threshold against the stated +denominator. Keeping the strongest individual loop at each step is insufficient: +shares `+0.4, -0.3, -0.3` have a balancing majority, while the report +`+0.4, -0.3` does not establish a dominant set. A gap in dominant periods can +mean insufficient reported evidence, not just inactive feedback. + +## Numerical stability belongs in the shared scorer + +Raw loop products and sums of absolute scores can overflow even when every +input is finite. Saturating a denominator at `f64::MAX` is not normalized +arithmetic: two raw scores of `1e308` receive shares of about 0.556 each. +Allowing an intermediate product to underflow can also erase a loop whose +later factors would restore a representable product. + +Use a signed, scaled representation for products and group sums, or an +equivalent log-magnitude representation, with explicit treatment of zero and +nonfinite input. Normalize before converting to ordinary `f64` shares. +Evaluate both numerical error and throughput before choosing the +representation. This must be one policy across retention, relative link and +loop scores, module composites, and runtime polarity. A local clamp in one +consumer cannot recover information another stage already discarded. + +## Remove redundant work before changing the metric + +The most direct optimization is to reuse compiled target expressions under +the validated live-read selection, with per-link memoization. The current +whole-model synthetic-equation collection causes unrelated link programs to +be revisited after an edit. A bytecode or typed-IR transformation can avoid +printing, reparsing and retaining duplicate syntax trees. Preserve read +provenance until this transformation no longer needs it. Pure arithmetic can +be reused; clock, snapshots, dynamic views and effectful operations need +explicit rules rather than an unrestricted opcode copy. + +Fuse the link-score guard and share source/target deltas where profiling +justifies it. This reduces repeated scaffolding; it does not eliminate the +counterfactual target evaluation itself. Analytic fast paths for affine +expressions are possible, but their finite-difference semantics and floating +point behavior must be tested against the general path. + +Separate VM working slots from saved results. Partial values, scratch space, +capture helpers and intermediate pathway products need not all become full +history columns. Retain the evidence the chosen analysis needs, with one +owner for its storage, rather than copying the complete simulation slab into +another layout for discovery. + +For loop-only analysis, a scored edge can be omitted if it cannot lie on a +directed cycle. Apply this proof on the graph with the necessary module, +aggregate and temporal-state detail. A parent module outside a parent-level +cycle may still contain internal feedback. Ordinary exogenous inputs must +still execute in the simulation and be available to partials even when their +link scores are not collected. Scoring every causal link remains a separate +capability for link-oriented explanations. + +A streaming pass can evaluate analysis once the relevant dt state is final, +before history is discarded. A deferred pass can parallelize independent +steps only when it retains that same evidence. With `save_step > dt`, adjacent +public result rows do not supply the previous dt state, and lagged co-inputs +can require more history still. Measure the retention cost and native/browser +behavior before selecting either approach. Avoid unnecessary analysis work +at intermediate Runge-Kutta trial states when only final-state scores are used. + +## Bounded discovery + +Activity-bitset pruning is exact for the scored sampled graph: a loop's +product can be nonzero only where all of its edges are active. This is a +useful extension beyond heuristic path search when enumeration completes. +It does not remove the combinatorial cost of elementary cycles on arbitrary +dense graphs. + +A responsive scheduler needs a budget for graph preparation, search, scoring, +materialization and reporting, not just candidate generation. Make work +resumable and distribute it across independent SCCs so a dense early component +does not starve later ones. Keep partial normalization visibly conditional. + +In the browser, perform this work outside the interaction thread and publish +results tagged with the run identity. An edit can supersede foreground demand +for an older run without attaching its unfinished explanation to the new run. +For a requested complete analysis, a short scheduling slice pauses and resumes +work; it does not discard the search frontier. A resource limit that prevents +completion must be reported as such. Exact large-model explanations need +complete denominators and polarity totals before presentation caps, together +with an expandable account of omitted loop mass. + +Deleting a heuristic fallback is a separate decision from consolidating score +computation. Compare equal-budget candidate quality against partial exact +enumeration, including renamed nodes, multiple SCCs and late-important cycles. +A low recall figure for a heuristic does not prove that a traversal prefix has +better recall. Similarly, a determinant, spectral statistic, or sum of closed +walks is not automatically the sum of elementary-loop scores; any such +acceleration needs a proof of equivalence or a separately named metric. + +## Acceptance evidence + +Use tests that begin with production model parsing and dependency extraction. +Derive expected results independently from the equations, and enumerate the +temporal-read, shape, reducer and backend arms under test. Goldens and +VM/WASM agreement are regression evidence; two implementations can share a +wrong premise. + +Maintain scalar-expansion comparisons for arrays and representation twins for +split/net flows and named/inline reducers. Verify ordinary simulated values +are unchanged by the overlay, and exercise sparse save steps, all integration +methods, inactive periods, editing after a run and parameter-dependent branch +changes. Test budget exhaustion with tiny configurable limits. + +Measure cold compilation, warm edits, simulation, discovery, result extraction +and peak/retained memory separately. Native measurements do not establish +browser latency or WASM memory behavior. The full user journey must establish +that enabling analysis neither blocks ordinary simulation nor silently reports +unsupported analysis as an absence of feedback. diff --git a/docs/reference/ltm--loops-that-matter.md b/docs/reference/ltm--loops-that-matter.md index ad9642b58..f0a76f488 100644 --- a/docs/reference/ltm--loops-that-matter.md +++ b/docs/reference/ltm--loops-that-matter.md @@ -237,9 +237,15 @@ Where: - Measures the *force* that input x exerts on output z, relative to the total effect on z - Unlike a partial derivative (which measures sensitivity), this measures how much the change in x *contributed* to the total change in z -- For linear equations (addition/subtraction only), values are always in [0, 1] -- For nonlinear equations with mixed polarities, can take very large values -- but this - does not jeopardize analysis since relative values are compared +- There is no general upper bound, even for addition/subtraction: for `z = x - y`, + changes `Delta(x) = 2` and `Delta(y) = 1` give magnitudes 2 and 1. Opposing + contributions can nearly cancel in `Delta(z)` while their partial changes remain large. + The bound [0, 1] requires non-cancelling additive contributions. The 2020 paper's + prose on p. 165 states a broader bound for linear equations; its Eq. 1 itself gives + this counterexample. +- Relative normalization makes large finite scores interpretable, but its implementation + must also prevent overflow in products and denominator sums. Normalization alone does + not recover information lost to floating-point overflow. **Polarity** `sign(Delta_x(z) / Delta(x))`: - Uses Richardson's (1995) polarity definition @@ -1524,6 +1530,14 @@ average contribution to behavior. penalizing loops during initialization) - A threshold of 0.01 (1%) means the loop contributes only 1% of total behavior on average +Simlin's discovery ranking uses a different averaging window: all steps with +positive partition mass and a defined score for the loop, including zero scores +before that loop first becomes active. This measures sustained contribution +within the partition. Discovery retention instead uses a peak contribution +threshold, and its report cap protects per-step leading loops. These are +implementation policies, distinct from the thesis's delayed averaging and +average-based CLD inclusion threshold. + #### Simplification Examples (Market Growth Model) | Thresholds | Variables | Description | diff --git a/docs/reference/papers/schoenberg2020.1-seamlessly-integrating-ltm--summary.md b/docs/reference/papers/schoenberg2020.1-seamlessly-integrating-ltm--summary.md index 60401d923..110546bec 100644 --- a/docs/reference/papers/schoenberg2020.1-seamlessly-integrating-ltm--summary.md +++ b/docs/reference/papers/schoenberg2020.1-seamlessly-integrating-ltm--summary.md @@ -240,8 +240,10 @@ The simplest population model: one stock (Population), one flow (births), one pa ### 5.2 Population with Births and Deaths (Figure 5) Adding deaths with an average lifetime of 20 produces slower exponential growth with two loops: -- R1 (reinforcing): 67% contribution -- B1 (balancing): 35% contribution (note: magnitudes sum to more than 100% in instantaneous terms because opposing effects inflate both) +- R1 (reinforcing): +66.67% contribution +- B1 (balancing): -33.33% contribution + +**Editorial source note:** Figure 5 on PDF page 12 displays +66.67% and -33.33%. The adjacent prose says 67% and 35%; the latter appears to be a typographical error. Opposing effects do not make absolute **relative** loop scores sum above 100%: normalization divides by the sum of absolute loop scores. **Pedagogical insight:** If students set "average lifetime" to exactly 10, nothing changes and no loops are reported. This is a **learning moment**: the lack of reported loops is an artifact of LTM (it cannot analyze equilibrium) but also reflects that two opposing loops are perfectly balanced -- either cosmic coincidence or meaningful. This naturally leads to introducing **carrying capacity**. diff --git a/src/simlin-engine/src/db/fragment_char_golden/ltm_loop_discovery.txt b/src/simlin-engine/src/db/fragment_char_golden/ltm_loop_discovery.txt index 4cff17a51..6ee97b363 100644 --- a/src/simlin-engine/src/db/fragment_char_golden/ltm_loop_discovery.txt +++ b/src/simlin-engine/src/db/fragment_char_golden/ltm_loop_discovery.txt @@ -138,24 +138,26 @@ 0020 LoadConstant #0 (=0.0) 0021 LoadPrev $⁚ltm⁚net⁚level@0 0022 Op2 Sub - 0023 LoadConstant #0 (=0.0) - 0024 Op2 Eq - 0025 LoadVar growth@0 - 0026 LoadConstant #0 (=0.0) - 0027 LoadPrev growth@0 - 0028 Op2 Sub - 0029 LoadConstant #0 (=0.0) - 0030 Op2 Eq - 0031 Op2 Or - 0032 SetCond - 0033 If - 0034 LoadGlobalVar off=0 (time) - 0035 LoadGlobalVar off=2 (initial_time) - 0036 Op2 Eq - 0037 SetCond - 0038 If - 0039 AssignCurr $⁚ltm⁚link_score⁚growth→level@0 - 0040 Ret + 0023 Apply Abs + 0024 LoadConstant #0 (=0.0) + 0025 Op2 Lte + 0026 LoadVar growth@0 + 0027 LoadConstant #0 (=0.0) + 0028 LoadPrev growth@0 + 0029 Op2 Sub + 0030 Apply Abs + 0031 LoadConstant #0 (=0.0) + 0032 Op2 Lte + 0033 Op2 Or + 0034 SetCond + 0035 If + 0036 LoadGlobalVar off=0 (time) + 0037 LoadGlobalVar off=2 (initial_time) + 0038 Op2 Eq + 0039 SetCond + 0040 If + 0041 AssignCurr $⁚ltm⁚link_score⁚growth→level@0 + 0042 Ret stock: == main::$⁚ltm⁚link_score⁚idx→growth [ltm-synthetic] : flow == initial: @@ -197,24 +199,26 @@ 0027 LoadConstant #0 (=0.0) 0028 LoadPrev growth@0 0029 Op2 Sub - 0030 LoadConstant #0 (=0.0) - 0031 Op2 Eq - 0032 LoadVar idx@0 - 0033 LoadConstant #0 (=0.0) - 0034 LoadPrev idx@0 - 0035 Op2 Sub - 0036 LoadConstant #0 (=0.0) - 0037 Op2 Eq - 0038 Op2 Or - 0039 SetCond - 0040 If - 0041 LoadGlobalVar off=0 (time) - 0042 LoadGlobalVar off=2 (initial_time) - 0043 Op2 Eq - 0044 SetCond - 0045 If - 0046 AssignCurr $⁚ltm⁚link_score⁚idx→growth@0 - 0047 Ret + 0030 Apply Abs + 0031 LoadConstant #0 (=0.0) + 0032 Op2 Lte + 0033 LoadVar idx@0 + 0034 LoadConstant #0 (=0.0) + 0035 LoadPrev idx@0 + 0036 Op2 Sub + 0037 Apply Abs + 0038 LoadConstant #0 (=0.0) + 0039 Op2 Lte + 0040 Op2 Or + 0041 SetCond + 0042 If + 0043 LoadGlobalVar off=0 (time) + 0044 LoadGlobalVar off=2 (initial_time) + 0045 Op2 Eq + 0046 SetCond + 0047 If + 0048 AssignCurr $⁚ltm⁚link_score⁚idx→growth@0 + 0049 Ret stock: == main::$⁚ltm⁚link_score⁚level→growth [ltm-synthetic] : flow == initial: @@ -255,24 +259,26 @@ 0026 LoadConstant #0 (=0.0) 0027 LoadPrev growth@0 0028 Op2 Sub - 0029 LoadConstant #0 (=0.0) - 0030 Op2 Eq - 0031 LoadVar level@0 - 0032 LoadConstant #0 (=0.0) - 0033 LoadPrev level@0 - 0034 Op2 Sub - 0035 LoadConstant #0 (=0.0) - 0036 Op2 Eq - 0037 Op2 Or - 0038 SetCond - 0039 If - 0040 LoadGlobalVar off=0 (time) - 0041 LoadGlobalVar off=2 (initial_time) - 0042 Op2 Eq - 0043 SetCond - 0044 If - 0045 AssignCurr $⁚ltm⁚link_score⁚level→growth@0 - 0046 Ret + 0029 Apply Abs + 0030 LoadConstant #0 (=0.0) + 0031 Op2 Lte + 0032 LoadVar level@0 + 0033 LoadConstant #0 (=0.0) + 0034 LoadPrev level@0 + 0035 Op2 Sub + 0036 Apply Abs + 0037 LoadConstant #0 (=0.0) + 0038 Op2 Lte + 0039 Op2 Or + 0040 SetCond + 0041 If + 0042 LoadGlobalVar off=0 (time) + 0043 LoadGlobalVar off=2 (initial_time) + 0044 Op2 Eq + 0045 SetCond + 0046 If + 0047 AssignCurr $⁚ltm⁚link_score⁚level→growth@0 + 0048 Ret stock: == main::$⁚ltm⁚link_score⁚rate→growth [ltm-synthetic] : flow == initial: @@ -313,24 +319,26 @@ 0026 LoadConstant #0 (=0.0) 0027 LoadPrev growth@0 0028 Op2 Sub - 0029 LoadConstant #0 (=0.0) - 0030 Op2 Eq - 0031 LoadVar rate@0 - 0032 LoadConstant #0 (=0.0) - 0033 LoadPrev rate@0 - 0034 Op2 Sub - 0035 LoadConstant #0 (=0.0) - 0036 Op2 Eq - 0037 Op2 Or - 0038 SetCond - 0039 If - 0040 LoadGlobalVar off=0 (time) - 0041 LoadGlobalVar off=2 (initial_time) - 0042 Op2 Eq - 0043 SetCond - 0044 If - 0045 AssignCurr $⁚ltm⁚link_score⁚rate→growth@0 - 0046 Ret + 0029 Apply Abs + 0030 LoadConstant #0 (=0.0) + 0031 Op2 Lte + 0032 LoadVar rate@0 + 0033 LoadConstant #0 (=0.0) + 0034 LoadPrev rate@0 + 0035 Op2 Sub + 0036 Apply Abs + 0037 LoadConstant #0 (=0.0) + 0038 Op2 Lte + 0039 Op2 Or + 0040 SetCond + 0041 If + 0042 LoadGlobalVar off=0 (time) + 0043 LoadGlobalVar off=2 (initial_time) + 0044 Op2 Eq + 0045 SetCond + 0046 If + 0047 AssignCurr $⁚ltm⁚link_score⁚rate→growth@0 + 0048 Ret stock: == main::$⁚ltm⁚link_score⁚weight→growth [ltm-synthetic] : flow == initial: @@ -379,29 +387,31 @@ 0034 LoadConstant #0 (=0.0) 0035 LoadPrev growth@0 0036 Op2 Sub - 0037 LoadConstant #0 (=0.0) - 0038 Op2 Eq - 0039 PushVarViewDirect weight@0 dim_list=1 - 0040 LoadVar idx@0 - 0041 LoadPrev idx@0 - 0042 ViewSubscriptDynamic dim=0 - 0043 ArraySum - 0044 PopView - 0045 LoadConstant #0 (=0.0) - 0046 LoadPrev $⁚$⁚ltm⁚link_score⁚weight→growth⁚0⁚arg0@0 - 0047 Op2 Sub - 0048 LoadConstant #0 (=0.0) - 0049 Op2 Eq - 0050 Op2 Or - 0051 SetCond - 0052 If - 0053 LoadGlobalVar off=0 (time) - 0054 LoadGlobalVar off=2 (initial_time) - 0055 Op2 Eq - 0056 SetCond - 0057 If - 0058 AssignCurr $⁚ltm⁚link_score⁚weight→growth@0 - 0059 Ret + 0037 Apply Abs + 0038 LoadConstant #0 (=0.0) + 0039 Op2 Lte + 0040 PushVarViewDirect weight@0 dim_list=1 + 0041 LoadVar idx@0 + 0042 LoadPrev idx@0 + 0043 ViewSubscriptDynamic dim=0 + 0044 ArraySum + 0045 PopView + 0046 LoadConstant #0 (=0.0) + 0047 LoadPrev $⁚$⁚ltm⁚link_score⁚weight→growth⁚0⁚arg0@0 + 0048 Op2 Sub + 0049 Apply Abs + 0050 LoadConstant #0 (=0.0) + 0051 Op2 Lte + 0052 Op2 Or + 0053 SetCond + 0054 If + 0055 LoadGlobalVar off=0 (time) + 0056 LoadGlobalVar off=2 (initial_time) + 0057 Op2 Eq + 0058 SetCond + 0059 If + 0060 AssignCurr $⁚ltm⁚link_score⁚weight→growth@0 + 0061 Ret stock: == main::$⁚ltm⁚net⁚level [ltm-synthetic] : flow == initial: diff --git a/src/simlin-engine/src/db/fragment_char_golden/ltm_loop_exhaustive.txt b/src/simlin-engine/src/db/fragment_char_golden/ltm_loop_exhaustive.txt index 20f4e1850..9acbdd860 100644 --- a/src/simlin-engine/src/db/fragment_char_golden/ltm_loop_exhaustive.txt +++ b/src/simlin-engine/src/db/fragment_char_golden/ltm_loop_exhaustive.txt @@ -61,24 +61,26 @@ 0020 LoadConstant #0 (=0.0) 0021 LoadPrev $⁚ltm⁚net⁚level@0 0022 Op2 Sub - 0023 LoadConstant #0 (=0.0) - 0024 Op2 Eq - 0025 LoadVar growth@0 - 0026 LoadConstant #0 (=0.0) - 0027 LoadPrev growth@0 - 0028 Op2 Sub - 0029 LoadConstant #0 (=0.0) - 0030 Op2 Eq - 0031 Op2 Or - 0032 SetCond - 0033 If - 0034 LoadGlobalVar off=0 (time) - 0035 LoadGlobalVar off=2 (initial_time) - 0036 Op2 Eq - 0037 SetCond - 0038 If - 0039 AssignCurr $⁚ltm⁚link_score⁚growth→level@0 - 0040 Ret + 0023 Apply Abs + 0024 LoadConstant #0 (=0.0) + 0025 Op2 Lte + 0026 LoadVar growth@0 + 0027 LoadConstant #0 (=0.0) + 0028 LoadPrev growth@0 + 0029 Op2 Sub + 0030 Apply Abs + 0031 LoadConstant #0 (=0.0) + 0032 Op2 Lte + 0033 Op2 Or + 0034 SetCond + 0035 If + 0036 LoadGlobalVar off=0 (time) + 0037 LoadGlobalVar off=2 (initial_time) + 0038 Op2 Eq + 0039 SetCond + 0040 If + 0041 AssignCurr $⁚ltm⁚link_score⁚growth→level@0 + 0042 Ret stock: == main::$⁚ltm⁚link_score⁚level→growth [ltm-synthetic] : flow == initial: @@ -119,24 +121,26 @@ 0026 LoadConstant #0 (=0.0) 0027 LoadPrev growth@0 0028 Op2 Sub - 0029 LoadConstant #0 (=0.0) - 0030 Op2 Eq - 0031 LoadVar level@0 - 0032 LoadConstant #0 (=0.0) - 0033 LoadPrev level@0 - 0034 Op2 Sub - 0035 LoadConstant #0 (=0.0) - 0036 Op2 Eq - 0037 Op2 Or - 0038 SetCond - 0039 If - 0040 LoadGlobalVar off=0 (time) - 0041 LoadGlobalVar off=2 (initial_time) - 0042 Op2 Eq - 0043 SetCond - 0044 If - 0045 AssignCurr $⁚ltm⁚link_score⁚level→growth@0 - 0046 Ret + 0029 Apply Abs + 0030 LoadConstant #0 (=0.0) + 0031 Op2 Lte + 0032 LoadVar level@0 + 0033 LoadConstant #0 (=0.0) + 0034 LoadPrev level@0 + 0035 Op2 Sub + 0036 Apply Abs + 0037 LoadConstant #0 (=0.0) + 0038 Op2 Lte + 0039 Op2 Or + 0040 SetCond + 0041 If + 0042 LoadGlobalVar off=0 (time) + 0043 LoadGlobalVar off=2 (initial_time) + 0044 Op2 Eq + 0045 SetCond + 0046 If + 0047 AssignCurr $⁚ltm⁚link_score⁚level→growth@0 + 0048 Ret stock: == main::$⁚ltm⁚loop_score⁚r1 [ltm-synthetic] : flow == initial: diff --git a/src/simlin-engine/src/db/ltm_char_golden/agg_nested_reducer.txt b/src/simlin-engine/src/db/ltm_char_golden/agg_nested_reducer.txt index 1cc5b9765..41573a6fd 100644 --- a/src/simlin-engine/src/db/ltm_char_golden/agg_nested_reducer.txt +++ b/src/simlin-engine/src/db/ltm_char_golden/agg_nested_reducer.txt @@ -1,10 +1,10 @@ $⁚ltm⁚link_score⁚$⁚ltm⁚agg⁚0→growth[boston] -scalar: if (TIME = INITIAL_TIME) then 0 else if ((growth[region·boston] - PREVIOUS(growth[region·boston])) = 0) OR (("$⁚ltm⁚agg⁚0" - PREVIOUS("$⁚ltm⁚agg⁚0")) = 0) then 0 else SAFEDIV(((sum(PREVIOUS(matrix[region·boston, *]) * PREVIOUS(other[region·nyc, *]) * "$⁚ltm⁚agg⁚0") * 0.001) - PREVIOUS(growth[region·boston])), ABS((growth[region·boston] - PREVIOUS(growth[region·boston]))), 0) * SIGN(("$⁚ltm⁚agg⁚0" - PREVIOUS("$⁚ltm⁚agg⁚0"))) +scalar: if (TIME = INITIAL_TIME) then 0 else if (ABS((growth[region·boston] - PREVIOUS(growth[region·boston]))) <= 0) OR (ABS(("$⁚ltm⁚agg⁚0" - PREVIOUS("$⁚ltm⁚agg⁚0"))) <= 0) then 0 else SAFEDIV(((sum(PREVIOUS(matrix[region·boston, *]) * PREVIOUS(other[region·nyc, *]) * "$⁚ltm⁚agg⁚0") * 0.001) - PREVIOUS(growth[region·boston])), ABS((growth[region·boston] - PREVIOUS(growth[region·boston]))), 0) * SIGN(("$⁚ltm⁚agg⁚0" - PREVIOUS("$⁚ltm⁚agg⁚0"))) $⁚ltm⁚link_score⁚$⁚ltm⁚agg⁚0→growth[nyc] -scalar: if (TIME = INITIAL_TIME) then 0 else if ((growth[region·nyc] - PREVIOUS(growth[region·nyc])) = 0) OR (("$⁚ltm⁚agg⁚0" - PREVIOUS("$⁚ltm⁚agg⁚0")) = 0) then 0 else SAFEDIV(((sum(PREVIOUS(matrix[region·nyc, *]) * PREVIOUS(other[region·nyc, *]) * "$⁚ltm⁚agg⁚0") * 0.001) - PREVIOUS(growth[region·nyc])), ABS((growth[region·nyc] - PREVIOUS(growth[region·nyc]))), 0) * SIGN(("$⁚ltm⁚agg⁚0" - PREVIOUS("$⁚ltm⁚agg⁚0"))) +scalar: if (TIME = INITIAL_TIME) then 0 else if (ABS((growth[region·nyc] - PREVIOUS(growth[region·nyc]))) <= 0) OR (ABS(("$⁚ltm⁚agg⁚0" - PREVIOUS("$⁚ltm⁚agg⁚0"))) <= 0) then 0 else SAFEDIV(((sum(PREVIOUS(matrix[region·nyc, *]) * PREVIOUS(other[region·nyc, *]) * "$⁚ltm⁚agg⁚0") * 0.001) - PREVIOUS(growth[region·nyc])), ABS((growth[region·nyc] - PREVIOUS(growth[region·nyc]))), 0) * SIGN(("$⁚ltm⁚agg⁚0" - PREVIOUS("$⁚ltm⁚agg⁚0"))) $⁚ltm⁚link_score⁚growth→stock dims=[Region] -a2a[Region]: if (TIME = INITIAL_TIME) then 0 else if (("$⁚ltm⁚net⁚stock"[Region] - PREVIOUS("$⁚ltm⁚net⁚stock"[Region])) = 0) OR ((growth[Region] - PREVIOUS(growth[Region])) = 0) then 0 else SAFEDIV((growth[Region] - PREVIOUS(growth[Region])), ABS(("$⁚ltm⁚net⁚stock"[Region] - PREVIOUS("$⁚ltm⁚net⁚stock"[Region]))), 0) * SIGN((growth[Region] - PREVIOUS(growth[Region]))) +a2a[Region]: if (TIME = INITIAL_TIME) then 0 else if (ABS(("$⁚ltm⁚net⁚stock"[Region] - PREVIOUS("$⁚ltm⁚net⁚stock"[Region]))) <= 0) OR (ABS((growth[Region] - PREVIOUS(growth[Region]))) <= 0) then 0 else SAFEDIV((growth[Region] - PREVIOUS(growth[Region])), ABS(("$⁚ltm⁚net⁚stock"[Region] - PREVIOUS("$⁚ltm⁚net⁚stock"[Region]))), 0) * SIGN((growth[Region] - PREVIOUS(growth[Region]))) $⁚ltm⁚link_score⁚pop[boston]→$⁚ltm⁚agg⁚0 -scalar: if (TIME = INITIAL_TIME) then 0 else if (("$⁚ltm⁚agg⁚0" - PREVIOUS("$⁚ltm⁚agg⁚0")) = 0) OR ((pop[region·boston] - PREVIOUS(pop[region·boston])) = 0) then 0 else SAFEDIV((PREVIOUS("$⁚ltm⁚agg⁚0") + (pop[region·boston] - PREVIOUS(pop[region·boston])) - PREVIOUS("$⁚ltm⁚agg⁚0")), ABS(("$⁚ltm⁚agg⁚0" - PREVIOUS("$⁚ltm⁚agg⁚0"))), 0) * SIGN((pop[region·boston] - PREVIOUS(pop[region·boston]))) +scalar: if (TIME = INITIAL_TIME) then 0 else if (ABS(("$⁚ltm⁚agg⁚0" - PREVIOUS("$⁚ltm⁚agg⁚0"))) <= 0) OR (ABS((pop[region·boston] - PREVIOUS(pop[region·boston]))) <= 0) then 0 else SAFEDIV((PREVIOUS("$⁚ltm⁚agg⁚0") + (pop[region·boston] - PREVIOUS(pop[region·boston])) - PREVIOUS("$⁚ltm⁚agg⁚0")), ABS(("$⁚ltm⁚agg⁚0" - PREVIOUS("$⁚ltm⁚agg⁚0"))), 0) * SIGN((pop[region·boston] - PREVIOUS(pop[region·boston]))) $⁚ltm⁚link_score⁚pop[nyc]→$⁚ltm⁚agg⁚0 -scalar: if (TIME = INITIAL_TIME) then 0 else if (("$⁚ltm⁚agg⁚0" - PREVIOUS("$⁚ltm⁚agg⁚0")) = 0) OR ((pop[region·nyc] - PREVIOUS(pop[region·nyc])) = 0) then 0 else SAFEDIV((PREVIOUS("$⁚ltm⁚agg⁚0") + (pop[region·nyc] - PREVIOUS(pop[region·nyc])) - PREVIOUS("$⁚ltm⁚agg⁚0")), ABS(("$⁚ltm⁚agg⁚0" - PREVIOUS("$⁚ltm⁚agg⁚0"))), 0) * SIGN((pop[region·nyc] - PREVIOUS(pop[region·nyc]))) +scalar: if (TIME = INITIAL_TIME) then 0 else if (ABS(("$⁚ltm⁚agg⁚0" - PREVIOUS("$⁚ltm⁚agg⁚0"))) <= 0) OR (ABS((pop[region·nyc] - PREVIOUS(pop[region·nyc]))) <= 0) then 0 else SAFEDIV((PREVIOUS("$⁚ltm⁚agg⁚0") + (pop[region·nyc] - PREVIOUS(pop[region·nyc])) - PREVIOUS("$⁚ltm⁚agg⁚0")), ABS(("$⁚ltm⁚agg⁚0" - PREVIOUS("$⁚ltm⁚agg⁚0"))), 0) * SIGN((pop[region·nyc] - PREVIOUS(pop[region·nyc]))) diff --git a/src/simlin-engine/src/db/ltm_char_golden/agg_to_arrayed_target.txt b/src/simlin-engine/src/db/ltm_char_golden/agg_to_arrayed_target.txt index 698fff12e..7f691ca1f 100644 --- a/src/simlin-engine/src/db/ltm_char_golden/agg_to_arrayed_target.txt +++ b/src/simlin-engine/src/db/ltm_char_golden/agg_to_arrayed_target.txt @@ -1,4 +1,4 @@ $⁚ltm⁚link_score⁚$⁚ltm⁚agg⁚0→outflow[boston] -scalar: if (TIME = INITIAL_TIME) then 0 else if ((outflow[region·boston] - PREVIOUS(outflow[region·boston])) = 0) OR (("$⁚ltm⁚agg⁚0" - PREVIOUS("$⁚ltm⁚agg⁚0")) = 0) then 0 else SAFEDIV((("$⁚ltm⁚agg⁚0" * PREVIOUS(frac[region·boston])) - PREVIOUS(outflow[region·boston])), ABS((outflow[region·boston] - PREVIOUS(outflow[region·boston]))), 0) * SIGN(("$⁚ltm⁚agg⁚0" - PREVIOUS("$⁚ltm⁚agg⁚0"))) +scalar: if (TIME = INITIAL_TIME) then 0 else if (ABS((outflow[region·boston] - PREVIOUS(outflow[region·boston]))) <= 0) OR (ABS(("$⁚ltm⁚agg⁚0" - PREVIOUS("$⁚ltm⁚agg⁚0"))) <= 0) then 0 else SAFEDIV((("$⁚ltm⁚agg⁚0" * PREVIOUS(frac[region·boston])) - PREVIOUS(outflow[region·boston])), ABS((outflow[region·boston] - PREVIOUS(outflow[region·boston]))), 0) * SIGN(("$⁚ltm⁚agg⁚0" - PREVIOUS("$⁚ltm⁚agg⁚0"))) $⁚ltm⁚link_score⁚$⁚ltm⁚agg⁚0→outflow[nyc] -scalar: if (TIME = INITIAL_TIME) then 0 else if ((outflow[region·nyc] - PREVIOUS(outflow[region·nyc])) = 0) OR (("$⁚ltm⁚agg⁚0" - PREVIOUS("$⁚ltm⁚agg⁚0")) = 0) then 0 else SAFEDIV((("$⁚ltm⁚agg⁚0" * PREVIOUS(frac[region·nyc])) - PREVIOUS(outflow[region·nyc])), ABS((outflow[region·nyc] - PREVIOUS(outflow[region·nyc]))), 0) * SIGN(("$⁚ltm⁚agg⁚0" - PREVIOUS("$⁚ltm⁚agg⁚0"))) +scalar: if (TIME = INITIAL_TIME) then 0 else if (ABS((outflow[region·nyc] - PREVIOUS(outflow[region·nyc]))) <= 0) OR (ABS(("$⁚ltm⁚agg⁚0" - PREVIOUS("$⁚ltm⁚agg⁚0"))) <= 0) then 0 else SAFEDIV((("$⁚ltm⁚agg⁚0" * PREVIOUS(frac[region·nyc])) - PREVIOUS(outflow[region·nyc])), ABS((outflow[region·nyc] - PREVIOUS(outflow[region·nyc]))), 0) * SIGN(("$⁚ltm⁚agg⁚0" - PREVIOUS("$⁚ltm⁚agg⁚0"))) diff --git a/src/simlin-engine/src/db/ltm_char_golden/agg_to_scalar_target.txt b/src/simlin-engine/src/db/ltm_char_golden/agg_to_scalar_target.txt index 429bfbe43..856c7f93e 100644 --- a/src/simlin-engine/src/db/ltm_char_golden/agg_to_scalar_target.txt +++ b/src/simlin-engine/src/db/ltm_char_golden/agg_to_scalar_target.txt @@ -1,2 +1,2 @@ $⁚ltm⁚link_score⁚$⁚ltm⁚agg⁚0→total -scalar: if (TIME = INITIAL_TIME) then 0 else if ((total - PREVIOUS(total)) = 0) OR (("$⁚ltm⁚agg⁚0" - PREVIOUS("$⁚ltm⁚agg⁚0")) = 0) then 0 else SAFEDIV((("$⁚ltm⁚agg⁚0" * 2) - PREVIOUS(total)), ABS((total - PREVIOUS(total))), 0) * SIGN(("$⁚ltm⁚agg⁚0" - PREVIOUS("$⁚ltm⁚agg⁚0"))) +scalar: if (TIME = INITIAL_TIME) then 0 else if (ABS((total - PREVIOUS(total))) <= 0) OR (ABS(("$⁚ltm⁚agg⁚0" - PREVIOUS("$⁚ltm⁚agg⁚0"))) <= 0) then 0 else SAFEDIV((("$⁚ltm⁚agg⁚0" * 2) - PREVIOUS(total)), ABS((total - PREVIOUS(total))), 0) * SIGN(("$⁚ltm⁚agg⁚0" - PREVIOUS("$⁚ltm⁚agg⁚0"))) diff --git a/src/simlin-engine/src/db/ltm_char_golden/already_lagged_other_dep.txt b/src/simlin-engine/src/db/ltm_char_golden/already_lagged_other_dep.txt index bca137f8d..a40d566bb 100644 --- a/src/simlin-engine/src/db/ltm_char_golden/already_lagged_other_dep.txt +++ b/src/simlin-engine/src/db/ltm_char_golden/already_lagged_other_dep.txt @@ -1,2 +1,2 @@ $⁚ltm⁚link_score⁚from→to -scalar: if (TIME = INITIAL_TIME) then 0 else if ((to - PREVIOUS(to)) = 0) OR ((from - PREVIOUS(from)) = 0) then 0 else SAFEDIV(((from + previous(g, 0)) - PREVIOUS(to)), ABS((to - PREVIOUS(to))), 0) * SIGN((from - PREVIOUS(from))) +scalar: if (TIME = INITIAL_TIME) then 0 else if (ABS((to - PREVIOUS(to))) <= 0) OR (ABS((from - PREVIOUS(from))) <= 0) then 0 else SAFEDIV(((from + PREVIOUS(previous(g, 0))) - PREVIOUS(to)), ABS((to - PREVIOUS(to))), 0) * SIGN((from - PREVIOUS(from))) diff --git a/src/simlin-engine/src/db/ltm_char_golden/arrayed_agg_to_target.txt b/src/simlin-engine/src/db/ltm_char_golden/arrayed_agg_to_target.txt index 8c620da54..fc2f5480a 100644 --- a/src/simlin-engine/src/db/ltm_char_golden/arrayed_agg_to_target.txt +++ b/src/simlin-engine/src/db/ltm_char_golden/arrayed_agg_to_target.txt @@ -1,14 +1,14 @@ $⁚ltm⁚link_score⁚$⁚ltm⁚agg⁚0[boston]→outflow[boston] -scalar: if (TIME = INITIAL_TIME) then 0 else if ((outflow[region·boston] - PREVIOUS(outflow[region·boston])) = 0) OR (("$⁚ltm⁚agg⁚0"[boston] - PREVIOUS("$⁚ltm⁚agg⁚0"[boston])) = 0) then 0 else SAFEDIV((("$⁚ltm⁚agg⁚0"[region·boston] * 0.1) - PREVIOUS(outflow[region·boston])), ABS((outflow[region·boston] - PREVIOUS(outflow[region·boston]))), 0) * SIGN(("$⁚ltm⁚agg⁚0"[boston] - PREVIOUS("$⁚ltm⁚agg⁚0"[boston]))) +scalar: if (TIME = INITIAL_TIME) then 0 else if (ABS((outflow[region·boston] - PREVIOUS(outflow[region·boston]))) <= 0) OR (ABS(("$⁚ltm⁚agg⁚0"[boston] - PREVIOUS("$⁚ltm⁚agg⁚0"[boston]))) <= 0) then 0 else SAFEDIV((("$⁚ltm⁚agg⁚0"[region·boston] * 0.1) - PREVIOUS(outflow[region·boston])), ABS((outflow[region·boston] - PREVIOUS(outflow[region·boston]))), 0) * SIGN(("$⁚ltm⁚agg⁚0"[boston] - PREVIOUS("$⁚ltm⁚agg⁚0"[boston]))) $⁚ltm⁚link_score⁚$⁚ltm⁚agg⁚0[nyc]→outflow[nyc] -scalar: if (TIME = INITIAL_TIME) then 0 else if ((outflow[region·nyc] - PREVIOUS(outflow[region·nyc])) = 0) OR (("$⁚ltm⁚agg⁚0"[nyc] - PREVIOUS("$⁚ltm⁚agg⁚0"[nyc])) = 0) then 0 else SAFEDIV((("$⁚ltm⁚agg⁚0"[region·nyc] * 0.1) - PREVIOUS(outflow[region·nyc])), ABS((outflow[region·nyc] - PREVIOUS(outflow[region·nyc]))), 0) * SIGN(("$⁚ltm⁚agg⁚0"[nyc] - PREVIOUS("$⁚ltm⁚agg⁚0"[nyc]))) +scalar: if (TIME = INITIAL_TIME) then 0 else if (ABS((outflow[region·nyc] - PREVIOUS(outflow[region·nyc]))) <= 0) OR (ABS(("$⁚ltm⁚agg⁚0"[nyc] - PREVIOUS("$⁚ltm⁚agg⁚0"[nyc]))) <= 0) then 0 else SAFEDIV((("$⁚ltm⁚agg⁚0"[region·nyc] * 0.1) - PREVIOUS(outflow[region·nyc])), ABS((outflow[region·nyc] - PREVIOUS(outflow[region·nyc]))), 0) * SIGN(("$⁚ltm⁚agg⁚0"[nyc] - PREVIOUS("$⁚ltm⁚agg⁚0"[nyc]))) $⁚ltm⁚link_score⁚matrix[boston,a]→$⁚ltm⁚agg⁚0[boston] -scalar: if (TIME = INITIAL_TIME) then 0 else if (("$⁚ltm⁚agg⁚0"[boston] - PREVIOUS("$⁚ltm⁚agg⁚0"[boston])) = 0) OR ((matrix[region·boston,dim2·a] - PREVIOUS(matrix[region·boston,dim2·a])) = 0) then 0 else SAFEDIV((PREVIOUS("$⁚ltm⁚agg⁚0"[boston]) + (matrix[region·boston,dim2·a] - PREVIOUS(matrix[region·boston,dim2·a])) - PREVIOUS("$⁚ltm⁚agg⁚0"[boston])), ABS(("$⁚ltm⁚agg⁚0"[boston] - PREVIOUS("$⁚ltm⁚agg⁚0"[boston]))), 0) * SIGN((matrix[region·boston,dim2·a] - PREVIOUS(matrix[region·boston,dim2·a]))) +scalar: if (TIME = INITIAL_TIME) then 0 else if (ABS(("$⁚ltm⁚agg⁚0"[boston] - PREVIOUS("$⁚ltm⁚agg⁚0"[boston]))) <= 0) OR (ABS((matrix[region·boston,dim2·a] - PREVIOUS(matrix[region·boston,dim2·a]))) <= 0) then 0 else SAFEDIV((PREVIOUS("$⁚ltm⁚agg⁚0"[boston]) + (matrix[region·boston,dim2·a] - PREVIOUS(matrix[region·boston,dim2·a])) - PREVIOUS("$⁚ltm⁚agg⁚0"[boston])), ABS(("$⁚ltm⁚agg⁚0"[boston] - PREVIOUS("$⁚ltm⁚agg⁚0"[boston]))), 0) * SIGN((matrix[region·boston,dim2·a] - PREVIOUS(matrix[region·boston,dim2·a]))) $⁚ltm⁚link_score⁚matrix[boston,b]→$⁚ltm⁚agg⁚0[boston] -scalar: if (TIME = INITIAL_TIME) then 0 else if (("$⁚ltm⁚agg⁚0"[boston] - PREVIOUS("$⁚ltm⁚agg⁚0"[boston])) = 0) OR ((matrix[region·boston,dim2·b] - PREVIOUS(matrix[region·boston,dim2·b])) = 0) then 0 else SAFEDIV((PREVIOUS("$⁚ltm⁚agg⁚0"[boston]) + (matrix[region·boston,dim2·b] - PREVIOUS(matrix[region·boston,dim2·b])) - PREVIOUS("$⁚ltm⁚agg⁚0"[boston])), ABS(("$⁚ltm⁚agg⁚0"[boston] - PREVIOUS("$⁚ltm⁚agg⁚0"[boston]))), 0) * SIGN((matrix[region·boston,dim2·b] - PREVIOUS(matrix[region·boston,dim2·b]))) +scalar: if (TIME = INITIAL_TIME) then 0 else if (ABS(("$⁚ltm⁚agg⁚0"[boston] - PREVIOUS("$⁚ltm⁚agg⁚0"[boston]))) <= 0) OR (ABS((matrix[region·boston,dim2·b] - PREVIOUS(matrix[region·boston,dim2·b]))) <= 0) then 0 else SAFEDIV((PREVIOUS("$⁚ltm⁚agg⁚0"[boston]) + (matrix[region·boston,dim2·b] - PREVIOUS(matrix[region·boston,dim2·b])) - PREVIOUS("$⁚ltm⁚agg⁚0"[boston])), ABS(("$⁚ltm⁚agg⁚0"[boston] - PREVIOUS("$⁚ltm⁚agg⁚0"[boston]))), 0) * SIGN((matrix[region·boston,dim2·b] - PREVIOUS(matrix[region·boston,dim2·b]))) $⁚ltm⁚link_score⁚matrix[nyc,a]→$⁚ltm⁚agg⁚0[nyc] -scalar: if (TIME = INITIAL_TIME) then 0 else if (("$⁚ltm⁚agg⁚0"[nyc] - PREVIOUS("$⁚ltm⁚agg⁚0"[nyc])) = 0) OR ((matrix[region·nyc,dim2·a] - PREVIOUS(matrix[region·nyc,dim2·a])) = 0) then 0 else SAFEDIV((PREVIOUS("$⁚ltm⁚agg⁚0"[nyc]) + (matrix[region·nyc,dim2·a] - PREVIOUS(matrix[region·nyc,dim2·a])) - PREVIOUS("$⁚ltm⁚agg⁚0"[nyc])), ABS(("$⁚ltm⁚agg⁚0"[nyc] - PREVIOUS("$⁚ltm⁚agg⁚0"[nyc]))), 0) * SIGN((matrix[region·nyc,dim2·a] - PREVIOUS(matrix[region·nyc,dim2·a]))) +scalar: if (TIME = INITIAL_TIME) then 0 else if (ABS(("$⁚ltm⁚agg⁚0"[nyc] - PREVIOUS("$⁚ltm⁚agg⁚0"[nyc]))) <= 0) OR (ABS((matrix[region·nyc,dim2·a] - PREVIOUS(matrix[region·nyc,dim2·a]))) <= 0) then 0 else SAFEDIV((PREVIOUS("$⁚ltm⁚agg⁚0"[nyc]) + (matrix[region·nyc,dim2·a] - PREVIOUS(matrix[region·nyc,dim2·a])) - PREVIOUS("$⁚ltm⁚agg⁚0"[nyc])), ABS(("$⁚ltm⁚agg⁚0"[nyc] - PREVIOUS("$⁚ltm⁚agg⁚0"[nyc]))), 0) * SIGN((matrix[region·nyc,dim2·a] - PREVIOUS(matrix[region·nyc,dim2·a]))) $⁚ltm⁚link_score⁚matrix[nyc,b]→$⁚ltm⁚agg⁚0[nyc] -scalar: if (TIME = INITIAL_TIME) then 0 else if (("$⁚ltm⁚agg⁚0"[nyc] - PREVIOUS("$⁚ltm⁚agg⁚0"[nyc])) = 0) OR ((matrix[region·nyc,dim2·b] - PREVIOUS(matrix[region·nyc,dim2·b])) = 0) then 0 else SAFEDIV((PREVIOUS("$⁚ltm⁚agg⁚0"[nyc]) + (matrix[region·nyc,dim2·b] - PREVIOUS(matrix[region·nyc,dim2·b])) - PREVIOUS("$⁚ltm⁚agg⁚0"[nyc])), ABS(("$⁚ltm⁚agg⁚0"[nyc] - PREVIOUS("$⁚ltm⁚agg⁚0"[nyc]))), 0) * SIGN((matrix[region·nyc,dim2·b] - PREVIOUS(matrix[region·nyc,dim2·b]))) +scalar: if (TIME = INITIAL_TIME) then 0 else if (ABS(("$⁚ltm⁚agg⁚0"[nyc] - PREVIOUS("$⁚ltm⁚agg⁚0"[nyc]))) <= 0) OR (ABS((matrix[region·nyc,dim2·b] - PREVIOUS(matrix[region·nyc,dim2·b]))) <= 0) then 0 else SAFEDIV((PREVIOUS("$⁚ltm⁚agg⁚0"[nyc]) + (matrix[region·nyc,dim2·b] - PREVIOUS(matrix[region·nyc,dim2·b])) - PREVIOUS("$⁚ltm⁚agg⁚0"[nyc])), ABS(("$⁚ltm⁚agg⁚0"[nyc] - PREVIOUS("$⁚ltm⁚agg⁚0"[nyc]))), 0) * SIGN((matrix[region·nyc,dim2·b] - PREVIOUS(matrix[region·nyc,dim2·b]))) $⁚ltm⁚link_score⁚outflow→stock dims=[Region] -a2a[Region]: if (TIME = INITIAL_TIME) then 0 else if (("$⁚ltm⁚net⁚stock"[Region] - PREVIOUS("$⁚ltm⁚net⁚stock"[Region])) = 0) OR ((outflow[Region] - PREVIOUS(outflow[Region])) = 0) then 0 else SAFEDIV((outflow[Region] - PREVIOUS(outflow[Region])), ABS(("$⁚ltm⁚net⁚stock"[Region] - PREVIOUS("$⁚ltm⁚net⁚stock"[Region]))), 0) * SIGN((outflow[Region] - PREVIOUS(outflow[Region]))) +a2a[Region]: if (TIME = INITIAL_TIME) then 0 else if (ABS(("$⁚ltm⁚net⁚stock"[Region] - PREVIOUS("$⁚ltm⁚net⁚stock"[Region]))) <= 0) OR (ABS((outflow[Region] - PREVIOUS(outflow[Region]))) <= 0) then 0 else SAFEDIV((outflow[Region] - PREVIOUS(outflow[Region])), ABS(("$⁚ltm⁚net⁚stock"[Region] - PREVIOUS("$⁚ltm⁚net⁚stock"[Region]))), 0) * SIGN((outflow[Region] - PREVIOUS(outflow[Region]))) diff --git a/src/simlin-engine/src/db/ltm_char_golden/arrayed_target_no_default_slot_scores.txt b/src/simlin-engine/src/db/ltm_char_golden/arrayed_target_no_default_slot_scores.txt index adaabdbfa..a1a8b67fb 100644 --- a/src/simlin-engine/src/db/ltm_char_golden/arrayed_target_no_default_slot_scores.txt +++ b/src/simlin-engine/src/db/ltm_char_golden/arrayed_target_no_default_slot_scores.txt @@ -1,8 +1,8 @@ $⁚ltm⁚link_score⁚pop[boston]→mp dims=[Region] arrayed[Region] (apply_default=false): - boston => if (TIME = INITIAL_TIME) then 0 else if ((mp - PREVIOUS(mp)) = 0) OR ((pop[region·boston] - PREVIOUS(pop[region·boston])) = 0) then 0 else SAFEDIV((((pop[boston] - PREVIOUS(pop[region·nyc])) * 0.01) - PREVIOUS(mp)), ABS((mp - PREVIOUS(mp))), 0) * SIGN((pop[region·boston] - PREVIOUS(pop[region·boston]))) - nyc => if (TIME = INITIAL_TIME) then 0 else if ((mp - PREVIOUS(mp)) = 0) OR ((pop[region·boston] - PREVIOUS(pop[region·boston])) = 0) then 0 else SAFEDIV((((PREVIOUS(pop[region·nyc]) - pop[boston]) * 0.01) - PREVIOUS(mp)), ABS((mp - PREVIOUS(mp))), 0) * SIGN((pop[region·boston] - PREVIOUS(pop[region·boston]))) + boston => if (TIME = INITIAL_TIME) then 0 else if (ABS((mp - PREVIOUS(mp))) <= 0) OR (ABS((pop[region·boston] - PREVIOUS(pop[region·boston]))) <= 0) then 0 else SAFEDIV((((pop[boston] - PREVIOUS(pop[region·nyc])) * 0.01) - PREVIOUS(mp)), ABS((mp - PREVIOUS(mp))), 0) * SIGN((pop[region·boston] - PREVIOUS(pop[region·boston]))) + nyc => if (TIME = INITIAL_TIME) then 0 else if (ABS((mp - PREVIOUS(mp))) <= 0) OR (ABS((pop[region·boston] - PREVIOUS(pop[region·boston]))) <= 0) then 0 else SAFEDIV((((PREVIOUS(pop[region·nyc]) - pop[boston]) * 0.01) - PREVIOUS(mp)), ABS((mp - PREVIOUS(mp))), 0) * SIGN((pop[region·boston] - PREVIOUS(pop[region·boston]))) $⁚ltm⁚link_score⁚pop[nyc]→mp dims=[Region] arrayed[Region] (apply_default=false): - boston => if (TIME = INITIAL_TIME) then 0 else if ((mp - PREVIOUS(mp)) = 0) OR ((pop[region·nyc] - PREVIOUS(pop[region·nyc])) = 0) then 0 else SAFEDIV((((PREVIOUS(pop[region·boston]) - pop[nyc]) * 0.01) - PREVIOUS(mp)), ABS((mp - PREVIOUS(mp))), 0) * SIGN((pop[region·nyc] - PREVIOUS(pop[region·nyc]))) - nyc => if (TIME = INITIAL_TIME) then 0 else if ((mp - PREVIOUS(mp)) = 0) OR ((pop[region·nyc] - PREVIOUS(pop[region·nyc])) = 0) then 0 else SAFEDIV((((pop[nyc] - PREVIOUS(pop[region·boston])) * 0.01) - PREVIOUS(mp)), ABS((mp - PREVIOUS(mp))), 0) * SIGN((pop[region·nyc] - PREVIOUS(pop[region·nyc]))) + boston => if (TIME = INITIAL_TIME) then 0 else if (ABS((mp - PREVIOUS(mp))) <= 0) OR (ABS((pop[region·nyc] - PREVIOUS(pop[region·nyc]))) <= 0) then 0 else SAFEDIV((((PREVIOUS(pop[region·boston]) - pop[nyc]) * 0.01) - PREVIOUS(mp)), ABS((mp - PREVIOUS(mp))), 0) * SIGN((pop[region·nyc] - PREVIOUS(pop[region·nyc]))) + nyc => if (TIME = INITIAL_TIME) then 0 else if (ABS((mp - PREVIOUS(mp))) <= 0) OR (ABS((pop[region·nyc] - PREVIOUS(pop[region·nyc]))) <= 0) then 0 else SAFEDIV((((pop[nyc] - PREVIOUS(pop[region·boston])) * 0.01) - PREVIOUS(mp)), ABS((mp - PREVIOUS(mp))), 0) * SIGN((pop[region·nyc] - PREVIOUS(pop[region·nyc]))) diff --git a/src/simlin-engine/src/db/ltm_char_golden/arrayed_target_slot_scores.txt b/src/simlin-engine/src/db/ltm_char_golden/arrayed_target_slot_scores.txt index 529087691..bcbd47e34 100644 --- a/src/simlin-engine/src/db/ltm_char_golden/arrayed_target_slot_scores.txt +++ b/src/simlin-engine/src/db/ltm_char_golden/arrayed_target_slot_scores.txt @@ -1,10 +1,10 @@ $⁚ltm⁚link_score⁚pop[boston]→mp dims=[Region] arrayed[Region] (apply_default=true): - boston => if (TIME = INITIAL_TIME) then 0 else if ((mp - PREVIOUS(mp)) = 0) OR ((pop[region·boston] - PREVIOUS(pop[region·boston])) = 0) then 0 else SAFEDIV((((pop[boston] - PREVIOUS(pop[region·nyc])) * 0.01) - PREVIOUS(mp)), ABS((mp - PREVIOUS(mp))), 0) * SIGN((pop[region·boston] - PREVIOUS(pop[region·boston]))) - nyc => if (TIME = INITIAL_TIME) then 0 else if ((mp - PREVIOUS(mp)) = 0) OR ((pop[region·boston] - PREVIOUS(pop[region·boston])) = 0) then 0 else SAFEDIV((((PREVIOUS(pop[region·nyc]) - pop[boston]) * 0.01) - PREVIOUS(mp)), ABS((mp - PREVIOUS(mp))), 0) * SIGN((pop[region·boston] - PREVIOUS(pop[region·boston]))) - => if (TIME = INITIAL_TIME) then 0 else if ((mp - PREVIOUS(mp)) = 0) OR ((pop[region·boston] - PREVIOUS(pop[region·boston])) = 0) then 0 else SAFEDIV(((0) - PREVIOUS(mp)), ABS((mp - PREVIOUS(mp))), 0) * SIGN((pop[region·boston] - PREVIOUS(pop[region·boston]))) + boston => if (TIME = INITIAL_TIME) then 0 else if (ABS((mp - PREVIOUS(mp))) <= 0) OR (ABS((pop[region·boston] - PREVIOUS(pop[region·boston]))) <= 0) then 0 else SAFEDIV((((pop[boston] - PREVIOUS(pop[region·nyc])) * 0.01) - PREVIOUS(mp)), ABS((mp - PREVIOUS(mp))), 0) * SIGN((pop[region·boston] - PREVIOUS(pop[region·boston]))) + nyc => if (TIME = INITIAL_TIME) then 0 else if (ABS((mp - PREVIOUS(mp))) <= 0) OR (ABS((pop[region·boston] - PREVIOUS(pop[region·boston]))) <= 0) then 0 else SAFEDIV((((PREVIOUS(pop[region·nyc]) - pop[boston]) * 0.01) - PREVIOUS(mp)), ABS((mp - PREVIOUS(mp))), 0) * SIGN((pop[region·boston] - PREVIOUS(pop[region·boston]))) + => if (TIME = INITIAL_TIME) then 0 else if (ABS((mp - PREVIOUS(mp))) <= 0) OR (ABS((pop[region·boston] - PREVIOUS(pop[region·boston]))) <= 0) then 0 else SAFEDIV(((0) - PREVIOUS(mp)), ABS((mp - PREVIOUS(mp))), 0) * SIGN((pop[region·boston] - PREVIOUS(pop[region·boston]))) $⁚ltm⁚link_score⁚pop[nyc]→mp dims=[Region] arrayed[Region] (apply_default=true): - boston => if (TIME = INITIAL_TIME) then 0 else if ((mp - PREVIOUS(mp)) = 0) OR ((pop[region·nyc] - PREVIOUS(pop[region·nyc])) = 0) then 0 else SAFEDIV((((PREVIOUS(pop[region·boston]) - pop[nyc]) * 0.01) - PREVIOUS(mp)), ABS((mp - PREVIOUS(mp))), 0) * SIGN((pop[region·nyc] - PREVIOUS(pop[region·nyc]))) - nyc => if (TIME = INITIAL_TIME) then 0 else if ((mp - PREVIOUS(mp)) = 0) OR ((pop[region·nyc] - PREVIOUS(pop[region·nyc])) = 0) then 0 else SAFEDIV((((pop[nyc] - PREVIOUS(pop[region·boston])) * 0.01) - PREVIOUS(mp)), ABS((mp - PREVIOUS(mp))), 0) * SIGN((pop[region·nyc] - PREVIOUS(pop[region·nyc]))) - => if (TIME = INITIAL_TIME) then 0 else if ((mp - PREVIOUS(mp)) = 0) OR ((pop[region·nyc] - PREVIOUS(pop[region·nyc])) = 0) then 0 else SAFEDIV(((0) - PREVIOUS(mp)), ABS((mp - PREVIOUS(mp))), 0) * SIGN((pop[region·nyc] - PREVIOUS(pop[region·nyc]))) + boston => if (TIME = INITIAL_TIME) then 0 else if (ABS((mp - PREVIOUS(mp))) <= 0) OR (ABS((pop[region·nyc] - PREVIOUS(pop[region·nyc]))) <= 0) then 0 else SAFEDIV((((PREVIOUS(pop[region·boston]) - pop[nyc]) * 0.01) - PREVIOUS(mp)), ABS((mp - PREVIOUS(mp))), 0) * SIGN((pop[region·nyc] - PREVIOUS(pop[region·nyc]))) + nyc => if (TIME = INITIAL_TIME) then 0 else if (ABS((mp - PREVIOUS(mp))) <= 0) OR (ABS((pop[region·nyc] - PREVIOUS(pop[region·nyc]))) <= 0) then 0 else SAFEDIV((((pop[nyc] - PREVIOUS(pop[region·boston])) * 0.01) - PREVIOUS(mp)), ABS((mp - PREVIOUS(mp))), 0) * SIGN((pop[region·nyc] - PREVIOUS(pop[region·nyc]))) + => if (TIME = INITIAL_TIME) then 0 else if (ABS((mp - PREVIOUS(mp))) <= 0) OR (ABS((pop[region·nyc] - PREVIOUS(pop[region·nyc]))) <= 0) then 0 else SAFEDIV(((0) - PREVIOUS(mp)), ABS((mp - PREVIOUS(mp))), 0) * SIGN((pop[region·nyc] - PREVIOUS(pop[region·nyc]))) diff --git a/src/simlin-engine/src/db/ltm_char_golden/per_element_ambiguous_pin.txt b/src/simlin-engine/src/db/ltm_char_golden/per_element_ambiguous_pin.txt index 8a0bf294c..165cf41ae 100644 --- a/src/simlin-engine/src/db/ltm_char_golden/per_element_ambiguous_pin.txt +++ b/src/simlin-engine/src/db/ltm_char_golden/per_element_ambiguous_pin.txt @@ -1,10 +1,10 @@ $⁚ltm⁚link_score⁚pop[boston,old]→growth dims=[Region,Age] -a2a[Region,Age]: if (TIME = INITIAL_TIME) then 0 else if ((growth - PREVIOUS(growth)) = 0) OR ((pop[region·boston,age·old] - PREVIOUS(pop[region·boston,age·old])) = 0) then 0 else SAFEDIV(((PREVIOUS(pop[region, age·young]) * pop[boston, old]) - PREVIOUS(growth)), ABS((growth - PREVIOUS(growth))), 0) * SIGN((pop[region·boston,age·old] - PREVIOUS(pop[region·boston,age·old]))) +a2a[Region,Age]: if (TIME = INITIAL_TIME) then 0 else if (ABS((growth - PREVIOUS(growth))) <= 0) OR (ABS((pop[region·boston,age·old] - PREVIOUS(pop[region·boston,age·old]))) <= 0) then 0 else SAFEDIV(((PREVIOUS(pop[region, age·young]) * pop[boston, old]) - PREVIOUS(growth)), ABS((growth - PREVIOUS(growth))), 0) * SIGN((pop[region·boston,age·old] - PREVIOUS(pop[region·boston,age·old]))) $⁚ltm⁚link_score⁚pop[boston,young]→growth[boston,old] -scalar: if (TIME = INITIAL_TIME) then 0 else if ((growth[region·boston,age·old] - PREVIOUS(growth[region·boston,age·old])) = 0) OR ((pop[region·boston,age·young] - PREVIOUS(pop[region·boston,age·young])) = 0) then 0 else SAFEDIV(((pop[boston, young] * PREVIOUS(pop[region·boston, age·old])) - PREVIOUS(growth[region·boston,age·old])), ABS((growth[region·boston,age·old] - PREVIOUS(growth[region·boston,age·old]))), 0) * SIGN((pop[region·boston,age·young] - PREVIOUS(pop[region·boston,age·young]))) +scalar: if (TIME = INITIAL_TIME) then 0 else if (ABS((growth[region·boston,age·old] - PREVIOUS(growth[region·boston,age·old]))) <= 0) OR (ABS((pop[region·boston,age·young] - PREVIOUS(pop[region·boston,age·young]))) <= 0) then 0 else SAFEDIV(((pop[boston, young] * PREVIOUS(pop[region·boston, age·old])) - PREVIOUS(growth[region·boston,age·old])), ABS((growth[region·boston,age·old] - PREVIOUS(growth[region·boston,age·old]))), 0) * SIGN((pop[region·boston,age·young] - PREVIOUS(pop[region·boston,age·young]))) $⁚ltm⁚link_score⁚pop[boston,young]→growth[boston,young] -scalar: if (TIME = INITIAL_TIME) then 0 else if ((growth[region·boston,age·young] - PREVIOUS(growth[region·boston,age·young])) = 0) OR ((pop[region·boston,age·young] - PREVIOUS(pop[region·boston,age·young])) = 0) then 0 else SAFEDIV(((pop[boston, young] * PREVIOUS(pop[region·boston, age·old])) - PREVIOUS(growth[region·boston,age·young])), ABS((growth[region·boston,age·young] - PREVIOUS(growth[region·boston,age·young]))), 0) * SIGN((pop[region·boston,age·young] - PREVIOUS(pop[region·boston,age·young]))) +scalar: if (TIME = INITIAL_TIME) then 0 else if (ABS((growth[region·boston,age·young] - PREVIOUS(growth[region·boston,age·young]))) <= 0) OR (ABS((pop[region·boston,age·young] - PREVIOUS(pop[region·boston,age·young]))) <= 0) then 0 else SAFEDIV(((pop[boston, young] * PREVIOUS(pop[region·boston, age·old])) - PREVIOUS(growth[region·boston,age·young])), ABS((growth[region·boston,age·young] - PREVIOUS(growth[region·boston,age·young]))), 0) * SIGN((pop[region·boston,age·young] - PREVIOUS(pop[region·boston,age·young]))) $⁚ltm⁚link_score⁚pop[nyc,young]→growth[nyc,old] -scalar: if (TIME = INITIAL_TIME) then 0 else if ((growth[region·nyc,age·old] - PREVIOUS(growth[region·nyc,age·old])) = 0) OR ((pop[region·nyc,age·young] - PREVIOUS(pop[region·nyc,age·young])) = 0) then 0 else SAFEDIV(((pop[nyc, young] * PREVIOUS(pop[region·boston, age·old])) - PREVIOUS(growth[region·nyc,age·old])), ABS((growth[region·nyc,age·old] - PREVIOUS(growth[region·nyc,age·old]))), 0) * SIGN((pop[region·nyc,age·young] - PREVIOUS(pop[region·nyc,age·young]))) +scalar: if (TIME = INITIAL_TIME) then 0 else if (ABS((growth[region·nyc,age·old] - PREVIOUS(growth[region·nyc,age·old]))) <= 0) OR (ABS((pop[region·nyc,age·young] - PREVIOUS(pop[region·nyc,age·young]))) <= 0) then 0 else SAFEDIV(((pop[nyc, young] * PREVIOUS(pop[region·boston, age·old])) - PREVIOUS(growth[region·nyc,age·old])), ABS((growth[region·nyc,age·old] - PREVIOUS(growth[region·nyc,age·old]))), 0) * SIGN((pop[region·nyc,age·young] - PREVIOUS(pop[region·nyc,age·young]))) $⁚ltm⁚link_score⁚pop[nyc,young]→growth[nyc,young] -scalar: if (TIME = INITIAL_TIME) then 0 else if ((growth[region·nyc,age·young] - PREVIOUS(growth[region·nyc,age·young])) = 0) OR ((pop[region·nyc,age·young] - PREVIOUS(pop[region·nyc,age·young])) = 0) then 0 else SAFEDIV(((pop[nyc, young] * PREVIOUS(pop[region·boston, age·old])) - PREVIOUS(growth[region·nyc,age·young])), ABS((growth[region·nyc,age·young] - PREVIOUS(growth[region·nyc,age·young]))), 0) * SIGN((pop[region·nyc,age·young] - PREVIOUS(pop[region·nyc,age·young]))) +scalar: if (TIME = INITIAL_TIME) then 0 else if (ABS((growth[region·nyc,age·young] - PREVIOUS(growth[region·nyc,age·young]))) <= 0) OR (ABS((pop[region·nyc,age·young] - PREVIOUS(pop[region·nyc,age·young]))) <= 0) then 0 else SAFEDIV(((pop[nyc, young] * PREVIOUS(pop[region·boston, age·old])) - PREVIOUS(growth[region·nyc,age·young])), ABS((growth[region·nyc,age·young] - PREVIOUS(growth[region·nyc,age·young]))), 0) * SIGN((pop[region·nyc,age·young] - PREVIOUS(pop[region·nyc,age·young]))) diff --git a/src/simlin-engine/src/db/ltm_char_golden/per_element_dynamic_index.txt b/src/simlin-engine/src/db/ltm_char_golden/per_element_dynamic_index.txt index 12e5a56a0..10fd4ef26 100644 --- a/src/simlin-engine/src/db/ltm_char_golden/per_element_dynamic_index.txt +++ b/src/simlin-engine/src/db/ltm_char_golden/per_element_dynamic_index.txt @@ -1,6 +1,6 @@ $⁚ltm⁚link_score⁚pop[boston,young]→growth[boston] -scalar: if (TIME = INITIAL_TIME) then 0 else if ((growth[region·boston] - PREVIOUS(growth[region·boston])) = 0) OR ((pop[region·boston,age·young] - PREVIOUS(pop[region·boston,age·young])) = 0) then 0 else SAFEDIV(((pop[boston, young] * PREVIOUS(pop[region·boston, PREVIOUS(idx, idx)]) * 0.001) - PREVIOUS(growth[region·boston])), ABS((growth[region·boston] - PREVIOUS(growth[region·boston]))), 0) * SIGN((pop[region·boston,age·young] - PREVIOUS(pop[region·boston,age·young]))) +scalar: if (TIME = INITIAL_TIME) then 0 else if (ABS((growth[region·boston] - PREVIOUS(growth[region·boston]))) <= 0) OR (ABS((pop[region·boston,age·young] - PREVIOUS(pop[region·boston,age·young]))) <= 0) then 0 else SAFEDIV(((pop[boston, young] * PREVIOUS(pop[region·boston, PREVIOUS(idx, idx)]) * 0.001) - PREVIOUS(growth[region·boston])), ABS((growth[region·boston] - PREVIOUS(growth[region·boston]))), 0) * SIGN((pop[region·boston,age·young] - PREVIOUS(pop[region·boston,age·young]))) $⁚ltm⁚link_score⁚pop[nyc,young]→growth[nyc] -scalar: if (TIME = INITIAL_TIME) then 0 else if ((growth[region·nyc] - PREVIOUS(growth[region·nyc])) = 0) OR ((pop[region·nyc,age·young] - PREVIOUS(pop[region·nyc,age·young])) = 0) then 0 else SAFEDIV(((pop[nyc, young] * PREVIOUS(pop[region·nyc, PREVIOUS(idx, idx)]) * 0.001) - PREVIOUS(growth[region·nyc])), ABS((growth[region·nyc] - PREVIOUS(growth[region·nyc]))), 0) * SIGN((pop[region·nyc,age·young] - PREVIOUS(pop[region·nyc,age·young]))) +scalar: if (TIME = INITIAL_TIME) then 0 else if (ABS((growth[region·nyc] - PREVIOUS(growth[region·nyc]))) <= 0) OR (ABS((pop[region·nyc,age·young] - PREVIOUS(pop[region·nyc,age·young]))) <= 0) then 0 else SAFEDIV(((pop[nyc, young] * PREVIOUS(pop[region·nyc, PREVIOUS(idx, idx)]) * 0.001) - PREVIOUS(growth[region·nyc])), ABS((growth[region·nyc] - PREVIOUS(growth[region·nyc]))), 0) * SIGN((pop[region·nyc,age·young] - PREVIOUS(pop[region·nyc,age·young]))) $⁚ltm⁚link_score⁚pop→growth dims=[Region] -a2a[Region]: if (TIME = INITIAL_TIME) then 0 else if ((growth - PREVIOUS(growth)) = 0) OR ((SUM(pop[region, PREVIOUS(idx, idx)]) - PREVIOUS(SUM(pop[region, PREVIOUS(idx, idx)]))) = 0) then 0 else SAFEDIV(((PREVIOUS(pop[region, age·young]) * pop[region, PREVIOUS(idx, idx)] * 0.001) - PREVIOUS(growth)), ABS((growth - PREVIOUS(growth))), 0) * SIGN((SUM(pop[region, PREVIOUS(idx, idx)]) - PREVIOUS(SUM(pop[region, PREVIOUS(idx, idx)])))) +a2a[Region]: if (TIME = INITIAL_TIME) then 0 else if (ABS((growth - PREVIOUS(growth))) <= 0) OR (ABS((SUM(pop[region, PREVIOUS(idx, idx)]) - PREVIOUS(SUM(pop[region, PREVIOUS(idx, idx)])))) <= 0) then 0 else SAFEDIV(((PREVIOUS(pop[region, age·young]) * pop[region, PREVIOUS(idx, idx)] * 0.001) - PREVIOUS(growth)), ABS((growth - PREVIOUS(growth))), 0) * SIGN((SUM(pop[region, PREVIOUS(idx, idx)]) - PREVIOUS(SUM(pop[region, PREVIOUS(idx, idx)])))) diff --git a/src/simlin-engine/src/db/ltm_char_golden/per_element_index_nested_occurrence.txt b/src/simlin-engine/src/db/ltm_char_golden/per_element_index_nested_occurrence.txt index f6e73c1d6..08821e97d 100644 --- a/src/simlin-engine/src/db/ltm_char_golden/per_element_index_nested_occurrence.txt +++ b/src/simlin-engine/src/db/ltm_char_golden/per_element_index_nested_occurrence.txt @@ -1,4 +1,4 @@ $⁚ltm⁚link_score⁚pop[boston,young]→growth[boston] -scalar: if (TIME = INITIAL_TIME) then 0 else if ((growth[region·boston] - PREVIOUS(growth[region·boston])) = 0) OR ((pop[region·boston,age·young] - PREVIOUS(pop[region·boston,age·young])) = 0) then 0 else SAFEDIV(((pop[boston, young] * PREVIOUS(other[pop[region·boston, age·young]])) - PREVIOUS(growth[region·boston])), ABS((growth[region·boston] - PREVIOUS(growth[region·boston]))), 0) * SIGN((pop[region·boston,age·young] - PREVIOUS(pop[region·boston,age·young]))) +scalar: if (TIME = INITIAL_TIME) then 0 else if (ABS((growth[region·boston] - PREVIOUS(growth[region·boston]))) <= 0) OR (ABS((pop[region·boston,age·young] - PREVIOUS(pop[region·boston,age·young]))) <= 0) then 0 else SAFEDIV(((pop[boston, young] * PREVIOUS(other[pop[region·boston, age·young]])) - PREVIOUS(growth[region·boston])), ABS((growth[region·boston] - PREVIOUS(growth[region·boston]))), 0) * SIGN((pop[region·boston,age·young] - PREVIOUS(pop[region·boston,age·young]))) $⁚ltm⁚link_score⁚pop[nyc,young]→growth[nyc] -scalar: if (TIME = INITIAL_TIME) then 0 else if ((growth[region·nyc] - PREVIOUS(growth[region·nyc])) = 0) OR ((pop[region·nyc,age·young] - PREVIOUS(pop[region·nyc,age·young])) = 0) then 0 else SAFEDIV(((pop[nyc, young] * PREVIOUS(other[pop[region·nyc, age·young]])) - PREVIOUS(growth[region·nyc])), ABS((growth[region·nyc] - PREVIOUS(growth[region·nyc]))), 0) * SIGN((pop[region·nyc,age·young] - PREVIOUS(pop[region·nyc,age·young]))) +scalar: if (TIME = INITIAL_TIME) then 0 else if (ABS((growth[region·nyc] - PREVIOUS(growth[region·nyc]))) <= 0) OR (ABS((pop[region·nyc,age·young] - PREVIOUS(pop[region·nyc,age·young]))) <= 0) then 0 else SAFEDIV(((pop[nyc, young] * PREVIOUS(other[pop[region·nyc, age·young]])) - PREVIOUS(growth[region·nyc])), ABS((growth[region·nyc] - PREVIOUS(growth[region·nyc]))), 0) * SIGN((pop[region·nyc,age·young] - PREVIOUS(pop[region·nyc,age·young]))) diff --git a/src/simlin-engine/src/db/ltm_char_golden/per_element_link_scores.txt b/src/simlin-engine/src/db/ltm_char_golden/per_element_link_scores.txt index 30bb08123..1e7d426a1 100644 --- a/src/simlin-engine/src/db/ltm_char_golden/per_element_link_scores.txt +++ b/src/simlin-engine/src/db/ltm_char_golden/per_element_link_scores.txt @@ -1,4 +1,4 @@ $⁚ltm⁚link_score⁚pop[boston,young]→growth[boston] -scalar: if (TIME = INITIAL_TIME) then 0 else if ((growth[region·boston] - PREVIOUS(growth[region·boston])) = 0) OR ((pop[region·boston,age·young] - PREVIOUS(pop[region·boston,age·young])) = 0) then 0 else SAFEDIV(((pop[boston, young] * 0.1) - PREVIOUS(growth[region·boston])), ABS((growth[region·boston] - PREVIOUS(growth[region·boston]))), 0) * SIGN((pop[region·boston,age·young] - PREVIOUS(pop[region·boston,age·young]))) +scalar: if (TIME = INITIAL_TIME) then 0 else if (ABS((growth[region·boston] - PREVIOUS(growth[region·boston]))) <= 0) OR (ABS((pop[region·boston,age·young] - PREVIOUS(pop[region·boston,age·young]))) <= 0) then 0 else SAFEDIV(((pop[boston, young] * 0.1) - PREVIOUS(growth[region·boston])), ABS((growth[region·boston] - PREVIOUS(growth[region·boston]))), 0) * SIGN((pop[region·boston,age·young] - PREVIOUS(pop[region·boston,age·young]))) $⁚ltm⁚link_score⁚pop[nyc,young]→growth[nyc] -scalar: if (TIME = INITIAL_TIME) then 0 else if ((growth[region·nyc] - PREVIOUS(growth[region·nyc])) = 0) OR ((pop[region·nyc,age·young] - PREVIOUS(pop[region·nyc,age·young])) = 0) then 0 else SAFEDIV(((pop[nyc, young] * 0.1) - PREVIOUS(growth[region·nyc])), ABS((growth[region·nyc] - PREVIOUS(growth[region·nyc]))), 0) * SIGN((pop[region·nyc,age·young] - PREVIOUS(pop[region·nyc,age·young]))) +scalar: if (TIME = INITIAL_TIME) then 0 else if (ABS((growth[region·nyc] - PREVIOUS(growth[region·nyc]))) <= 0) OR (ABS((pop[region·nyc,age·young] - PREVIOUS(pop[region·nyc,age·young]))) <= 0) then 0 else SAFEDIV(((pop[nyc, young] * 0.1) - PREVIOUS(growth[region·nyc])), ABS((growth[region·nyc] - PREVIOUS(growth[region·nyc]))), 0) * SIGN((pop[region·nyc,age·young] - PREVIOUS(pop[region·nyc,age·young]))) diff --git a/src/simlin-engine/src/db/ltm_char_golden/per_element_mapped_occurrence.txt b/src/simlin-engine/src/db/ltm_char_golden/per_element_mapped_occurrence.txt index 707f69597..5c83bc9f4 100644 --- a/src/simlin-engine/src/db/ltm_char_golden/per_element_mapped_occurrence.txt +++ b/src/simlin-engine/src/db/ltm_char_golden/per_element_mapped_occurrence.txt @@ -1,10 +1,10 @@ $⁚ltm⁚link_score⁚pop[boston,young]→growth[east,old] -scalar: if (TIME = INITIAL_TIME) then 0 else if ((growth[state·east,age·old] - PREVIOUS(growth[state·east,age·old])) = 0) OR ((pop[region·boston,age·young] - PREVIOUS(pop[region·boston,age·young])) = 0) then 0 else SAFEDIV(((pop[boston, young] * PREVIOUS(pop[region·boston, age·old]) * 0.01) - PREVIOUS(growth[state·east,age·old])), ABS((growth[state·east,age·old] - PREVIOUS(growth[state·east,age·old]))), 0) * SIGN((pop[region·boston,age·young] - PREVIOUS(pop[region·boston,age·young]))) +scalar: if (TIME = INITIAL_TIME) then 0 else if (ABS((growth[state·east,age·old] - PREVIOUS(growth[state·east,age·old]))) <= 0) OR (ABS((pop[region·boston,age·young] - PREVIOUS(pop[region·boston,age·young]))) <= 0) then 0 else SAFEDIV(((pop[boston, young] * PREVIOUS(pop[region·boston, age·old]) * 0.01) - PREVIOUS(growth[state·east,age·old])), ABS((growth[state·east,age·old] - PREVIOUS(growth[state·east,age·old]))), 0) * SIGN((pop[region·boston,age·young] - PREVIOUS(pop[region·boston,age·young]))) $⁚ltm⁚link_score⁚pop[boston,young]→growth[east,young] -scalar: if (TIME = INITIAL_TIME) then 0 else if ((growth[state·east,age·young] - PREVIOUS(growth[state·east,age·young])) = 0) OR ((pop[region·boston,age·young] - PREVIOUS(pop[region·boston,age·young])) = 0) then 0 else SAFEDIV(((pop[boston, young] * PREVIOUS(pop[region·boston, age·young]) * 0.01) - PREVIOUS(growth[state·east,age·young])), ABS((growth[state·east,age·young] - PREVIOUS(growth[state·east,age·young]))), 0) * SIGN((pop[region·boston,age·young] - PREVIOUS(pop[region·boston,age·young]))) +scalar: if (TIME = INITIAL_TIME) then 0 else if (ABS((growth[state·east,age·young] - PREVIOUS(growth[state·east,age·young]))) <= 0) OR (ABS((pop[region·boston,age·young] - PREVIOUS(pop[region·boston,age·young]))) <= 0) then 0 else SAFEDIV(((pop[boston, young] * PREVIOUS(pop[region·boston, age·young]) * 0.01) - PREVIOUS(growth[state·east,age·young])), ABS((growth[state·east,age·young] - PREVIOUS(growth[state·east,age·young]))), 0) * SIGN((pop[region·boston,age·young] - PREVIOUS(pop[region·boston,age·young]))) $⁚ltm⁚link_score⁚pop[nyc,young]→growth[west,old] -scalar: if (TIME = INITIAL_TIME) then 0 else if ((growth[state·west,age·old] - PREVIOUS(growth[state·west,age·old])) = 0) OR ((pop[region·nyc,age·young] - PREVIOUS(pop[region·nyc,age·young])) = 0) then 0 else SAFEDIV(((pop[nyc, young] * PREVIOUS(pop[region·nyc, age·old]) * 0.01) - PREVIOUS(growth[state·west,age·old])), ABS((growth[state·west,age·old] - PREVIOUS(growth[state·west,age·old]))), 0) * SIGN((pop[region·nyc,age·young] - PREVIOUS(pop[region·nyc,age·young]))) +scalar: if (TIME = INITIAL_TIME) then 0 else if (ABS((growth[state·west,age·old] - PREVIOUS(growth[state·west,age·old]))) <= 0) OR (ABS((pop[region·nyc,age·young] - PREVIOUS(pop[region·nyc,age·young]))) <= 0) then 0 else SAFEDIV(((pop[nyc, young] * PREVIOUS(pop[region·nyc, age·old]) * 0.01) - PREVIOUS(growth[state·west,age·old])), ABS((growth[state·west,age·old] - PREVIOUS(growth[state·west,age·old]))), 0) * SIGN((pop[region·nyc,age·young] - PREVIOUS(pop[region·nyc,age·young]))) $⁚ltm⁚link_score⁚pop[nyc,young]→growth[west,young] -scalar: if (TIME = INITIAL_TIME) then 0 else if ((growth[state·west,age·young] - PREVIOUS(growth[state·west,age·young])) = 0) OR ((pop[region·nyc,age·young] - PREVIOUS(pop[region·nyc,age·young])) = 0) then 0 else SAFEDIV(((pop[nyc, young] * PREVIOUS(pop[region·nyc, age·young]) * 0.01) - PREVIOUS(growth[state·west,age·young])), ABS((growth[state·west,age·young] - PREVIOUS(growth[state·west,age·young]))), 0) * SIGN((pop[region·nyc,age·young] - PREVIOUS(pop[region·nyc,age·young]))) +scalar: if (TIME = INITIAL_TIME) then 0 else if (ABS((growth[state·west,age·young] - PREVIOUS(growth[state·west,age·young]))) <= 0) OR (ABS((pop[region·nyc,age·young] - PREVIOUS(pop[region·nyc,age·young]))) <= 0) then 0 else SAFEDIV(((pop[nyc, young] * PREVIOUS(pop[region·nyc, age·young]) * 0.01) - PREVIOUS(growth[state·west,age·young])), ABS((growth[state·west,age·young] - PREVIOUS(growth[state·west,age·young]))), 0) * SIGN((pop[region·nyc,age·young] - PREVIOUS(pop[region·nyc,age·young]))) $⁚ltm⁚link_score⁚pop→growth dims=[State,Age] -a2a[State,Age]: if (TIME = INITIAL_TIME) then 0 else if ((growth - PREVIOUS(growth)) = 0) OR ((pop - PREVIOUS(pop)) = 0) then 0 else SAFEDIV(((PREVIOUS(pop[state, age·young]) * pop * 0.01) - PREVIOUS(growth)), ABS((growth - PREVIOUS(growth))), 0) * SIGN((pop - PREVIOUS(pop))) +a2a[State,Age]: if (TIME = INITIAL_TIME) then 0 else if (ABS((growth - PREVIOUS(growth))) <= 0) OR (ABS((pop - PREVIOUS(pop))) <= 0) then 0 else SAFEDIV(((PREVIOUS(pop[state, age·young]) * pop * 0.01) - PREVIOUS(growth)), ABS((growth - PREVIOUS(growth))), 0) * SIGN((pop - PREVIOUS(pop))) diff --git a/src/simlin-engine/src/db/ltm_char_golden/per_element_mixed_occurrences.txt b/src/simlin-engine/src/db/ltm_char_golden/per_element_mixed_occurrences.txt index f38db6b40..31081a02c 100644 --- a/src/simlin-engine/src/db/ltm_char_golden/per_element_mixed_occurrences.txt +++ b/src/simlin-engine/src/db/ltm_char_golden/per_element_mixed_occurrences.txt @@ -1,10 +1,10 @@ $⁚ltm⁚link_score⁚pop[boston,young]→mixed[boston,old] -scalar: if (TIME = INITIAL_TIME) then 0 else if ((mixed[region·boston,age·old] - PREVIOUS(mixed[region·boston,age·old])) = 0) OR ((pop[region·boston,age·young] - PREVIOUS(pop[region·boston,age·young])) = 0) then 0 else SAFEDIV(((pop[boston, young] * PREVIOUS(pop[region·boston, age·old])) - PREVIOUS(mixed[region·boston,age·old])), ABS((mixed[region·boston,age·old] - PREVIOUS(mixed[region·boston,age·old]))), 0) * SIGN((pop[region·boston,age·young] - PREVIOUS(pop[region·boston,age·young]))) +scalar: if (TIME = INITIAL_TIME) then 0 else if (ABS((mixed[region·boston,age·old] - PREVIOUS(mixed[region·boston,age·old]))) <= 0) OR (ABS((pop[region·boston,age·young] - PREVIOUS(pop[region·boston,age·young]))) <= 0) then 0 else SAFEDIV(((pop[boston, young] * PREVIOUS(pop[region·boston, age·old])) - PREVIOUS(mixed[region·boston,age·old])), ABS((mixed[region·boston,age·old] - PREVIOUS(mixed[region·boston,age·old]))), 0) * SIGN((pop[region·boston,age·young] - PREVIOUS(pop[region·boston,age·young]))) $⁚ltm⁚link_score⁚pop[boston,young]→mixed[boston,young] -scalar: if (TIME = INITIAL_TIME) then 0 else if ((mixed[region·boston,age·young] - PREVIOUS(mixed[region·boston,age·young])) = 0) OR ((pop[region·boston,age·young] - PREVIOUS(pop[region·boston,age·young])) = 0) then 0 else SAFEDIV(((pop[boston, young] * PREVIOUS(pop[region·boston, age·young])) - PREVIOUS(mixed[region·boston,age·young])), ABS((mixed[region·boston,age·young] - PREVIOUS(mixed[region·boston,age·young]))), 0) * SIGN((pop[region·boston,age·young] - PREVIOUS(pop[region·boston,age·young]))) +scalar: if (TIME = INITIAL_TIME) then 0 else if (ABS((mixed[region·boston,age·young] - PREVIOUS(mixed[region·boston,age·young]))) <= 0) OR (ABS((pop[region·boston,age·young] - PREVIOUS(pop[region·boston,age·young]))) <= 0) then 0 else SAFEDIV(((pop[boston, young] * PREVIOUS(pop[region·boston, age·young])) - PREVIOUS(mixed[region·boston,age·young])), ABS((mixed[region·boston,age·young] - PREVIOUS(mixed[region·boston,age·young]))), 0) * SIGN((pop[region·boston,age·young] - PREVIOUS(pop[region·boston,age·young]))) $⁚ltm⁚link_score⁚pop[nyc,young]→mixed[nyc,old] -scalar: if (TIME = INITIAL_TIME) then 0 else if ((mixed[region·nyc,age·old] - PREVIOUS(mixed[region·nyc,age·old])) = 0) OR ((pop[region·nyc,age·young] - PREVIOUS(pop[region·nyc,age·young])) = 0) then 0 else SAFEDIV(((pop[nyc, young] * PREVIOUS(pop[region·nyc, age·old])) - PREVIOUS(mixed[region·nyc,age·old])), ABS((mixed[region·nyc,age·old] - PREVIOUS(mixed[region·nyc,age·old]))), 0) * SIGN((pop[region·nyc,age·young] - PREVIOUS(pop[region·nyc,age·young]))) +scalar: if (TIME = INITIAL_TIME) then 0 else if (ABS((mixed[region·nyc,age·old] - PREVIOUS(mixed[region·nyc,age·old]))) <= 0) OR (ABS((pop[region·nyc,age·young] - PREVIOUS(pop[region·nyc,age·young]))) <= 0) then 0 else SAFEDIV(((pop[nyc, young] * PREVIOUS(pop[region·nyc, age·old])) - PREVIOUS(mixed[region·nyc,age·old])), ABS((mixed[region·nyc,age·old] - PREVIOUS(mixed[region·nyc,age·old]))), 0) * SIGN((pop[region·nyc,age·young] - PREVIOUS(pop[region·nyc,age·young]))) $⁚ltm⁚link_score⁚pop[nyc,young]→mixed[nyc,young] -scalar: if (TIME = INITIAL_TIME) then 0 else if ((mixed[region·nyc,age·young] - PREVIOUS(mixed[region·nyc,age·young])) = 0) OR ((pop[region·nyc,age·young] - PREVIOUS(pop[region·nyc,age·young])) = 0) then 0 else SAFEDIV(((pop[nyc, young] * PREVIOUS(pop[region·nyc, age·young])) - PREVIOUS(mixed[region·nyc,age·young])), ABS((mixed[region·nyc,age·young] - PREVIOUS(mixed[region·nyc,age·young]))), 0) * SIGN((pop[region·nyc,age·young] - PREVIOUS(pop[region·nyc,age·young]))) +scalar: if (TIME = INITIAL_TIME) then 0 else if (ABS((mixed[region·nyc,age·young] - PREVIOUS(mixed[region·nyc,age·young]))) <= 0) OR (ABS((pop[region·nyc,age·young] - PREVIOUS(pop[region·nyc,age·young]))) <= 0) then 0 else SAFEDIV(((pop[nyc, young] * PREVIOUS(pop[region·nyc, age·young])) - PREVIOUS(mixed[region·nyc,age·young])), ABS((mixed[region·nyc,age·young] - PREVIOUS(mixed[region·nyc,age·young]))), 0) * SIGN((pop[region·nyc,age·young] - PREVIOUS(pop[region·nyc,age·young]))) $⁚ltm⁚link_score⁚pop→mixed dims=[Region,Age] -a2a[Region,Age]: if (TIME = INITIAL_TIME) then 0 else if ((mixed - PREVIOUS(mixed)) = 0) OR ((pop - PREVIOUS(pop)) = 0) then 0 else SAFEDIV(((PREVIOUS(pop[region, age·young]) * pop) - PREVIOUS(mixed)), ABS((mixed - PREVIOUS(mixed))), 0) * SIGN((pop - PREVIOUS(pop))) +a2a[Region,Age]: if (TIME = INITIAL_TIME) then 0 else if (ABS((mixed - PREVIOUS(mixed))) <= 0) OR (ABS((pop - PREVIOUS(pop))) <= 0) then 0 else SAFEDIV(((PREVIOUS(pop[region, age·young]) * pop) - PREVIOUS(mixed)), ABS((mixed - PREVIOUS(mixed))), 0) * SIGN((pop - PREVIOUS(pop))) diff --git a/src/simlin-engine/src/db/ltm_char_golden/per_element_other_deps.txt b/src/simlin-engine/src/db/ltm_char_golden/per_element_other_deps.txt index 166f439d7..12dfe7f28 100644 --- a/src/simlin-engine/src/db/ltm_char_golden/per_element_other_deps.txt +++ b/src/simlin-engine/src/db/ltm_char_golden/per_element_other_deps.txt @@ -1,8 +1,8 @@ $⁚ltm⁚link_score⁚pop[boston,young]→growth[boston,old] -scalar: if (TIME = INITIAL_TIME) then 0 else if ((growth[region·boston,age·old] - PREVIOUS(growth[region·boston,age·old])) = 0) OR ((pop[region·boston,age·young] - PREVIOUS(pop[region·boston,age·young])) = 0) then 0 else SAFEDIV(((pop[boston, young] * PREVIOUS(w[age·old]) * PREVIOUS(v[age·old, region·boston])) - PREVIOUS(growth[region·boston,age·old])), ABS((growth[region·boston,age·old] - PREVIOUS(growth[region·boston,age·old]))), 0) * SIGN((pop[region·boston,age·young] - PREVIOUS(pop[region·boston,age·young]))) +scalar: if (TIME = INITIAL_TIME) then 0 else if (ABS((growth[region·boston,age·old] - PREVIOUS(growth[region·boston,age·old]))) <= 0) OR (ABS((pop[region·boston,age·young] - PREVIOUS(pop[region·boston,age·young]))) <= 0) then 0 else SAFEDIV(((pop[boston, young] * PREVIOUS(w[age·old]) * PREVIOUS(v[age·old, region·boston])) - PREVIOUS(growth[region·boston,age·old])), ABS((growth[region·boston,age·old] - PREVIOUS(growth[region·boston,age·old]))), 0) * SIGN((pop[region·boston,age·young] - PREVIOUS(pop[region·boston,age·young]))) $⁚ltm⁚link_score⁚pop[boston,young]→growth[boston,young] -scalar: if (TIME = INITIAL_TIME) then 0 else if ((growth[region·boston,age·young] - PREVIOUS(growth[region·boston,age·young])) = 0) OR ((pop[region·boston,age·young] - PREVIOUS(pop[region·boston,age·young])) = 0) then 0 else SAFEDIV(((pop[boston, young] * PREVIOUS(w[age·young]) * PREVIOUS(v[age·young, region·boston])) - PREVIOUS(growth[region·boston,age·young])), ABS((growth[region·boston,age·young] - PREVIOUS(growth[region·boston,age·young]))), 0) * SIGN((pop[region·boston,age·young] - PREVIOUS(pop[region·boston,age·young]))) +scalar: if (TIME = INITIAL_TIME) then 0 else if (ABS((growth[region·boston,age·young] - PREVIOUS(growth[region·boston,age·young]))) <= 0) OR (ABS((pop[region·boston,age·young] - PREVIOUS(pop[region·boston,age·young]))) <= 0) then 0 else SAFEDIV(((pop[boston, young] * PREVIOUS(w[age·young]) * PREVIOUS(v[age·young, region·boston])) - PREVIOUS(growth[region·boston,age·young])), ABS((growth[region·boston,age·young] - PREVIOUS(growth[region·boston,age·young]))), 0) * SIGN((pop[region·boston,age·young] - PREVIOUS(pop[region·boston,age·young]))) $⁚ltm⁚link_score⁚pop[nyc,young]→growth[nyc,old] -scalar: if (TIME = INITIAL_TIME) then 0 else if ((growth[region·nyc,age·old] - PREVIOUS(growth[region·nyc,age·old])) = 0) OR ((pop[region·nyc,age·young] - PREVIOUS(pop[region·nyc,age·young])) = 0) then 0 else SAFEDIV(((pop[nyc, young] * PREVIOUS(w[age·old]) * PREVIOUS(v[age·old, region·nyc])) - PREVIOUS(growth[region·nyc,age·old])), ABS((growth[region·nyc,age·old] - PREVIOUS(growth[region·nyc,age·old]))), 0) * SIGN((pop[region·nyc,age·young] - PREVIOUS(pop[region·nyc,age·young]))) +scalar: if (TIME = INITIAL_TIME) then 0 else if (ABS((growth[region·nyc,age·old] - PREVIOUS(growth[region·nyc,age·old]))) <= 0) OR (ABS((pop[region·nyc,age·young] - PREVIOUS(pop[region·nyc,age·young]))) <= 0) then 0 else SAFEDIV(((pop[nyc, young] * PREVIOUS(w[age·old]) * PREVIOUS(v[age·old, region·nyc])) - PREVIOUS(growth[region·nyc,age·old])), ABS((growth[region·nyc,age·old] - PREVIOUS(growth[region·nyc,age·old]))), 0) * SIGN((pop[region·nyc,age·young] - PREVIOUS(pop[region·nyc,age·young]))) $⁚ltm⁚link_score⁚pop[nyc,young]→growth[nyc,young] -scalar: if (TIME = INITIAL_TIME) then 0 else if ((growth[region·nyc,age·young] - PREVIOUS(growth[region·nyc,age·young])) = 0) OR ((pop[region·nyc,age·young] - PREVIOUS(pop[region·nyc,age·young])) = 0) then 0 else SAFEDIV(((pop[nyc, young] * PREVIOUS(w[age·young]) * PREVIOUS(v[age·young, region·nyc])) - PREVIOUS(growth[region·nyc,age·young])), ABS((growth[region·nyc,age·young] - PREVIOUS(growth[region·nyc,age·young]))), 0) * SIGN((pop[region·nyc,age·young] - PREVIOUS(pop[region·nyc,age·young]))) +scalar: if (TIME = INITIAL_TIME) then 0 else if (ABS((growth[region·nyc,age·young] - PREVIOUS(growth[region·nyc,age·young]))) <= 0) OR (ABS((pop[region·nyc,age·young] - PREVIOUS(pop[region·nyc,age·young]))) <= 0) then 0 else SAFEDIV(((pop[nyc, young] * PREVIOUS(w[age·young]) * PREVIOUS(v[age·young, region·nyc])) - PREVIOUS(growth[region·nyc,age·young])), ABS((growth[region·nyc,age·young] - PREVIOUS(growth[region·nyc,age·young]))), 0) * SIGN((pop[region·nyc,age·young] - PREVIOUS(pop[region·nyc,age·young]))) diff --git a/src/simlin-engine/src/db/ltm_char_golden/reducer_index_nested_freeze.txt b/src/simlin-engine/src/db/ltm_char_golden/reducer_index_nested_freeze.txt index 53ec11ca0..049ffef65 100644 --- a/src/simlin-engine/src/db/ltm_char_golden/reducer_index_nested_freeze.txt +++ b/src/simlin-engine/src/db/ltm_char_golden/reducer_index_nested_freeze.txt @@ -1,2 +1,2 @@ $⁚ltm⁚link_score⁚from→to -scalar: if (TIME = INITIAL_TIME) then 0 else if ((to - PREVIOUS(to)) = 0) OR ((from - PREVIOUS(from)) = 0) then 0 else SAFEDIV(((PREVIOUS(sum(w[from, *])) + from) - PREVIOUS(to)), ABS((to - PREVIOUS(to))), 0) * SIGN((from - PREVIOUS(from))) +scalar: if (TIME = INITIAL_TIME) then 0 else if (ABS((to - PREVIOUS(to))) <= 0) OR (ABS((from - PREVIOUS(from))) <= 0) then 0 else SAFEDIV(((PREVIOUS(sum(w[from, *])) + from) - PREVIOUS(to)), ABS((to - PREVIOUS(to))), 0) * SIGN((from - PREVIOUS(from))) diff --git a/src/simlin-engine/src/db/ltm_char_golden/scalar_feeder_bare_in_hoisted_reducer.txt b/src/simlin-engine/src/db/ltm_char_golden/scalar_feeder_bare_in_hoisted_reducer.txt index d6f596c77..509e041ed 100644 --- a/src/simlin-engine/src/db/ltm_char_golden/scalar_feeder_bare_in_hoisted_reducer.txt +++ b/src/simlin-engine/src/db/ltm_char_golden/scalar_feeder_bare_in_hoisted_reducer.txt @@ -1,4 +1,4 @@ $⁚ltm⁚link_score⁚scale→$⁚ltm⁚agg⁚0 -scalar: if (TIME = INITIAL_TIME) then 0 else if (("$⁚ltm⁚agg⁚0" - PREVIOUS("$⁚ltm⁚agg⁚0")) = 0) OR ((scale - PREVIOUS(scale)) = 0) then 0 else SAFEDIV(("$⁚ltm⁚agg⁚0" - (sum(arr[*] * PREVIOUS(scale)))), ABS(("$⁚ltm⁚agg⁚0" - PREVIOUS("$⁚ltm⁚agg⁚0"))), 0) * SIGN((scale - PREVIOUS(scale))) +scalar: if (TIME = INITIAL_TIME) then 0 else if (ABS(("$⁚ltm⁚agg⁚0" - PREVIOUS("$⁚ltm⁚agg⁚0"))) <= 0) OR (ABS((scale - PREVIOUS(scale))) <= 0) then 0 else SAFEDIV(("$⁚ltm⁚agg⁚0" - (sum(arr[*] * PREVIOUS(scale)))), ABS(("$⁚ltm⁚agg⁚0" - PREVIOUS("$⁚ltm⁚agg⁚0"))), 0) * SIGN((scale - PREVIOUS(scale))) $⁚ltm⁚link_score⁚scale→total -scalar: if (TIME = INITIAL_TIME) then 0 else if ((total - PREVIOUS(total)) = 0) OR ((scale - PREVIOUS(scale)) = 0) then 0 else SAFEDIV(((scale + sum("$⁚ltm⁚freeze⁚arr[*]"[*] * scale)) - PREVIOUS(total)), ABS((total - PREVIOUS(total))), 0) * SIGN((scale - PREVIOUS(scale))) +scalar: if (TIME = INITIAL_TIME) then 0 else if (ABS((total - PREVIOUS(total))) <= 0) OR (ABS((scale - PREVIOUS(scale))) <= 0) then 0 else SAFEDIV(((scale + sum("$⁚ltm⁚freeze⁚arr[*]"[*] * scale)) - PREVIOUS(total)), ABS((total - PREVIOUS(total))), 0) * SIGN((scale - PREVIOUS(scale))) diff --git a/src/simlin-engine/src/db/ltm_char_golden/unquotable_source_name.txt b/src/simlin-engine/src/db/ltm_char_golden/unquotable_source_name.txt index 6e8d707b0..26f3a727e 100644 --- a/src/simlin-engine/src/db/ltm_char_golden/unquotable_source_name.txt +++ b/src/simlin-engine/src/db/ltm_char_golden/unquotable_source_name.txt @@ -1,4 +1,4 @@ $⁚ltm⁚link_score⁚1pop[boston,young]→growth[boston] -scalar: if (TIME = INITIAL_TIME) then 0 else if ((growth[region·boston] - PREVIOUS(growth[region·boston])) = 0) OR (("1pop"[region·boston,age·young] - PREVIOUS("1pop"[region·boston,age·young])) = 0) then 0 else SAFEDIV((("1pop"[boston, young] * 0.1) - PREVIOUS(growth[region·boston])), ABS((growth[region·boston] - PREVIOUS(growth[region·boston]))), 0) * SIGN(("1pop"[region·boston,age·young] - PREVIOUS("1pop"[region·boston,age·young]))) +scalar: if (TIME = INITIAL_TIME) then 0 else if (ABS((growth[region·boston] - PREVIOUS(growth[region·boston]))) <= 0) OR (ABS(("1pop"[region·boston,age·young] - PREVIOUS("1pop"[region·boston,age·young]))) <= 0) then 0 else SAFEDIV((("1pop"[boston, young] * 0.1) - PREVIOUS(growth[region·boston])), ABS((growth[region·boston] - PREVIOUS(growth[region·boston]))), 0) * SIGN(("1pop"[region·boston,age·young] - PREVIOUS("1pop"[region·boston,age·young]))) $⁚ltm⁚link_score⁚1pop[nyc,young]→growth[nyc] -scalar: if (TIME = INITIAL_TIME) then 0 else if ((growth[region·nyc] - PREVIOUS(growth[region·nyc])) = 0) OR (("1pop"[region·nyc,age·young] - PREVIOUS("1pop"[region·nyc,age·young])) = 0) then 0 else SAFEDIV((("1pop"[nyc, young] * 0.1) - PREVIOUS(growth[region·nyc])), ABS((growth[region·nyc] - PREVIOUS(growth[region·nyc]))), 0) * SIGN(("1pop"[region·nyc,age·young] - PREVIOUS("1pop"[region·nyc,age·young]))) +scalar: if (TIME = INITIAL_TIME) then 0 else if (ABS((growth[region·nyc] - PREVIOUS(growth[region·nyc]))) <= 0) OR (ABS(("1pop"[region·nyc,age·young] - PREVIOUS("1pop"[region·nyc,age·young]))) <= 0) then 0 else SAFEDIV((("1pop"[nyc, young] * 0.1) - PREVIOUS(growth[region·nyc])), ABS((growth[region·nyc] - PREVIOUS(growth[region·nyc]))), 0) * SIGN(("1pop"[region·nyc,age·young] - PREVIOUS("1pop"[region·nyc,age·young]))) diff --git a/src/simlin-engine/src/db/ltm_char_tests.rs b/src/simlin-engine/src/db/ltm_char_tests.rs index 99a437fef..64b9933dc 100644 --- a/src/simlin-engine/src/db/ltm_char_tests.rs +++ b/src/simlin-engine/src/db/ltm_char_tests.rs @@ -1872,13 +1872,10 @@ fn reducer_index_nested_freeze_preserves_loud_failure_not_silent_compile() { // Model G (Track A3 stage 2, review finding 2): an already-lagged other-dep // (Fig. 2 Q3). // -// `to = from + PREVIOUS(g)`, edge `(from -> to)`, `Bare` shape. `g` occurs -// only inside `PREVIOUS(g)` -- it is already lagged. The changed-first partial -// holds `from` live and must LEAVE `PREVIOUS(g)` untouched -// (`from + PREVIOUS(g)`), NOT re-wrap it to `PREVIOUS(PREVIOUS(g))` (a t-2 -// read). This pins the already-lagged selection semantics the wrap reproduces by -// recognizing a `PREVIOUS`/`INIT` node structurally: it suppresses the wrap of an -// already-lagged occurrence but not its live selection. +// `to = from + PREVIOUS(g)`, edge `(from -> to)`, `Bare` shape. The anchor +// PREVIOUS(to) contains g from two steps ago, so the partial freezes the whole +// PREVIOUS(g) co-input to preserve that same value. The fixture constrains +// rendering and compilation; ltm_snapshot_inputs checks moving values. // --------------------------------------------------------------------------- fn already_lagged_other_dep_model() -> datamodel::Project { diff --git a/src/simlin-engine/src/db/ltm_value_gate_tests.rs b/src/simlin-engine/src/db/ltm_value_gate_tests.rs index 12f6922c5..4d8337a3f 100644 --- a/src/simlin-engine/src/db/ltm_value_gate_tests.rs +++ b/src/simlin-engine/src/db/ltm_value_gate_tests.rs @@ -299,33 +299,12 @@ fn a_structural_zero_arm_is_exactly_zero() { } } -/// Mechanism 4: an arm whose lag is MISALIGNED with the anchor must not be -/// claimed as a structural zero, even though every leaf sits under a -/// `PREVIOUS`. -/// -/// The omission's soundness condition is `partial(t) == target(t-1)`, which -/// needs every read lagged by exactly ONE step. Two things break that while -/// leaving the emitted tree looking entirely frozen: -/// -/// * an ORIGINAL `PREVIOUS(z)` from the target's own equation, which -/// `wrap_non_matching_in_previous` deliberately leaves untouched -- so the -/// partial reads `z(t-1)` where `target(t-1)` read `z(t-2)`; -/// * a synthesized `PREVIOUS` nested inside another, which the subscript-index -/// freeze produces (`PREVIOUS(q[PREVIOUS(ctr, ctr)])` reads `q` at `t-1` -/// indexed at `t-2`). -/// -/// This fixture is the first. `growth[boston] = PREVIOUS(z) * 0.02 + pop[la] * -/// 0.001` has no live `pop[nyc]` reference, so the shape match records none and -/// every occurrence is frozen -- yet the arm is worth ~0.985, near the -/// canonical +/-1 single-input attribution. Omitting it rewrites a real score -/// to zero, which is exactly the failure GH #977 rejected the negative -/// criterion for, reached by a different route. -/// -/// `INIT` is deliberately NOT in this class and is not tested here as a -/// failure: `INIT(x)` is the run's initial value, identical at `t` and `t-1`, -/// so it aligns at every step. -fn lag_misalignment_project() -> datamodel::Project { - TestProject::new("lag_misalignment") +/// A materialized per-element arm must respect the same causal zero as an +/// omitted arm. Boston never reads pop[nyc]; its PREVIOUS(z) co-input must be +/// frozen to the value the previous growth[boston] evaluation used. An +/// unchanged snapshot would credit z's history to pop[nyc]. +fn snapshot_co_input_project() -> datamodel::Project { + TestProject::new("snapshot_co_input") .with_sim_time(0.0, 5.0, 1.0) .named_dimension("Region", &["nyc", "boston", "la"]) .aux("z", "1 + TIME", None) @@ -342,32 +321,22 @@ fn lag_misalignment_project() -> datamodel::Project { } #[test] -fn an_original_previous_arm_is_not_a_structural_zero() { - let series = ltm_slot_series(&lag_misalignment_project()); +fn an_original_previous_co_input_cannot_create_an_unrelated_score() { + let series = ltm_slot_series(&snapshot_co_input_project()); // Region declaration order: nyc=0, boston=1, la=2. let boston = slot(&series, "link_score\u{205A}pop[nyc]\u{2192}growth", 1); - assert!( - boston.iter().any(|v| v.abs() > 0.5 && v.is_finite()), - "the `boston` arm carries an ORIGINAL PREVIOUS, so its partial reads \ - z(t-1) where the PREVIOUS(target) anchor read z(t-2); the arm is worth \ - ~0.985 and must not be omitted as a structural zero; got {boston:?}" - ); + assert!(boston.iter().all(|v| *v == 0.0), "{boston:?}"); } -/// Mechanism 4, second clause: a synthesized `PREVIOUS` NESTED inside another. +/// A synthesized `PREVIOUS` NESTED inside another can misalign an index. /// -/// `growth[boston] = q[ctr] * 0.002` has no original `PREVIOUS` at all, so the -/// clause above cannot see it. The subscript-index freeze produces +/// `growth[boston] = q[ctr] * 0.002` has no original `PREVIOUS` at all, but +/// the subscript-index freeze produces /// `PREVIOUS(q[PREVIOUS(ctr, ctr)])` -- `q` read at `t-1` indexed by `ctr` at /// `t-2`, where the `PREVIOUS(growth)` anchor indexed at `t-1`. Every leaf is /// under a `PREVIOUS`, so a walk that stops at the first one calls this a /// structural zero; it is not. /// -/// The two clauses need separate rows because either one alone leaves the other -/// case omitted: reverting only the `contains_previous_call(original)` check -/// keeps this row green, and reverting only the nested-`PREVIOUS` descent keeps -/// the row above green. Both were measured that way. -/// /// This is the shape `db::ltm_tests::colliding_index_boston_series` documents a /// -1.06/+0.73/-1.03/+0.82 residual for, under the heading of an unadjudicated /// double-lag. That residual is not only a semantics question: it is also the diff --git a/src/simlin-engine/src/ltm_augment.rs b/src/simlin-engine/src/ltm_augment.rs index a1629ffd7..aeba2861d 100644 --- a/src/simlin-engine/src/ltm_augment.rs +++ b/src/simlin-engine/src/ltm_augment.rs @@ -757,16 +757,24 @@ fn wrap_non_matching_in_previous( } } Expr0::App(UntypedBuiltinFn(name, args), loc) => { - // A PREVIOUS(...) / INIT(...) call from the original equation: - // everything inside it is already lagged (read at the prior step) - // or frozen (read at t=0), so it is already ceteris-paribus -- the - // current-step perturbation cannot affect it. Wrapping its - // contents again would read values from TWO steps ago - // (semantically wrong) and force a nested-PREVIOUS helper chain - // (one synthesized helper variable per occurrence; on - // SAMPLE-IF-TRUE-heavy models like C-LEARN this was the dominant - // helper source). Leave the whole call untouched. + // An original PREVIOUS is a changing input: at t its value is + // g(t-1), while the PREVIOUS(target) anchor contains g(t-2). The + // changed-first partial must freeze that WHOLE input, including + // its initial fallback, or g's motion is credited to this source. + // This is the previous-input rule in Schoenberg et al. 2020, + // p. 171 / Fig. 1, applied to the value of a snapshot expression. + // INIT is constant. A PREVIOUS of the selected source retains + // the existing temporal-edge policy: the graph groups that read + // with its current reads. Its fallback is initial-only, so it + // cannot make the snapshot a selected-source read at later steps. if name.eq_ignore_ascii_case("previous") || name.eq_ignore_ascii_case("init") { + let freeze_snapshot = name.eq_ignore_ascii_case("previous") + && !frozen + && !ctx.occ.subtree_has_live_shape( + &child_path(path, 0), + live_source, + live_shape, + ); // The wrap adds nothing inside, but the `PerElement` row pinning // still has to reach the source references in there: an already- // lagged read is still a read of a concrete element, and leaving @@ -775,7 +783,7 @@ fn wrap_non_matching_in_previous( // does exactly that lowering with the same path cursor and the // same occurrence IR, and wraps nothing. let call = Expr0::App(UntypedBuiltinFn(name, args), loc); - return match ctx.pin { + let call = match ctx.pin { Some(pin_ctx) => post_transform::pin_only_source_refs( call, pin_ctx, @@ -785,6 +793,11 @@ fn wrap_non_matching_in_previous( ), None => call, }; + return if freeze_snapshot { + freeze_at_previous(call, loc, in_subscript_index) + } else { + call + }; } // Whether the reducer held LIVE by `live_reducer_text` sits inside // this call's arguments (Track A stage 1, finding 2; see the reducer @@ -3353,9 +3366,12 @@ fn link_score_guard_form_with_numerator( // the partial, and the per-step evaluation cost. let target_diff = format!("({target_ref} - PREVIOUS({target_ref}))"); let source_diff = format!("({source_ref} - PREVIOUS({source_ref}))"); + // Equation equality is approximate. ABS(delta) <= 0 tests exact zero + // without imposing a unit-dependent epsilon, and stays false for NaN so + // an undefined score keeps propagating instead of becoming a zero. format!( "if (TIME = INITIAL_TIME) then 0 \ - else if ({target_diff} = 0) OR ({source_diff} = 0) then 0 \ + else if (ABS({target_diff}) <= 0) OR (ABS({source_diff}) <= 0) then 0 \ else SAFEDIV({numerator}, ABS({target_diff}), 0) * SIGN({source_diff})" ) } @@ -5089,8 +5105,9 @@ fn pin_body_to_row(expr: Expr0, ctx: &ReducerBodyCtx<'_>, row_parts: &[String]) /// causal references); on a non-model head (whose expression indices /// [`pin_body_to_row`] preserves) any index reference is left live -- the /// same model/non-model boundary the pinning walk draws. The contents of -/// `PREVIOUS`/`INIT` calls are already lagged/frozen so they are not -/// re-wrapped (mirroring [`wrap_matching_in_previous`]). +/// `PREVIOUS` co-inputs freeze whole so their history aligns with the +/// previous-step anchor; a snapshot of `keep_live` retains the selected-source +/// temporal policy. `INIT` is already constant. fn freeze_pinned_body(expr: Expr0, freeze: &HashSet, keep_live: Option<&str>) -> Expr0 { let should_freeze = |ident: &str| -> bool { let c = canonicalize(ident); @@ -5122,7 +5139,14 @@ fn freeze_pinned_body(expr: Expr0, freeze: &HashSet, keep_live: Option<& } Expr0::App(UntypedBuiltinFn(name, args), loc) => { if name.eq_ignore_ascii_case("previous") || name.eq_ignore_ascii_case("init") { - return Expr0::App(UntypedBuiltinFn(name, args), loc); + let freeze_snapshot = + name.eq_ignore_ascii_case("previous") && !args.first().is_some_and(reads_live); + let call = Expr0::App(UntypedBuiltinFn(name, args), loc); + return if freeze_snapshot { + freeze_at_previous(call, loc, false) + } else { + call + }; } if is_time_dependent_builtin(&name) && !args.iter().any(reads_live) { return freeze_clock_read( @@ -5426,11 +5450,9 @@ fn generate_linear_body_partial( /// previous step's evaluation of that row and `R(all-frozen terms) = /// PREVIOUS(agg)` exactly, because per-variable `PREVIOUS` sampling commutes /// with arithmetic; a row that is never the argmin therefore scores 0 -/// (`tests/integration/ltm_frozen_clock.rs`, the GH #763 repro). The one -/// residue is an ORIGINAL `PREVIOUS(x)` in the body: it is left untouched, -/// so the frozen term reads `x(t-1)` where the anchor read `x(t-2)`, and the -/// misalignment is attributed to every row -- the same lag-misalignment -/// class `ltm_augment_zero_slot` documents. +/// (`tests/integration/ltm_frozen_clock.rs`, the GH #763 repro). An original +/// `PREVIOUS` co-input is frozen whole as well; the selected row's lagged +/// source reads retain the temporal-edge policy of the ordinary wrapper. /// /// When the pinned body is the bare source reference the legacy /// [`generate_nonlinear_partial`] is returned byte-identically; RANK is @@ -5748,7 +5770,7 @@ fn build_element_reducer_link_score( (TIME = INITIAL_TIME) \ then 0 \ else if \ - (({target_ref} - PREVIOUS({target_ref})) = 0) OR (({source_elem} - PREVIOUS({source_elem})) = 0) \ + (ABS(({target_ref} - PREVIOUS({target_ref}))) <= 0) OR (ABS(({source_elem} - PREVIOUS({source_elem}))) <= 0) \ then 0 \ else SAFEDIV(({partial_eq} - PREVIOUS({target_ref})), ABS(({target_ref} - PREVIOUS({target_ref}))), 0) * SIGN(({source_elem} - PREVIOUS({source_elem})))" ) diff --git a/src/simlin-engine/src/ltm_augment_occurrence.rs b/src/simlin-engine/src/ltm_augment_occurrence.rs index 5fec6f83d..bf2f5123e 100644 --- a/src/simlin-engine/src/ltm_augment_occurrence.rs +++ b/src/simlin-engine/src/ltm_augment_occurrence.rs @@ -148,7 +148,7 @@ impl<'a> OccurrenceLookup<'a> { } /// Does any occurrence of the live source (a model `Variable` or a - /// `ModuleOutput` composite) STRICTLY under `prefix` carry access shape + /// `ModuleOutput` composite) at or under `prefix` carry access shape /// `shape`? This is the occurrence-IR form of the retired /// `expr0_contains_live_match` lookahead: an array-reducer `App` at /// `prefix` is frozen whole (GH #517) unless it genuinely holds the live @@ -178,7 +178,7 @@ impl<'a> OccurrenceLookup<'a> { shape: &RefShape, ) -> bool { self.entries.iter().any(|(p, o)| { - p.len() > prefix.len() + p.len() >= prefix.len() && p.starts_with(prefix) && !o.index_nested && occurrence_realizes_shape(o, shape) diff --git a/src/simlin-engine/src/ltm_augment_pin_tests.rs b/src/simlin-engine/src/ltm_augment_pin_tests.rs index 193d821d6..d3cf699b5 100644 --- a/src/simlin-engine/src/ltm_augment_pin_tests.rs +++ b/src/simlin-engine/src/ltm_augment_pin_tests.rs @@ -834,9 +834,8 @@ fn per_element_pin_reaches_inside_a_whole_frozen_reducer() { /// must still be row-pinned. /// /// `growth[Region] = pop[Region, young] + PREVIOUS(pop[Region, young]) + -/// INIT(pop[Region, old])` -- the wrap deliberately does not descend into an -/// already-lagged or already-frozen call (wrapping its contents again would read -/// two steps back and force a nested-PREVIOUS helper chain), so the row pinning +/// INIT(pop[Region, old])` -- the selected-source PREVIOUS retains its temporal +/// read and INIT is constant, so neither call's contents are wrapped. Row pinning /// has to reach in through the pin-only descent. Left un-pinned, /// `pop[region, young]` -- a DIMENSION-name subscript -- survives into a scalar /// link-score fragment, which cannot compile: the fragment is dropped, the diff --git a/src/simlin-engine/src/ltm_augment_tests.rs b/src/simlin-engine/src/ltm_augment_tests.rs index 5fa4b9a79..9bd89edab 100644 --- a/src/simlin-engine/src/ltm_augment_tests.rs +++ b/src/simlin-engine/src/ltm_augment_tests.rs @@ -864,7 +864,7 @@ fn test_generate_stddev_equation() { ); assert_eq!( eq, - "if (TIME = INITIAL_TIME) then 0 else if ((total - PREVIOUS(total)) = 0) OR ((s[d1] - PREVIOUS(s[d1])) = 0) then 0 else SAFEDIV((sqrt((((s[d1] - ((s[d1] + PREVIOUS(s[d2]) + PREVIOUS(s[d3])) / 3))^2) + ((PREVIOUS(s[d2]) - ((s[d1] + PREVIOUS(s[d2]) + PREVIOUS(s[d3])) / 3))^2) + ((PREVIOUS(s[d3]) - ((s[d1] + PREVIOUS(s[d2]) + PREVIOUS(s[d3])) / 3))^2)) / 3) - PREVIOUS(total)), ABS((total - PREVIOUS(total))), 0) * SIGN((s[d1] - PREVIOUS(s[d1])))" + "if (TIME = INITIAL_TIME) then 0 else if (ABS((total - PREVIOUS(total))) <= 0) OR (ABS((s[d1] - PREVIOUS(s[d1]))) <= 0) then 0 else SAFEDIV((sqrt((((s[d1] - ((s[d1] + PREVIOUS(s[d2]) + PREVIOUS(s[d3])) / 3))^2) + ((PREVIOUS(s[d2]) - ((s[d1] + PREVIOUS(s[d2]) + PREVIOUS(s[d3])) / 3))^2) + ((PREVIOUS(s[d3]) - ((s[d1] + PREVIOUS(s[d2]) + PREVIOUS(s[d3])) / 3))^2)) / 3) - PREVIOUS(total)), ABS((total - PREVIOUS(total))), 0) * SIGN((s[d1] - PREVIOUS(s[d1])))" ); // The live source element drives the partial; the other elements // are frozen at PREVIOUS. @@ -1186,9 +1186,12 @@ fn test_generate_link_score_wrapping() { // Should have initial time guard assert!(eq.contains("TIME = INITIAL_TIME"), "equation: {eq}"); // Should have zero-change guards - assert!(eq.contains("(tgt - PREVIOUS(tgt)) = 0"), "equation: {eq}"); assert!( - eq.contains("(src[a] - PREVIOUS(src[a])) = 0"), + eq.contains("ABS((tgt - PREVIOUS(tgt))) <= 0"), + "equation: {eq}" + ); + assert!( + eq.contains("ABS((src[a] - PREVIOUS(src[a]))) <= 0"), "equation: {eq}" ); // Should have the single-numerator magnitude and sign parts @@ -1674,12 +1677,12 @@ fn test_generate_reduced_sum_equation() { ); // Target reference is subscripted by the result element. assert!( - eq.contains("(agg[a] - PREVIOUS(agg[a])) = 0"), + eq.contains("ABS((agg[a] - PREVIOUS(agg[a]))) <= 0"), "equation: {eq}" ); // Source reference is the full source tuple. assert!( - eq.contains("(matrix[a,x] - PREVIOUS(matrix[a,x])) = 0"), + eq.contains("ABS((matrix[a,x] - PREVIOUS(matrix[a,x]))) <= 0"), "equation: {eq}" ); // The other reduced-axis element must not be enumerated. @@ -1839,7 +1842,7 @@ fn test_generate_full_reduce_unchanged_after_refactor() { // stable (the explicit-string assertion below catches regressions). assert_eq!( scalar_eq, - "if (TIME = INITIAL_TIME) then 0 else if ((total_pop - PREVIOUS(total_pop)) = 0) OR ((population[nyc] - PREVIOUS(population[nyc])) = 0) then 0 else SAFEDIV((PREVIOUS(total_pop) + (population[nyc] - PREVIOUS(population[nyc])) - PREVIOUS(total_pop)), ABS((total_pop - PREVIOUS(total_pop))), 0) * SIGN((population[nyc] - PREVIOUS(population[nyc])))" + "if (TIME = INITIAL_TIME) then 0 else if (ABS((total_pop - PREVIOUS(total_pop))) <= 0) OR (ABS((population[nyc] - PREVIOUS(population[nyc]))) <= 0) then 0 else SAFEDIV((PREVIOUS(total_pop) + (population[nyc] - PREVIOUS(population[nyc])) - PREVIOUS(total_pop)), ABS((total_pop - PREVIOUS(total_pop))), 0) * SIGN((population[nyc] - PREVIOUS(population[nyc])))" ); } @@ -3193,15 +3196,12 @@ fn partial_equation_wildcard_live_iterated_dim_index_not_wrapped() { assert_eq!(partial, "sum(matrix[state, *])"); } -/// References that are already inside a PREVIOUS() call's argument are -/// already lagged: their value is fixed at the prior step and cannot be -/// affected by the current-step ceteris-paribus perturbation, so the -/// wrapper must NOT wrap them again. Double-wrapping reads the value from -/// two steps ago (semantically wrong) and forces a nested-PREVIOUS helper -/// chain (one synthesized helper variable per occurrence -- the dominant -/// remaining helper source on SAMPLE-IF-TRUE-heavy models like C-LEARN). +/// A sample-and-hold memory is a co-input of the selected current input. +/// Freezing the whole memory call preserves its fallback at the first scored +/// step; the fallback's reference to input is initial-only and does not make +/// the memory a selected-source read at later steps. #[test] -fn partial_equation_does_not_rewrap_inside_previous() { +fn partial_equation_freezes_sample_and_hold_memory() { // Target equation shape: `if cond then input else previous(self, input)` // -- the SAMPLE IF TRUE pattern. `target` (the self-reference) and // `cond` are other-deps; `input` is the live source. @@ -3220,21 +3220,15 @@ fn partial_equation_does_not_rewrap_inside_previous() { ) .unwrap(); - // The dep `cond` (outside any PREVIOUS) is wrapped... assert!( partial.to_lowercase().contains("previous(cond)"), "cond must be wrapped for ceteris-paribus; got: {partial}", ); - // ...but `target` inside the original PREVIOUS call is already - // lagged and must NOT be double-wrapped. - assert!( - !partial.to_lowercase().contains("previous(previous(target)"), - "reference inside PREVIOUS must not be double-wrapped; got: {partial}", - ); - // The original previous(target, input) call survives intact. assert!( - partial.to_lowercase().contains("previous(target,"), - "the original lagged self-reference must survive; got: {partial}", + partial + .to_lowercase() + .contains("previous(previous(target, input))"), + "the previous evaluation of the memory, including its fallback, must survive; got: {partial}", ); } @@ -3283,20 +3277,10 @@ fn partial_freezes_whole_reducer_over_index_nested_live_source() { ); } -/// Fig. 2 Q3 (Track A3 stage 2, review finding 2): an already-lagged other-dep -/// occurrence -- one that sits inside an ORIGINAL `PREVIOUS(...)` -- must be -/// left untouched (its live selection is suppressed AND it is never re-wrapped), -/// so the changed-first partial does not double-lag it to a t-2 read. -/// -/// `to = from + PREVIOUS(g)` with live source `from` (Bare): `from` stays live; -/// the already-lagged `PREVIOUS(g)` survives verbatim, NOT wrapped again as -/// `PREVIOUS(PREVIOUS(g))`. This complements -/// `partial_equation_does_not_rewrap_inside_previous` (which uses the -/// two-argument SAMPLE-IF-TRUE `PREVIOUS(target, input)` shape) with the exact -/// `to = from + PREVIOUS(g)` shape the wrap's structural PREVIOUS/INIT skip must -/// reproduce. -#[test] -fn partial_leaves_already_lagged_other_dep_untouched() { +/// The anchor for `to = from + PREVIOUS(g)` contains g from two steps ago. +/// The partial must use that same memory value to isolate from's change. +#[test] +fn partial_freezes_the_previous_value_of_a_lagged_other_dep() { let deps = deps_set(&["from", "g"]); let live = Ident::::new("from"); let shape = RefShape::Bare; @@ -3306,13 +3290,8 @@ fn partial_leaves_already_lagged_other_dep_untouched() { .unwrap(); assert_eq!( - partial, "from + previous(g)", - "the already-lagged `PREVIOUS(g)` must survive verbatim and `from` must \ - stay live; got: {partial}" - ); - assert!( - !partial.to_lowercase().contains("previous(previous(g"), - "an already-lagged occurrence must not be double-wrapped; got: {partial}" + partial, "from + PREVIOUS(previous(g))", + "the memory must be held at its previous value and from must stay live" ); } @@ -4153,8 +4132,8 @@ fn flow_to_stock_scalar_inflow_is_the_net_flow_partial() { assert_eq!( &*arm.text, format!( - "if (TIME = INITIAL_TIME) then 0 else if (({net} - PREVIOUS({net})) = 0) OR \ - ((births - PREVIOUS(births)) = 0) then 0 else SAFEDIV((births - \ + "if (TIME = INITIAL_TIME) then 0 else if (ABS(({net} - PREVIOUS({net}))) <= 0) OR \ + (ABS((births - PREVIOUS(births))) <= 0) then 0 else SAFEDIV((births - \ PREVIOUS(births)), ABS(({net} - PREVIOUS({net}))), 0) * SIGN((births - \ PREVIOUS(births)))" ) @@ -4186,8 +4165,8 @@ fn flow_to_stock_arrayed_inflow_subscripts_flow_and_net() { assert_eq!( &*arm.text, format!( - "if (TIME = INITIAL_TIME) then 0 else if (({net} - PREVIOUS({net})) = 0) OR \ - ((growth[region] - PREVIOUS(growth[region])) = 0) then 0 else \ + "if (TIME = INITIAL_TIME) then 0 else if (ABS(({net} - PREVIOUS({net}))) <= 0) OR \ + (ABS((growth[region] - PREVIOUS(growth[region]))) <= 0) then 0 else \ SAFEDIV((growth[region] - PREVIOUS(growth[region])), ABS(({net} - \ PREVIOUS({net}))), 0) * SIGN((growth[region] - PREVIOUS(growth[region])))" ) @@ -4255,8 +4234,8 @@ fn flow_to_stock_flow_over_other_dims_or_scalar_is_spelled_bare() { assert_eq!( &*arm.text, format!( - "if (TIME = INITIAL_TIME) then 0 else if (({net} - PREVIOUS({net})) = 0) OR \ - (({name} - PREVIOUS({name})) = 0) then 0 else SAFEDIV(({name} - \ + "if (TIME = INITIAL_TIME) then 0 else if (ABS(({net} - PREVIOUS({net}))) <= 0) OR \ + (ABS(({name} - PREVIOUS({name}))) <= 0) then 0 else SAFEDIV(({name} - \ PREVIOUS({name})), ABS(({net} - PREVIOUS({net}))), 0) * SIGN(({name} - \ PREVIOUS({name})))" ) @@ -4853,9 +4832,9 @@ fn test_generate_iterated_feeder_to_agg_equation_pins_slot_and_freezes_feeder() .expect("the feeder occurrence freezes"); assert_eq!( eq, - "if (TIME = INITIAL_TIME) then 0 else if ((growth[d1\u{B7}r1] - \ - PREVIOUS(growth[d1\u{B7}r1])) = 0) OR ((frac[d1\u{B7}r1] - \ - PREVIOUS(frac[d1\u{B7}r1])) = 0) then 0 else \ + "if (TIME = INITIAL_TIME) then 0 else if (ABS((growth[d1\u{B7}r1] - \ + PREVIOUS(growth[d1\u{B7}r1]))) <= 0) OR (ABS((frac[d1\u{B7}r1] - \ + PREVIOUS(frac[d1\u{B7}r1]))) <= 0) then 0 else \ SAFEDIV((growth[d1\u{B7}r1] - (sum(matrix[d1\u{B7}r1, *] * \ PREVIOUS(frac[d1\u{B7}r1])))), ABS((growth[d1\u{B7}r1] - \ PREVIOUS(growth[d1\u{B7}r1]))), 0) * SIGN((frac[d1\u{B7}r1] - \ @@ -5420,7 +5399,7 @@ fn shaped_guard_form_falls_back_to_changed_last_for_unfreezable_co_source() { assert_eq!( text, "if (TIME = INITIAL_TIME) then 0 \ - else if ((growth - PREVIOUS(growth)) = 0) OR ((frac - PREVIOUS(frac)) = 0) then 0 \ + else if (ABS((growth - PREVIOUS(growth))) <= 0) OR (ABS((frac - PREVIOUS(frac))) <= 0) then 0 \ else SAFEDIV((growth - (sum(matrix[d1, *] * PREVIOUS(frac)))), \ ABS((growth - PREVIOUS(growth))), 0) * SIGN((frac - PREVIOUS(frac)))" ); @@ -5460,7 +5439,7 @@ fn shaped_guard_form_changed_last_keeps_the_clock_live() { assert_eq!( text, "if (TIME = INITIAL_TIME) then 0 \ - else if ((growth - PREVIOUS(growth)) = 0) OR ((frac - PREVIOUS(frac)) = 0) then 0 \ + else if (ABS((growth - PREVIOUS(growth))) <= 0) OR (ABS((frac - PREVIOUS(frac))) <= 0) then 0 \ else SAFEDIV((growth - (sum(matrix[d1, *] * PREVIOUS(frac)) + time())), \ ABS((growth - PREVIOUS(growth))), 0) * SIGN((frac - PREVIOUS(frac)))" ); @@ -5507,7 +5486,7 @@ fn shaped_guard_form_keeps_changed_first_when_freezable() { text, format!( "if (TIME = INITIAL_TIME) then 0 \ - else if ((share - PREVIOUS(share)) = 0) OR ((population - PREVIOUS(population)) = 0) then 0 \ + else if (ABS((share - PREVIOUS(share))) <= 0) OR (ABS((population - PREVIOUS(population))) <= 0) then 0 \ else SAFEDIV((({expected_partial}) - PREVIOUS(share)), \ ABS((share - PREVIOUS(share))), 0) * SIGN((population - PREVIOUS(population)))" ) diff --git a/src/simlin-engine/src/ltm_augment_zero_slot.rs b/src/simlin-engine/src/ltm_augment_zero_slot.rs index 2155b7e75..9194af76c 100644 --- a/src/simlin-engine/src/ltm_augment_zero_slot.rs +++ b/src/simlin-engine/src/ltm_augment_zero_slot.rs @@ -40,14 +40,12 @@ pub(crate) enum ZeroSlotPolicy { /// positive predicate. Bit-exactness rests on a LAG-ALIGNMENT requirement /// that is easy to state and easy to miss: the partial equals /// `PREVIOUS(target)` only if every read in it is lagged by EXACTLY one - /// step. Two shapes look entirely frozen and are not aligned -- an ORIGINAL - /// `PREVIOUS(z)` from the target's own equation, which the wrap - /// deliberately leaves untouched (so the partial reads `z(t-1)` where the - /// anchor read `z(t-2)`), and a synthesized `PREVIOUS` nested inside - /// another, which the subscript-index freeze produces. Both are rejected by - /// [`partial_is_provably_previous_target`], and each is pinned by its own - /// row in `db::ltm_value_gate_tests`; skipping either omits an arm worth - /// close to the canonical +/-1 attribution. + /// step relative to the ORIGINAL target expression. Original `PREVIOUS` + /// calls require a temporal proof: co-input snapshots gain one freeze, + /// while selected-source snapshots retain their lag. The predicate + /// conservatively refuses every original `PREVIOUS`, and synthesized + /// nested freezes as well, because the subscript-index freeze can lag an + /// index twice. `db::ltm_value_gate_tests` covers these refusal classes. /// /// Bit-exactness has ONE disclosed exception, and it is a value change /// rather than a representation one: when the target slot is NON-FINITE. @@ -180,15 +178,14 @@ fn classify_builtin_reach(name: &str) -> BuiltinReach { /// negative form asks a different question, one that says nothing about the rest /// of the arm; see [`ZeroSlotPolicy::OmitStructuralZero`] for what that costs. /// -/// "Frozen" is not enough on its own: the partial reproduces `target(t-1)` only -/// if every read is lagged by EXACTLY one step, so this takes the ORIGINAL -/// element expression as well as the emitted partial. An original `PREVIOUS(z)` -/// has to be found in the original, because in the emitted tree it is the same -/// node as a synthesized freeze and nothing distinguishes them; a NESTED -/// `PREVIOUS` is found in the partial, because the wrap is what introduces it. -/// Neither check subsumes the other -- reverting either one alone leaves the -/// other case wrongly omitted, measured row by row in -/// `db::ltm_value_gate_tests`. +/// "Frozen" is not enough on its own: every read must be lagged by exactly +/// one step RELATIVE TO its position in the original target. Co-input +/// snapshots acquire that extra lag while selected-source snapshots retain +/// their existing temporal read. Rather than prove those cases separately, +/// this predicate conservatively refuses every original `PREVIOUS` outside +/// an `INIT`. It also refuses nested freezes in the partial, because a frozen +/// dynamic index can have acquired two lags. These checks can retain an +/// aligned zero arm; they must never omit a misaligned one. /// /// A `Var` or `Subscript` reached outside a frozen subtree is a live read and /// ends the walk, which is why subscript INDICES are never descended into: the @@ -206,11 +203,10 @@ pub(super) fn partial_is_provably_previous_target(original: &Expr0, partial: &Ex /// Does `expr` call `PREVIOUS` outside every `INIT(..)` subtree? /// -/// Asked of the ORIGINAL element equation, never of the partial, because in the -/// emitted tree an original `PREVIOUS(z)` and a synthesized freeze -/// `PREVIOUS(x)` are the same node and nothing distinguishes them. `INIT` -/// subtrees are skipped: `INIT(PREVIOUS(z))` is the run's initial value, a -/// constant, so it aligns at every step. +/// Asked of the original element equation to conservatively exclude its +/// temporal-source policy from the one-step proof. `INIT` subtrees are +/// skipped: `INIT(PREVIOUS(z))` is the run's initial value, a constant, so it +/// aligns at every step. fn contains_previous_call(expr: &Expr0) -> bool { match expr { Expr0::Const(..) | Expr0::Var(..) => false, diff --git a/src/simlin-engine/src/ltm_dominance.rs b/src/simlin-engine/src/ltm_dominance.rs index 1f2ac28be..d249428ea 100644 --- a/src/simlin-engine/src/ltm_dominance.rs +++ b/src/simlin-engine/src/ltm_dominance.rs @@ -121,6 +121,13 @@ impl FeedbackLoop { /// /// `dt` is the time between consecutive entries in each loop's importance_series. /// `start_time` is the simulation start time. +/// +/// A reported set must reach 50% of the mass its importance series were +/// normalized against (LTM's dominance definition, reference section 2.1). +/// Missing periods mean no supplied set establishes that threshold: the +/// partition may be inactive, or retention/the report cap may have omitted +/// necessary loops. A gap does not establish an absence of feedback. With +/// sample-normalized scores, dominance describes that sample only. pub fn calculate_dominant_periods( loops: &[FeedbackLoop], start_time: f64, @@ -178,10 +185,11 @@ pub enum PartitionSurface { /// determined by score sign (positive = reinforcing, negative = balancing), /// matching the Praxis reference. A two-pass approach first computes /// aggregate totals per polarity, then selects the winning polarity and -/// accumulates loops until the combined score reaches 0.5. If neither -/// polarity reaches 0.5, all loops from whichever polarity has the higher -/// total are used. Consecutive timesteps with the same dominant loop set -/// are grouped into a single `DominantPeriod` tagged with `partition`. +/// accumulates loops until the combined score reaches 0.5. If neither +/// polarity reaches 0.5, the step has no reported dominant set: missing +/// loops could reverse the apparent polarity winner. Consecutive timesteps +/// with the same dominant loop set are grouped into a single `DominantPeriod` +/// tagged with `partition`. fn calculate_dominant_periods_for_group( loops: &[&FeedbackLoop], partition: Option, @@ -228,16 +236,11 @@ fn calculate_dominant_periods_for_group( // Pass 1: compute polarity totals using score sign let mut reinforcing_sum = 0.0_f64; let mut balancing_sum = 0.0_f64; - let mut reinforcing_loops: Vec<&str> = Vec::new(); - let mut balancing_loops: Vec<&str> = Vec::new(); - - for &(name, score) in &scored { + for &(_, score) in &scored { if score > 0.0 { reinforcing_sum += score; - reinforcing_loops.push(name); } else if score < 0.0 { balancing_sum += score.abs(); - balancing_loops.push(name); } } @@ -270,20 +273,14 @@ fn calculate_dominant_periods_for_group( } } } - } else if reinforcing_wins { - // Fallback: use ALL loops from the higher-scoring polarity - dominant_names = reinforcing_loops.iter().map(|s| s.to_string()).collect(); - combined = reinforcing_sum; - } else { - dominant_names = balancing_loops.iter().map(|s| s.to_string()).collect(); - combined = balancing_sum; } // Sorted copy for order-independent set comparison let mut sorted_names = dominant_names.clone(); sorted_names.sort(); - // Skip timesteps with no meaningful dominance + // An unresolved step splits periods even if the same set qualifies + // again later; joining across the gap would assert missing evidence. if combined == 0.0 { if let Some(last) = periods.last_mut() && score_count > 0 @@ -623,9 +620,10 @@ mod tests { } #[test] - fn test_dominant_periods_fallback_uses_all_from_higher_polarity() { - // Neither polarity reaches 0.5, so all loops from the polarity - // with the higher aggregate total should be used. + fn test_dominant_periods_require_half_the_normalization_mass() { + // These are valid shares from an incomplete report: the omitted + // 40% could all be balancing, reversing the apparent 40%-vs-20% + // winner. No set supplied here establishes the LTM 50% criterion. let loops = vec![ FeedbackLoop { name: "R1".to_string(), @@ -653,11 +651,55 @@ mod tests { }, ]; let periods = calculate_dominant_periods(&loops, 0.0, 1.0, PartitionSurface::NoMetadata); + assert!(periods.is_empty()); + } + + #[test] + fn dominance_threshold_and_unresolved_gaps_cover_both_polarities_and_surfaces() { + // This isolates the threshold/period-grouping decision; the + // production-derived capped population is covered by + // ltm_finding::tests::capped_analysis_does_not_reverse_the_dominant_polarity. + for surface in [ + PartitionSurface::NoMetadata, + PartitionSurface::PartitionBearing, + ] { + for sign in [-1.0, 1.0] { + let series = vec![0.5, 0.75, 0.0, 0.5, 0.49, 0.5, f64::NAN, 0.5]; + let loops = vec![partitioned_loop( + "loop", + series.into_iter().map(|v| sign * v).collect(), + None, + )]; + let periods = calculate_dominant_periods(&loops, 10.0, 0.25, surface); + let intervals: Vec<_> = periods.iter().map(|p| (p.start, p.end)).collect(); + assert_eq!( + intervals, + vec![ + (10.0, 10.25), + (10.75, 10.75), + (11.25, 11.25), + (11.75, 11.75) + ] + ); + assert_eq!(periods[0].combined_score, 0.625); + assert!(periods.iter().all(|p| p.combined_score >= 0.5)); + } + } + } + + #[test] + fn equal_polarity_halves_meet_the_threshold() { + // A 50/50 split has two qualifying sets. The reinforcing-first + // tie break is deterministic, without requiring a strict majority. + let loops = vec![ + partitioned_loop("balancing", vec![-0.5], Some(0)), + partitioned_loop("reinforcing", vec![0.5], Some(0)), + ]; + let periods = + calculate_dominant_periods(&loops, 0.0, 1.0, PartitionSurface::PartitionBearing); assert_eq!(periods.len(), 1); - // Reinforcing total (0.4) > Balancing total (0.2), so both R1+R2 - let mut names = periods[0].dominant_loops.clone(); - names.sort(); - assert_eq!(names, vec!["R1", "R2"]); + assert_eq!(periods[0].dominant_loops, vec!["reinforcing"]); + assert_eq!(periods[0].combined_score, 0.5); } #[test] @@ -709,15 +751,15 @@ mod tests { #[test] fn test_dominant_periods_zero_score_loops_excluded_from_dominant_set() { - // One loop has a small negative score, another has zero score. + // One loop meets the dominance threshold, another has zero score. // The zero-score loop contributes nothing and should not inflate - // the dominant set in the fallback path. + // the dominant set. let loops = vec![ FeedbackLoop { name: "B1".to_string(), polarity: LoopPolarity::Balancing, variables: vec!["a".to_string()], - importance_series: vec![-0.01], + importance_series: vec![-0.5], dominant_period: None, partition: None, }, diff --git a/src/simlin-engine/src/ltm_finding.rs b/src/simlin-engine/src/ltm_finding.rs index 3c2406358..14c341cdf 100644 --- a/src/simlin-engine/src/ltm_finding.rs +++ b/src/simlin-engine/src/ltm_finding.rs @@ -160,7 +160,7 @@ pub struct LinkExpansionContext { /// Maximum loops to retain after discovery (paper uses 200) const MAX_LOOPS: usize = 200; -/// Minimum average relative contribution to keep a loop (paper uses 0.1%) +/// Minimum peak relative contribution to keep a loop (0.1%). const MIN_CONTRIBUTION: f64 = 0.001; #[cfg(test)] @@ -3631,14 +3631,17 @@ fn subtract_reported_mass_from_totals( } } -/// Mean magnitude of a loop's relative loop score over the steps where it is -/// active -- the partition-relative importance statistic (GH #543). +/// Mean magnitude of a loop's relative loop score over the steps where its +/// partition is active and its own score is defined (GH #543). /// /// `totals[t]` is the loop's cycle-partition denominator at step `t` (the sum -/// of `|loop_score_j|` over the partition, `NaN` excluded). The loop is *active* -/// at step `t` iff its own `score[t]` is non-`NaN` and `totals[t] > 0`; the mean -/// is taken only over active steps ("delayed averaging", ref 13.3). A loop with -/// no active steps returns `NaN` (it sorts last). `Inf/Inf = NaN` at a +/// of `|loop_score_j|` over the partition, `NaN` excluded). A step is included +/// iff the loop's `score[t]` is non-`NaN` and `totals[t] > 0`; a zero score +/// counts in the mean whenever a sibling is active, including before this +/// loop's first nonzero score. This is Simlin's partition-active averaging +/// policy, distinct from the thesis's per-loop delayed start (reference +/// section 13.3). A loop with no included steps returns `NaN` (it sorts last). +/// `Inf/Inf = NaN` at a /// dominance inflection is naturally excluded since `NaN` is not active. fn mean_relative_contribution(fl: &FoundLoop, totals: &[f64]) -> f64 { // The same division every relative series gets (`ltm_post::relative_series`, @@ -3682,9 +3685,9 @@ fn mean_relative_contribution(fl: &FoundLoop, totals: &[f64]) -> f64 { /// as the ranking and truncation key (GH #543). /// /// `mean_rel` is the **mean magnitude of the loop's relative loop score** over -/// the steps where it is active -- `mean_t(|score[t]| / partition_total[t])`, -/// the literature's loop-inclusion measure ("average magnitude of the relative -/// loop score across the simulation period"; docs/reference 13.3). +/// the steps included by [`mean_relative_contribution`] -- +/// `mean_t(|score[t]| / partition_total[t])`. It measures average contribution +/// across the partition's activity, including this loop's zero-score steps. /// /// `competing` is whether the loop shares its cycle partition with at least /// one other DISCOVERED loop. A loop that is trivially alone in its partition @@ -3792,21 +3795,18 @@ fn cmp_relative_importance(a: &RelativeImportance, b: &RelativeImportance) -> st /// **Ranking key choice (mean vs peak).** The retention filter (step 3) uses /// the *peak* per-timestep relative contribution -- "did this loop ever /// matter?" The *ranking* key (steps 2/4/6) uses the *mean* relative -/// contribution -- "how important is it overall?" The mean is the -/// literature-aligned loop-inclusion measure (docs/reference 13.3). These are -/// deliberately different statistics for two different questions. +/// contribution -- "how important is it overall?" These are deliberately +/// different statistics for two different questions. /// -/// **Active-step (delayed) averaging.** A loop is *active* at step `t` when its -/// own `score[t]` is non-`NaN` and `partition_total[t] > 0`. The mean is taken -/// only over active steps (skip, do not count-as-zero). This is the -/// literature's "delayed averaging: starts from the first instant the loop -/// becomes active" (ref 13.3) generalized to "any inactive step" -- counting an -/// inactive step as a 0 contribution would penalize a loop that is sharply -/// dominant for a brief window (the briefly-dominant loop the retention filter -/// is specifically built to keep), pushing it below a perpetually-mediocre -/// loop. A loop with no active steps gets `NaN`, which sorts last. +/// **Partition-active averaging.** A step is included when the loop's own +/// `score[t]` is non-`NaN` and `partition_total[t] > 0`. Its zero scores count +/// whenever a sibling is active, including before this loop first becomes +/// active. This differs from the thesis's per-loop delayed averaging +/// (reference section 13.3): the ranking measures sustained contribution, +/// while the peak retention filter and coverage-aware cap protect brief +/// contributions. A loop with no included steps gets `NaN`, which sorts last. /// -/// Because the mean is over each loop's *own* active-step set, a loop that +/// Because zero-mass partition steps are excluded, a loop that /// dominates a partition that is active for only a brief window ties one /// that dominates an always-active partition: both have a mean relative /// contribution near `1.0` over their respective active steps. This diff --git a/src/simlin-engine/src/ltm_finding_enum.rs b/src/simlin-engine/src/ltm_finding_enum.rs index 6c0d9f979..bdb6c2914 100644 --- a/src/simlin-engine/src/ltm_finding_enum.rs +++ b/src/simlin-engine/src/ltm_finding_enum.rs @@ -1690,14 +1690,13 @@ fn circuit_partition( /// `ModuleOverrideCache::series`'s doc), so the substituted product is 0 or /// NaN there whatever the other rows do. /// -/// An elementary circuit repeats no node, so at most one row's target is a -/// module instance and one lookup decides the window (a memoized `HashMap` -/// hit after the first). Circuits touching no module node never look up. -/// The intersection matters for cost, not just tightness: on World3 most -/// circuits pass through a SMOOTH/DELAY module whose pathway is active for -/// nearly the whole run, so the override window alone is ~5x wider than the -/// circuit's own ~79-step activity window; scoring over the override window -/// alone regressed discovery from ~0.4 s to ~1.1 s. +/// A circuit may traverse several distinct module instances. Intersecting +/// with the first one's override is a conservative bound: later overrides +/// can narrow the active range further, but cannot make the product active +/// outside this range. This uses one memoized lookup per module-bearing +/// circuit; circuits touching no module node never look up. Keeping the raw +/// window in the intersection avoids widening a short-lived circuit to a +/// pathway that is active for most of the run. fn effective_scoring_window( rows: &[u32], graph: &ActivityGraph, diff --git a/src/simlin-engine/src/ltm_finding_tests.rs b/src/simlin-engine/src/ltm_finding_tests.rs index 91957b4da..7223e44fb 100644 --- a/src/simlin-engine/src/ltm_finding_tests.rs +++ b/src/simlin-engine/src/ltm_finding_tests.rs @@ -2939,6 +2939,72 @@ fn discovery_no_agg_model_unaffected_by_stitching() { } } +/// The report cap can keep the largest individual loop while dropping part +/// of the opposite polarity's dominant set. Drive the production analysis +/// pipeline so both the shares and the capped loop population come from the +/// simulator/discovery, rather than supplying a fabricated incomplete list. +#[test] +fn capped_analysis_does_not_reverse_the_dominant_polarity() { + use crate::test_common::TestProject; + + // Both polarity directions: the single largest loop has 40% of the + // partition's mass; the two opposite-polarity loops jointly have 60%. + for leader_reinforces in [false, true] { + let (inflows, outflows) = if leader_reinforces { + (vec!["leader"], vec!["member_a", "member_b"]) + } else { + (vec!["member_a", "member_b"], vec!["leader"]) + }; + let project = TestProject::new("capped_dominance") + .with_sim_time(0.0, 3.0, 1.0) + .stock("population", "100", &inflows, &outflows, None) + .flow("leader", "population * 0.4", None) + .flow("member_a", "population * 0.3", None) + .flow("member_b", "population * 0.3", None) + .build_datamodel(); + let mut db = crate::db::SimlinDb::default(); + let source = crate::db::sync_from_datamodel_incremental(&mut db, &project, None).project; + + let full = crate::analysis::analyze_model(&project, &mut db, source, "main", None) + .expect("analysis succeeds"); + assert!(full.analysis_error.is_none()); + assert!(full.enumeration_complete); + assert_eq!(full.loop_dominance.len(), 3); + assert!(!full.dominant_loops_by_period.is_empty()); + for period in &full.dominant_loops_by_period { + assert!(period.combined_score >= 0.5); + assert_eq!(period.dominant_loops.len(), 2); + for id in &period.dominant_loops { + let member = full + .loop_dominance + .iter() + .find(|l| &l.loop_id == id) + .unwrap(); + assert!(member.variables.iter().all(|name| name != "leader")); + } + } + + let _cap = MaxLoopsGuard::new(2); + let capped = crate::analysis::analyze_model(&project, &mut db, source, "main", None) + .expect("capped analysis succeeds"); + assert!(capped.analysis_error.is_none()); + assert!(capped.enumeration_complete); + assert_eq!(capped.retained_loops, 3); + assert_eq!(capped.loop_dominance.len(), 2); + assert!( + capped + .loop_dominance + .iter() + .any(|l| l.variables.iter().any(|v| v == "leader")) + ); + assert!( + capped.dominant_loops_by_period.is_empty(), + "the reported 40% leader cannot establish dominance over the omitted 60% set: {:?}", + capped.dominant_loops_by_period, + ); + } +} + /// GH #698 / PR #705 r3353758167: `recompute_module_input_edge_series` must /// strip element subscripts before its name-sensitive lookups. Discovery /// runs on the ELEMENT-LEVEL graph, so an arrayed loop edge carries diff --git a/src/simlin-engine/tests/integration/ltm_array_agg.rs b/src/simlin-engine/tests/integration/ltm_array_agg.rs index df6943bca..297b869cc 100644 --- a/src/simlin-engine/tests/integration/ltm_array_agg.rs +++ b/src/simlin-engine/tests/integration/ltm_array_agg.rs @@ -2919,9 +2919,9 @@ fn bare_body_and_feeder_agg_equations_unchanged() { ); assert_eq!( bare_score.equation.source_text(), - "if (TIME = INITIAL_TIME) then 0 else if ((\"$\u{205A}ltm\u{205A}agg\u{205A}0\" - \ - PREVIOUS(\"$\u{205A}ltm\u{205A}agg\u{205A}0\")) = 0) OR ((pop[region\u{B7}north] - \ - PREVIOUS(pop[region\u{B7}north])) = 0) then 0 else \ + "if (TIME = INITIAL_TIME) then 0 else if (ABS((\"$\u{205A}ltm\u{205A}agg\u{205A}0\" - \ + PREVIOUS(\"$\u{205A}ltm\u{205A}agg\u{205A}0\"))) <= 0) OR (ABS((pop[region\u{B7}north] - \ + PREVIOUS(pop[region\u{B7}north]))) <= 0) then 0 else \ SAFEDIV((PREVIOUS(\"$\u{205A}ltm\u{205A}agg\u{205A}0\") + (pop[region\u{B7}north] - \ PREVIOUS(pop[region\u{B7}north])) - PREVIOUS(\"$\u{205A}ltm\u{205A}agg\u{205A}0\")), \ ABS((\"$\u{205A}ltm\u{205A}agg\u{205A}0\" - PREVIOUS(\"$\u{205A}ltm\u{205A}agg\u{205A}0\"))), 0) * \ @@ -2937,8 +2937,8 @@ fn bare_body_and_feeder_agg_equations_unchanged() { ); assert_eq!( feeder_score.equation.source_text(), - "if (TIME = INITIAL_TIME) then 0 else if ((\"$\u{205A}ltm\u{205A}agg\u{205A}0\" - \ - PREVIOUS(\"$\u{205A}ltm\u{205A}agg\u{205A}0\")) = 0) OR ((scale - PREVIOUS(scale)) = 0) \ + "if (TIME = INITIAL_TIME) then 0 else if (ABS((\"$\u{205A}ltm\u{205A}agg\u{205A}0\" - \ + PREVIOUS(\"$\u{205A}ltm\u{205A}agg\u{205A}0\"))) <= 0) OR (ABS((scale - PREVIOUS(scale))) <= 0) \ then 0 else SAFEDIV((\"$\u{205A}ltm\u{205A}agg\u{205A}0\" - (sum(pop[*] * \ PREVIOUS(scale)))), ABS((\"$\u{205A}ltm\u{205A}agg\u{205A}0\" - \ PREVIOUS(\"$\u{205A}ltm\u{205A}agg\u{205A}0\"))), 0) * SIGN((scale - PREVIOUS(scale)))", @@ -3204,9 +3204,9 @@ fn nonlinear_bare_body_equations_unchanged() { ); assert_eq!( min_score.equation.source_text(), - "if (TIME = INITIAL_TIME) then 0 else if ((\"$\u{205A}ltm\u{205A}agg\u{205A}0\" - \ - PREVIOUS(\"$\u{205A}ltm\u{205A}agg\u{205A}0\")) = 0) OR ((pop[region\u{B7}north] - \ - PREVIOUS(pop[region\u{B7}north])) = 0) then 0 else \ + "if (TIME = INITIAL_TIME) then 0 else if (ABS((\"$\u{205A}ltm\u{205A}agg\u{205A}0\" - \ + PREVIOUS(\"$\u{205A}ltm\u{205A}agg\u{205A}0\"))) <= 0) OR (ABS((pop[region\u{B7}north] - \ + PREVIOUS(pop[region\u{B7}north]))) <= 0) then 0 else \ SAFEDIV((MIN(pop[region\u{B7}north], PREVIOUS(pop[region\u{B7}south])) - \ PREVIOUS(\"$\u{205A}ltm\u{205A}agg\u{205A}0\")), \ ABS((\"$\u{205A}ltm\u{205A}agg\u{205A}0\" - PREVIOUS(\"$\u{205A}ltm\u{205A}agg\u{205A}0\"))), 0) * \ @@ -3221,9 +3221,9 @@ fn nonlinear_bare_body_equations_unchanged() { ); assert_eq!( stddev_score.equation.source_text(), - "if (TIME = INITIAL_TIME) then 0 else if ((\"$\u{205A}ltm\u{205A}agg\u{205A}0\" - \ - PREVIOUS(\"$\u{205A}ltm\u{205A}agg\u{205A}0\")) = 0) OR ((pop[region\u{B7}north] - \ - PREVIOUS(pop[region\u{B7}north])) = 0) then 0 else \ + "if (TIME = INITIAL_TIME) then 0 else if (ABS((\"$\u{205A}ltm\u{205A}agg\u{205A}0\" - \ + PREVIOUS(\"$\u{205A}ltm\u{205A}agg\u{205A}0\"))) <= 0) OR (ABS((pop[region\u{B7}north] - \ + PREVIOUS(pop[region\u{B7}north]))) <= 0) then 0 else \ SAFEDIV((sqrt((((pop[region\u{B7}north] - ((pop[region\u{B7}north] + \ PREVIOUS(pop[region\u{B7}south])) / 2))^2) + ((PREVIOUS(pop[region\u{B7}south]) - \ ((pop[region\u{B7}north] + PREVIOUS(pop[region\u{B7}south])) / 2))^2)) / 2) - \ @@ -3326,9 +3326,9 @@ fn iterated_dim_feeder_closure_scores_via_hoist() { let var = ltm_var(<m_vars, &feeder_r1); assert_eq!( var.equation.source_text(), - "if (TIME = INITIAL_TIME) then 0 else if ((growth[d1\u{B7}r1] - \ - PREVIOUS(growth[d1\u{B7}r1])) = 0) OR ((frac[d1\u{B7}r1] - \ - PREVIOUS(frac[d1\u{B7}r1])) = 0) then 0 else \ + "if (TIME = INITIAL_TIME) then 0 else if (ABS((growth[d1\u{B7}r1] - \ + PREVIOUS(growth[d1\u{B7}r1]))) <= 0) OR (ABS((frac[d1\u{B7}r1] - \ + PREVIOUS(frac[d1\u{B7}r1]))) <= 0) then 0 else \ SAFEDIV((growth[d1\u{B7}r1] - (sum(matrix[d1\u{B7}r1, *] * \ PREVIOUS(frac[d1\u{B7}r1])))), ABS((growth[d1\u{B7}r1] - \ PREVIOUS(growth[d1\u{B7}r1]))), 0) * SIGN((frac[d1\u{B7}r1] - \ @@ -3471,9 +3471,9 @@ fn iterated_dim_feeder_co_source_closure_scores_real_values() { let var = ltm_var(<m_vars, &m_r1c1); assert_eq!( var.equation.source_text(), - "if (TIME = INITIAL_TIME) then 0 else if ((growth[d1\u{B7}r1] - \ - PREVIOUS(growth[d1\u{B7}r1])) = 0) OR ((matrix[d1\u{B7}r1,d2\u{B7}c1] - \ - PREVIOUS(matrix[d1\u{B7}r1,d2\u{B7}c1])) = 0) then 0 else \ + "if (TIME = INITIAL_TIME) then 0 else if (ABS((growth[d1\u{B7}r1] - \ + PREVIOUS(growth[d1\u{B7}r1]))) <= 0) OR (ABS((matrix[d1\u{B7}r1,d2\u{B7}c1] - \ + PREVIOUS(matrix[d1\u{B7}r1,d2\u{B7}c1]))) <= 0) then 0 else \ SAFEDIV((PREVIOUS(growth[d1\u{B7}r1]) + ((matrix[d1\u{B7}r1, d2\u{B7}c1] * \ PREVIOUS(frac[d1\u{B7}r1])) - (PREVIOUS(matrix[d1\u{B7}r1, d2\u{B7}c1]) * \ PREVIOUS(frac[d1\u{B7}r1]))) - PREVIOUS(growth[d1\u{B7}r1])), \ @@ -9535,9 +9535,9 @@ fn aligned_partial_reduce_emissions_stay_byte_identical() { // The exact equation text of the first row's score, captured at the T3 // parent commit. Byte-identity here is the regression guard for the // derivation swap on already-correct shapes. - let golden = "if (TIME = INITIAL_TIME) then 0 else if ((inflow[d1\u{B7}a] - \ - PREVIOUS(inflow[d1\u{B7}a])) = 0) OR ((matrix[d1\u{B7}a,d2\u{B7}x] - \ - PREVIOUS(matrix[d1\u{B7}a,d2\u{B7}x])) = 0) then 0 else \ + let golden = "if (TIME = INITIAL_TIME) then 0 else if (ABS((inflow[d1\u{B7}a] - \ + PREVIOUS(inflow[d1\u{B7}a]))) <= 0) OR (ABS((matrix[d1\u{B7}a,d2\u{B7}x] - \ + PREVIOUS(matrix[d1\u{B7}a,d2\u{B7}x]))) <= 0) then 0 else \ SAFEDIV((PREVIOUS(inflow[d1\u{B7}a]) + (matrix[d1\u{B7}a,d2\u{B7}x] - \ PREVIOUS(matrix[d1\u{B7}a,d2\u{B7}x])) - PREVIOUS(inflow[d1\u{B7}a])), \ ABS((inflow[d1\u{B7}a] - PREVIOUS(inflow[d1\u{B7}a]))), 0) * \ @@ -10336,9 +10336,9 @@ fn whole_rhs_mapped_reduce_emissions_stay_byte_identical() { // The exact source-half equation text captured at the T4 parent commit // (148a17d8). - let golden = "if (TIME = INITIAL_TIME) then 0 else if ((\"$\u{205A}ltm\u{205A}agg\u{205A}0\"\ -[ca] - PREVIOUS(\"$\u{205A}ltm\u{205A}agg\u{205A}0\"[ca])) = 0) OR ((matrix[region\u{B7}west,\ -d2\u{B7}x] - PREVIOUS(matrix[region\u{B7}west,d2\u{B7}x])) = 0) then 0 else SAFEDIV((PREVIOUS\ + let golden = "if (TIME = INITIAL_TIME) then 0 else if (ABS((\"$\u{205A}ltm\u{205A}agg\u{205A}0\"\ +[ca] - PREVIOUS(\"$\u{205A}ltm\u{205A}agg\u{205A}0\"[ca]))) <= 0) OR (ABS((matrix[region\u{B7}west,\ +d2\u{B7}x] - PREVIOUS(matrix[region\u{B7}west,d2\u{B7}x]))) <= 0) then 0 else SAFEDIV((PREVIOUS\ (\"$\u{205A}ltm\u{205A}agg\u{205A}0\"[ca]) + (matrix[region\u{B7}west,d2\u{B7}x] - PREVIOUS(\ matrix[region\u{B7}west,d2\u{B7}x])) - PREVIOUS(\"$\u{205A}ltm\u{205A}agg\u{205A}0\"[ca])), \ ABS((\"$\u{205A}ltm\u{205A}agg\u{205A}0\"[ca] - PREVIOUS(\"$\u{205A}ltm\u{205A}agg\u{205A}0\"\ @@ -11988,8 +11988,8 @@ fn scalar_feeder_of_whole_rhs_reduce_scores_via_agg_arm() { ); assert_eq!( feeder.equation.source_text(), - "if (TIME = INITIAL_TIME) then 0 else if ((growth - PREVIOUS(growth)) = 0) OR \ - ((scale - PREVIOUS(scale)) = 0) then 0 else SAFEDIV((growth - (sum(matrix[d1, *] * \ + "if (TIME = INITIAL_TIME) then 0 else if (ABS((growth - PREVIOUS(growth))) <= 0) OR \ + (ABS((scale - PREVIOUS(scale))) <= 0) then 0 else SAFEDIV((growth - (sum(matrix[d1, *] * \ PREVIOUS(scale)))), ABS((growth - PREVIOUS(growth))), 0) * SIGN((scale - PREVIOUS(scale)))", "the scalar-feeder changed-last equation must match the hand-derived form" ); @@ -12962,8 +12962,8 @@ fn scalar_feeder_of_broadcast_reduce_scores_via_agg_arm() { ); assert_eq!( feeder.equation.source_text(), - "if (TIME = INITIAL_TIME) then 0 else if ((growth - PREVIOUS(growth)) = 0) OR \ - ((scale - PREVIOUS(scale)) = 0) then 0 else SAFEDIV((growth - (sum(matrix[a, *] * \ + "if (TIME = INITIAL_TIME) then 0 else if (ABS((growth - PREVIOUS(growth))) <= 0) OR \ + (ABS((scale - PREVIOUS(scale))) <= 0) then 0 else SAFEDIV((growth - (sum(matrix[a, *] * \ PREVIOUS(scale)))), ABS((growth - PREVIOUS(growth))), 0) * SIGN((scale - PREVIOUS(scale)))", "the broadcast scalar-feeder changed-last equation must match the hand-derived form" ); diff --git a/src/simlin-engine/tests/integration/ltm_snapshot_inputs.rs b/src/simlin-engine/tests/integration/ltm_snapshot_inputs.rs new file mode 100644 index 000000000..02d3e3387 --- /dev/null +++ b/src/simlin-engine/tests/integration/ltm_snapshot_inputs.rs @@ -0,0 +1,242 @@ +// 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. + +//! A snapshot expression is an input of the ceteris-paribus evaluation. +//! `PREVIOUS(g)` changes when g's history advances; its value at the previous +//! step, including its initial fallback, belongs in a changed-first partial. +//! `INIT(g)` is constant. These tests derive all inputs through the production +//! parser, dependency extraction, augmentation, and VM. + +use simlin_engine::test_common::TestProject; + +use crate::test_helpers::{ltm_run, ltm_series}; + +fn score_key(from: &str, to: &str) -> String { + format!("$⁚ltm⁚link_score⁚{from}→{to}") +} + +fn snapshot_project(memory: &str, coefficient: f64) -> simlin_engine::datamodel::Project { + TestProject::new("snapshot_partial") + .with_sim_time(0.0, 4.0, 1.0) + .stock("x", "10", &["z"], &[], None) + .flow("z", &format!("10 + {coefficient} * x + {memory}"), None) + .stock("g", "30", &["growing"], &[], None) + .flow("growing", "3", None) + .build_datamodel() +} + +/// For z = c*x + memory, only the current occurrence of x changes in the +/// partial. The selected-source temporal policy has a separate test below. +fn assert_additive_snapshot_score(memory: &str, coefficient: f64) { + let project = snapshot_project(memory, coefficient); + for discovery in [false, true] { + let run = ltm_run(&project, discovery); + assert!(run.diagnostics.is_empty(), "{:?}", run.diagnostics); + let x = ltm_series(&run.results, "x", 0); + let z = ltm_series(&run.results, "z", 0); + let scores = ltm_series(&run.results, &score_key("x", "z"), 0); + assert_eq!(scores[0], 0.0); + for step in 1..scores.len() { + let dx = x[step] - x[step - 1]; + let dz = z[step] - z[step - 1]; + let expected = if dx == 0.0 || dz == 0.0 { + 0.0 + } else { + coefficient * dx.abs() / dz.abs() + }; + assert!( + (scores[step] - expected).abs() < 1e-12, + "{memory}, coefficient {coefficient}, discovery={discovery}, step {step}: \ + got {}, want {expected}; x={x:?}, z={z:?}", + scores[step] + ); + } + } +} + +#[test] +fn scoring_snapshots_preserves_the_ordinary_trajectory() { + use simlin_engine::db::{ + LtmOverlay, SimlinDb, compile_project_incremental, sync_from_datamodel_incremental, + }; + + for method in [ + simlin_engine::datamodel::SimMethod::Euler, + simlin_engine::datamodel::SimMethod::RungeKutta2, + simlin_engine::datamodel::SimMethod::RungeKutta4, + ] { + let mut project = snapshot_project("PREVIOUS(PREVIOUS(g, 17), x)", 0.1); + project.sim_specs.sim_method = method; + let mut db = SimlinDb::default(); + let sync = sync_from_datamodel_incremental(&mut db, &project, None); + let compiled = compile_project_incremental(&db, sync.project, "main", LtmOverlay::Off) + .expect("ordinary compilation"); + let mut vm = simlin_engine::Vm::new(compiled).expect("ordinary VM"); + vm.run_to_end().expect("ordinary simulation"); + let ordinary = vm.into_results(); + let scored = ltm_run(&project, true); + for name in ["x", "z", "g", "growing"] { + assert_eq!( + ltm_series(&ordinary, name, 0), + ltm_series(&scored.results, name, 0), + "{method:?}: {name} must have the same values with LTM enabled" + ); + } + } +} + +#[test] +fn lagged_input_motion_is_not_attributed_to_an_inert_source() { + assert_additive_snapshot_score("PREVIOUS(g, 17)", 0.0); +} + +#[test] +fn snapshots_are_frozen_at_their_previous_evaluation() { + // Both snapshot builtins, nested combinations, and both PREVIOUS arities + // cover the co-input temporal arms. Selected-source snapshots and + // reducer-body freezing have their own production tests below. + for memory in [ + "PREVIOUS(g)", + "PREVIOUS(g, 17)", + "PREVIOUS(g, x)", + "PREVIOUS(PREVIOUS(g, 17), 23)", + "INIT(g)", + "INIT(PREVIOUS(g, 17))", + "PREVIOUS(INIT(g), 17)", + ] { + assert_additive_snapshot_score(memory, 0.1); + } +} + +#[test] +fn selected_source_snapshots_keep_the_existing_temporal_policy() { + // A Previous read still forms a causal edge. LTM groups current and past + // reads of the selected source into that edge; freezing those reads would + // silently erase temporal feedback. The increasing source has a decreasing + // first lagged value (fallback 17 -> initial x=10), and the resulting -1 + // documents the unresolved source-window polarity limitation. + for equation in ["10 + PREVIOUS(x, 17)", "10 + 0.1*x + PREVIOUS(x, 17)"] { + let project = TestProject::new("selected_snapshot") + .with_sim_time(0.0, 4.0, 1.0) + .stock("x", "10", &["z"], &[], None) + .flow("z", equation, None) + .build_datamodel(); + let run = ltm_run(&project, true); + let scores = ltm_series(&run.results, &score_key("x", "z"), 0); + assert_eq!(scores, vec![0.0, -1.0, 1.0, 1.0, 1.0], "{equation}"); + } +} + +#[test] +fn lagged_reducer_input_does_not_credit_a_row_that_never_wins() { + let mut project = TestProject::new("snapshot_min") + .with_sim_time(0.0, 4.0, 1.0) + .named_dimension("Region", &["a", "b"]) + .array_stock("x[Region]", "10", &["growth"], &[], None) + .array_flow("growth[Region]", "1 + 0.01 * total", None) + .aux("total", "MIN(x[*] + PREVIOUS(g, 17))", None) + .stock("g", "30", &["growing"], &[], None) + .flow("growing", "3", None) + .build_datamodel(); + for var in &mut project.models[0].variables { + if let simlin_engine::datamodel::Variable::Stock(stock) = var + && stock.ident == "x" + { + stock.equation = simlin_engine::datamodel::Equation::Arrayed( + vec!["Region".to_owned()], + vec![ + ("a".to_owned(), "10".to_owned(), None, None), + ("b".to_owned(), "100".to_owned(), None, None), + ], + None, + false, + ); + } + } + let run = ltm_run(&project, true); + let scores = ltm_series(&run.results, &score_key("x[b]", "total"), 0); + assert_eq!(scores, vec![0.0; 5], "the other row is always the minimum"); +} + +#[test] +fn proportional_loop_scores_are_invariant_to_the_units_scale() { + for method in [ + simlin_engine::datamodel::SimMethod::Euler, + simlin_engine::datamodel::SimMethod::RungeKutta2, + simlin_engine::datamodel::SimMethod::RungeKutta4, + ] { + for scale in [1.0, 1e-18] { + let project = TestProject::new("scaled_loop") + .with_sim_time(0.0, 3.0, 1.0) + .with_sim_method(method) + .stock("s", &scale.to_string(), &["growth"], &[], None) + .flow("growth", "0.1 * s", None) + .build_datamodel(); + let run = ltm_run(&project, false); + for (source, target) in [("s", "growth"), ("growth", "s")] { + assert_eq!( + ltm_series(&run.results, &score_key(source, target), 0), + vec![0.0, 1.0, 1.0, 1.0], + "{method:?}, scale {scale}: {source} -> {target}" + ); + } + } + } +} + +#[test] +fn fixed_index_snapshot_co_inputs_keep_each_arrayed_targets_history() { + let project = TestProject::new("arrayed_snapshot") + .with_sim_time(0.0, 4.0, 1.0) + .named_dimension("Region", &["a", "b"]) + .array_stock("x[Region]", "10", &["z"], &[], None) + .array_stock("g[Region]", "30", &["growing"], &[], None) + .array_flow("growing[Region]", "3", None) + .array_flow_with_ranges( + "z[Region]", + vec![ + ("a", "10 + 0.1 * x[a] + PREVIOUS(g[b], 17)"), + ("b", "10 + 0.1 * x[b] + PREVIOUS(g[a], 23)"), + ], + ) + .build_datamodel(); + let run = ltm_run(&project, true); + for (element, slot) in [("a", 0), ("b", 1)] { + let source = format!("x[{element}]"); + let x = ltm_series(&run.results, &source, 0); + let z = ltm_series(&run.results, &format!("z[{element}]"), 0); + let scores = ltm_series(&run.results, &score_key(&source, "z"), slot); + for step in 1..scores.len() { + let expected = 0.1 * (x[step] - x[step - 1]).abs() / (z[step] - z[step - 1]).abs(); + assert!( + (scores[step] - expected).abs() < 1e-12, + "{element}, step {step}: {} != {expected}", + scores[step] + ); + } + } +} + +#[test] +fn element_reducer_scores_are_invariant_to_the_units_scale() { + for scale in [1.0, 1e-18] { + let project = TestProject::new("scaled_reducer") + .with_sim_time(0.0, 3.0, 1.0) + .named_dimension("Region", &["a", "b"]) + .array_stock("x[Region]", &scale.to_string(), &["growth"], &[], None) + .array_flow("growth[Region]", "0.1 * total", None) + .aux("total", "SUM(x[*])", None) + .build_datamodel(); + let run = ltm_run(&project, true); + for element in ["a", "b"] { + let scores = ltm_series( + &run.results, + &score_key(&format!("x[{element}]"), "total"), + 0, + ); + assert_eq!(scores[0], 0.0); + assert!(scores[1..].iter().all(|s| (*s - 0.5).abs() < 1e-12)); + } + } +} diff --git a/src/simlin-engine/tests/integration/main.rs b/src/simlin-engine/tests/integration/main.rs index a2223cdb8..e91ec2bfd 100644 --- a/src/simlin-engine/tests/integration/main.rs +++ b/src/simlin-engine/tests/integration/main.rs @@ -39,6 +39,7 @@ mod ltm_frozen_clock; mod ltm_integration_method; mod ltm_loop_nodes; mod ltm_relative_scores; +mod ltm_snapshot_inputs; // Compares xmutil-based MDL parsing against the native Rust parser, so it // needs the optional xmutil C++ converter compiled in. #[cfg(feature = "xmutil")] From bb2f03e2359b6b1abb54cced535b5c0440050e2a Mon Sep 17 00:00:00 2001 From: Bobby Powers Date: Wed, 9 Sep 2026 22:45:04 -0700 Subject: [PATCH 2/5] engine: split large generated wasm phase functions Large LTM models can exceed V8's per-function byte limit even when the simulation is otherwise valid. Partition encoded phase programs at points where runtime evaluation state is no longer live, preserving instruction order, module parameters, and stable phase entry points. Check every final body against the host limit, including indivisible expressions and drivers. Exercise partitioning with small production-compiled fixtures across integration methods, modules, snapshots, arrays, and special stocks, plus an opt-in Node benchmark for C-LEARN with LTM enabled. --- docs/dev/benchmarks.md | 17 +- src/engine/tests/backend-bench.test.ts | 5 +- src/engine/tests/backend-bench.ts | 75 ++-- src/engine/tests/bench-stats.test.ts | 25 +- src/engine/tests/bench-stats.ts | 14 + src/simlin-engine/src/bytecode.rs | 6 +- src/simlin-engine/src/wasmgen/lower.rs | 108 +++-- src/simlin-engine/src/wasmgen/mod.rs | 18 +- src/simlin-engine/src/wasmgen/module.rs | 112 ++++-- src/simlin-engine/src/wasmgen/module_tests.rs | 20 +- src/simlin-engine/src/wasmgen/split.rs | 158 ++++++++ src/simlin-engine/src/wasmgen/split_tests.rs | 377 ++++++++++++++++++ .../src/wasmgen/split_unit_tests.rs | 157 ++++++++ 13 files changed, 971 insertions(+), 121 deletions(-) create mode 100644 src/simlin-engine/src/wasmgen/split.rs create mode 100644 src/simlin-engine/src/wasmgen/split_tests.rs create mode 100644 src/simlin-engine/src/wasmgen/split_unit_tests.rs diff --git a/docs/dev/benchmarks.md b/docs/dev/benchmarks.md index 7cadd7340..feb63b7c6 100644 --- a/docs/dev/benchmarks.md +++ b/docs/dev/benchmarks.md @@ -46,7 +46,7 @@ C-LEARN currently uses builtins that are not yet implemented in the bytecode com ## Node VM-vs-wasm eval benchmark -`@simlin/engine` can run a model on two backends: the libsimlin VM or a compiled WebAssembly blob. This benchmark compares their **simulation (eval) time** through the public `Model.simulate({ engine })` API, on fishbanks, WORLD3, and C-LEARN. +`@simlin/engine` can run a model on two backends: the libsimlin VM or a compiled WebAssembly blob. This benchmark compares their **simulation (eval) time** through the public `Model.simulate({}, { engine, enableLtm })` API, on fishbanks, WORLD3, and C-LEARN. It is an [rstest](https://rstest.rs/) test gated behind `RUN_BENCH` so it stays out of the default `pnpm test` (a full C-LEARN run on both engines exceeds the per-test time budget): @@ -56,15 +56,26 @@ RUN_BENCH=1 pnpm -C src/engine exec rstest run backend-bench # Subset the models (comma-separated: fishbanks, wrld3, clearn) RUN_BENCH=1 BENCH_MODELS=fishbanks,wrld3 pnpm -C src/engine exec rstest run backend-bench + +# Exercise Node/V8 acceptance and numerical parity on a large LTM model +RUN_BENCH=1 BENCH_MODELS=clearn BENCH_LTM=on pnpm -C src/engine exec rstest run backend-bench + +# Compare both LTM settings (off is the default; choices: off, on, both) +RUN_BENCH=1 BENCH_LTM=both pnpm -C src/engine exec rstest run backend-bench ``` -It prints a markdown table of the warm **median** eval time per engine plus the wasm/VM ratio. +Use the system Node v24 and build shipping artifacts with +`env -u DISABLE_WASM_OPT bash src/engine/build.sh` before measuring performance. +Pre-commit stages a faster gate build whose timings do not represent shipping. +The benchmark runs native Node/V8; it does not establish browser UI latency. + +It prints a markdown table of the warm **median** eval time per engine and LTM setting plus the VM/wasm ratio. What it measures, and what it deliberately excludes: - **Eval only.** The `Sim` for each `(model, engine)` is built once in untimed setup; for wasm that one-time cost is the blob compile and instantiate. Each measured iteration is a `reset()` (also untimed) followed by a timed `runToEnd()`. Result extraction (`getRun`/`getSeries`) is not timed. - **Median over an explicit warmup.** A discard-only warmup runs first, then the harness collects timings adaptively (until a max iteration count or a per-model wall-clock budget) and reports the median. The pure stats/harness lives in `src/engine/tests/bench-stats.ts` and is always-on unit-tested. -- **Cross-checked before trusted.** Before timing, the benchmark runs each model on both engines and compares a representative series within the engine's tolerance, so a broken run can't masquerade as a fast one. +- **Cross-checked before trusted.** Before timing, the benchmark runs each model and selected LTM setting on both engines and compares every public variable series within the engine's tolerance. A generated module that V8 rejects fails the test. This checks public trajectories; link-score analysis has separate tests. Absolute numbers include the async public-API overhead, so the VM/wasm ratio is the figure to compare across runs. diff --git a/src/engine/tests/backend-bench.test.ts b/src/engine/tests/backend-bench.test.ts index 8ce400191..4b99a9dbf 100644 --- a/src/engine/tests/backend-bench.test.ts +++ b/src/engine/tests/backend-bench.test.ts @@ -15,13 +15,16 @@ import { it, expect } from '@rstest/core'; import { runBenchmark } from './backend-bench'; +import { selectLtmModes } from './bench-stats'; const RUN = process.env.RUN_BENCH === '1'; (RUN ? it : it.skip)( - 'benchmarks VM vs wasm eval (fishbanks/WORLD3/C-LEARN)', + 'benchmarks VM vs wasm eval with selected LTM modes (fishbanks/WORLD3/C-LEARN)', async () => { const rows = await runBenchmark({ warmup: 3, minIters: 3, maxIters: 100, budgetMs: 2500 }); + expect(rows.length).toBeGreaterThan(0); + expect([...new Set(rows.map((r) => r.enableLtm))]).toEqual(selectLtmModes(process.env.BENCH_LTM)); for (const r of rows) { expect(Number.isFinite(r.vm.medianMs)).toBe(true); expect(r.vm.medianMs).toBeGreaterThan(0); diff --git a/src/engine/tests/backend-bench.ts b/src/engine/tests/backend-bench.ts index 1b299a6fd..306e3698d 100644 --- a/src/engine/tests/backend-bench.ts +++ b/src/engine/tests/backend-bench.ts @@ -19,7 +19,7 @@ import { performance } from 'node:perf_hooks'; import { Project } from '../src/project'; import { Model } from '../src/model'; -import { runTimedAsync, seriesClose, type BenchOpts, type Stat } from './bench-stats'; +import { runTimedAsync, selectLtmModes, seriesClose, type BenchOpts, type Stat } from './bench-stats'; // Which execution backend a Sim runs on. Mirrors SimEngine in src/backend.ts; // the public Model.simulate accepts this union directly, so the benchmark needs @@ -60,6 +60,7 @@ const MODEL_ORDER: ReadonlyArray = ['fishbanks', 'wrld3', 'clearn']; export type BenchRow = { readonly model: string; + readonly enableLtm: boolean; readonly vm: Stat; readonly wasm: Stat; readonly ratio: number; @@ -93,8 +94,8 @@ function selectedModels(): ReadonlyArray { * reset re-runs without recompiling) BEFORE the clock is sampled, then times * only runToEnd(). Result extraction (getRun/getSeries) is never timed. */ -async function timeEngine(model: Model, engine: Engine, opts: Readonly): Promise { - const sim = await model.simulate({}, { engine }); +async function timeEngine(model: Model, engine: Engine, enableLtm: boolean, opts: Readonly): Promise { + const sim = await model.simulate({}, { engine, enableLtm }); try { return await runTimedAsync(opts, async () => { await sim.reset(); @@ -115,38 +116,45 @@ async function timeEngine(model: Model, engine: Engine, opts: Readonly { - const vmSim = await model.simulate({}, { engine: 'vm' }); - const wasmSim = await model.simulate({}, { engine: 'wasm' }); +async function crossCheck(model: Model, label: string, enableLtm: boolean): Promise { + const vmSim = await model.simulate({}, { engine: 'vm', enableLtm }); try { - await vmSim.runToEnd(); - await wasmSim.runToEnd(); + const wasmSim = await model.simulate({}, { engine: 'wasm', enableLtm }); + try { + await vmSim.runToEnd(); + await wasmSim.runToEnd(); - const names = await wasmSim.getVarNames(); - if (names.length === 0) { - throw new Error(`cross-check failed for ${label}: model produced no variables`); - } + const names = await wasmSim.getVarNames(); + if (names.length === 0) { + throw new Error(`cross-check failed for ${label}: model produced no variables`); + } + const vmNames = new Set(await vmSim.getVarNames()); + if (vmNames.size !== names.length || names.some((name) => !vmNames.has(name))) { + throw new Error(`cross-check failed for ${label}: public variable sets differ`); + } - for (const name of names) { - const vmSeries = await vmSim.getSeries(name); - const wasmSeries = await wasmSim.getSeries(name); - const result = seriesClose(vmSeries, wasmSeries); - if (!result.match) { - if (result.index < 0) { + for (const name of names) { + const vmSeries = await vmSim.getSeries(name); + const wasmSeries = await wasmSim.getSeries(name); + const result = seriesClose(vmSeries, wasmSeries); + if (!result.match) { + if (result.index < 0) { + throw new Error( + `cross-check failed for ${label}: series length differs for '${name}' ` + + `(vm ${result.expected} vs wasm ${result.actual})`, + ); + } throw new Error( - `cross-check failed for ${label}: series length differs for '${name}' ` + + `cross-check failed for ${label}: '${name}' diverges at step ${result.index} ` + `(vm ${result.expected} vs wasm ${result.actual})`, ); } - throw new Error( - `cross-check failed for ${label}: '${name}' diverges at step ${result.index} ` + - `(vm ${result.expected} vs wasm ${result.actual})`, - ); } + } finally { + await wasmSim.dispose(); } } finally { await vmSim.dispose(); - await wasmSim.dispose(); } } @@ -172,11 +180,11 @@ function printSummary(rows: BenchTable): void { lines.push(''); lines.push('### Node VM-vs-wasm eval benchmark (median ms)'); lines.push(''); - lines.push('| model | VM eval (median ms) | wasm eval (median ms) | wasm/VM | iters |'); - lines.push('|---|--:|--:|--:|--:|'); + lines.push('| model | LTM | VM eval (median ms) | wasm eval (median ms) | VM/wasm | iters |'); + lines.push('|---|---|--:|--:|--:|--:|'); for (const r of rows) { lines.push( - `| ${r.model} | ${fmtMs(r.vm.medianMs)} | ${fmtMs(r.wasm.medianMs)} | ${fmtRatio(r.ratio)} ` + + `| ${r.model} | ${r.enableLtm ? 'on' : 'off'} | ${fmtMs(r.vm.medianMs)} | ${fmtMs(r.wasm.medianMs)} | ${fmtRatio(r.ratio)} ` + `| vm ${r.vm.iters} / wasm ${r.wasm.iters} |`, ); } @@ -184,7 +192,7 @@ function printSummary(rows: BenchTable): void { lines.push( '_Eval-only: blob compile/instantiate and result extraction are excluded. ' + 'Absolute numbers include the async public-API overhead (per-call await), ' + - 'so the wasm/VM ratio is the meaningful figure._', + 'so the VM/wasm ratio is the meaningful figure._', ); console.log(lines.join('\n')); } @@ -196,6 +204,7 @@ function printSummary(rows: BenchTable): void { */ export async function runBenchmark(opts: Readonly): Promise { const rows: Array = []; + const ltmModes = selectLtmModes(process.env.BENCH_LTM); for (const key of selectedModels()) { const spec = MODELS[key]; @@ -204,12 +213,14 @@ export async function runBenchmark(opts: Readonly): Promise { + it('defaults to LTM off', () => { + expect(selectLtmModes(undefined)).toEqual([false]); + }); + + it('selects every supported mode without duplicates', () => { + // All BENCH_LTM choices; both preserves off-before-on table ordering. + for (const [selection, expected] of [ + ['off', [false]], + ['on', [true]], + ['both', [false, true]], + ] as const) { + expect(selectLtmModes(selection)).toEqual(expected); + } + }); + + it('rejects invalid choices rather than silently running without LTM', () => { + for (const selection of ['', 'true', 'OFF', 'on,off']) { + expect(() => selectLtmModes(selection)).toThrow('BENCH_LTM must be off, on, or both'); + } + }); +}); describe('median', () => { it('returns the middle element of an odd-length input', () => { diff --git a/src/engine/tests/bench-stats.ts b/src/engine/tests/bench-stats.ts index 78b9a0eae..03c5f59d4 100644 --- a/src/engine/tests/bench-stats.ts +++ b/src/engine/tests/bench-stats.ts @@ -26,6 +26,20 @@ export type Stat = { readonly iters: number; }; +/** Select the benchmark's LTM configurations without silently ignoring a typo. */ +export function selectLtmModes(selection: string | undefined): ReadonlyArray { + switch (selection ?? 'off') { + case 'off': + return [false]; + case 'on': + return [true]; + case 'both': + return [false, true]; + default: + throw new Error(`BENCH_LTM must be off, on, or both; got '${selection}'`); + } +} + /** * The median of `times`, in milliseconds. * diff --git a/src/simlin-engine/src/bytecode.rs b/src/simlin-engine/src/bytecode.rs index 619d5c3bb..d35fa84e6 100644 --- a/src/simlin-engine/src/bytecode.rs +++ b/src/simlin-engine/src/bytecode.rs @@ -1230,13 +1230,13 @@ macro_rules! declare_opcodes { impl Opcode { /// Returns (pops, pushes) describing this opcode's effect on the arithmetic stack. - /// Used by `ByteCode::max_stack_depth` to statically validate that compiled - /// bytecode cannot overflow the fixed-size VM stack. + /// Used by `ByteCode::max_stack_depth` to validate the VM stack bound + /// and by wasm lowering to locate empty-stack function boundaries. /// /// Opcodes that only affect the view stack or the iter stack return /// (0, 0) since they don't touch the arithmetic stack. #[allow(unused_variables)] - fn stack_effect(&self) -> (u8, u8) { + pub(crate) fn stack_effect(&self) -> (u8, u8) { match *self { $( Opcode::$name $({ $($field),* })? => ($pops, $pushes), )* } diff --git a/src/simlin-engine/src/wasmgen/lower.rs b/src/simlin-engine/src/wasmgen/lower.rs index c4e86151e..faaff3688 100644 --- a/src/simlin-engine/src/wasmgen/lower.rs +++ b/src/simlin-engine/src/wasmgen/lower.rs @@ -1079,6 +1079,12 @@ fn slot_byte_offset(chunk_base: u32, off: u16) -> u64 { /// [`emit_ops`] so an unrolled iteration body can be re-emitted at each /// compile-time index without re-deriving the view stack. struct EmitState { + /// Arithmetic values cannot cross a generated helper's void call boundary. + arithmetic_depth: usize, + /// Encoded byte offsets where no operand or local-backed state is live. + /// Offsets include the function's local declarations, even when several + /// initial programs append to the same function with fresh emitter state. + safe_offsets: Vec, /// Emit-time stack pointer into `ctx.condition_locals`, mirroring the VM's /// single `condition` register but generalized to nested `If`s. cond_sp: usize, @@ -1101,67 +1107,85 @@ struct EmitState { /// subscript draws fresh i32 locals from here; the count is pre-sized by /// [`count_extra_i32_locals`], so this never exceeds the declared count. next_i32_local: u32, - /// Cumulative count of unrolled element-emit "units" for the function being - /// lowered, checked against [`MAX_UNROLL_UNITS`] (see [`EmitState::charge_unroll`]). + /// Cumulative unrolled element-emission work for this bytecode program, + /// checked against [`MAX_UNROLL_UNITS`] (see [`EmitState::charge_unroll`]). /// Every full unroll -- a reducer fold, a `BeginIter` body re-emission -- /// charges its iteration count here. Nested iterations /// multiply naturally: an inner site is reached once per outer iteration, so /// each inner charge already reflects the outer multiplier. When the running /// total would exceed the cap, lowering aborts with `Unsupported` (the caller - /// receives an explicit error, no silent VM fallback) instead of emitting a - /// multi-megabyte function body that a wasm engine would reject. + /// receives an explicit error, no silent VM fallback). Splitting the emitted + /// program into helper functions does not reset this work budget. unroll_units: usize, } -/// Upper bound on the cumulative number of unrolled element-emit "units" per -/// wasm function (one reducer-fold element, or one `BeginIter` body -/// re-emission, is one unit). +/// Upper bound on cumulative array-emission work per [`emit_bytecode`] call: +/// one reducer-fold element or `BeginIter` body re-emission is one unit. /// /// Every array reducer and iteration loop is fully unrolled at compile time /// (each element address becomes a wasm constant -- see [`emit_reduce_fold`] and -/// the `BeginIter` arm). Without a bound, a large arrayed -/// model -- especially nested iterations whose counts multiply -- could emit a -/// function body exceeding what wasm engines accept (V8, for instance, caps a -/// single function near ~7.6 MB of bytecode; the spec's 4 GiB ceiling is -/// academic). At a generous ~50 bytes of emitted code per unit, this cap bounds -/// unroll-driven code at roughly 3 MB, comfortably under the strictest engine -/// limit. +/// the `BeginIter` arm). Nested iterations multiply this work, so partitioning +/// the resulting function alone cannot bound compilation cost. This budget is +/// charged before each unroll site emits its body; it applies to each complete +/// Flows/Stocks program and separately to each variable's initials program, +/// regardless of how many wasm helpers subsequently hold those instructions. +/// The actual encoded-byte acceptance limit is enforced independently by +/// [`super::split::check_function`]; element counts cannot prove a byte bound. /// -/// The value `65_536` (2^16) is the natural ceiling of a single `u16` array -/// dimension (`ViewDesc::dims` entries are `u16`, so one dimension tops out at -/// 65_535). Real system-dynamics arrays are tiny -- the test corpus's largest -/// single dimension is 9, and even a region x sector x cohort nest is on the -/// order of 10^3 elements -- so this leaves >60x headroom for legitimate models -/// while rejecting pathological products (e.g. a `[300, 300]` view, 90_000 -/// elements) before any code is emitted. +/// `65_536` permits a full single `u16` dimension while bounding larger products +/// such as a `[300, 300]` view. The cap can reject valid large arrayed models; +/// it is an explicit compilation-work policy, not a model-semantics restriction. /// /// future: a runtime wasm loop driven by a precomputed offset table (per the /// Phase 5 design's non-contiguous path) would lift this cap entirely, trading a /// constant-size loop body for the current fully-unrolled form. const MAX_UNROLL_UNITS: usize = 65_536; -// The headroom claim above is a compile-time obligation, not a comment: a -// future narrowing of the cap that drops below a deliberately roomy 10^4 -// would start rejecting legitimate arrayed models, and it fails to build -// here rather than surfacing as an `Unsupported` on a user's model. +// Keep a deliberate minimum amount of array work available even if this +// compilation-cost policy is tightened. const _: () = assert!( MAX_UNROLL_UNITS >= 10_000, "the unroll cap must leave ample headroom for realistic arrayed models" ); impl EmitState { - /// Charge `units` against the per-function unroll budget, returning + /// A helper shares the slab and parameters, but none of its caller's operand + /// stack or scratch locals. Temp contents and static view geometry can span + /// a cut: temp storage is shared and view addresses are already emitted. + /// Views with runtime offsets or validity flags retain live scratch locals. + /// + /// Each opcode must close every wasm block it opens and must not emit a + /// phase-level Return or branch to an enclosing opcode's block. The recorded + /// offset is after that opcode's complete lowering, so splitting cannot alter + /// structured branch targets or change a whole-phase return into a helper return. + fn record_boundary(&mut self, f: &Function) { + if self.arithmetic_depth == 0 + && self.cond_sp == 0 + && self.iter_stack.is_empty() + && self.subscript.indices.is_empty() + && self.subscript.valid_local.is_none() + && self.safe_offsets.last().copied() != Some(f.byte_len()) + && self + .view_stack + .iter() + .all(|view| view.runtime_off_local.is_none() && view.valid_local.is_none()) + { + self.safe_offsets.push(f.byte_len()); + } + } + + /// Charge `units` against the per-program emission-work budget, returning /// `Unsupported` (an explicit error to the caller, no silent VM fallback) /// if the running total would exceed [`MAX_UNROLL_UNITS`]. Called *before* /// an unroll site emits its body, so an over-budget model is rejected - /// without ever materializing the oversized function. `units` saturates + /// without materializing that site's body. `units` saturates /// rather than wrapping, so a pathological multi-dimensional product can /// never alias back under the cap. fn charge_unroll(&mut self, units: usize) -> Result<(), WasmGenError> { self.unroll_units = self.unroll_units.saturating_add(units); if self.unroll_units > MAX_UNROLL_UNITS { return Err(WasmGenError::Unsupported(format!( - "wasmgen: array unrolling exceeds the per-function budget of \ + "wasmgen: array unrolling exceeds the per-program budget of \ {MAX_UNROLL_UNITS} elements (a large arrayed model); the caller \ receives an explicit Unsupported error (no silent VM fallback)" ))); @@ -1203,12 +1227,15 @@ struct IterCtx { /// the wasm operand stack; the assignment opcodes emit a store and leave the /// stack empty, exactly as the VM's stack-machine arms do. `Ret` is a no-op /// here: the wasm function's terminating `End` is emitted by the caller. +/// Returns encoded byte offsets at which a new void helper can safely begin. pub(crate) fn emit_bytecode( bc: &ByteCode, ctx: &EmitCtx, f: &mut Function, -) -> Result<(), WasmGenError> { +) -> Result, WasmGenError> { let mut state = EmitState { + arithmetic_depth: 0, + safe_offsets: Vec::new(), cond_sp: 0, view_stack: Vec::new(), iter_stack: Vec::new(), @@ -1216,7 +1243,15 @@ pub(crate) fn emit_bytecode( next_i32_local: ctx.extra_i32_local_base, unroll_units: 0, }; - emit_ops(&bc.code, &bc.literals, ctx, &mut state, f) + emit_ops(&bc.code, &bc.literals, ctx, &mut state, f)?; + // The VM discards its evaluation state when a program returns. Descriptors + // and scratch locals need not survive this endpoint, even if an unrolled + // iteration leaves view descriptors behind. Arithmetic results still must + // stay in their caller, as lower-level expression tests return those values. + if state.arithmetic_depth == 0 && state.safe_offsets.last().copied() != Some(f.byte_len()) { + state.safe_offsets.push(f.byte_len()); + } + Ok(state.safe_offsets) } /// An upper bound on the extra i32 wasm locals a program's dynamic subscripts @@ -1628,6 +1663,7 @@ fn emit_ops( state.iter_stack.pop(); } pc = end_pc; + state.record_boundary(f); continue; } // `NextIterOrJump`/`EndIter` are consumed by the `BeginIter` unroll @@ -1779,6 +1815,18 @@ fn emit_ops( } other => return Err(WasmGenError::Unsupported(unsupported_opcode(other))), } + let (pops, pushes) = op.stack_effect(); + state.arithmetic_depth = state + .arithmetic_depth + .checked_sub(usize::from(pops)) + .ok_or_else(|| { + WasmGenError::Unsupported(format!( + "wasmgen: arithmetic stack underflow at {}", + op.name() + )) + })? + + usize::from(pushes); + state.record_boundary(f); pc += 1; } Ok(()) diff --git a/src/simlin-engine/src/wasmgen/mod.rs b/src/simlin-engine/src/wasmgen/mod.rs index 2372d7513..eeff76dd8 100644 --- a/src/simlin-engine/src/wasmgen/mod.rs +++ b/src/simlin-engine/src/wasmgen/mod.rs @@ -11,10 +11,11 @@ //! The intended use case is interactive scrubbing: compile a model to wasm //! once, then re-run it on every slider change at display refresh rates. //! -//! The backend walks every module instance's un-fused opcode programs +//! The backend walks every module instance's opcode programs //! (`compiled_initials`/`compiled_flows`/`compiled_stocks`) and emits a wasm -//! function-triple per `(model, input_set)` instance plus a `run` driver (see -//! `lower` for the per-opcode lowering and `module` for whole-model assembly). +//! function-triple per `(model, input_set)` instance plus simulation drivers. +//! Large phase bodies call smaller helpers partitioned at boundaries with no +//! live runtime evaluation state (see `lower`, `split`, and `module`). //! Modules are emitted with the `wasm-encoder` crate; correctness is validated //! in tests by executing the emitted module under the DLR-FT `wasm-interpreter` //! and comparing against the bytecode VM. @@ -27,7 +28,8 @@ //! leaks, zones, discrete admission, ``/``, container access, //! and queue coupling -- is hand-lowered by `belt`) are in place. A genuine //! runtime view range (`ViewRangeDynamic`), array unrolling past the -//! per-function budget, or a conveyor inflow carrying a non-default +//! per-program work budget, an emitted function exceeding the host's byte limit, +//! or a conveyor inflow carrying a non-default //! `isee:spreadflow` (`even`/`dest`/`dist`/`source`, §8, GH #946 -- see //! `belt::reject_unsupported`) returns `WasmGenError::Unsupported`. //! @@ -56,6 +58,7 @@ mod lower; mod math; mod module; mod passes; +mod split; mod vector; mod views; @@ -71,10 +74,11 @@ use std::fmt; /// /// The backend covers the full scalar + array opcode set, Euler/RK2/RK4 /// integration, nested modules (including SMOOTH/DELAY stdlib expansions), and both -/// special stock types -- queue models and conveyor models. Four constructs return +/// special stock types -- queue models and conveyor models. Unsupported constructs return /// `Unsupported` rather than silently emitting an incorrect module: a genuine -/// runtime view range (`ViewRangeDynamic`), array unrolling past the per-function -/// budget, and the two conditions `belt::reject_unsupported` refuses -- a conveyor +/// runtime view range (`ViewRangeDynamic`), array unrolling past the per-program +/// work budget, an emitted function exceeding the host's byte limit, and the two +/// conditions `belt::reject_unsupported` refuses -- a conveyor /// inflow carrying a non-default `isee:spreadflow` (`even`/`dest`/`dist`/`source`, /// §8, GH #946), and a `conveyor::slat_bound()` above `i32::MAX` (a soundness guard /// on the emitted `i32.trunc_f64_s` narrowings, not a feature gap). diff --git a/src/simlin-engine/src/wasmgen/module.rs b/src/simlin-engine/src/wasmgen/module.rs index 2f8f1544b..2ba89640e 100644 --- a/src/simlin-engine/src/wasmgen/module.rs +++ b/src/simlin-engine/src/wasmgen/module.rs @@ -39,8 +39,9 @@ //! nested modules (incl. SMOOTH/DELAY stdlib expansions), QUEUE models (whose //! per-step FIFO side-table pass is lowered by [`super::passes`]), and CONVEYOR //! models (whose per-step belt pass is lowered by [`super::belt`]). A genuine -//! runtime view range (`ViewRangeDynamic`), array unrolling past the per-function -//! budget, or a conveyor construct [`super::belt::reject_unsupported`] refuses +//! runtime view range (`ViewRangeDynamic`), array unrolling past the per-program +//! work budget, an emitted function exceeding the host's byte limit, or a +//! conveyor construct [`super::belt::reject_unsupported`] refuses //! returns `WasmGenError::Unsupported`. use wasm_encoder::Instruction as I; @@ -65,6 +66,7 @@ use super::lower::{ self, BuiltHelpers, HelperFn, build_helpers, f64_const, max_condition_depth, memarg, }; use super::passes::{self, QueuePass, QueuePassLayout, QueuePassLocals}; +use super::split::{self, ProgramHelper, ProgramSplitter}; // Reserved global slots, mirroring `crate::vm`. const TIME_OFF: usize = 0; @@ -467,7 +469,8 @@ fn build_gf_regions( // The module's function slots are: the emitted helper functions // ([`lower::build_helpers`]) at `0..n_helpers`, then one // `[initials, flows, stocks]` triple per module instance (in `instance_order`), -// then `run` last. So instance `i`'s `StepPart` function is at +// then the fixed driver entries, optional reconciliation initials, and appended +// program-partition helpers. Instance `i`'s `StepPart` function is at // `n_helpers + i*FUNCS_PER_INSTANCE + {F_INITIALS,F_FLOWS,F_STOCKS}`, and `run` // is at `n_helpers + n_instances*FUNCS_PER_INSTANCE`. Keeping these relative // (and adding `n_helpers`/the triple base at the call/export sites) means new @@ -476,12 +479,13 @@ const F_INITIALS: u32 = 0; const F_FLOWS: u32 = 1; const F_STOCKS: u32 = 2; const FUNCS_PER_INSTANCE: u32 = 3; +const DRIVER_FUNCTION_COUNT: u32 = 7; /// The function index of `run` (the first driver function, after the helpers and /// the per-instance triples). The driver functions follow in this fixed order: /// `run`, `set_value`, `reset`, `clear_values`, `run_to`, `run_initials`, -/// `get_error` -- each addition appending, so earlier indices stay stable. The -/// optional container-skipping initials is last of all. Used both at emit time +/// `get_error`. Optional container-skipping initials follow the drivers, then +/// program-partition helpers. Used both at emit time /// (`compile_with_passes`, to resolve the delegation targets) and at assembly time /// (`assemble_simulation`), so the two never drift. fn run_fn_index_of(n_helpers: u32, n_instances: u32) -> u32 { @@ -539,7 +543,7 @@ struct PerInstance<'a> { /// plain, un-fused scalar set (the VM's superinstruction fusion runs on a private /// execution copy), so each `Opcode` lowers via [`lower::emit_bytecode`]. /// Anything outside the supported set -- an unsupported opcode, or array -/// unrolling past the per-function budget -- returns [`WasmGenError::Unsupported`] +/// unrolling past the per-program work budget -- returns [`WasmGenError::Unsupported`] /// rather than emitting a wrong module. /// /// This is the ORDINARY-model entry point: no conveyor or queue pass is @@ -566,7 +570,7 @@ pub(super) fn compile_simulation_with_fault( sim: &CompiledSimulation, fault: errors::FaultInjection, ) -> Result { - compile_with_passes(sim, &[], &[], Some(fault)) + compile_with_passes(sim, &[], &[], Some(fault), split::TARGET_BODY_BYTES) } /// [`compile_simulation`] for a special-stock `CompiledSimulation`: additionally @@ -583,7 +587,13 @@ pub fn compile_simulation_with_plans( conveyor_plans: &[ConveyorPlan], queue_plans: &[QueuePlan], ) -> Result { - compile_with_passes(sim, conveyor_plans, queue_plans, None) + compile_with_passes( + sim, + conveyor_plans, + queue_plans, + None, + split::TARGET_BODY_BYTES, + ) } /// Whether a module's passes can raise a runtime error, i.e. whether the drivers @@ -627,6 +637,7 @@ fn compile_with_passes( conveyor_plans: &[ConveyorPlan], queue_plans: &[QueuePlan], fault: Option, + target_body_bytes: usize, ) -> Result { belt::reject_unsupported(conveyor_plans)?; // Decides three things, all no-ops for a model whose passes are total: the @@ -1068,6 +1079,12 @@ fn compile_with_passes( ) .collect(); let mut initials_skipping_fn: Option = None; + // Program partitions follow every fixed entry point, so a phase can call + // any child's phase before the number of partitions is known. + let first_partition_index = run_fn_index_of(n_helpers, instances.len() as u32) + + DRIVER_FUNCTION_COUNT + + u32::from(!reconcile_skip.is_empty()); + let mut splitter = ProgramSplitter::new(first_partition_index, target_body_bytes); let mut program_fns: Vec = Vec::with_capacity(instances.len() * 3); for (inst_idx, inst) in instances.iter().enumerate() { @@ -1112,18 +1129,21 @@ fn compile_with_passes( inst.n_inputs, &make_ctx, None, + &mut splitter, )?); program_fns.push(emit_opcode_fn( &inst.module.compiled_flows, inst.n_inputs, StepPart::Flows, &make_ctx, + &mut splitter, )?); program_fns.push(emit_opcode_fn( &inst.module.compiled_stocks, inst.n_inputs, StepPart::Stocks, &make_ctx, + &mut splitter, )?); // Container stocks and conveyor stocks are variables of the MAIN model // (expansion appends them there), so only the root instance can hold one; a @@ -1135,6 +1155,7 @@ fn compile_with_passes( inst.n_inputs, &make_ctx, Some(&reconcile_skip), + &mut splitter, )?); } } @@ -1172,7 +1193,7 @@ fn compile_with_passes( let reset_fn_index = run_fn_index + 2; let run_to_fn_index = run_fn_index + 4; let run_initials_fn_index = run_fn_index + 5; - let initials_skipping_fn_index = run_fn_index + 7; + let initials_skipping_fn_index = run_fn_index + DRIVER_FUNCTION_COUNT; // The resumable run ABI: `run_initials` (idempotent), `run_to(target)` (the // single shared stepping loop), and `run` (re-expressed as `reset; @@ -1236,6 +1257,7 @@ fn compile_with_passes( let wasm = assemble_simulation(AssembleParts { helpers, program_fns, + program_helpers: splitter.functions, run_fn, set_value_fn, reset_fn, @@ -1253,7 +1275,7 @@ fn compile_with_passes( gf_regions: &gf_images, const_init: &const_init, belt_init_data: &belt_init_data, - }); + })?; let var_offsets = sim .offsets @@ -1326,6 +1348,7 @@ fn emit_initials_fn<'a>( n_inputs: u32, make_ctx: &impl Fn(usize, u32, StepPart) -> lower::EmitCtx<'a>, skip_offsets: Option<&std::collections::HashSet>, + splitter: &mut ProgramSplitter, ) -> Result { let cond_depth = module .compiled_initials @@ -1350,7 +1373,9 @@ fn emit_initials_fn<'a>( .max() .unwrap_or(0); let ctx = make_ctx(cond_depth, extra_i32, StepPart::Initials); - let mut f = new_opcode_fn(n_inputs, cond_depth, extra_i32, module_input_scratch); + let frame = new_opcode_fn(n_inputs, cond_depth, extra_i32, module_input_scratch); + let mut f = frame.clone(); + let mut safe_offsets = Vec::new(); for ci in module.compiled_initials.iter() { if let Some(skip) = skip_offsets && !ci.offsets.is_empty() @@ -1358,10 +1383,10 @@ fn emit_initials_fn<'a>( { continue; } - lower::emit_bytecode(&ci.bytecode, &ctx, &mut f)?; + safe_offsets.extend(lower::emit_bytecode(&ci.bytecode, &ctx, &mut f)?); } f.instruction(&I::End); - Ok(f) + splitter.finish(frame, f, &safe_offsets, n_inputs) } /// Build one opcode-program function from a single `ByteCode`, lowering it as @@ -1373,15 +1398,17 @@ fn emit_opcode_fn<'a>( n_inputs: u32, step_part: StepPart, make_ctx: &impl Fn(usize, u32, StepPart) -> lower::EmitCtx<'a>, + splitter: &mut ProgramSplitter, ) -> Result { let cond_depth = max_condition_depth(bc); let extra_i32 = lower::count_extra_i32_locals(bc); let module_input_scratch = lower::count_module_input_scratch(bc); let ctx = make_ctx(cond_depth, extra_i32, step_part); - let mut f = new_opcode_fn(n_inputs, cond_depth, extra_i32, module_input_scratch); - lower::emit_bytecode(bc, &ctx, &mut f)?; + let frame = new_opcode_fn(n_inputs, cond_depth, extra_i32, module_input_scratch); + let mut f = frame.clone(); + let safe_offsets = lower::emit_bytecode(bc, &ctx, &mut f)?; f.instruction(&I::End); - Ok(f) + splitter.finish(frame, f, &safe_offsets, n_inputs) } /// A fresh opcode-program `Function` for an instance with `n_inputs` f64 input @@ -2935,6 +2962,9 @@ struct AssembleParts<'a> { /// `[initials_0, flows_0, stocks_0, initials_1, ...]`. `instance_input_counts` /// (same instance order) gives each triple's f64 input-param count. program_fns: Vec, + /// Bounded opcode-program bodies called by the phase entries, appended + /// after the driver functions and optional reconcile-skipping initials. + program_helpers: Vec, /// `run() -> ()`, re-expressed as `reset; run_to(stop)`. run_fn: Function, /// `set_value(offset: i32, val: f64) -> i32` (Phase 7 Task 2). @@ -2984,15 +3014,16 @@ struct AssembleParts<'a> { /// functions ([`build_helpers`], plus the queue pass's FIFO primitives when the /// model has one) lead the function/code sections (indices `0..n_helpers`); then /// one `[initials, flows, stocks]` triple per module instance (in -/// `instance_order`); then the seven drivers, and last -- for a queue model with -/// container variables -- the root's container-skipping initials. Exports +/// `instance_order`); then the fixed drivers and optional reconciliation initials, +/// followed by all opcode-program partitions. Exports /// `memory`, `run`, and the three self-describing i32 geometry globals. Each /// GF-bearing instance contributes two active `DataSection` segments (its /// directory + data) at its own bases. -fn assemble_simulation(parts: AssembleParts) -> Vec { +fn assemble_simulation(parts: AssembleParts) -> Result, WasmGenError> { let AssembleParts { helpers, program_fns, + program_helpers, run_fn, set_value_fn, reset_fn, @@ -3096,6 +3127,9 @@ fn assemble_simulation(parts: AssembleParts) -> Vec { // type `(module_off: i32) -> ()`; the root always takes 0 module inputs. functions.function(opcode_type_index[&0]); } + for helper in &program_helpers { + functions.function(opcode_type_index[&helper.n_inputs]); + } wasm.section(&functions); let mut memories = MemorySection::new(); @@ -3188,21 +3222,25 @@ fn assemble_simulation(parts: AssembleParts) -> Vec { // functions in index order: `run`, `set_value`, `reset`, `clear_values`, // `run_to`, `run_initials`, `get_error`. let mut code = CodeSection::new(); - for hf in &helpers.functions { - code.function(&hf.body); - } - for program in &program_fns { - code.function(program); - } - code.function(&run_fn); - code.function(&set_value_fn); - code.function(&reset_fn); - code.function(&clear_values_fn); - code.function(&run_to_fn); - code.function(&run_initials_fn); - code.function(&get_error_fn); - if let Some(skipping) = &initials_skipping_fn { - code.function(skipping); + for body in helpers + .functions + .iter() + .map(|helper| &helper.body) + .chain(&program_fns) + .chain([ + &run_fn, + &set_value_fn, + &reset_fn, + &clear_values_fn, + &run_to_fn, + &run_initials_fn, + &get_error_fn, + ]) + .chain(initials_skipping_fn.as_ref()) + .chain(program_helpers.iter().map(|helper| &helper.body)) + { + split::check_function(body)?; + code.function(body); } wasm.section(&code); @@ -3248,9 +3286,13 @@ fn assemble_simulation(parts: AssembleParts) -> Vec { wasm.section(&data); } - wasm.finish() + Ok(wasm.finish()) } #[cfg(test)] #[path = "module_tests.rs"] mod tests; + +#[cfg(test)] +#[path = "split_tests.rs"] +mod split_tests; diff --git a/src/simlin-engine/src/wasmgen/module_tests.rs b/src/simlin-engine/src/wasmgen/module_tests.rs index ee6d36a2a..9431d340b 100644 --- a/src/simlin-engine/src/wasmgen/module_tests.rs +++ b/src/simlin-engine/src/wasmgen/module_tests.rs @@ -212,6 +212,7 @@ fn assembled_module_initializes_gf_regions_in_memory() { let wasm = assemble_simulation(AssembleParts { helpers, program_fns: vec![empty(), empty(), empty()], + program_helpers: Vec::new(), run_fn: empty(), // Empty (no-op) override functions: this test only checks the GF data // segments, so the override exports are present but trivial. @@ -245,7 +246,8 @@ fn assembled_module_initializes_gf_regions_in_memory() { gf_regions: &[®ions], const_init: &empty_const_init, belt_init_data: &[], - }); + }) + .expect("assembly should succeed"); let info = validate(&wasm).expect("module must validate"); let mut store = Store::new(()); @@ -400,7 +402,7 @@ fn compile_datamodel_to_wasm_validates() { /// Build a `CompiledSimulation` for the named model of `datamodel` via the /// production incremental pipeline (the same path the VM corpus uses). -fn compile_sim( +pub(super) fn compile_sim( datamodel: &crate::datamodel::Project, model_name: &str, ) -> std::sync::Arc { @@ -412,7 +414,7 @@ fn compile_sim( /// Run a `WasmArtifact` under the DLR-FT interpreter and return the /// step-major results slab (`n_chunks * n_slots` f64, row-major by step). -fn run_artifact_results(artifact: &WasmArtifact) -> Vec { +pub(super) fn run_artifact_results(artifact: &WasmArtifact) -> Vec { let info = validate(&artifact.wasm).expect("generated module must validate"); let mut store = Store::new(()); let inst = store @@ -654,7 +656,7 @@ fn compile_simulation_previous_matches_vm() { /// on every change" usage (interactive scrubbing; the POC's `run` "re-runs /// the whole simulation" per call) -- which exercises the cross-run state /// reset that a single `run` invocation cannot. -fn run_artifact_results_repeated(artifact: &WasmArtifact, runs: usize) -> Vec> { +pub(super) fn run_artifact_results_repeated(artifact: &WasmArtifact, runs: usize) -> Vec> { let info = validate(&artifact.wasm).expect("generated module must validate"); let mut store = Store::new(()); let inst = store @@ -1239,7 +1241,7 @@ fn compile_simulation_rk4_with_previous_and_init_matches_vm() { /// submodel carries internal stocks reached only through `EvalModule` (the /// nested-stock-offset case). `TestProject` only emits a single `main` model, /// so this is built as an explicit datamodel. -fn submodel_project( +pub(super) fn submodel_project( name: &str, method: crate::datamodel::SimMethod, in_value: &str, @@ -2166,7 +2168,7 @@ fn compile_simulation_transpose_reducer_matches_vm() { /// Assert every layout variable matches the VM, treating a NaN on both sides /// as equal (the OOB-subscript result). The plain `assert_matches_vm` uses a /// finite-difference compare that a NaN would fail, so the OOB tests use this. -fn assert_matches_vm_nan_aware( +pub(super) fn assert_matches_vm_nan_aware( sim: std::sync::Arc, artifact: &WasmArtifact, ) -> usize { @@ -2395,7 +2397,7 @@ fn set_value_rc(artifact: &WasmArtifact, off: i32, val: f64) -> i32 { } /// The absolute slab offset of `name` in the artifact's layout. -fn layout_offset(artifact: &WasmArtifact, name: &str) -> usize { +pub(super) fn layout_offset(artifact: &WasmArtifact, name: &str) -> usize { artifact .layout .var_offsets @@ -2409,7 +2411,7 @@ fn layout_offset(artifact: &WasmArtifact, name: &str) -> usize { /// `var` (`Ident::join`, the U+00B7 module-hierarchy separator), e.g. /// `sub0·k`. Built the same way `db::layout::flattened_offsets` keys the /// layout, so it stays correct if the separator ever changes. -fn qualified_ident(instance: &str, var: &str) -> Ident { +pub(super) fn qualified_ident(instance: &str, var: &str) -> Ident { Ident::::join( &Ident::::new(instance).as_canonical_str(), &Ident::::new(var).as_canonical_str(), @@ -2811,7 +2813,7 @@ fn resumable_fixture(stop: f64) -> crate::datamodel::Project { /// step-major slab out. The in-module peer of the integration-test helper /// `run_wasm_results_segmented`; kept here because the lib `#[cfg(test)]` /// module cannot reach the integration crate's private helpers. -fn run_artifact_segmented(artifact: &WasmArtifact, targets: &[f64]) -> Vec { +pub(super) fn run_artifact_segmented(artifact: &WasmArtifact, targets: &[f64]) -> Vec { let info = validate(&artifact.wasm).expect("generated module must validate"); let mut store = Store::new(()); let inst = store diff --git a/src/simlin-engine/src/wasmgen/split.rs b/src/simlin-engine/src/wasmgen/split.rs new file mode 100644 index 000000000..e339b30af --- /dev/null +++ b/src/simlin-engine/src/wasmgen/split.rs @@ -0,0 +1,158 @@ +// 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. + +// pattern: Functional Core +//! Partition encoded opcode programs at the lowering pass's safe boundaries. + +use wasm_encoder::{Function, Instruction}; + +use super::WasmGenError; + +/// A sizing policy for ordinary helper bodies, independent of the engine's +/// acceptance limit. An indivisible expression may exceed this target. +pub(super) const TARGET_BODY_BYTES: usize = 64 * 1024; + +/// V8's `kV8MaxWasmFunctionSize`, including the local-declaration prefix: +/// https://github.com/v8/v8/blob/main/src/wasm/wasm-limits.h +/// Enforce this on every emitted body so an oversized indivisible expression +/// or driver returns a compiler error rather than failing in the JS host. +const MAX_BODY_BYTES: usize = 7_654_321; + +/// A partition shares the original opcode program's parameter signature. +pub(super) struct ProgramHelper { + pub n_inputs: u32, + pub body: Function, +} + +/// Own the function indices and bodies appended after the fixed phase/driver +/// functions. Existing calls keep targeting the original phase entry points. +pub(super) struct ProgramSplitter { + first_index: u32, + target_bytes: usize, + max_bytes: usize, + pub functions: Vec, +} + +impl ProgramSplitter { + pub fn new(first_index: u32, target_bytes: usize) -> Self { + Self { + first_index, + target_bytes: target_bytes.min(MAX_BODY_BYTES), + max_bytes: MAX_BODY_BYTES, + functions: Vec::new(), + } + } + + /// Keep small functions intact. Large functions become a sequence of calls + /// whose bodies retain the original local numbering and instruction bytes. + /// `frame` contains only local declarations; `body` includes its final End; + /// `safe_offsets` are absolute encoded-body offsets supplied by lowering. + pub fn finish( + &mut self, + frame: Function, + body: Function, + safe_offsets: &[usize], + n_inputs: u32, + ) -> Result { + if body.byte_len() <= self.target_bytes { + return Ok(body); + } + let prefix_len = frame.byte_len(); + let code_end = body.byte_len() - 1; + if code_end == prefix_len { + check_size(&body, self.max_bytes)?; + return Ok(body); + } + if safe_offsets.last().copied() != Some(code_end) { + return Err(WasmGenError::Unsupported( + "wasmgen: opcode program ends with live evaluation state".to_string(), + )); + } + if !safe_offsets + .iter() + .any(|&end| end > prefix_len && end < code_end) + { + if body.byte_len() > self.max_bytes { + return Err(WasmGenError::Unsupported(format!( + "wasmgen: indivisible expression exceeds the {}-byte function limit", + self.max_bytes + ))); + } + return Ok(body); + } + let raw = body.into_raw_body(); + + let mut dispatcher = Function::new([]); + let mut start = prefix_len; + let mut previous = start; + for &end in safe_offsets { + if end <= start { + continue; + } + if end - start + prefix_len + 1 > self.target_bytes && previous > start { + self.append(&frame, &raw[start..previous], n_inputs, &mut dispatcher)?; + start = previous; + } + // A single expression cannot be cut while its operand/local state + // is live. It may exceed the target, but never the host's limit. + if end - start + prefix_len + 1 > self.max_bytes { + return Err(WasmGenError::Unsupported(format!( + "wasmgen: indivisible expression exceeds the {}-byte function limit", + self.max_bytes + ))); + } + previous = end; + } + if start < code_end { + self.append(&frame, &raw[start..code_end], n_inputs, &mut dispatcher)?; + } + dispatcher.instruction(&Instruction::End); + check_size(&dispatcher, self.max_bytes)?; + Ok(dispatcher) + } + + fn append( + &mut self, + frame: &Function, + code: &[u8], + n_inputs: u32, + dispatcher: &mut Function, + ) -> Result<(), WasmGenError> { + let index = u32::try_from(self.functions.len()) + .ok() + .and_then(|n| self.first_index.checked_add(n)) + .ok_or_else(|| { + WasmGenError::Unsupported("wasmgen: too many function partitions".to_string()) + })?; + let mut body = frame.clone(); + body.raw(code.iter().copied()); + body.instruction(&Instruction::End); + check_size(&body, self.max_bytes)?; + self.functions.push(ProgramHelper { n_inputs, body }); + for param in 0..=n_inputs { + dispatcher.instruction(&Instruction::LocalGet(param)); + } + dispatcher.instruction(&Instruction::Call(index)); + Ok(()) + } +} + +/// This gate also covers helpers and drivers not emitted from opcode programs. +pub(super) fn check_function(body: &Function) -> Result<(), WasmGenError> { + check_size(body, MAX_BODY_BYTES) +} + +fn check_size(body: &Function, max_bytes: usize) -> Result<(), WasmGenError> { + if body.byte_len() > max_bytes { + return Err(WasmGenError::Unsupported(format!( + "wasmgen: generated function is {} bytes, exceeding the {max_bytes}-byte limit", + body.byte_len() + ))); + } + Ok(()) +} + +#[cfg(test)] +#[path = "split_unit_tests.rs"] +mod tests; diff --git a/src/simlin-engine/src/wasmgen/split_tests.rs b/src/simlin-engine/src/wasmgen/split_tests.rs new file mode 100644 index 000000000..42b31b612 --- /dev/null +++ b/src/simlin-engine/src/wasmgen/split_tests.rs @@ -0,0 +1,377 @@ +// 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. + +//! Function partitioning through production compilation, with small byte targets +//! so runtime-state boundaries are exercised without production-sized fixtures. + +use super::tests::{ + assert_matches_vm_nan_aware, compile_sim, layout_offset, qualified_ident, run_artifact_results, + run_artifact_results_repeated, run_artifact_segmented, submodel_project, +}; +use super::*; +use crate::bytecode::Opcode; +use crate::common::{Canonical, Ident}; +use crate::datamodel::{self, SimMethod}; +use crate::test_common::TestProject; + +/// Read section/vector lengths only; instruction validation stays with the wasm +/// interpreter. Inspecting the code section proves a test actually split its +/// target phases, rather than merely comparing two unsplit executions. +fn read_uleb(bytes: &[u8], cursor: &mut usize) -> usize { + let mut result = 0; + let mut shift = 0; + loop { + let byte = bytes[*cursor]; + *cursor += 1; + result |= usize::from(byte & 0x7f) << shift; + if byte & 0x80 == 0 { + return result; + } + shift += 7; + assert!(shift < 35, "malformed wasm length"); + } +} + +fn function_bodies(artifact: &WasmArtifact) -> Vec<&[u8]> { + let bytes = &artifact.wasm; + assert_eq!(&bytes[..8], b"\0asm\x01\0\0\0"); + let mut cursor = 8; + while cursor < bytes.len() { + let section = bytes[cursor]; + cursor += 1; + let size = read_uleb(bytes, &mut cursor); + let end = cursor + size; + if section == 10 { + let count = read_uleb(bytes, &mut cursor); + let bodies = (0..count) + .map(|_| { + let len = read_uleb(bytes, &mut cursor); + let body = &bytes[cursor..cursor + len]; + cursor += len; + body + }) + .collect(); + assert_eq!(cursor, end, "code-section lengths must cover its payload"); + return bodies; + } + cursor = end; + } + panic!("generated module has no code section") +} + +fn assert_same_slab(expected: &[f64], actual: &[f64]) { + assert_eq!(actual.len(), expected.len()); + for (i, (&expected, &actual)) in expected.iter().zip(actual).enumerate() { + assert!( + (expected.is_nan() && actual.is_nan()) || expected.to_bits() == actual.to_bits(), + "slot {i}: unsplit={expected}, split={actual}" + ); + } +} + +fn split_and_unsplit(sim: &CompiledSimulation, target: usize) -> (WasmArtifact, WasmArtifact) { + let unsplit = compile_with_passes(sim, &[], &[], None, usize::MAX).expect("unsplit wasm"); + let split = compile_with_passes(sim, &[], &[], None, target).expect("split wasm"); + assert_eq!(split.layout.serialize(), unsplit.layout.serialize()); + assert!( + function_bodies(&split).len() > function_bodies(&unsplit).len(), + "the small target must actually create function chunks" + ); + (split, unsplit) +} + +/// All integration methods call all three program phases. Distinct initial +/// literal pools and PREVIOUS/INIT values must survive those phase dispatchers; +/// neither restarting a run nor stopping between save points may change them. +#[test] +fn split_phases_preserve_integrators_snapshots_and_resumed_runs() { + for method in [ + SimMethod::Euler, + SimMethod::RungeKutta2, + SimMethod::RungeKutta4, + ] { + let project = TestProject::new("split_phases") + .with_sim_time(0.0, 3.0, 0.25) + .with_save_step(0.5) + .with_sim_method(method) + .stock("first", "11", &["grow_first"], &[], None) + .stock("second", "first + 23", &["grow_second"], &[], None) + .stock("third", "second + 37", &["grow_third"], &[], None) + .flow("grow_first", "first * 0.1", None) + .flow("grow_second", "first + second * 0.01", None) + .flow("grow_third", "second - third * 0.02", None) + .scalar_aux("snapshot", "INIT(first) + INIT(second) + INIT(third)") + .scalar_aux("lagged", "PREVIOUS(first, 101) + PREVIOUS(second, 211)") + .scalar_aux( + "nested", + "IF TIME < 1 THEN first ELSE IF TIME < 2 THEN second ELSE third", + ) + .build_datamodel(); + let sim = compile_sim(&project, "main"); + for target in [1, 128] { + let (split, unsplit) = split_and_unsplit(&sim, target); + let split_bodies = function_bodies(&split); + let unsplit_bodies = function_bodies(&unsplit); + let first_program = build_helpers().functions.len(); + for phase in [F_INITIALS, F_FLOWS, F_STOCKS] { + let index = first_program + phase as usize; + assert_ne!( + split_bodies[index], unsplit_bodies[index], + "phase {phase} must split" + ); + } + let expected = run_artifact_results(&unsplit); + assert_same_slab(&expected, &run_artifact_results(&split)); + assert_same_slab( + &expected, + &run_artifact_segmented(&split, &[0.75, 1.75, 3.0]), + ); + for rerun in run_artifact_results_repeated(&split, 2) { + assert_same_slab(&expected, &rerun); + } + assert!(assert_matches_vm_nan_aware(sim.clone(), &split) >= 9); + } + } +} + +/// Each shape is generated by the ordinary compiler, and its opcode family is +/// asserted below. The one-byte target attempts a cut at every legal boundary: +/// dynamic index and view locals must remain local to their helper, +/// while materialized temp arrays can cross helper calls in linear memory. +/// The hoisted loops also leave static descriptors for the end-of-program +/// discard rule. Dynamic descriptors in this fixture are consumed within the +/// program; it does not establish a producer that leaves dynamic descriptors +/// live at the final bytecode boundary. +#[test] +fn split_preserves_condition_subscript_view_and_temp_lifetimes() { + let project = TestProject::new("split_arrays") + .with_sim_time(0.0, 4.0, 1.0) + .indexed_dimension("A", 5) + .indexed_dimension("B", 3) + .array_aux("source[A]", "A + TIME") + .array_aux("matrix[A,B]", "A * 10 + B + TIME") + .scalar_aux("idx", "TIME + 1") + .scalar_aux("row", "TIME + 1") + .scalar_aux("col", "TIME + 1") + .scalar_aux("picked", "source[idx]") + .scalar_aux("picked_matrix", "matrix[row,col]") + .scalar_aux("view_sum", "SUM(matrix[row,1])") + .scalar_aux("temp_sum", "SUM(2 * source[3:5] + 1)") + .scalar_aux("transpose_sum", "SUM(matrix')") + .array_aux("order[A]", "VECTOR SORT ORDER(source[A], -1)") + .scalar_aux("order_sum", "SUM(order[*])") + .scalar_aux( + "nested", + "IF TIME < 2 THEN picked ELSE IF TIME < 3 THEN temp_sum ELSE transpose_sum", + ) + .build_datamodel(); + let sim = compile_sim(&project, "main"); + let code = &sim.modules[&sim.root].compiled_flows.code; + for (name, present) in [ + ( + "condition", + code.iter().any(|op| matches!(op, Opcode::SetCond {})), + ), + ( + "scalar index", + code.iter() + .any(|op| matches!(op, Opcode::PushSubscriptIndex { .. })), + ), + ( + "dynamic view", + code.iter() + .any(|op| matches!(op, Opcode::ViewSubscriptDynamic { .. })), + ), + ( + "temp iteration", + code.iter().any(|op| matches!(op, Opcode::BeginIter { .. })), + ), + ( + "temp write", + code.iter() + .any(|op| matches!(op, Opcode::StoreIterElement {})), + ), + ( + "vector scratch", + code.iter() + .any(|op| matches!(op, Opcode::VectorSortOrder { .. })), + ), + ] { + assert!(present, "production fixture must exercise {name}"); + } + let (split, unsplit) = split_and_unsplit(&sim, 1); + let expected = run_artifact_results(&unsplit); + assert_same_slab(&expected, &run_artifact_results(&split)); + assert!(assert_matches_vm_nan_aware(sim, &split) >= 10); + let matrix_off = layout_offset(&split, "picked_matrix"); + assert!(expected[matrix_off].is_finite()); + assert!(expected[4 * split.layout.n_slots + matrix_off].is_nan()); +} + +/// Two module parameters have distinct, time-varying values and are consumed +/// noncommutatively in both initial and flow equations. Two instances exercise +/// relative addressing and different input values through the same child body. +#[test] +fn split_module_dispatchers_preserve_inputs_and_instance_offsets() { + for method in [ + SimMethod::Euler, + SimMethod::RungeKutta2, + SimMethod::RungeKutta4, + ] { + let mut project = submodel_project( + "split_modules", + method, + "2 + TIME", + "in + 10 * other + out * 0.01", + true, + 2, + ); + let mut additions = TestProject::new("inputs") + .scalar_aux("other_value", "7 + TIME * 2") + .build_datamodel(); + project.models[0] + .variables + .append(&mut additions.models[0].variables); + for variable in &mut project.models[0].variables { + if let datamodel::Variable::Module(module) = variable { + module.references.push(datamodel::ModuleReference { + src: "other_value".to_string(), + dst: format!("{}.other", module.ident), + }); + if module.ident == "sub1" { + module.references[0].src = "other_value".to_string(); + module.references[1].src = "in_value".to_string(); + } + } + } + let mut child_additions = TestProject::new("child") + .scalar_aux("other", "-997") + .stock("extra", "other + 30 * in", &["extra_grow"], &[], None) + .flow("extra_grow", "other + out * 0.03", None) + .scalar_aux("initial_out", "INIT(out)") + .scalar_aux("previous_out", "PREVIOUS(out, -101)") + .build_datamodel(); + if let datamodel::Variable::Aux(other) = &mut child_additions.models[0].variables[0] { + other.compat.can_be_module_input = true; + } + project.models[1] + .variables + .append(&mut child_additions.models[0].variables); + for variable in &mut project.models[1].variables { + if let datamodel::Variable::Stock(stock) = variable + && stock.ident == "out" + { + stock.equation = datamodel::Equation::Scalar("in + 10 * other".to_string()); + } + } + let sim = compile_sim(&project, "main"); + assert!(sim.modules.values().any(|module| { + module + .compiled_flows + .code + .iter() + .any(|op| matches!(op, Opcode::LoadModuleInput { input: 1 })) + })); + let (split, unsplit) = split_and_unsplit(&sim, 1); + let expected = run_artifact_results(&unsplit); + assert_same_slab(&expected, &run_artifact_results(&split)); + assert!(assert_matches_vm_nan_aware(sim, &split) >= 10); + assert_eq!( + expected[layout_offset(&split, qualified_ident("sub0", "out").as_str())], + 72.0 + ); + assert_eq!( + expected[layout_offset(&split, qualified_ident("sub1", "out").as_str())], + 27.0 + ); + } +} + +/// Special stocks take the production expansion dispatch. Their reconciliation +/// initials form a fourth emitted program variant and must split without +/// reinitializing the container slots the side-table pass has just published. +#[test] +fn split_preserves_special_stock_reconciliation_initials() { + let xml = r#" + +
split_queuett
+ 03
1
+ + 40arrivals + 10 + INIT(SUM(waiting)) + INIT(SIZE(waiting)) + SUM(waiting) / INIT(SUM(waiting)) + +
"#; + let queue = crate::xmile::project_from_reader(&mut std::io::BufReader::new(xml.as_bytes())) + .expect("queue model"); + let conveyor = crate::xmile::project_from_reader(&mut std::io::BufReader::new( + include_str!("../../../../test/conveyors/conveyor_containers.xmile").as_bytes(), + )) + .expect("conveyor model"); + for project in [queue, conveyor] { + let main = project.models[0].name.as_str(); + let mut db = crate::db::SimlinDb::default(); + let sync = crate::db::sync_from_datamodel_incremental(&mut db, &project, None); + let built = crate::queue_compile::compile_sim( + &mut db, + sync.project, + &project, + main, + crate::db::LtmOverlay::Off, + ) + .expect("special-stock production dispatch"); + let unsplit = compile_with_passes( + &built.compiled, + &built.conveyor_plans, + &built.queue_plans, + None, + usize::MAX, + ) + .expect("unsplit special wasm"); + let split = compile_with_passes( + &built.compiled, + &built.conveyor_plans, + &built.queue_plans, + None, + 1, + ) + .expect("split special wasm"); + let plain_bodies = function_bodies(&unsplit); + let split_bodies = function_bodies(&split); + assert!(split_bodies.len() > plain_bodies.len()); + let reconciliation_index = plain_bodies.len() - 1; + assert_ne!( + plain_bodies[reconciliation_index], split_bodies[reconciliation_index], + "reconciliation initials must split" + ); + let expected = run_artifact_results(&unsplit); + assert_same_slab(&expected, &run_artifact_results(&split)); + for rerun in run_artifact_results_repeated(&split, 2) { + assert_same_slab(&expected, &rerun); + } + let mut vm = crate::queue_compile::build_vm(&project, main).expect("special-stock VM"); + vm.run_to_end().expect("VM run"); + let vm = vm.into_results(); + assert_eq!(vm.step_count, split.layout.n_chunks); + let mut compared = 0; + for (name, wasm_off) in &split.layout.var_offsets { + let ident = Ident::::from_str_unchecked(name); + let Some(&vm_off) = vm.offsets.get(&ident) else { + continue; + }; + for row in 0..vm.step_count { + let left = vm.data[row * vm.step_size + vm_off]; + let right = expected[row * split.layout.n_slots + wasm_off]; + assert!( + (left.is_nan() && right.is_nan()) || (left - right).abs() < 1e-9, + "{name}, row {row}: VM={left}, wasm={right}" + ); + } + compared += 1; + } + assert!(compared >= 5); + } +} diff --git a/src/simlin-engine/src/wasmgen/split_unit_tests.rs b/src/simlin-engine/src/wasmgen/split_unit_tests.rs new file mode 100644 index 000000000..09e090b9e --- /dev/null +++ b/src/simlin-engine/src/wasmgen/split_unit_tests.rs @@ -0,0 +1,157 @@ +// 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. + +//! Encoded partition mechanics. NOP spans deliberately isolate byte accounting +//! and function signatures; production boundary discovery and runtime semantics +//! are covered by `module::split_tests` using compiler-produced programs. + +use super::*; +use wasm_encoder::ValType; + +fn splitter(target_bytes: usize, max_bytes: usize, first_index: u32) -> ProgramSplitter { + assert!(target_bytes <= max_bytes); + ProgramSplitter { + first_index, + target_bytes, + max_bytes, + functions: Vec::new(), + } +} + +/// Each span is independently stack neutral and has no locals in flight. This +/// supplies legal boundaries by construction for testing the partitioner itself. +fn nop_spans(frame: &Function, spans: &[usize]) -> (Function, Vec) { + let mut body = frame.clone(); + let mut offsets = Vec::new(); + for &len in spans { + for _ in 0..len { + body.instruction(&Instruction::Nop); + } + offsets.push(body.byte_len()); + } + body.instruction(&Instruction::End); + (body, offsets) +} + +#[test] +fn duplicate_offsets_do_not_create_empty_helpers() { + let frame = Function::new([]); + let (body, offsets) = nop_spans(&frame, &[2, 2, 2]); + let mut duplicate = vec![frame.byte_len(), frame.byte_len()]; + for &offset in &offsets { + duplicate.extend([offset, offset]); + } + let mut once = splitter(4, 80, 17); + let mut repeated = splitter(4, 80, 17); + let expected = once + .finish(frame.clone(), body.clone(), &offsets, 0) + .unwrap(); + let actual = repeated.finish(frame, body, &duplicate, 0).unwrap(); + assert_eq!(expected.into_raw_body(), actual.into_raw_body()); + assert_eq!(once.functions.len(), 3); + assert_eq!(repeated.functions.len(), 3); + for (left, right) in once.functions.into_iter().zip(repeated.functions) { + assert_eq!(left.body.into_raw_body(), right.body.into_raw_body()); + } +} + +#[test] +fn byte_cap_includes_local_declarations_and_final_end() { + // The 128-local group also covers a multibyte LEB local-count prefix. + let frame = Function::new([(128, ValType::I32), (5, ValType::F64)]); + let (body, offsets) = nop_spans(&frame, &[3, 3]); + let exact_cap = frame.byte_len() + 3 + 1; + let mut parts = splitter(exact_cap, exact_cap, 1); + parts.finish(frame.clone(), body, &offsets, 0).unwrap(); + assert_eq!(parts.functions.len(), 2); + for helper in parts.functions { + assert_eq!(helper.body.byte_len(), exact_cap); + assert!(check_size(&helper.body, exact_cap).is_ok()); + assert!(check_size(&helper.body, exact_cap - 1).is_err()); + } + let (one_expression, offsets) = nop_spans(&frame, &[3]); + let error = splitter(exact_cap - 1, exact_cap - 1, 1) + .finish(frame, one_expression, &offsets, 0) + .expect_err("one byte over the cap must be refused"); + assert!(error.to_string().contains("indivisible expression")); +} + +#[test] +fn empty_programs_and_indivisible_expressions_do_not_add_dispatchers() { + let frame = Function::new([(1, ValType::F64)]); + let (empty, _) = nop_spans(&frame, &[]); + let empty_bytes = empty.clone().into_raw_body(); + let mut parts = splitter(1, 80, 0); + let actual = parts.finish(frame.clone(), empty, &[], 0).unwrap(); + assert_eq!(actual.into_raw_body(), empty_bytes); + assert!(parts.functions.is_empty()); + + let (expression, offsets) = nop_spans(&frame, &[12]); + let expected = expression.clone().into_raw_body(); + let actual = parts.finish(frame, expression, &offsets, 0).unwrap(); + assert_eq!(actual.into_raw_body(), expected); + assert!(parts.functions.is_empty()); +} + +#[test] +fn indivisible_span_above_hard_cap_is_rejected() { + let frame = Function::new([]); + let (body, offsets) = nop_spans(&frame, &[2, 19, 2]); + let error = splitter(5, 20, 0) + .finish(frame, body, &offsets, 0) + .expect_err("the 21-byte helper cannot fit the hard cap"); + assert!(error.to_string().contains("indivisible expression")); +} + +#[test] +fn dispatcher_cannot_exceed_the_hard_cap() { + let frame = Function::new([]); + let (body, offsets) = nop_spans(&frame, &[2, 2, 2, 2]); + let mut parts = splitter(4, 20, 0); + let error = parts + .finish(frame, body, &offsets, 5) + .expect_err("forwarding six parameters four times exceeds 20 bytes"); + assert!(error.to_string().contains("generated function")); + assert!( + parts + .functions + .iter() + .all(|helper| helper.body.byte_len() <= 20) + ); +} + +#[test] +fn dispatchers_forward_all_parameters_and_preserve_helper_local_prefixes() { + let frame = Function::new([(1, ValType::I32), (2, ValType::F64)]); + let (body, offsets) = nop_spans(&frame, &[2, 2]); + let mut parts = splitter(frame.byte_len() + 3, 80, 127); + let actual = parts.finish(frame.clone(), body, &offsets, 2).unwrap(); + let mut expected = Function::new([]); + for index in [127, 128] { + for parameter in 0..=2 { + expected.instruction(&Instruction::LocalGet(parameter)); + } + expected.instruction(&Instruction::Call(index)); + } + expected.instruction(&Instruction::End); + assert_eq!(actual.into_raw_body(), expected.into_raw_body()); + assert_eq!(parts.functions.len(), 2); + let expected_helper = nop_spans(&frame, &[2]).0.into_raw_body(); + for helper in parts.functions { + assert_eq!(helper.n_inputs, 2); + assert_eq!(helper.body.into_raw_body(), expected_helper); + } +} + +#[test] +fn program_ending_with_live_evaluation_state_is_rejected() { + let frame = Function::new([]); + let mut body = frame.clone(); + body.instruction(&Instruction::F64Const(1.0.into())); + body.instruction(&Instruction::End); + let error = splitter(1, 80, 0) + .finish(frame, body, &[], 0) + .expect_err("a void phase cannot transfer a live arithmetic value to a helper"); + assert!(error.to_string().contains("live evaluation state")); +} From 103a51ea05b0a4752d1df64aeb58f975a5cce279 Mon Sep 17 00:00:00 2001 From: Bobby Powers Date: Wed, 9 Sep 2026 22:46:38 -0700 Subject: [PATCH 3/5] libsimlin: reuse compiler state for wasm simulations The project already maintains an incremental compiler database, but the WASM compilation endpoint rebuilt it on every request. Compile through the shared special-stock dispatch under the established lock order, then emit WASM from the immutable compiled snapshot after releasing the locks. Restore per-call discovery mode and latch LTM diagnostics like the VM path. Verify actual query reuse through the public FFI, edit invalidation, old artifact ownership, mode isolation on success and failure, and special-stock warnings after LTM requests. --- src/libsimlin/CLAUDE.md | 5 +- src/libsimlin/simlin.h | 20 +- src/libsimlin/src/analysis.rs | 10 +- src/libsimlin/src/model.rs | 78 +++-- src/libsimlin/tests/integration/wasm.rs | 2 + .../tests/integration/wasm/incremental.rs | 309 ++++++++++++++++++ src/simlin-engine/src/db.rs | 7 +- src/simlin-engine/src/db/exec_probe.rs | 15 +- 8 files changed, 402 insertions(+), 44 deletions(-) create mode 100644 src/libsimlin/tests/integration/wasm/incremental.rs diff --git a/src/libsimlin/CLAUDE.md b/src/libsimlin/CLAUDE.md index 598312916..f466faf8f 100644 --- a/src/libsimlin/CLAUDE.md +++ b/src/libsimlin/CLAUDE.md @@ -43,7 +43,7 @@ Error formatting has no module here: `src/patch.rs` imports `simlin_engine::erro - **`src/model.rs`** - Inspect model structure: - `simlin_model_{ref,unref}()`, `simlin_model_get_var_count()`, `simlin_model_get_var_names()` - `simlin_model_get_dependencies()`, `simlin_model_get_links()`, `simlin_model_get_equations()` - - `simlin_model_compile_to_wasm(model, ltm_enabled, ltm_discovery_mode, out_wasm, out_wasm_len, out_layout, out_layout_len, out_error)` - Compile the model to a self-contained wasm module (engine `wasmgen` backend, an alternative to the VM for fast repeated re-simulation). Returns two malloc'd buffers, each freed with `simlin_free`: the wasm blob and a serialized `WasmLayout` (length-prefixed, little-endian: geometry `n_slots`/`n_chunks`/`results_offset` then a canonical-name -> slot-offset map a host strides the results region with). Every blob also exports `get_error() -> i64`, the engine's runtime error channel (GH #921): a host that DRIVES a blob must poll it after each `run`/`run_to`/`run_initials`, since a wasm export cannot return a `Result`. libsimlin itself never runs the blob (it only compiles one), so nothing here polls it; `simlin_engine::wasmgen::{decode_error_word, reconstruct_error}` are the unpacker and the VM-identical message rebuilder for hosts that do. A CONVEYOR model can raise (a non-positive or out-of-bound transit); a queue-only or ordinary model never does, and its blob elides the guards entirely. Works from a `SimlinModel`'s datamodel alone -- no `SimlinSim` required -- and stores a `SimlinError` (never panics) on any compile/codegen failure. The two bool flags thread through to the salsa compile so `ltm_enabled` produces a blob whose results region carries the LTM link/loop-score columns the VM emits (read back via the from-wasm analyze FFIs in `src/analysis.rs`); `ltm_discovery_mode` additionally enables the discovery-mode synthetic variables. An unlowerable LTM construct still surfaces a `SimlinError` -- no silent VM fallback. BOTH special stock types compile (GH #884, closed by #924): the engine routes a queue or conveyor model through the same special-stock dispatch the VM uses and emits the FIFO / belt side-table pass natively into the blob. Two conveyor conditions still refuse, each surfacing as a `SimlinError` rather than a mis-lowered blob: an inflow carrying a non-default `isee:spreadflow` -- `even`, `dest`, `dist`, or `source` (GH #946) -- and a `conveyor::slat_bound()` above `i32::MAX` (a soundness guard on the emitted narrowings, not a feature gap) + - `simlin_model_compile_to_wasm(model, ltm_enabled, ltm_discovery_mode, out_wasm, out_wasm_len, out_layout, out_layout_len, out_error)` - Compile the model to a self-contained wasm module (engine `wasmgen` backend, an alternative to the VM for fast repeated re-simulation). Returns two malloc'd buffers, each freed with `simlin_free`: the wasm blob and a serialized `WasmLayout` (length-prefixed, little-endian: geometry `n_slots`/`n_chunks`/`results_offset` then a canonical-name -> slot-offset map a host strides the results region with). Every blob also exports `get_error() -> i64`, the engine's runtime error channel (GH #921): a host that DRIVES a blob must poll it after each `run`/`run_to`/`run_initials`, since a wasm export cannot return a `Result`. libsimlin itself never runs the blob (it only compiles one), so nothing here polls it; `simlin_engine::wasmgen::{decode_error_word, reconstruct_error}` are the unpacker and the VM-identical message rebuilder for hosts that do. A CONVEYOR model can raise (a non-positive or out-of-bound transit); a queue-only or ordinary model never does, and its blob elides the guards entirely. Uses the `SimlinModel` project's persistent incremental database and the same special-stock compile dispatch as VM creation -- no `SimlinSim` required -- and stores a `SimlinError` on any compile/codegen failure. Repeated unchanged compiles reuse tracked compiler queries when the requested discovery mode matches the project setting; a differing per-call override changes salsa inputs and can reexecute affected queries. WASM emission still runs per call. Both project locks follow datamodel-then-db order and are released once the immutable compiled snapshot is captured. The discovery flag overrides only this call and is restored on success or failure; LTM requests latch diagnostic collection as VM requests do. The two bool flags thread through to the salsa compile so `ltm_enabled` produces a blob whose results region carries the LTM link/loop-score columns the VM emits (read back via the from-wasm analyze FFIs in `src/analysis.rs`); `ltm_discovery_mode` additionally enables the discovery-mode synthetic variables. An unlowerable LTM construct still surfaces a `SimlinError` -- no silent VM fallback. BOTH special stock types compile (GH #884, closed by #924): the engine routes a queue or conveyor model through the same special-stock dispatch the VM uses and emits the FIFO / belt side-table pass natively into the blob. Two conveyor conditions still refuse, each surfacing as a `SimlinError` rather than a mis-lowered blob: an inflow carrying a non-default `isee:spreadflow` -- `even`, `dest`, `dist`, or `source` (GH #946) -- and a `conveyor::slat_bound()` above `i32::MAX` (a soundness guard on the emitted narrowings, not a feature gap) ### Standalone results (VDF import) @@ -66,7 +66,7 @@ Error formatting has no module here: `src/patch.rs` imports `simlin_engine::erro - `simlin_free_loops()`, `simlin_free_links()` - `simlin_analyze_get_links`'s `include_internal` flag selects the view: `false` (the user-facing default) collapses macro/module-internal synthetic nodes (`$⁚{var}⁚{n}⁚{func}`, `$⁚ltm⁚agg⁚{n}`, ...) out of the returned graph via `engine::ltm_finding::collapse_synthetic_links` -- each chain `X -> internal -> Y` becomes one composite edge `X -> Y` carrying the composite (largest-magnitude path) link score, preserving the through-contribution (LTM ref 6.3/6.4); `true` returns the raw causal graph. The collapse runs in the engine (one impl, mirrors the loop-level `trim_synthetic_aggs_from_loop_links`). The `simlin_analyze_links_from_wasm_results` twin takes the same flag. - `simlin_sim_get_ltm_mode(sim, out_error) -> SimlinLtmMode` -- reports the resolved LTM loop-enumeration mode (`Disabled` when `enable_ltm=false`, `Exhaustive` for Johnson enumeration, `Discovery` when the model tripped the SCC-size gate or discovery was requested). Captured at `simlin_sim_new` time onto `SimState::ltm_mode` (`analysis::ltm_snapshots`, reading `model_ltm_variables(..).mode` under the db lock: an overlay-independent derivation, so nothing on the project is toggled), so it needs no run. - - `simlin_analyze_links_from_wasm_results(model, slab_ptr, slab_len, layout_ptr, layout_len, out_error)` and `simlin_analyze_rel_loop_score_from_wasm_results(model, slab_ptr, slab_len, layout_ptr, layout_len, loop_id, results_ptr, len, out_written, out_error)` -- the wasm-backend twins of `simlin_analyze_get_links` / `simlin_analyze_get_relative_loop_score`. They rebuild an `engine::Results` from the host-extracted slab (the `n_chunks * n_slots * 8` bytes at `WasmLayout::results_offset`) plus the serialized `WasmLayout` returned by `simlin_model_compile_to_wasm(model, ltm_enabled=true, ..)`, then funnel through the same `analyze_links_core` / `rel_loop_score_series` cores the VM FFIs use -- so link sets, polarities, and per-step scores agree with the VM by construction. The rel-loop-score twin additionally runs `analysis::ltm_snapshots` (the one derivation of `loop_partitions` / `loop_element_index` / `ltm_mode`, the same call `simlin_sim_new` stores onto `SimState`; a read of the overlay-independent LTM derivation under the db lock, nothing on the shared `SourceProject` is toggled) at the project db's current revision -- which matches the blob's layout only for a blob compiled from the same contents in exhaustive mode, since `simlin_model_compile_to_wasm` compiles in a throwaway db under its own discovery flag (the helper's rustdoc spells out the discovery-mode mismatch); the links twin needs no such dance because the link set comes from `model_causal_edges`, which has no LTM dependency + - `simlin_analyze_links_from_wasm_results(model, slab_ptr, slab_len, layout_ptr, layout_len, out_error)` and `simlin_analyze_rel_loop_score_from_wasm_results(model, slab_ptr, slab_len, layout_ptr, layout_len, loop_id, results_ptr, len, out_written, out_error)` -- the wasm-backend twins of `simlin_analyze_get_links` / `simlin_analyze_get_relative_loop_score`. They rebuild an `engine::Results` from the host-extracted slab (the `n_chunks * n_slots * 8` bytes at `WasmLayout::results_offset`) plus the serialized `WasmLayout` returned by `simlin_model_compile_to_wasm(model, ltm_enabled=true, ..)`, then funnel through the same `analyze_links_core` / `rel_loop_score_series` cores the VM FFIs use -- so link sets, polarities, and per-step scores agree with the VM by construction. The rel-loop-score twin additionally runs `analysis::ltm_snapshots` (the one derivation of `loop_partitions` / `loop_element_index` / `ltm_mode`, the same call `simlin_sim_new` stores onto `SimState`; a read of the overlay-independent LTM derivation under the db lock, nothing on the shared `SourceProject` is toggled) at the project db's current revision -- which matches the blob's layout only for a blob compiled from the same contents and discovery mode, since `simlin_model_compile_to_wasm` restores its per-call discovery override before returning (the helper's rustdoc spells out the discovery-mode mismatch); the links twin needs no such dance because the link set comes from `model_causal_edges`, which has no LTM dependency ### Layout @@ -97,6 +97,7 @@ Integration tests live in the single consolidated `tests/integration` harness (o - **`tests/integration/patch.rs`** - JSON patch application, error collection, unit warnings, XMILE patches - **`tests/integration/incremental.rs`** - Incremental compilation path (patch-then-sim, snapshot isolation) - **`tests/integration/analysis.rs`** - Causal analysis: incoming links, loop detection, loop scores, and the runtime loop classification of a sim whose project was edited after the run (the slot width comes from the sim's snapshot, not the edited model) +- **`tests/integration/wasm/incremental.rs`** - Exercises the exported WASM compiler with actual salsa `WillExecute` counts to prove reuse across both LTM overlay transitions. Covers patch invalidation by executing before/after blobs, the cross-product of prior/requested discovery and LTM flags, flag restoration after engine/codegen errors, and both special-stock diagnostic latches. The execution probe is exposed only by the engine's `test-support` feature and is absent from shipping artifacts. - **`tests/integration/wasm.rs`** - `simlin_model_compile_to_wasm`: validates and executes the returned blob under the DLR-FT interpreter (a libsimlin dev-dependency), parses the returned layout per its documented wire format, and checks the strided series against the VM via `simlin_sim_get_series`; also asserts a graceful `SimlinError` (no panic) for an unsupported model. `compile_to_wasm_blob_supports_resumable_run` further drives the FFI-compiled blob's resumable ABI (`run_initials`/`run_to`/`set_value`/`reset`, the additive exports) against a VM oracle driven identically through `simlin_sim_run_to`/`simlin_sim_set_value`/`simlin_sim_run_to_end`, confirming the resumable surface survives the FFI compile path and the export set grew purely additively. `compile_to_wasm_unsupported_ltm_model_surfaces_error` exercises the `ltm_enabled=true` path against an intentionally unlowerable LTM model and asserts the `SimlinError` reaches the caller (no silent VM fallback). `compile_to_wasm_conveyor_model_runs` and its sibling `compile_to_wasm_queue_model_runs` pin the special-stock half of GH #884 (closed by #924): a conveyor / queue model compiles through the FFI and its blob's strided series match `simlin_sim_get_series` for both the pass-driven flows and the ordinary variables. The from-wasm analysis twins are covered by `links_from_wasm_match_vm` and `rel_loop_score_from_wasm_matches_vm`: each compiles an LTM-enabled wasm blob, runs it under the interpreter to extract the slab, and asserts that `simlin_analyze_links_from_wasm_results` / `simlin_analyze_rel_loop_score_from_wasm_results` agree with their VM-side FFI counterparts on the link set and per-step scores - **`tests/integration/rendering.rs`** - SVG and PNG diagram rendering - **`tests/integration/results.rs`** - Standalone results FFI via VDF import: run-file and dataset-file opens with pinned series values (fixtures under `/test/bobby/vdf/`), canonical + display-name series lookup, sorted name listing, malformed-input rejection (empty/garbage/truncated/NULL) without crashing, refcounting, and NULL-handle errors diff --git a/src/libsimlin/simlin.h b/src/libsimlin/simlin.h index 961b119ef..e4aede8dc 100644 --- a/src/libsimlin/simlin.h +++ b/src/libsimlin/simlin.h @@ -943,15 +943,17 @@ void simlin_free_string(char *s); // series from the results region using `results_offset`, `n_slots`, and the // variable's `offset` from this map. // -// Works from the model's datamodel alone -- no `SimlinSim` is required. Any -// compile or codegen failure stores a `SimlinError` (never panics across the -// boundary) and leaves both output buffers NULL. -// -// `ltm_enabled` selects the LTM overlay for this compile (the same choice -// `simlin_sim_new(.., enable_ltm)` makes) and `ltm_discovery_mode` sets the -// discovery flag on this compile's own `SourceProject`: the produced blob's -// layout includes the `$\u{205A}ltm\u{205A}*` synthetic series iff -// `ltm_enabled` is true. +// Uses the project's persistent incremental compiler -- no `SimlinSim` is +// required. Unchanged compilation reuses the same queries as VM creation when +// the requested discovery mode matches the project setting. +// Any compile or codegen failure stores a `SimlinError` and leaves both +// output buffers NULL. +// +// `ltm_enabled` selects the LTM overlay and latches the project's LTM +// diagnostic request, as `simlin_sim_new` does. `ltm_discovery_mode` overrides +// discovery for this compile only; the shared project's prior flag is restored +// on success and failure. Special stocks follow the VM's expansion path and +// LTM degradation contract. // // # Safety // - `model` must be a valid pointer to a SimlinModel diff --git a/src/libsimlin/src/analysis.rs b/src/libsimlin/src/analysis.rs index ed3e9a53e..e855daeef 100644 --- a/src/libsimlin/src/analysis.rs +++ b/src/libsimlin/src/analysis.rs @@ -1549,11 +1549,11 @@ pub(crate) struct LtmSnapshots { /// consistent with the results after the project is edited. /// - `simlin_analyze_rel_loop_score_from_wasm_results` has no `SimState` and /// calls it per query. The blob it analyzes was compiled by -/// `simlin_model_compile_to_wasm` in a fresh, throwaway db under that call's -/// own `ltm_discovery_mode` flag, so the snapshot matches the blob's layout -/// only when the project's contents are the ones the blob was compiled from -/// and the blob was compiled in exhaustive mode (the FFI never sets the -/// project db's discovery flag). A discovery-mode blob carries loop-score +/// `simlin_model_compile_to_wasm` under that call's own `ltm_discovery_mode` +/// override, which is restored before returning. The snapshot matches the +/// blob's layout only when the project contents and discovery mode match +/// those used to compile it. In the default exhaustive project mode, a +/// discovery-mode blob carries loop-score /// columns for pinned loops only while this snapshot names every enumerated /// loop: a query for one of those resolves here, then fails the /// `results.offsets` lookup in the core (`DoesNotExist`), and the diff --git a/src/libsimlin/src/model.rs b/src/libsimlin/src/model.rs index 769c9f72c..c877f7f59 100644 --- a/src/libsimlin/src/model.rs +++ b/src/libsimlin/src/model.rs @@ -73,15 +73,17 @@ unsafe fn parse_filter(filter: *const c_char) -> Result, SimlinEr /// series from the results region using `results_offset`, `n_slots`, and the /// variable's `offset` from this map. /// -/// Works from the model's datamodel alone -- no `SimlinSim` is required. Any -/// compile or codegen failure stores a `SimlinError` (never panics across the -/// boundary) and leaves both output buffers NULL. +/// Uses the project's persistent incremental compiler -- no `SimlinSim` is +/// required. Unchanged compilation reuses the same queries as VM creation when +/// the requested discovery mode matches the project setting. +/// Any compile or codegen failure stores a `SimlinError` and leaves both +/// output buffers NULL. /// -/// `ltm_enabled` selects the LTM overlay for this compile (the same choice -/// `simlin_sim_new(.., enable_ltm)` makes) and `ltm_discovery_mode` sets the -/// discovery flag on this compile's own `SourceProject`: the produced blob's -/// layout includes the `$\u{205A}ltm\u{205A}*` synthetic series iff -/// `ltm_enabled` is true. +/// `ltm_enabled` selects the LTM overlay and latches the project's LTM +/// diagnostic request, as `simlin_sim_new` does. `ltm_discovery_mode` overrides +/// discovery for this compile only; the shared project's prior flag is restored +/// on success and failure. Special stocks follow the VM's expansion path and +/// LTM degradation contract. /// /// # Safety /// - `model` must be a valid pointer to a SimlinModel @@ -125,18 +127,56 @@ pub unsafe extern "C" fn simlin_model_compile_to_wasm( } }; - // The compiled-model wasm is regenerated from the project's datamodel; it - // does not depend on the VM `SimState`, so this works even before a - // `SimlinSim` has been created for the model. let project_ref = &*model_ref.project; - let datamodel = project_ref.datamodel.lock().unwrap(); - - let artifact = match engine::wasmgen::compile_datamodel_to_artifact( - &datamodel, - model_ref.model_name.as_str(), - ltm_enabled, - ltm_discovery_mode, - ) { + let build = { + // Edits and VM creation take these locks in the same order. Keep both + // until the snapshot is assembled so special-stock dispatch cannot + // observe markers from a different revision than its source inputs. + let datamodel = project_ref.datamodel.lock().unwrap(); + let mut db = project_ref.lock_db(); + if let Some(source_project) = db.current_source_project() { + if ltm_enabled { + project_ref + .ltm_requested + .store(true, std::sync::atomic::Ordering::Release); + } + // Discovery is per-call, unlike the project's persistent input. + // Restore before inspecting the result so compilation errors cannot + // leak the override into VM creation, analysis, or diagnostics. + let prior_discovery = source_project.ltm_discovery_mode(&*db); + engine::db::set_project_ltm_discovery_mode(&mut db, source_project, ltm_discovery_mode); + let build = engine::queue_compile::compile_sim( + &mut db, + source_project, + &datamodel, + model_ref.model_name.as_str(), + engine::db::LtmOverlay::from(ltm_enabled), + ); + engine::db::set_project_ltm_discovery_mode(&mut db, source_project, prior_discovery); + build + } else { + Err(engine::Error { + kind: engine::ErrorKind::Simulation, + code: engine::ErrorCode::NotSimulatable, + details: Some("incremental compilation: no sync state available".to_string()), + }) + } + }; + // Emission reads only the immutable compiled snapshot and its side plans; + // release the project locks before doing this independent work. + let artifact = match build + .map_err(|e| { + engine::wasmgen::WasmGenError::Unsupported(format!( + "wasmgen: incremental compile failed: {e:?}" + )) + }) + .and_then(|build| { + engine::wasmgen::compile_simulation_with_plans( + &build.compiled, + &build.conveyor_plans, + &build.queue_plans, + ) + }) { Ok(artifact) => artifact, Err(err) => { store_error( diff --git a/src/libsimlin/tests/integration/wasm.rs b/src/libsimlin/tests/integration/wasm.rs index 1286d1246..0de581ec4 100644 --- a/src/libsimlin/tests/integration/wasm.rs +++ b/src/libsimlin/tests/integration/wasm.rs @@ -13,6 +13,8 @@ //! stride from the results region (using only the returned layout) is checked //! against the bytecode VM via `simlin_sim_get_series`. +mod incremental; + use std::ptr; use crate::common::open_project_from_datamodel; diff --git a/src/libsimlin/tests/integration/wasm/incremental.rs b/src/libsimlin/tests/integration/wasm/incremental.rs new file mode 100644 index 000000000..1d03dc0ca --- /dev/null +++ b/src/libsimlin/tests/integration/wasm/incremental.rs @@ -0,0 +1,309 @@ +// 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. + +//! Persistent compilation through the public WASM FFI. Reuse is measured by +//! actual salsa body executions, not equal output or memo pointer identity. + +use super::{parse_layout, run_and_stride, ParsedLayout}; +use crate::common::{expect_no_error, open_project_from_datamodel}; +use simlin::*; +use simlin_engine::{self as engine, db::exec_probe::ProbedDb, test_common::TestProject}; +use std::{ffi::CStr, ptr}; + +struct Fixture { + project: *mut SimlinProject, + model: *mut SimlinModel, +} + +impl Fixture { + fn new(datamodel: &engine::datamodel::Project) -> Self { + let project = open_project_from_datamodel(datamodel); + unsafe { + let mut error = ptr::null_mut(); + let model = simlin_project_get_model(project, ptr::null(), &mut error); + expect_no_error(error, "get model"); + assert!(!model.is_null()); + Self { project, model } + } + } + + fn compile(&self, ltm: bool, discovery: bool) -> Result<(Vec, ParsedLayout), String> { + unsafe { + let (mut wasm, mut layout, mut error) = + (ptr::null_mut(), ptr::null_mut(), ptr::null_mut()); + let (mut wasm_len, mut layout_len) = (0, 0); + simlin_model_compile_to_wasm( + self.model, + ltm, + discovery, + &mut wasm, + &mut wasm_len, + &mut layout, + &mut layout_len, + &mut error, + ); + if !error.is_null() { + assert!(wasm.is_null() && layout.is_null()); + assert_eq!((wasm_len, layout_len), (0, 0)); + let message = CStr::from_ptr(simlin_error_get_message(error)) + .to_string_lossy() + .into_owned(); + simlin_error_free(error); + return Err(message); + } + let blob = std::slice::from_raw_parts(wasm, wasm_len).to_vec(); + let parsed = parse_layout(std::slice::from_raw_parts(layout, layout_len)); + simlin_free(wasm); + simlin_free(layout); + Ok((blob, parsed)) + } + } + + fn discovery(&self) -> bool { + let db = unsafe { &*self.project }.lock_db(); + db.current_source_project() + .unwrap() + .ltm_discovery_mode(&*db) + } + + fn set_discovery(&self, value: bool) { + let mut db = unsafe { &*self.project }.lock_db(); + let source = db.current_source_project().unwrap(); + engine::db::set_project_ltm_discovery_mode(&mut db, source, value); + } + + fn swap_db(&self, probe: &mut ProbedDb) { + std::mem::swap(&mut *unsafe { &*self.project }.lock_db(), probe.db_mut()); + } +} + +impl Drop for Fixture { + fn drop(&mut self) { + unsafe { + simlin_model_unref(self.model); + simlin_project_unref(self.project); + } + } +} + +fn feedback_model() -> engine::datamodel::Project { + TestProject::new("wasm_incremental") + .with_sim_time(0.0, 3.0, 1.0) + .aux("rate", "0.1", None) + .stock("level", "1", &["growth"], &[], None) + .flow("growth", "rate * level", None) + .build_datamodel() +} + +/// Cover both overlay values and transition directions under each matching +/// project/call discovery setting. A differing per-call discovery override +/// changes salsa inputs and is tested for isolation below, not cache reuse. +/// The initial cold +/// call must populate THIS project's db; otherwise a throwaway compiler would +/// falsely look like zero query executions on every subsequent call. +#[test] +fn compile_to_wasm_reuses_project_queries_across_overlay_toggles() { + for discovery in [false, true] { + let datamodel = feedback_model(); + let fixture = Fixture::new(&datamodel); + let mut probe = ProbedDb::new(); + // The same sync entry point project constructors use supplies real source + // inputs; the probe changes only salsa's event sink, not the input shape. + let source = probe.db_mut().sync(&datamodel); + engine::db::set_project_ltm_discovery_mode(probe.db_mut(), source, discovery); + for (ltm, cold) in [ + (false, true), + (false, false), + (true, true), + (true, false), + (false, false), + (true, false), + ] { + probe.reset(); + fixture.swap_db(&mut probe); + let result = fixture.compile(ltm, discovery); + fixture.swap_db(&mut probe); + let (_, layout) = result.unwrap(); + let counts = probe.counts(); + if cold { + assert!( + counts + .get("assemble_simulation") + .is_some_and(|(runs, _)| *runs > 0), + "first overlay={ltm} compile must execute in the project db: {counts:?}" + ); + } else { + assert!( + counts.is_empty(), + "overlay={ltm} must reuse query results: {counts:?}" + ); + } + assert_eq!( + layout + .var_offsets + .iter() + .any(|(name, _)| name.starts_with("$⁚ltm⁚")), + ltm + ); + } + } +} + +/// Every cross-product arm matters: the per-call discovery flag overrides the +/// project flag even with the overlay disabled, then restores either prior +/// value. Separate tests below cover compile and codegen failure paths. +#[test] +fn compile_to_wasm_discovery_flags_are_per_call() { + let fixture = Fixture::new(&feedback_model()); + for prior in [false, true] { + fixture.set_discovery(prior); + for ltm in [false, true] { + for discovery in [false, true] { + let (blob, layout) = fixture.compile(ltm, discovery).unwrap(); + assert_eq!(fixture.discovery(), prior); + let loop_columns = layout + .var_offsets + .iter() + .any(|(name, _)| name.starts_with("$⁚ltm⁚loop_score⁚")); + assert_eq!( + loop_columns, + ltm && !discovery, + "ltm={ltm}, discovery={discovery}, prior={prior}" + ); + let level = layout + .var_offsets + .iter() + .find(|(name, _)| name == "level") + .unwrap() + .1; + assert_eq!( + run_and_stride(&blob, &layout, level), + vec![1.0, 1.1, 1.2100000000000002, 1.3310000000000002] + ); + } + } + } +} + +#[test] +fn compile_to_wasm_patch_invalidates_the_program() { + let fixture = Fixture::new(&super::simple_model()); + let (before, before_layout) = fixture.compile(false, false).unwrap(); + let patch = br#"{"models":[{"name":"main","ops":[{"type":"upsertAux","payload":{"aux":{"name":"inflow_rate","equation":"3"}}}]}]}"#; + unsafe { + let (mut collected, mut error) = (ptr::null_mut(), ptr::null_mut()); + simlin_project_apply_patch( + fixture.project, + patch.as_ptr(), + patch.len(), + false, + true, + &mut collected, + &mut error, + ); + expect_no_error(error, "patch rate"); + if !collected.is_null() { + simlin_error_free(collected); + } + } + for ltm in [false, true] { + let (after, layout) = fixture.compile(ltm, false).unwrap(); + let level = layout + .var_offsets + .iter() + .find(|(name, _)| name == "level") + .unwrap() + .1; + assert_eq!( + *run_and_stride(&after, &layout, level).last().unwrap(), + 30.0 + ); + } + let old_level = before_layout + .var_offsets + .iter() + .find(|(name, _)| name == "level") + .unwrap() + .1; + assert_eq!( + *run_and_stride(&before, &before_layout, old_level) + .last() + .unwrap(), + 20.0 + ); +} + +/// Engine compilation and WASM lowering fail at different stages. Both must +/// restore either prior discovery flag and leave both FFI buffers unset. +#[test] +fn compile_to_wasm_errors_restore_discovery() { + let invalid = TestProject::new("invalid") + .aux("a", "missing_variable + 1", None) + .build_datamodel(); + let unsupported = TestProject::new("unsupported") + .with_sim_time(0.0, 1.0, 1.0) + .indexed_dimension("A", 3) + .array_aux("source[A]", "A") + .scalar_aux("lo", "1") + .scalar_aux("hi", "2") + .scalar_aux("total", "SUM(source[lo:hi])") + .build_datamodel(); + for datamodel in [&invalid, &unsupported] { + let fixture = Fixture::new(datamodel); + for prior in [false, true] { + fixture.set_discovery(prior); + for ltm in [false, true] { + for discovery in [false, true] { + assert!(fixture.compile(ltm, discovery).is_err()); + assert_eq!(fixture.discovery(), prior); + } + } + } + } +} + +/// Enumerate both special-stock dispatch arms and the false/true/false request +/// sequence: warnings are absent before a request and remain latched afterward. +#[test] +fn compile_to_wasm_latches_special_stock_ltm_diagnostics() { + for (xml, stock_type) in [ + ( + include_str!("../../../../../test/conveyors/minimal_conveyor.xmile"), + "conveyor stock", + ), + ( + include_str!("../../../../../test/queues/queue_drain.xmile"), + "queue stock", + ), + ] { + let datamodel = engine::open_xmile(&mut std::io::BufReader::new(xml.as_bytes())).unwrap(); + let fixture = Fixture::new(&datamodel); + for (request, expected) in [(false, false), (true, true), (false, true)] { + fixture.compile(request, false).unwrap(); + unsafe { + let mut error = ptr::null_mut(); + let diagnostics = simlin_project_get_errors(fixture.project, &mut error); + expect_no_error(error, "get diagnostics"); + let mut degraded = false; + if !diagnostics.is_null() { + let count = simlin_error_get_detail_count(diagnostics); + if count > 0 { + let details = std::slice::from_raw_parts( + simlin_error_get_details(diagnostics), + count, + ); + degraded = details.iter().any(|detail| { + !detail.message.is_null() && { + let message = CStr::from_ptr(detail.message).to_string_lossy(); + message.contains(stock_type) && message.contains("is degraded") + } + }); + } + simlin_error_free(diagnostics); + } + assert_eq!(degraded, expected, "{stock_type}, request={request}"); + } + } + } +} diff --git a/src/simlin-engine/src/db.rs b/src/simlin-engine/src/db.rs index 44d858f07..143dac8d0 100644 --- a/src/simlin-engine/src/db.rs +++ b/src/simlin-engine/src/db.rs @@ -48,8 +48,9 @@ pub(crate) use dep_graph::var_noninitial_lowered_exprs; mod element_graph_proptest; // Salsa execution counting for incrementality tests: which tracked queries ran // a body over a measured region, from salsa's own `WillExecute` events. -#[cfg(test)] -mod exec_probe; +#[cfg(any(test, feature = "test-support"))] +#[doc(hidden)] +pub mod exec_probe; mod invariance; pub(crate) use invariance::model_flows_invariant; // `pub(crate)` (not private-to-`db`) so the Track-A classifier-agreement gate, @@ -301,7 +302,7 @@ pub(crate) struct StdlibModels { #[salsa::db] impl salsa::Database for SimlinDb {} -#[cfg(test)] +#[cfg(any(test, feature = "test-support"))] impl SimlinDb { /// A db over caller-supplied salsa storage, so a test can attach an event /// callback (`db::exec_probe`). Every other field takes its `Default`, diff --git a/src/simlin-engine/src/db/exec_probe.rs b/src/simlin-engine/src/db/exec_probe.rs index 1435f2816..f0a8ab034 100644 --- a/src/simlin-engine/src/db/exec_probe.rs +++ b/src/simlin-engine/src/db/exec_probe.rs @@ -34,13 +34,14 @@ use super::SimlinDb; /// The db is reached through [`ProbedDb::db`] / [`ProbedDb::db_mut`] and used /// exactly like any other; [`ProbedDb::reset`] starts a measured region and /// [`ProbedDb::counts`] reads it back as query name -> execution count. -pub(crate) struct ProbedDb { +pub struct ProbedDb { db: SimlinDb, log: Arc>>, } impl ProbedDb { - pub(crate) fn new() -> Self { + /// Create an empty database whose tracked-query executions are recorded. + pub fn new() -> Self { let log: Arc>> = Arc::new(Mutex::new(Vec::new())); let sink = Arc::clone(&log); let storage = salsa::Storage::new(Some(Box::new(move |event: Event| { @@ -62,18 +63,20 @@ impl ProbedDb { } } - pub(crate) fn db(&self) -> &SimlinDb { + /// Read the database without changing its execution log. + pub fn db(&self) -> &SimlinDb { &self.db } - pub(crate) fn db_mut(&mut self) -> &mut SimlinDb { + /// Mutably access the database for production sync and compilation calls. + pub fn db_mut(&mut self) -> &mut SimlinDb { &mut self.db } /// Start (or restart) a measured region, discarding what came before. /// Call it after the fixture is built and primed, so setup is not charged /// to the region. - pub(crate) fn reset(&self) { + pub fn reset(&self) { self.log .lock() .expect("execution-probe log poisoned") @@ -87,7 +90,7 @@ impl ProbedDb { /// name. Both halves are reported because they answer different questions /// -- one key re-running `n` times is a query being re-demanded, `n` keys /// running once each is a query having been re-keyed or newly demanded. - pub(crate) fn counts(&self) -> BTreeMap { + pub fn counts(&self) -> BTreeMap { let log = self.log.lock().expect("execution-probe log poisoned"); let mut runs: BTreeMap = BTreeMap::new(); let mut keys: BTreeMap> = BTreeMap::new(); From ed108f4a8ef32a3fd628b293ee6be256bfd5f9f9 Mon Sep 17 00:00:00 2001 From: Bobby Powers Date: Wed, 9 Sep 2026 22:59:51 -0700 Subject: [PATCH 4/5] engine: clarify wasm numerical accuracy boundaries Per-helper accuracy checks do not bound composed finite-difference ratios near cancellation. Describe the separate raw, normalized, sign, and nonfinite comparisons required for LTM, and align the power lowering comment with its negative-base and special-value handling. --- src/simlin-engine/src/wasmgen/lower.rs | 5 ++--- src/simlin-engine/src/wasmgen/math.rs | 10 +++++++--- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/src/simlin-engine/src/wasmgen/lower.rs b/src/simlin-engine/src/wasmgen/lower.rs index faaff3688..48cd72bba 100644 --- a/src/simlin-engine/src/wasmgen/lower.rs +++ b/src/simlin-engine/src/wasmgen/lower.rs @@ -2190,9 +2190,8 @@ fn emit_op2(op: Op2, ctx: &EmitCtx, f: &mut Function) -> Result<(), WasmGenError // `And`/`Or` are `(is_truthy(l) OP is_truthy(r)) as f64`. Op2::And => emit_logical(ctx, f, Instruction::I32And), Op2::Or => emit_logical(ctx, f, Instruction::I32Or), - // `Exp` is `l.powf(r)`: the operands `[l, r]` are already in call - // order, so `call pow` directly. Matches `powf` for a positive base - // (a negative base diverges -- see `super::math::emit_pow`). + // `pow` handles negative bases and special values explicitly; ordinary + // finite results use the approximation documented in `math::emit_pow`. Op2::Exp => { f.instruction(&Instruction::Call(ctx.helpers.pow)); } diff --git a/src/simlin-engine/src/wasmgen/math.rs b/src/simlin-engine/src/wasmgen/math.rs index 2ee72e19c..b671a1e96 100644 --- a/src/simlin-engine/src/wasmgen/math.rs +++ b/src/simlin-engine/src/wasmgen/math.rs @@ -27,9 +27,13 @@ //! this backend must clear the same comparison the VM clears. The kernels here //! are chosen so each helper's worst-case error over its domain sits *far* //! inside that bar (each emitter's rustdoc records the measured worst-case error -//! and the test that pins it); the slack absorbs any DLR-FT-vs-native rounding -//! drift. The per-helper unit tests assert against Rust `f64` with a documented -//! tolerance comfortably tighter than the corpus bar. +//! and the test that samples it). The per-helper unit tests compare against +//! Rust `f64` with a documented tolerance tighter than the corpus bar; model +//! tests establish the accuracy of composed calculations on their fixtures. +//! Cancellation and division by a small delta can amplify helper differences, +//! especially in raw LTM scores. Ordinary trajectory tolerances therefore do +//! not imply a uniform absolute error bound on derived scores. Check raw score +//! magnitudes, normalized scores, signs, and nonfinite values separately. //! //! ## Composition //! From f9b53a70c52dbb5c119401e07427480a4b716b22 Mon Sep 17 00:00:00 2001 From: Bobby Powers Date: Thu, 10 Sep 2026 18:51:29 -0700 Subject: [PATCH 5/5] engine: share one exact guard across LTM link scores The zero-change guard lived in three hand-written copies, and the switch from approximate `=` to exact `ABS(delta) <= 0` reached only two of them. The black-box module transfer kept `(delta) = 0`, so a loop through a pathway-less module whose deltas fall under f64::EPSILON scored 0 at every step. Every generator now wraps its score in one builder, `ltm_augment::link_score_guard`, so the spelling cannot drift again. The first-step test had the same defect on the time axis: `TIME = INITIAL_TIME` treated steps within f64::EPSILON of a zero start, or within 4 ULPs of a large one, as the first evaluation. The VM and the wasm backend start the clock at exactly the start time and only advance it, so the guard is `TIME <= INITIAL_TIME`. Both regressions run through the production pipeline; the black-box one also asserts which generator produced the score, because a composite scores the same values. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01N838Szayoh8oz3snEC9na2 --- docs/design/ltm--loops-that-matter.md | 34 +++-- docs/reference/ltm--loops-that-matter.md | 2 +- src/simlin-engine/src/db.rs | 11 +- .../ltm_loop_discovery.txt | 10 +- .../ltm_loop_exhaustive.txt | 4 +- .../db/ltm_char_golden/agg_nested_reducer.txt | 10 +- .../ltm_char_golden/agg_to_arrayed_target.txt | 4 +- .../ltm_char_golden/agg_to_scalar_target.txt | 2 +- .../already_lagged_other_dep.txt | 2 +- .../ltm_char_golden/arrayed_agg_to_target.txt | 14 +- .../arrayed_target_no_default_slot_scores.txt | 8 +- .../arrayed_target_slot_scores.txt | 12 +- .../per_element_ambiguous_pin.txt | 10 +- .../per_element_dynamic_index.txt | 6 +- .../per_element_index_nested_occurrence.txt | 4 +- .../per_element_link_scores.txt | 4 +- .../per_element_mapped_occurrence.txt | 10 +- .../per_element_mixed_occurrences.txt | 10 +- .../per_element_other_deps.txt | 8 +- .../reducer_index_nested_freeze.txt | 2 +- .../scalar_feeder_bare_in_hoisted_reducer.txt | 4 +- .../unquotable_source_name.txt | 4 +- src/simlin-engine/src/db/ltm_char_tests.rs | 2 +- src/simlin-engine/src/db/ltm_tests.rs | 4 +- src/simlin-engine/src/db/ltm_unified_tests.rs | 2 +- src/simlin-engine/src/ltm_augment.rs | 56 +++++--- src/simlin-engine/src/ltm_augment_freeze.rs | 2 +- src/simlin-engine/src/ltm_augment_tests.rs | 26 ++-- src/simlin-engine/src/ltm_finding_enum.rs | 2 +- .../src/ltm_finding_enum_tests.rs | 4 +- src/simlin-engine/src/ltm_finding_fallback.rs | 2 +- src/simlin-engine/src/ltm_finding_tests.rs | 4 +- src/simlin-engine/src/wasmgen/module.rs | 12 +- .../tests/integration/ltm_array_agg.rs | 22 +-- .../integration/ltm_discovery_large_models.rs | 2 +- .../tests/integration/ltm_dt_invariance.rs | 2 +- .../tests/integration/ltm_snapshot_inputs.rs | 130 ++++++++++++++++++ 37 files changed, 299 insertions(+), 148 deletions(-) diff --git a/docs/design/ltm--loops-that-matter.md b/docs/design/ltm--loops-that-matter.md index 8f79b1ffb..7a3029951 100644 --- a/docs/design/ltm--loops-that-matter.md +++ b/docs/design/ltm--loops-that-matter.md @@ -311,19 +311,25 @@ For a link from `x` to `z` where `z = f(x, y, ...)`: modules: the `eqn` field holds the original text like `SMTH1(x, 5)` while the AST holds the expanded form like `$⁚s⁚0⁚smth1·output`). 2. Compute the dependency set from the AST via `identifier_set()`. -3. Build the ceteris-paribus partial equation using `build_partial_equation()`, - which parses the equation into an `Expr0` AST, recursively walks the tree - wrapping variable references in `PREVIOUS()` for all dependencies except `x` - (`wrap_non_matching_in_previous`), and prints the result back to equation text. This +3. Build the ceteris-paribus partial equation using `build_partial_equation_shaped()`, + which recursively walks the `Expr0` tree wrapping variable references in + `PREVIOUS()` for all dependencies except `x` (`wrap_non_matching_in_previous`), + and prints the result back to equation text. This AST-based approach avoids the pitfalls of text-based replacement (e.g., replacing `x` inside `x_rate`, or corrupting function names like `MAX`). 4. The link score is: ``` - if (TIME = INITIAL_TIME) then 0 - else if ((z - PREVIOUS(z)) = 0) OR ((x - PREVIOUS(x)) = 0) then 0 - else ABS(SAFEDIV((partial_eq - PREVIOUS(z)), (z - PREVIOUS(z)), 0)) - * SIGN(SAFEDIV((partial_eq - PREVIOUS(z)), (x - PREVIOUS(x)), 0)) + if (TIME <= INITIAL_TIME) then 0 + else if (ABS((z - PREVIOUS(z))) <= 0) OR (ABS((x - PREVIOUS(x))) <= 0) then 0 + else SAFEDIV(((partial_eq) - PREVIOUS(z)), ABS((z - PREVIOUS(z))), 0) + * SIGN((x - PREVIOUS(x))) ``` + `|N/Δz| * sign(N/Δx)` is written `SAFEDIV(N, |Δz|, 0) * sign(Δx)`, so the + partial appears once. Every generator (this form, the flow-to-stock and + element-reducer scores, and the black-box module transfer) wraps its score + in one guard, `link_score_guard`. Both of its comparisons are exact: + equation `=` is approximate, and an approximate zero-change or first-step + test would make a score depend on the model's units or time scale. ### Flow-to-Stock Links @@ -343,9 +349,9 @@ outflow -- so the emitted equation is the standard guard form with that numerator: ``` -if (TIME = INITIAL_TIME) then 0 -else if ((net - PREVIOUS(net)) = 0) OR ((flow - PREVIOUS(flow)) = 0) then 0 -else SAFEDIV(+/-(flow - PREVIOUS(flow)), ABS(net - PREVIOUS(net)), 0) * SIGN(flow - PREVIOUS(flow)) +if (TIME <= INITIAL_TIME) then 0 +else if (ABS((net - PREVIOUS(net))) <= 0) OR (ABS((flow - PREVIOUS(flow))) <= 0) then 0 +else SAFEDIV(+/-(flow - PREVIOUS(flow)), ABS((net - PREVIOUS(net))), 0) * SIGN((flow - PREVIOUS(flow))) ``` which evaluates to `sign * |Δflow / Δnet|`: the 2023 paper's Eq. 3 (its @@ -357,7 +363,7 @@ Polarity is structural: inflows +1, outflows -1. Both deltas are read over all describe one interval, the score is the same whether a stock's flows are written separately or as one net flow, and no `dt` appears (an isolated loop scores exactly `+/-1` at every `dt`, `tests/integration/ltm_dt_invariance.rs`). -Like every other score it is 0 at `TIME = INITIAL_TIME` and defined from the +Like every other score it is 0 at `TIME <= INITIAL_TIME` and defined from the first step after the start. The net aux is LTM machinery, not a causal node: the causal graph keeps its `flow -> stock` edges, and the aux appears in no loop and no link. @@ -975,7 +981,7 @@ edge: they cannot disagree about which cycles exist: a value is active when it is finite and nonzero, or infinite (a divergent link is real signal); only NaN and an exact zero are inactive. Step 0 is excluded from union membership and masked -out of every activity test: every link-score equation's `TIME = INITIAL_TIME` +out of every activity test: every link-score equation's `TIME <= INITIAL_TIME` guard arm is emitted as the literal constant `0`, so a cycle "active" only there is not a scorable loop. Self-edges are dropped at build time -- an elementary cycle never repeats a node, so a self-edge can neither be nor extend one, and a @@ -2150,7 +2156,7 @@ cases remain deliberate carve-outs: 5. **PREVIOUS is intrinsic**: The `PREVIOUS()` function used in link score equations is compiled as an intrinsic two-argument builtin. Unary syntax is desugared to `PREVIOUS(x, 0)`. LTM first-timestep behavior is handled - explicitly with `TIME = INITIAL_TIME`. + explicitly with `TIME <= INITIAL_TIME`. 6. **Relative loop score formula and timing**: The implementation computes `loop_score / sum_of_abs_scores` with explicit division-by-zero protection diff --git a/docs/reference/ltm--loops-that-matter.md b/docs/reference/ltm--loops-that-matter.md index f0a76f488..7a22b0940 100644 --- a/docs/reference/ltm--loops-that-matter.md +++ b/docs/reference/ltm--loops-that-matter.md @@ -417,7 +417,7 @@ score 1 for all net-flow-to-stock links. Both produce identical results. > `[t - dt, t]` window as every other link score in the model: a loop's link scores all > describe one interval, the score is the same whether the stock's flows are written > separately or as one net flow, and it is defined from the first step after the start like -> every other link (0 at `TIME = INITIAL_TIME`). +> every other link (0 at `TIME <= INITIAL_TIME`). > > **Time labeling, for every link score.** Simlin labels the score computed over > `[t - dt, t]` with `t` (its reading of `PREVIOUS`). Stella labels the score computed over diff --git a/src/simlin-engine/src/db.rs b/src/simlin-engine/src/db.rs index 143dac8d0..e3aab2da1 100644 --- a/src/simlin-engine/src/db.rs +++ b/src/simlin-engine/src/db.rs @@ -688,13 +688,10 @@ pub struct LtmVariablesResult { pub(super) fn black_box_unit_transfer_equation(from_ref: &str, to_ref: &str) -> String { let from_q = crate::ltm_augment::quote_ident(from_ref); let to_q = crate::ltm_augment::quote_ident(to_ref); - format!( - "if (TIME = INITIAL_TIME) then 0 \ - else if (({to_q} - PREVIOUS({to_q})) = 0) OR \ - (({from_q} - PREVIOUS({from_q})) = 0) \ - then 0 \ - else (SIGN({to_q} - PREVIOUS({to_q})) * \ - SIGN({from_q} - PREVIOUS({from_q})))" + crate::ltm_augment::link_score_guard( + &to_q, + &from_q, + &format!("(SIGN({to_q} - PREVIOUS({to_q})) * SIGN({from_q} - PREVIOUS({from_q})))"), ) } diff --git a/src/simlin-engine/src/db/fragment_char_golden/ltm_loop_discovery.txt b/src/simlin-engine/src/db/fragment_char_golden/ltm_loop_discovery.txt index 6ee97b363..f7523d365 100644 --- a/src/simlin-engine/src/db/fragment_char_golden/ltm_loop_discovery.txt +++ b/src/simlin-engine/src/db/fragment_char_golden/ltm_loop_discovery.txt @@ -153,7 +153,7 @@ 0035 If 0036 LoadGlobalVar off=0 (time) 0037 LoadGlobalVar off=2 (initial_time) - 0038 Op2 Eq + 0038 Op2 Lte 0039 SetCond 0040 If 0041 AssignCurr $⁚ltm⁚link_score⁚growth→level@0 @@ -214,7 +214,7 @@ 0042 If 0043 LoadGlobalVar off=0 (time) 0044 LoadGlobalVar off=2 (initial_time) - 0045 Op2 Eq + 0045 Op2 Lte 0046 SetCond 0047 If 0048 AssignCurr $⁚ltm⁚link_score⁚idx→growth@0 @@ -274,7 +274,7 @@ 0041 If 0042 LoadGlobalVar off=0 (time) 0043 LoadGlobalVar off=2 (initial_time) - 0044 Op2 Eq + 0044 Op2 Lte 0045 SetCond 0046 If 0047 AssignCurr $⁚ltm⁚link_score⁚level→growth@0 @@ -334,7 +334,7 @@ 0041 If 0042 LoadGlobalVar off=0 (time) 0043 LoadGlobalVar off=2 (initial_time) - 0044 Op2 Eq + 0044 Op2 Lte 0045 SetCond 0046 If 0047 AssignCurr $⁚ltm⁚link_score⁚rate→growth@0 @@ -407,7 +407,7 @@ 0054 If 0055 LoadGlobalVar off=0 (time) 0056 LoadGlobalVar off=2 (initial_time) - 0057 Op2 Eq + 0057 Op2 Lte 0058 SetCond 0059 If 0060 AssignCurr $⁚ltm⁚link_score⁚weight→growth@0 diff --git a/src/simlin-engine/src/db/fragment_char_golden/ltm_loop_exhaustive.txt b/src/simlin-engine/src/db/fragment_char_golden/ltm_loop_exhaustive.txt index 9acbdd860..4379e7e45 100644 --- a/src/simlin-engine/src/db/fragment_char_golden/ltm_loop_exhaustive.txt +++ b/src/simlin-engine/src/db/fragment_char_golden/ltm_loop_exhaustive.txt @@ -76,7 +76,7 @@ 0035 If 0036 LoadGlobalVar off=0 (time) 0037 LoadGlobalVar off=2 (initial_time) - 0038 Op2 Eq + 0038 Op2 Lte 0039 SetCond 0040 If 0041 AssignCurr $⁚ltm⁚link_score⁚growth→level@0 @@ -136,7 +136,7 @@ 0041 If 0042 LoadGlobalVar off=0 (time) 0043 LoadGlobalVar off=2 (initial_time) - 0044 Op2 Eq + 0044 Op2 Lte 0045 SetCond 0046 If 0047 AssignCurr $⁚ltm⁚link_score⁚level→growth@0 diff --git a/src/simlin-engine/src/db/ltm_char_golden/agg_nested_reducer.txt b/src/simlin-engine/src/db/ltm_char_golden/agg_nested_reducer.txt index 41573a6fd..8bd584d9b 100644 --- a/src/simlin-engine/src/db/ltm_char_golden/agg_nested_reducer.txt +++ b/src/simlin-engine/src/db/ltm_char_golden/agg_nested_reducer.txt @@ -1,10 +1,10 @@ $⁚ltm⁚link_score⁚$⁚ltm⁚agg⁚0→growth[boston] -scalar: if (TIME = INITIAL_TIME) then 0 else if (ABS((growth[region·boston] - PREVIOUS(growth[region·boston]))) <= 0) OR (ABS(("$⁚ltm⁚agg⁚0" - PREVIOUS("$⁚ltm⁚agg⁚0"))) <= 0) then 0 else SAFEDIV(((sum(PREVIOUS(matrix[region·boston, *]) * PREVIOUS(other[region·nyc, *]) * "$⁚ltm⁚agg⁚0") * 0.001) - PREVIOUS(growth[region·boston])), ABS((growth[region·boston] - PREVIOUS(growth[region·boston]))), 0) * SIGN(("$⁚ltm⁚agg⁚0" - PREVIOUS("$⁚ltm⁚agg⁚0"))) +scalar: if (TIME <= INITIAL_TIME) then 0 else if (ABS((growth[region·boston] - PREVIOUS(growth[region·boston]))) <= 0) OR (ABS(("$⁚ltm⁚agg⁚0" - PREVIOUS("$⁚ltm⁚agg⁚0"))) <= 0) then 0 else SAFEDIV(((sum(PREVIOUS(matrix[region·boston, *]) * PREVIOUS(other[region·nyc, *]) * "$⁚ltm⁚agg⁚0") * 0.001) - PREVIOUS(growth[region·boston])), ABS((growth[region·boston] - PREVIOUS(growth[region·boston]))), 0) * SIGN(("$⁚ltm⁚agg⁚0" - PREVIOUS("$⁚ltm⁚agg⁚0"))) $⁚ltm⁚link_score⁚$⁚ltm⁚agg⁚0→growth[nyc] -scalar: if (TIME = INITIAL_TIME) then 0 else if (ABS((growth[region·nyc] - PREVIOUS(growth[region·nyc]))) <= 0) OR (ABS(("$⁚ltm⁚agg⁚0" - PREVIOUS("$⁚ltm⁚agg⁚0"))) <= 0) then 0 else SAFEDIV(((sum(PREVIOUS(matrix[region·nyc, *]) * PREVIOUS(other[region·nyc, *]) * "$⁚ltm⁚agg⁚0") * 0.001) - PREVIOUS(growth[region·nyc])), ABS((growth[region·nyc] - PREVIOUS(growth[region·nyc]))), 0) * SIGN(("$⁚ltm⁚agg⁚0" - PREVIOUS("$⁚ltm⁚agg⁚0"))) +scalar: if (TIME <= INITIAL_TIME) then 0 else if (ABS((growth[region·nyc] - PREVIOUS(growth[region·nyc]))) <= 0) OR (ABS(("$⁚ltm⁚agg⁚0" - PREVIOUS("$⁚ltm⁚agg⁚0"))) <= 0) then 0 else SAFEDIV(((sum(PREVIOUS(matrix[region·nyc, *]) * PREVIOUS(other[region·nyc, *]) * "$⁚ltm⁚agg⁚0") * 0.001) - PREVIOUS(growth[region·nyc])), ABS((growth[region·nyc] - PREVIOUS(growth[region·nyc]))), 0) * SIGN(("$⁚ltm⁚agg⁚0" - PREVIOUS("$⁚ltm⁚agg⁚0"))) $⁚ltm⁚link_score⁚growth→stock dims=[Region] -a2a[Region]: if (TIME = INITIAL_TIME) then 0 else if (ABS(("$⁚ltm⁚net⁚stock"[Region] - PREVIOUS("$⁚ltm⁚net⁚stock"[Region]))) <= 0) OR (ABS((growth[Region] - PREVIOUS(growth[Region]))) <= 0) then 0 else SAFEDIV((growth[Region] - PREVIOUS(growth[Region])), ABS(("$⁚ltm⁚net⁚stock"[Region] - PREVIOUS("$⁚ltm⁚net⁚stock"[Region]))), 0) * SIGN((growth[Region] - PREVIOUS(growth[Region]))) +a2a[Region]: if (TIME <= INITIAL_TIME) then 0 else if (ABS(("$⁚ltm⁚net⁚stock"[Region] - PREVIOUS("$⁚ltm⁚net⁚stock"[Region]))) <= 0) OR (ABS((growth[Region] - PREVIOUS(growth[Region]))) <= 0) then 0 else SAFEDIV((growth[Region] - PREVIOUS(growth[Region])), ABS(("$⁚ltm⁚net⁚stock"[Region] - PREVIOUS("$⁚ltm⁚net⁚stock"[Region]))), 0) * SIGN((growth[Region] - PREVIOUS(growth[Region]))) $⁚ltm⁚link_score⁚pop[boston]→$⁚ltm⁚agg⁚0 -scalar: if (TIME = INITIAL_TIME) then 0 else if (ABS(("$⁚ltm⁚agg⁚0" - PREVIOUS("$⁚ltm⁚agg⁚0"))) <= 0) OR (ABS((pop[region·boston] - PREVIOUS(pop[region·boston]))) <= 0) then 0 else SAFEDIV((PREVIOUS("$⁚ltm⁚agg⁚0") + (pop[region·boston] - PREVIOUS(pop[region·boston])) - PREVIOUS("$⁚ltm⁚agg⁚0")), ABS(("$⁚ltm⁚agg⁚0" - PREVIOUS("$⁚ltm⁚agg⁚0"))), 0) * SIGN((pop[region·boston] - PREVIOUS(pop[region·boston]))) +scalar: if (TIME <= INITIAL_TIME) then 0 else if (ABS(("$⁚ltm⁚agg⁚0" - PREVIOUS("$⁚ltm⁚agg⁚0"))) <= 0) OR (ABS((pop[region·boston] - PREVIOUS(pop[region·boston]))) <= 0) then 0 else SAFEDIV((PREVIOUS("$⁚ltm⁚agg⁚0") + (pop[region·boston] - PREVIOUS(pop[region·boston])) - PREVIOUS("$⁚ltm⁚agg⁚0")), ABS(("$⁚ltm⁚agg⁚0" - PREVIOUS("$⁚ltm⁚agg⁚0"))), 0) * SIGN((pop[region·boston] - PREVIOUS(pop[region·boston]))) $⁚ltm⁚link_score⁚pop[nyc]→$⁚ltm⁚agg⁚0 -scalar: if (TIME = INITIAL_TIME) then 0 else if (ABS(("$⁚ltm⁚agg⁚0" - PREVIOUS("$⁚ltm⁚agg⁚0"))) <= 0) OR (ABS((pop[region·nyc] - PREVIOUS(pop[region·nyc]))) <= 0) then 0 else SAFEDIV((PREVIOUS("$⁚ltm⁚agg⁚0") + (pop[region·nyc] - PREVIOUS(pop[region·nyc])) - PREVIOUS("$⁚ltm⁚agg⁚0")), ABS(("$⁚ltm⁚agg⁚0" - PREVIOUS("$⁚ltm⁚agg⁚0"))), 0) * SIGN((pop[region·nyc] - PREVIOUS(pop[region·nyc]))) +scalar: if (TIME <= INITIAL_TIME) then 0 else if (ABS(("$⁚ltm⁚agg⁚0" - PREVIOUS("$⁚ltm⁚agg⁚0"))) <= 0) OR (ABS((pop[region·nyc] - PREVIOUS(pop[region·nyc]))) <= 0) then 0 else SAFEDIV((PREVIOUS("$⁚ltm⁚agg⁚0") + (pop[region·nyc] - PREVIOUS(pop[region·nyc])) - PREVIOUS("$⁚ltm⁚agg⁚0")), ABS(("$⁚ltm⁚agg⁚0" - PREVIOUS("$⁚ltm⁚agg⁚0"))), 0) * SIGN((pop[region·nyc] - PREVIOUS(pop[region·nyc]))) diff --git a/src/simlin-engine/src/db/ltm_char_golden/agg_to_arrayed_target.txt b/src/simlin-engine/src/db/ltm_char_golden/agg_to_arrayed_target.txt index 7f691ca1f..cb7cafbab 100644 --- a/src/simlin-engine/src/db/ltm_char_golden/agg_to_arrayed_target.txt +++ b/src/simlin-engine/src/db/ltm_char_golden/agg_to_arrayed_target.txt @@ -1,4 +1,4 @@ $⁚ltm⁚link_score⁚$⁚ltm⁚agg⁚0→outflow[boston] -scalar: if (TIME = INITIAL_TIME) then 0 else if (ABS((outflow[region·boston] - PREVIOUS(outflow[region·boston]))) <= 0) OR (ABS(("$⁚ltm⁚agg⁚0" - PREVIOUS("$⁚ltm⁚agg⁚0"))) <= 0) then 0 else SAFEDIV((("$⁚ltm⁚agg⁚0" * PREVIOUS(frac[region·boston])) - PREVIOUS(outflow[region·boston])), ABS((outflow[region·boston] - PREVIOUS(outflow[region·boston]))), 0) * SIGN(("$⁚ltm⁚agg⁚0" - PREVIOUS("$⁚ltm⁚agg⁚0"))) +scalar: if (TIME <= INITIAL_TIME) then 0 else if (ABS((outflow[region·boston] - PREVIOUS(outflow[region·boston]))) <= 0) OR (ABS(("$⁚ltm⁚agg⁚0" - PREVIOUS("$⁚ltm⁚agg⁚0"))) <= 0) then 0 else SAFEDIV((("$⁚ltm⁚agg⁚0" * PREVIOUS(frac[region·boston])) - PREVIOUS(outflow[region·boston])), ABS((outflow[region·boston] - PREVIOUS(outflow[region·boston]))), 0) * SIGN(("$⁚ltm⁚agg⁚0" - PREVIOUS("$⁚ltm⁚agg⁚0"))) $⁚ltm⁚link_score⁚$⁚ltm⁚agg⁚0→outflow[nyc] -scalar: if (TIME = INITIAL_TIME) then 0 else if (ABS((outflow[region·nyc] - PREVIOUS(outflow[region·nyc]))) <= 0) OR (ABS(("$⁚ltm⁚agg⁚0" - PREVIOUS("$⁚ltm⁚agg⁚0"))) <= 0) then 0 else SAFEDIV((("$⁚ltm⁚agg⁚0" * PREVIOUS(frac[region·nyc])) - PREVIOUS(outflow[region·nyc])), ABS((outflow[region·nyc] - PREVIOUS(outflow[region·nyc]))), 0) * SIGN(("$⁚ltm⁚agg⁚0" - PREVIOUS("$⁚ltm⁚agg⁚0"))) +scalar: if (TIME <= INITIAL_TIME) then 0 else if (ABS((outflow[region·nyc] - PREVIOUS(outflow[region·nyc]))) <= 0) OR (ABS(("$⁚ltm⁚agg⁚0" - PREVIOUS("$⁚ltm⁚agg⁚0"))) <= 0) then 0 else SAFEDIV((("$⁚ltm⁚agg⁚0" * PREVIOUS(frac[region·nyc])) - PREVIOUS(outflow[region·nyc])), ABS((outflow[region·nyc] - PREVIOUS(outflow[region·nyc]))), 0) * SIGN(("$⁚ltm⁚agg⁚0" - PREVIOUS("$⁚ltm⁚agg⁚0"))) diff --git a/src/simlin-engine/src/db/ltm_char_golden/agg_to_scalar_target.txt b/src/simlin-engine/src/db/ltm_char_golden/agg_to_scalar_target.txt index 856c7f93e..d8fe245cb 100644 --- a/src/simlin-engine/src/db/ltm_char_golden/agg_to_scalar_target.txt +++ b/src/simlin-engine/src/db/ltm_char_golden/agg_to_scalar_target.txt @@ -1,2 +1,2 @@ $⁚ltm⁚link_score⁚$⁚ltm⁚agg⁚0→total -scalar: if (TIME = INITIAL_TIME) then 0 else if (ABS((total - PREVIOUS(total))) <= 0) OR (ABS(("$⁚ltm⁚agg⁚0" - PREVIOUS("$⁚ltm⁚agg⁚0"))) <= 0) then 0 else SAFEDIV((("$⁚ltm⁚agg⁚0" * 2) - PREVIOUS(total)), ABS((total - PREVIOUS(total))), 0) * SIGN(("$⁚ltm⁚agg⁚0" - PREVIOUS("$⁚ltm⁚agg⁚0"))) +scalar: if (TIME <= INITIAL_TIME) then 0 else if (ABS((total - PREVIOUS(total))) <= 0) OR (ABS(("$⁚ltm⁚agg⁚0" - PREVIOUS("$⁚ltm⁚agg⁚0"))) <= 0) then 0 else SAFEDIV((("$⁚ltm⁚agg⁚0" * 2) - PREVIOUS(total)), ABS((total - PREVIOUS(total))), 0) * SIGN(("$⁚ltm⁚agg⁚0" - PREVIOUS("$⁚ltm⁚agg⁚0"))) diff --git a/src/simlin-engine/src/db/ltm_char_golden/already_lagged_other_dep.txt b/src/simlin-engine/src/db/ltm_char_golden/already_lagged_other_dep.txt index a40d566bb..11729ee1b 100644 --- a/src/simlin-engine/src/db/ltm_char_golden/already_lagged_other_dep.txt +++ b/src/simlin-engine/src/db/ltm_char_golden/already_lagged_other_dep.txt @@ -1,2 +1,2 @@ $⁚ltm⁚link_score⁚from→to -scalar: if (TIME = INITIAL_TIME) then 0 else if (ABS((to - PREVIOUS(to))) <= 0) OR (ABS((from - PREVIOUS(from))) <= 0) then 0 else SAFEDIV(((from + PREVIOUS(previous(g, 0))) - PREVIOUS(to)), ABS((to - PREVIOUS(to))), 0) * SIGN((from - PREVIOUS(from))) +scalar: if (TIME <= INITIAL_TIME) then 0 else if (ABS((to - PREVIOUS(to))) <= 0) OR (ABS((from - PREVIOUS(from))) <= 0) then 0 else SAFEDIV(((from + PREVIOUS(previous(g, 0))) - PREVIOUS(to)), ABS((to - PREVIOUS(to))), 0) * SIGN((from - PREVIOUS(from))) diff --git a/src/simlin-engine/src/db/ltm_char_golden/arrayed_agg_to_target.txt b/src/simlin-engine/src/db/ltm_char_golden/arrayed_agg_to_target.txt index fc2f5480a..ed098690a 100644 --- a/src/simlin-engine/src/db/ltm_char_golden/arrayed_agg_to_target.txt +++ b/src/simlin-engine/src/db/ltm_char_golden/arrayed_agg_to_target.txt @@ -1,14 +1,14 @@ $⁚ltm⁚link_score⁚$⁚ltm⁚agg⁚0[boston]→outflow[boston] -scalar: if (TIME = INITIAL_TIME) then 0 else if (ABS((outflow[region·boston] - PREVIOUS(outflow[region·boston]))) <= 0) OR (ABS(("$⁚ltm⁚agg⁚0"[boston] - PREVIOUS("$⁚ltm⁚agg⁚0"[boston]))) <= 0) then 0 else SAFEDIV((("$⁚ltm⁚agg⁚0"[region·boston] * 0.1) - PREVIOUS(outflow[region·boston])), ABS((outflow[region·boston] - PREVIOUS(outflow[region·boston]))), 0) * SIGN(("$⁚ltm⁚agg⁚0"[boston] - PREVIOUS("$⁚ltm⁚agg⁚0"[boston]))) +scalar: if (TIME <= INITIAL_TIME) then 0 else if (ABS((outflow[region·boston] - PREVIOUS(outflow[region·boston]))) <= 0) OR (ABS(("$⁚ltm⁚agg⁚0"[boston] - PREVIOUS("$⁚ltm⁚agg⁚0"[boston]))) <= 0) then 0 else SAFEDIV((("$⁚ltm⁚agg⁚0"[region·boston] * 0.1) - PREVIOUS(outflow[region·boston])), ABS((outflow[region·boston] - PREVIOUS(outflow[region·boston]))), 0) * SIGN(("$⁚ltm⁚agg⁚0"[boston] - PREVIOUS("$⁚ltm⁚agg⁚0"[boston]))) $⁚ltm⁚link_score⁚$⁚ltm⁚agg⁚0[nyc]→outflow[nyc] -scalar: if (TIME = INITIAL_TIME) then 0 else if (ABS((outflow[region·nyc] - PREVIOUS(outflow[region·nyc]))) <= 0) OR (ABS(("$⁚ltm⁚agg⁚0"[nyc] - PREVIOUS("$⁚ltm⁚agg⁚0"[nyc]))) <= 0) then 0 else SAFEDIV((("$⁚ltm⁚agg⁚0"[region·nyc] * 0.1) - PREVIOUS(outflow[region·nyc])), ABS((outflow[region·nyc] - PREVIOUS(outflow[region·nyc]))), 0) * SIGN(("$⁚ltm⁚agg⁚0"[nyc] - PREVIOUS("$⁚ltm⁚agg⁚0"[nyc]))) +scalar: if (TIME <= INITIAL_TIME) then 0 else if (ABS((outflow[region·nyc] - PREVIOUS(outflow[region·nyc]))) <= 0) OR (ABS(("$⁚ltm⁚agg⁚0"[nyc] - PREVIOUS("$⁚ltm⁚agg⁚0"[nyc]))) <= 0) then 0 else SAFEDIV((("$⁚ltm⁚agg⁚0"[region·nyc] * 0.1) - PREVIOUS(outflow[region·nyc])), ABS((outflow[region·nyc] - PREVIOUS(outflow[region·nyc]))), 0) * SIGN(("$⁚ltm⁚agg⁚0"[nyc] - PREVIOUS("$⁚ltm⁚agg⁚0"[nyc]))) $⁚ltm⁚link_score⁚matrix[boston,a]→$⁚ltm⁚agg⁚0[boston] -scalar: if (TIME = INITIAL_TIME) then 0 else if (ABS(("$⁚ltm⁚agg⁚0"[boston] - PREVIOUS("$⁚ltm⁚agg⁚0"[boston]))) <= 0) OR (ABS((matrix[region·boston,dim2·a] - PREVIOUS(matrix[region·boston,dim2·a]))) <= 0) then 0 else SAFEDIV((PREVIOUS("$⁚ltm⁚agg⁚0"[boston]) + (matrix[region·boston,dim2·a] - PREVIOUS(matrix[region·boston,dim2·a])) - PREVIOUS("$⁚ltm⁚agg⁚0"[boston])), ABS(("$⁚ltm⁚agg⁚0"[boston] - PREVIOUS("$⁚ltm⁚agg⁚0"[boston]))), 0) * SIGN((matrix[region·boston,dim2·a] - PREVIOUS(matrix[region·boston,dim2·a]))) +scalar: if (TIME <= INITIAL_TIME) then 0 else if (ABS(("$⁚ltm⁚agg⁚0"[boston] - PREVIOUS("$⁚ltm⁚agg⁚0"[boston]))) <= 0) OR (ABS((matrix[region·boston,dim2·a] - PREVIOUS(matrix[region·boston,dim2·a]))) <= 0) then 0 else SAFEDIV((PREVIOUS("$⁚ltm⁚agg⁚0"[boston]) + (matrix[region·boston,dim2·a] - PREVIOUS(matrix[region·boston,dim2·a])) - PREVIOUS("$⁚ltm⁚agg⁚0"[boston])), ABS(("$⁚ltm⁚agg⁚0"[boston] - PREVIOUS("$⁚ltm⁚agg⁚0"[boston]))), 0) * SIGN((matrix[region·boston,dim2·a] - PREVIOUS(matrix[region·boston,dim2·a]))) $⁚ltm⁚link_score⁚matrix[boston,b]→$⁚ltm⁚agg⁚0[boston] -scalar: if (TIME = INITIAL_TIME) then 0 else if (ABS(("$⁚ltm⁚agg⁚0"[boston] - PREVIOUS("$⁚ltm⁚agg⁚0"[boston]))) <= 0) OR (ABS((matrix[region·boston,dim2·b] - PREVIOUS(matrix[region·boston,dim2·b]))) <= 0) then 0 else SAFEDIV((PREVIOUS("$⁚ltm⁚agg⁚0"[boston]) + (matrix[region·boston,dim2·b] - PREVIOUS(matrix[region·boston,dim2·b])) - PREVIOUS("$⁚ltm⁚agg⁚0"[boston])), ABS(("$⁚ltm⁚agg⁚0"[boston] - PREVIOUS("$⁚ltm⁚agg⁚0"[boston]))), 0) * SIGN((matrix[region·boston,dim2·b] - PREVIOUS(matrix[region·boston,dim2·b]))) +scalar: if (TIME <= INITIAL_TIME) then 0 else if (ABS(("$⁚ltm⁚agg⁚0"[boston] - PREVIOUS("$⁚ltm⁚agg⁚0"[boston]))) <= 0) OR (ABS((matrix[region·boston,dim2·b] - PREVIOUS(matrix[region·boston,dim2·b]))) <= 0) then 0 else SAFEDIV((PREVIOUS("$⁚ltm⁚agg⁚0"[boston]) + (matrix[region·boston,dim2·b] - PREVIOUS(matrix[region·boston,dim2·b])) - PREVIOUS("$⁚ltm⁚agg⁚0"[boston])), ABS(("$⁚ltm⁚agg⁚0"[boston] - PREVIOUS("$⁚ltm⁚agg⁚0"[boston]))), 0) * SIGN((matrix[region·boston,dim2·b] - PREVIOUS(matrix[region·boston,dim2·b]))) $⁚ltm⁚link_score⁚matrix[nyc,a]→$⁚ltm⁚agg⁚0[nyc] -scalar: if (TIME = INITIAL_TIME) then 0 else if (ABS(("$⁚ltm⁚agg⁚0"[nyc] - PREVIOUS("$⁚ltm⁚agg⁚0"[nyc]))) <= 0) OR (ABS((matrix[region·nyc,dim2·a] - PREVIOUS(matrix[region·nyc,dim2·a]))) <= 0) then 0 else SAFEDIV((PREVIOUS("$⁚ltm⁚agg⁚0"[nyc]) + (matrix[region·nyc,dim2·a] - PREVIOUS(matrix[region·nyc,dim2·a])) - PREVIOUS("$⁚ltm⁚agg⁚0"[nyc])), ABS(("$⁚ltm⁚agg⁚0"[nyc] - PREVIOUS("$⁚ltm⁚agg⁚0"[nyc]))), 0) * SIGN((matrix[region·nyc,dim2·a] - PREVIOUS(matrix[region·nyc,dim2·a]))) +scalar: if (TIME <= INITIAL_TIME) then 0 else if (ABS(("$⁚ltm⁚agg⁚0"[nyc] - PREVIOUS("$⁚ltm⁚agg⁚0"[nyc]))) <= 0) OR (ABS((matrix[region·nyc,dim2·a] - PREVIOUS(matrix[region·nyc,dim2·a]))) <= 0) then 0 else SAFEDIV((PREVIOUS("$⁚ltm⁚agg⁚0"[nyc]) + (matrix[region·nyc,dim2·a] - PREVIOUS(matrix[region·nyc,dim2·a])) - PREVIOUS("$⁚ltm⁚agg⁚0"[nyc])), ABS(("$⁚ltm⁚agg⁚0"[nyc] - PREVIOUS("$⁚ltm⁚agg⁚0"[nyc]))), 0) * SIGN((matrix[region·nyc,dim2·a] - PREVIOUS(matrix[region·nyc,dim2·a]))) $⁚ltm⁚link_score⁚matrix[nyc,b]→$⁚ltm⁚agg⁚0[nyc] -scalar: if (TIME = INITIAL_TIME) then 0 else if (ABS(("$⁚ltm⁚agg⁚0"[nyc] - PREVIOUS("$⁚ltm⁚agg⁚0"[nyc]))) <= 0) OR (ABS((matrix[region·nyc,dim2·b] - PREVIOUS(matrix[region·nyc,dim2·b]))) <= 0) then 0 else SAFEDIV((PREVIOUS("$⁚ltm⁚agg⁚0"[nyc]) + (matrix[region·nyc,dim2·b] - PREVIOUS(matrix[region·nyc,dim2·b])) - PREVIOUS("$⁚ltm⁚agg⁚0"[nyc])), ABS(("$⁚ltm⁚agg⁚0"[nyc] - PREVIOUS("$⁚ltm⁚agg⁚0"[nyc]))), 0) * SIGN((matrix[region·nyc,dim2·b] - PREVIOUS(matrix[region·nyc,dim2·b]))) +scalar: if (TIME <= INITIAL_TIME) then 0 else if (ABS(("$⁚ltm⁚agg⁚0"[nyc] - PREVIOUS("$⁚ltm⁚agg⁚0"[nyc]))) <= 0) OR (ABS((matrix[region·nyc,dim2·b] - PREVIOUS(matrix[region·nyc,dim2·b]))) <= 0) then 0 else SAFEDIV((PREVIOUS("$⁚ltm⁚agg⁚0"[nyc]) + (matrix[region·nyc,dim2·b] - PREVIOUS(matrix[region·nyc,dim2·b])) - PREVIOUS("$⁚ltm⁚agg⁚0"[nyc])), ABS(("$⁚ltm⁚agg⁚0"[nyc] - PREVIOUS("$⁚ltm⁚agg⁚0"[nyc]))), 0) * SIGN((matrix[region·nyc,dim2·b] - PREVIOUS(matrix[region·nyc,dim2·b]))) $⁚ltm⁚link_score⁚outflow→stock dims=[Region] -a2a[Region]: if (TIME = INITIAL_TIME) then 0 else if (ABS(("$⁚ltm⁚net⁚stock"[Region] - PREVIOUS("$⁚ltm⁚net⁚stock"[Region]))) <= 0) OR (ABS((outflow[Region] - PREVIOUS(outflow[Region]))) <= 0) then 0 else SAFEDIV((outflow[Region] - PREVIOUS(outflow[Region])), ABS(("$⁚ltm⁚net⁚stock"[Region] - PREVIOUS("$⁚ltm⁚net⁚stock"[Region]))), 0) * SIGN((outflow[Region] - PREVIOUS(outflow[Region]))) +a2a[Region]: if (TIME <= INITIAL_TIME) then 0 else if (ABS(("$⁚ltm⁚net⁚stock"[Region] - PREVIOUS("$⁚ltm⁚net⁚stock"[Region]))) <= 0) OR (ABS((outflow[Region] - PREVIOUS(outflow[Region]))) <= 0) then 0 else SAFEDIV((outflow[Region] - PREVIOUS(outflow[Region])), ABS(("$⁚ltm⁚net⁚stock"[Region] - PREVIOUS("$⁚ltm⁚net⁚stock"[Region]))), 0) * SIGN((outflow[Region] - PREVIOUS(outflow[Region]))) diff --git a/src/simlin-engine/src/db/ltm_char_golden/arrayed_target_no_default_slot_scores.txt b/src/simlin-engine/src/db/ltm_char_golden/arrayed_target_no_default_slot_scores.txt index a1a8b67fb..bf4a9c924 100644 --- a/src/simlin-engine/src/db/ltm_char_golden/arrayed_target_no_default_slot_scores.txt +++ b/src/simlin-engine/src/db/ltm_char_golden/arrayed_target_no_default_slot_scores.txt @@ -1,8 +1,8 @@ $⁚ltm⁚link_score⁚pop[boston]→mp dims=[Region] arrayed[Region] (apply_default=false): - boston => if (TIME = INITIAL_TIME) then 0 else if (ABS((mp - PREVIOUS(mp))) <= 0) OR (ABS((pop[region·boston] - PREVIOUS(pop[region·boston]))) <= 0) then 0 else SAFEDIV((((pop[boston] - PREVIOUS(pop[region·nyc])) * 0.01) - PREVIOUS(mp)), ABS((mp - PREVIOUS(mp))), 0) * SIGN((pop[region·boston] - PREVIOUS(pop[region·boston]))) - nyc => if (TIME = INITIAL_TIME) then 0 else if (ABS((mp - PREVIOUS(mp))) <= 0) OR (ABS((pop[region·boston] - PREVIOUS(pop[region·boston]))) <= 0) then 0 else SAFEDIV((((PREVIOUS(pop[region·nyc]) - pop[boston]) * 0.01) - PREVIOUS(mp)), ABS((mp - PREVIOUS(mp))), 0) * SIGN((pop[region·boston] - PREVIOUS(pop[region·boston]))) + boston => if (TIME <= INITIAL_TIME) then 0 else if (ABS((mp - PREVIOUS(mp))) <= 0) OR (ABS((pop[region·boston] - PREVIOUS(pop[region·boston]))) <= 0) then 0 else SAFEDIV((((pop[boston] - PREVIOUS(pop[region·nyc])) * 0.01) - PREVIOUS(mp)), ABS((mp - PREVIOUS(mp))), 0) * SIGN((pop[region·boston] - PREVIOUS(pop[region·boston]))) + nyc => if (TIME <= INITIAL_TIME) then 0 else if (ABS((mp - PREVIOUS(mp))) <= 0) OR (ABS((pop[region·boston] - PREVIOUS(pop[region·boston]))) <= 0) then 0 else SAFEDIV((((PREVIOUS(pop[region·nyc]) - pop[boston]) * 0.01) - PREVIOUS(mp)), ABS((mp - PREVIOUS(mp))), 0) * SIGN((pop[region·boston] - PREVIOUS(pop[region·boston]))) $⁚ltm⁚link_score⁚pop[nyc]→mp dims=[Region] arrayed[Region] (apply_default=false): - boston => if (TIME = INITIAL_TIME) then 0 else if (ABS((mp - PREVIOUS(mp))) <= 0) OR (ABS((pop[region·nyc] - PREVIOUS(pop[region·nyc]))) <= 0) then 0 else SAFEDIV((((PREVIOUS(pop[region·boston]) - pop[nyc]) * 0.01) - PREVIOUS(mp)), ABS((mp - PREVIOUS(mp))), 0) * SIGN((pop[region·nyc] - PREVIOUS(pop[region·nyc]))) - nyc => if (TIME = INITIAL_TIME) then 0 else if (ABS((mp - PREVIOUS(mp))) <= 0) OR (ABS((pop[region·nyc] - PREVIOUS(pop[region·nyc]))) <= 0) then 0 else SAFEDIV((((pop[nyc] - PREVIOUS(pop[region·boston])) * 0.01) - PREVIOUS(mp)), ABS((mp - PREVIOUS(mp))), 0) * SIGN((pop[region·nyc] - PREVIOUS(pop[region·nyc]))) + boston => if (TIME <= INITIAL_TIME) then 0 else if (ABS((mp - PREVIOUS(mp))) <= 0) OR (ABS((pop[region·nyc] - PREVIOUS(pop[region·nyc]))) <= 0) then 0 else SAFEDIV((((PREVIOUS(pop[region·boston]) - pop[nyc]) * 0.01) - PREVIOUS(mp)), ABS((mp - PREVIOUS(mp))), 0) * SIGN((pop[region·nyc] - PREVIOUS(pop[region·nyc]))) + nyc => if (TIME <= INITIAL_TIME) then 0 else if (ABS((mp - PREVIOUS(mp))) <= 0) OR (ABS((pop[region·nyc] - PREVIOUS(pop[region·nyc]))) <= 0) then 0 else SAFEDIV((((pop[nyc] - PREVIOUS(pop[region·boston])) * 0.01) - PREVIOUS(mp)), ABS((mp - PREVIOUS(mp))), 0) * SIGN((pop[region·nyc] - PREVIOUS(pop[region·nyc]))) diff --git a/src/simlin-engine/src/db/ltm_char_golden/arrayed_target_slot_scores.txt b/src/simlin-engine/src/db/ltm_char_golden/arrayed_target_slot_scores.txt index bcbd47e34..dd2ad4d22 100644 --- a/src/simlin-engine/src/db/ltm_char_golden/arrayed_target_slot_scores.txt +++ b/src/simlin-engine/src/db/ltm_char_golden/arrayed_target_slot_scores.txt @@ -1,10 +1,10 @@ $⁚ltm⁚link_score⁚pop[boston]→mp dims=[Region] arrayed[Region] (apply_default=true): - boston => if (TIME = INITIAL_TIME) then 0 else if (ABS((mp - PREVIOUS(mp))) <= 0) OR (ABS((pop[region·boston] - PREVIOUS(pop[region·boston]))) <= 0) then 0 else SAFEDIV((((pop[boston] - PREVIOUS(pop[region·nyc])) * 0.01) - PREVIOUS(mp)), ABS((mp - PREVIOUS(mp))), 0) * SIGN((pop[region·boston] - PREVIOUS(pop[region·boston]))) - nyc => if (TIME = INITIAL_TIME) then 0 else if (ABS((mp - PREVIOUS(mp))) <= 0) OR (ABS((pop[region·boston] - PREVIOUS(pop[region·boston]))) <= 0) then 0 else SAFEDIV((((PREVIOUS(pop[region·nyc]) - pop[boston]) * 0.01) - PREVIOUS(mp)), ABS((mp - PREVIOUS(mp))), 0) * SIGN((pop[region·boston] - PREVIOUS(pop[region·boston]))) - => if (TIME = INITIAL_TIME) then 0 else if (ABS((mp - PREVIOUS(mp))) <= 0) OR (ABS((pop[region·boston] - PREVIOUS(pop[region·boston]))) <= 0) then 0 else SAFEDIV(((0) - PREVIOUS(mp)), ABS((mp - PREVIOUS(mp))), 0) * SIGN((pop[region·boston] - PREVIOUS(pop[region·boston]))) + boston => if (TIME <= INITIAL_TIME) then 0 else if (ABS((mp - PREVIOUS(mp))) <= 0) OR (ABS((pop[region·boston] - PREVIOUS(pop[region·boston]))) <= 0) then 0 else SAFEDIV((((pop[boston] - PREVIOUS(pop[region·nyc])) * 0.01) - PREVIOUS(mp)), ABS((mp - PREVIOUS(mp))), 0) * SIGN((pop[region·boston] - PREVIOUS(pop[region·boston]))) + nyc => if (TIME <= INITIAL_TIME) then 0 else if (ABS((mp - PREVIOUS(mp))) <= 0) OR (ABS((pop[region·boston] - PREVIOUS(pop[region·boston]))) <= 0) then 0 else SAFEDIV((((PREVIOUS(pop[region·nyc]) - pop[boston]) * 0.01) - PREVIOUS(mp)), ABS((mp - PREVIOUS(mp))), 0) * SIGN((pop[region·boston] - PREVIOUS(pop[region·boston]))) + => if (TIME <= INITIAL_TIME) then 0 else if (ABS((mp - PREVIOUS(mp))) <= 0) OR (ABS((pop[region·boston] - PREVIOUS(pop[region·boston]))) <= 0) then 0 else SAFEDIV(((0) - PREVIOUS(mp)), ABS((mp - PREVIOUS(mp))), 0) * SIGN((pop[region·boston] - PREVIOUS(pop[region·boston]))) $⁚ltm⁚link_score⁚pop[nyc]→mp dims=[Region] arrayed[Region] (apply_default=true): - boston => if (TIME = INITIAL_TIME) then 0 else if (ABS((mp - PREVIOUS(mp))) <= 0) OR (ABS((pop[region·nyc] - PREVIOUS(pop[region·nyc]))) <= 0) then 0 else SAFEDIV((((PREVIOUS(pop[region·boston]) - pop[nyc]) * 0.01) - PREVIOUS(mp)), ABS((mp - PREVIOUS(mp))), 0) * SIGN((pop[region·nyc] - PREVIOUS(pop[region·nyc]))) - nyc => if (TIME = INITIAL_TIME) then 0 else if (ABS((mp - PREVIOUS(mp))) <= 0) OR (ABS((pop[region·nyc] - PREVIOUS(pop[region·nyc]))) <= 0) then 0 else SAFEDIV((((pop[nyc] - PREVIOUS(pop[region·boston])) * 0.01) - PREVIOUS(mp)), ABS((mp - PREVIOUS(mp))), 0) * SIGN((pop[region·nyc] - PREVIOUS(pop[region·nyc]))) - => if (TIME = INITIAL_TIME) then 0 else if (ABS((mp - PREVIOUS(mp))) <= 0) OR (ABS((pop[region·nyc] - PREVIOUS(pop[region·nyc]))) <= 0) then 0 else SAFEDIV(((0) - PREVIOUS(mp)), ABS((mp - PREVIOUS(mp))), 0) * SIGN((pop[region·nyc] - PREVIOUS(pop[region·nyc]))) + boston => if (TIME <= INITIAL_TIME) then 0 else if (ABS((mp - PREVIOUS(mp))) <= 0) OR (ABS((pop[region·nyc] - PREVIOUS(pop[region·nyc]))) <= 0) then 0 else SAFEDIV((((PREVIOUS(pop[region·boston]) - pop[nyc]) * 0.01) - PREVIOUS(mp)), ABS((mp - PREVIOUS(mp))), 0) * SIGN((pop[region·nyc] - PREVIOUS(pop[region·nyc]))) + nyc => if (TIME <= INITIAL_TIME) then 0 else if (ABS((mp - PREVIOUS(mp))) <= 0) OR (ABS((pop[region·nyc] - PREVIOUS(pop[region·nyc]))) <= 0) then 0 else SAFEDIV((((pop[nyc] - PREVIOUS(pop[region·boston])) * 0.01) - PREVIOUS(mp)), ABS((mp - PREVIOUS(mp))), 0) * SIGN((pop[region·nyc] - PREVIOUS(pop[region·nyc]))) + => if (TIME <= INITIAL_TIME) then 0 else if (ABS((mp - PREVIOUS(mp))) <= 0) OR (ABS((pop[region·nyc] - PREVIOUS(pop[region·nyc]))) <= 0) then 0 else SAFEDIV(((0) - PREVIOUS(mp)), ABS((mp - PREVIOUS(mp))), 0) * SIGN((pop[region·nyc] - PREVIOUS(pop[region·nyc]))) diff --git a/src/simlin-engine/src/db/ltm_char_golden/per_element_ambiguous_pin.txt b/src/simlin-engine/src/db/ltm_char_golden/per_element_ambiguous_pin.txt index 165cf41ae..810988a48 100644 --- a/src/simlin-engine/src/db/ltm_char_golden/per_element_ambiguous_pin.txt +++ b/src/simlin-engine/src/db/ltm_char_golden/per_element_ambiguous_pin.txt @@ -1,10 +1,10 @@ $⁚ltm⁚link_score⁚pop[boston,old]→growth dims=[Region,Age] -a2a[Region,Age]: if (TIME = INITIAL_TIME) then 0 else if (ABS((growth - PREVIOUS(growth))) <= 0) OR (ABS((pop[region·boston,age·old] - PREVIOUS(pop[region·boston,age·old]))) <= 0) then 0 else SAFEDIV(((PREVIOUS(pop[region, age·young]) * pop[boston, old]) - PREVIOUS(growth)), ABS((growth - PREVIOUS(growth))), 0) * SIGN((pop[region·boston,age·old] - PREVIOUS(pop[region·boston,age·old]))) +a2a[Region,Age]: if (TIME <= INITIAL_TIME) then 0 else if (ABS((growth - PREVIOUS(growth))) <= 0) OR (ABS((pop[region·boston,age·old] - PREVIOUS(pop[region·boston,age·old]))) <= 0) then 0 else SAFEDIV(((PREVIOUS(pop[region, age·young]) * pop[boston, old]) - PREVIOUS(growth)), ABS((growth - PREVIOUS(growth))), 0) * SIGN((pop[region·boston,age·old] - PREVIOUS(pop[region·boston,age·old]))) $⁚ltm⁚link_score⁚pop[boston,young]→growth[boston,old] -scalar: if (TIME = INITIAL_TIME) then 0 else if (ABS((growth[region·boston,age·old] - PREVIOUS(growth[region·boston,age·old]))) <= 0) OR (ABS((pop[region·boston,age·young] - PREVIOUS(pop[region·boston,age·young]))) <= 0) then 0 else SAFEDIV(((pop[boston, young] * PREVIOUS(pop[region·boston, age·old])) - PREVIOUS(growth[region·boston,age·old])), ABS((growth[region·boston,age·old] - PREVIOUS(growth[region·boston,age·old]))), 0) * SIGN((pop[region·boston,age·young] - PREVIOUS(pop[region·boston,age·young]))) +scalar: if (TIME <= INITIAL_TIME) then 0 else if (ABS((growth[region·boston,age·old] - PREVIOUS(growth[region·boston,age·old]))) <= 0) OR (ABS((pop[region·boston,age·young] - PREVIOUS(pop[region·boston,age·young]))) <= 0) then 0 else SAFEDIV(((pop[boston, young] * PREVIOUS(pop[region·boston, age·old])) - PREVIOUS(growth[region·boston,age·old])), ABS((growth[region·boston,age·old] - PREVIOUS(growth[region·boston,age·old]))), 0) * SIGN((pop[region·boston,age·young] - PREVIOUS(pop[region·boston,age·young]))) $⁚ltm⁚link_score⁚pop[boston,young]→growth[boston,young] -scalar: if (TIME = INITIAL_TIME) then 0 else if (ABS((growth[region·boston,age·young] - PREVIOUS(growth[region·boston,age·young]))) <= 0) OR (ABS((pop[region·boston,age·young] - PREVIOUS(pop[region·boston,age·young]))) <= 0) then 0 else SAFEDIV(((pop[boston, young] * PREVIOUS(pop[region·boston, age·old])) - PREVIOUS(growth[region·boston,age·young])), ABS((growth[region·boston,age·young] - PREVIOUS(growth[region·boston,age·young]))), 0) * SIGN((pop[region·boston,age·young] - PREVIOUS(pop[region·boston,age·young]))) +scalar: if (TIME <= INITIAL_TIME) then 0 else if (ABS((growth[region·boston,age·young] - PREVIOUS(growth[region·boston,age·young]))) <= 0) OR (ABS((pop[region·boston,age·young] - PREVIOUS(pop[region·boston,age·young]))) <= 0) then 0 else SAFEDIV(((pop[boston, young] * PREVIOUS(pop[region·boston, age·old])) - PREVIOUS(growth[region·boston,age·young])), ABS((growth[region·boston,age·young] - PREVIOUS(growth[region·boston,age·young]))), 0) * SIGN((pop[region·boston,age·young] - PREVIOUS(pop[region·boston,age·young]))) $⁚ltm⁚link_score⁚pop[nyc,young]→growth[nyc,old] -scalar: if (TIME = INITIAL_TIME) then 0 else if (ABS((growth[region·nyc,age·old] - PREVIOUS(growth[region·nyc,age·old]))) <= 0) OR (ABS((pop[region·nyc,age·young] - PREVIOUS(pop[region·nyc,age·young]))) <= 0) then 0 else SAFEDIV(((pop[nyc, young] * PREVIOUS(pop[region·boston, age·old])) - PREVIOUS(growth[region·nyc,age·old])), ABS((growth[region·nyc,age·old] - PREVIOUS(growth[region·nyc,age·old]))), 0) * SIGN((pop[region·nyc,age·young] - PREVIOUS(pop[region·nyc,age·young]))) +scalar: if (TIME <= INITIAL_TIME) then 0 else if (ABS((growth[region·nyc,age·old] - PREVIOUS(growth[region·nyc,age·old]))) <= 0) OR (ABS((pop[region·nyc,age·young] - PREVIOUS(pop[region·nyc,age·young]))) <= 0) then 0 else SAFEDIV(((pop[nyc, young] * PREVIOUS(pop[region·boston, age·old])) - PREVIOUS(growth[region·nyc,age·old])), ABS((growth[region·nyc,age·old] - PREVIOUS(growth[region·nyc,age·old]))), 0) * SIGN((pop[region·nyc,age·young] - PREVIOUS(pop[region·nyc,age·young]))) $⁚ltm⁚link_score⁚pop[nyc,young]→growth[nyc,young] -scalar: if (TIME = INITIAL_TIME) then 0 else if (ABS((growth[region·nyc,age·young] - PREVIOUS(growth[region·nyc,age·young]))) <= 0) OR (ABS((pop[region·nyc,age·young] - PREVIOUS(pop[region·nyc,age·young]))) <= 0) then 0 else SAFEDIV(((pop[nyc, young] * PREVIOUS(pop[region·boston, age·old])) - PREVIOUS(growth[region·nyc,age·young])), ABS((growth[region·nyc,age·young] - PREVIOUS(growth[region·nyc,age·young]))), 0) * SIGN((pop[region·nyc,age·young] - PREVIOUS(pop[region·nyc,age·young]))) +scalar: if (TIME <= INITIAL_TIME) then 0 else if (ABS((growth[region·nyc,age·young] - PREVIOUS(growth[region·nyc,age·young]))) <= 0) OR (ABS((pop[region·nyc,age·young] - PREVIOUS(pop[region·nyc,age·young]))) <= 0) then 0 else SAFEDIV(((pop[nyc, young] * PREVIOUS(pop[region·boston, age·old])) - PREVIOUS(growth[region·nyc,age·young])), ABS((growth[region·nyc,age·young] - PREVIOUS(growth[region·nyc,age·young]))), 0) * SIGN((pop[region·nyc,age·young] - PREVIOUS(pop[region·nyc,age·young]))) diff --git a/src/simlin-engine/src/db/ltm_char_golden/per_element_dynamic_index.txt b/src/simlin-engine/src/db/ltm_char_golden/per_element_dynamic_index.txt index 10fd4ef26..174db126e 100644 --- a/src/simlin-engine/src/db/ltm_char_golden/per_element_dynamic_index.txt +++ b/src/simlin-engine/src/db/ltm_char_golden/per_element_dynamic_index.txt @@ -1,6 +1,6 @@ $⁚ltm⁚link_score⁚pop[boston,young]→growth[boston] -scalar: if (TIME = INITIAL_TIME) then 0 else if (ABS((growth[region·boston] - PREVIOUS(growth[region·boston]))) <= 0) OR (ABS((pop[region·boston,age·young] - PREVIOUS(pop[region·boston,age·young]))) <= 0) then 0 else SAFEDIV(((pop[boston, young] * PREVIOUS(pop[region·boston, PREVIOUS(idx, idx)]) * 0.001) - PREVIOUS(growth[region·boston])), ABS((growth[region·boston] - PREVIOUS(growth[region·boston]))), 0) * SIGN((pop[region·boston,age·young] - PREVIOUS(pop[region·boston,age·young]))) +scalar: if (TIME <= INITIAL_TIME) then 0 else if (ABS((growth[region·boston] - PREVIOUS(growth[region·boston]))) <= 0) OR (ABS((pop[region·boston,age·young] - PREVIOUS(pop[region·boston,age·young]))) <= 0) then 0 else SAFEDIV(((pop[boston, young] * PREVIOUS(pop[region·boston, PREVIOUS(idx, idx)]) * 0.001) - PREVIOUS(growth[region·boston])), ABS((growth[region·boston] - PREVIOUS(growth[region·boston]))), 0) * SIGN((pop[region·boston,age·young] - PREVIOUS(pop[region·boston,age·young]))) $⁚ltm⁚link_score⁚pop[nyc,young]→growth[nyc] -scalar: if (TIME = INITIAL_TIME) then 0 else if (ABS((growth[region·nyc] - PREVIOUS(growth[region·nyc]))) <= 0) OR (ABS((pop[region·nyc,age·young] - PREVIOUS(pop[region·nyc,age·young]))) <= 0) then 0 else SAFEDIV(((pop[nyc, young] * PREVIOUS(pop[region·nyc, PREVIOUS(idx, idx)]) * 0.001) - PREVIOUS(growth[region·nyc])), ABS((growth[region·nyc] - PREVIOUS(growth[region·nyc]))), 0) * SIGN((pop[region·nyc,age·young] - PREVIOUS(pop[region·nyc,age·young]))) +scalar: if (TIME <= INITIAL_TIME) then 0 else if (ABS((growth[region·nyc] - PREVIOUS(growth[region·nyc]))) <= 0) OR (ABS((pop[region·nyc,age·young] - PREVIOUS(pop[region·nyc,age·young]))) <= 0) then 0 else SAFEDIV(((pop[nyc, young] * PREVIOUS(pop[region·nyc, PREVIOUS(idx, idx)]) * 0.001) - PREVIOUS(growth[region·nyc])), ABS((growth[region·nyc] - PREVIOUS(growth[region·nyc]))), 0) * SIGN((pop[region·nyc,age·young] - PREVIOUS(pop[region·nyc,age·young]))) $⁚ltm⁚link_score⁚pop→growth dims=[Region] -a2a[Region]: if (TIME = INITIAL_TIME) then 0 else if (ABS((growth - PREVIOUS(growth))) <= 0) OR (ABS((SUM(pop[region, PREVIOUS(idx, idx)]) - PREVIOUS(SUM(pop[region, PREVIOUS(idx, idx)])))) <= 0) then 0 else SAFEDIV(((PREVIOUS(pop[region, age·young]) * pop[region, PREVIOUS(idx, idx)] * 0.001) - PREVIOUS(growth)), ABS((growth - PREVIOUS(growth))), 0) * SIGN((SUM(pop[region, PREVIOUS(idx, idx)]) - PREVIOUS(SUM(pop[region, PREVIOUS(idx, idx)])))) +a2a[Region]: if (TIME <= INITIAL_TIME) then 0 else if (ABS((growth - PREVIOUS(growth))) <= 0) OR (ABS((SUM(pop[region, PREVIOUS(idx, idx)]) - PREVIOUS(SUM(pop[region, PREVIOUS(idx, idx)])))) <= 0) then 0 else SAFEDIV(((PREVIOUS(pop[region, age·young]) * pop[region, PREVIOUS(idx, idx)] * 0.001) - PREVIOUS(growth)), ABS((growth - PREVIOUS(growth))), 0) * SIGN((SUM(pop[region, PREVIOUS(idx, idx)]) - PREVIOUS(SUM(pop[region, PREVIOUS(idx, idx)])))) diff --git a/src/simlin-engine/src/db/ltm_char_golden/per_element_index_nested_occurrence.txt b/src/simlin-engine/src/db/ltm_char_golden/per_element_index_nested_occurrence.txt index 08821e97d..1ec1bd6e4 100644 --- a/src/simlin-engine/src/db/ltm_char_golden/per_element_index_nested_occurrence.txt +++ b/src/simlin-engine/src/db/ltm_char_golden/per_element_index_nested_occurrence.txt @@ -1,4 +1,4 @@ $⁚ltm⁚link_score⁚pop[boston,young]→growth[boston] -scalar: if (TIME = INITIAL_TIME) then 0 else if (ABS((growth[region·boston] - PREVIOUS(growth[region·boston]))) <= 0) OR (ABS((pop[region·boston,age·young] - PREVIOUS(pop[region·boston,age·young]))) <= 0) then 0 else SAFEDIV(((pop[boston, young] * PREVIOUS(other[pop[region·boston, age·young]])) - PREVIOUS(growth[region·boston])), ABS((growth[region·boston] - PREVIOUS(growth[region·boston]))), 0) * SIGN((pop[region·boston,age·young] - PREVIOUS(pop[region·boston,age·young]))) +scalar: if (TIME <= INITIAL_TIME) then 0 else if (ABS((growth[region·boston] - PREVIOUS(growth[region·boston]))) <= 0) OR (ABS((pop[region·boston,age·young] - PREVIOUS(pop[region·boston,age·young]))) <= 0) then 0 else SAFEDIV(((pop[boston, young] * PREVIOUS(other[pop[region·boston, age·young]])) - PREVIOUS(growth[region·boston])), ABS((growth[region·boston] - PREVIOUS(growth[region·boston]))), 0) * SIGN((pop[region·boston,age·young] - PREVIOUS(pop[region·boston,age·young]))) $⁚ltm⁚link_score⁚pop[nyc,young]→growth[nyc] -scalar: if (TIME = INITIAL_TIME) then 0 else if (ABS((growth[region·nyc] - PREVIOUS(growth[region·nyc]))) <= 0) OR (ABS((pop[region·nyc,age·young] - PREVIOUS(pop[region·nyc,age·young]))) <= 0) then 0 else SAFEDIV(((pop[nyc, young] * PREVIOUS(other[pop[region·nyc, age·young]])) - PREVIOUS(growth[region·nyc])), ABS((growth[region·nyc] - PREVIOUS(growth[region·nyc]))), 0) * SIGN((pop[region·nyc,age·young] - PREVIOUS(pop[region·nyc,age·young]))) +scalar: if (TIME <= INITIAL_TIME) then 0 else if (ABS((growth[region·nyc] - PREVIOUS(growth[region·nyc]))) <= 0) OR (ABS((pop[region·nyc,age·young] - PREVIOUS(pop[region·nyc,age·young]))) <= 0) then 0 else SAFEDIV(((pop[nyc, young] * PREVIOUS(other[pop[region·nyc, age·young]])) - PREVIOUS(growth[region·nyc])), ABS((growth[region·nyc] - PREVIOUS(growth[region·nyc]))), 0) * SIGN((pop[region·nyc,age·young] - PREVIOUS(pop[region·nyc,age·young]))) diff --git a/src/simlin-engine/src/db/ltm_char_golden/per_element_link_scores.txt b/src/simlin-engine/src/db/ltm_char_golden/per_element_link_scores.txt index 1e7d426a1..7c4da09fa 100644 --- a/src/simlin-engine/src/db/ltm_char_golden/per_element_link_scores.txt +++ b/src/simlin-engine/src/db/ltm_char_golden/per_element_link_scores.txt @@ -1,4 +1,4 @@ $⁚ltm⁚link_score⁚pop[boston,young]→growth[boston] -scalar: if (TIME = INITIAL_TIME) then 0 else if (ABS((growth[region·boston] - PREVIOUS(growth[region·boston]))) <= 0) OR (ABS((pop[region·boston,age·young] - PREVIOUS(pop[region·boston,age·young]))) <= 0) then 0 else SAFEDIV(((pop[boston, young] * 0.1) - PREVIOUS(growth[region·boston])), ABS((growth[region·boston] - PREVIOUS(growth[region·boston]))), 0) * SIGN((pop[region·boston,age·young] - PREVIOUS(pop[region·boston,age·young]))) +scalar: if (TIME <= INITIAL_TIME) then 0 else if (ABS((growth[region·boston] - PREVIOUS(growth[region·boston]))) <= 0) OR (ABS((pop[region·boston,age·young] - PREVIOUS(pop[region·boston,age·young]))) <= 0) then 0 else SAFEDIV(((pop[boston, young] * 0.1) - PREVIOUS(growth[region·boston])), ABS((growth[region·boston] - PREVIOUS(growth[region·boston]))), 0) * SIGN((pop[region·boston,age·young] - PREVIOUS(pop[region·boston,age·young]))) $⁚ltm⁚link_score⁚pop[nyc,young]→growth[nyc] -scalar: if (TIME = INITIAL_TIME) then 0 else if (ABS((growth[region·nyc] - PREVIOUS(growth[region·nyc]))) <= 0) OR (ABS((pop[region·nyc,age·young] - PREVIOUS(pop[region·nyc,age·young]))) <= 0) then 0 else SAFEDIV(((pop[nyc, young] * 0.1) - PREVIOUS(growth[region·nyc])), ABS((growth[region·nyc] - PREVIOUS(growth[region·nyc]))), 0) * SIGN((pop[region·nyc,age·young] - PREVIOUS(pop[region·nyc,age·young]))) +scalar: if (TIME <= INITIAL_TIME) then 0 else if (ABS((growth[region·nyc] - PREVIOUS(growth[region·nyc]))) <= 0) OR (ABS((pop[region·nyc,age·young] - PREVIOUS(pop[region·nyc,age·young]))) <= 0) then 0 else SAFEDIV(((pop[nyc, young] * 0.1) - PREVIOUS(growth[region·nyc])), ABS((growth[region·nyc] - PREVIOUS(growth[region·nyc]))), 0) * SIGN((pop[region·nyc,age·young] - PREVIOUS(pop[region·nyc,age·young]))) diff --git a/src/simlin-engine/src/db/ltm_char_golden/per_element_mapped_occurrence.txt b/src/simlin-engine/src/db/ltm_char_golden/per_element_mapped_occurrence.txt index 5c83bc9f4..39df247f8 100644 --- a/src/simlin-engine/src/db/ltm_char_golden/per_element_mapped_occurrence.txt +++ b/src/simlin-engine/src/db/ltm_char_golden/per_element_mapped_occurrence.txt @@ -1,10 +1,10 @@ $⁚ltm⁚link_score⁚pop[boston,young]→growth[east,old] -scalar: if (TIME = INITIAL_TIME) then 0 else if (ABS((growth[state·east,age·old] - PREVIOUS(growth[state·east,age·old]))) <= 0) OR (ABS((pop[region·boston,age·young] - PREVIOUS(pop[region·boston,age·young]))) <= 0) then 0 else SAFEDIV(((pop[boston, young] * PREVIOUS(pop[region·boston, age·old]) * 0.01) - PREVIOUS(growth[state·east,age·old])), ABS((growth[state·east,age·old] - PREVIOUS(growth[state·east,age·old]))), 0) * SIGN((pop[region·boston,age·young] - PREVIOUS(pop[region·boston,age·young]))) +scalar: if (TIME <= INITIAL_TIME) then 0 else if (ABS((growth[state·east,age·old] - PREVIOUS(growth[state·east,age·old]))) <= 0) OR (ABS((pop[region·boston,age·young] - PREVIOUS(pop[region·boston,age·young]))) <= 0) then 0 else SAFEDIV(((pop[boston, young] * PREVIOUS(pop[region·boston, age·old]) * 0.01) - PREVIOUS(growth[state·east,age·old])), ABS((growth[state·east,age·old] - PREVIOUS(growth[state·east,age·old]))), 0) * SIGN((pop[region·boston,age·young] - PREVIOUS(pop[region·boston,age·young]))) $⁚ltm⁚link_score⁚pop[boston,young]→growth[east,young] -scalar: if (TIME = INITIAL_TIME) then 0 else if (ABS((growth[state·east,age·young] - PREVIOUS(growth[state·east,age·young]))) <= 0) OR (ABS((pop[region·boston,age·young] - PREVIOUS(pop[region·boston,age·young]))) <= 0) then 0 else SAFEDIV(((pop[boston, young] * PREVIOUS(pop[region·boston, age·young]) * 0.01) - PREVIOUS(growth[state·east,age·young])), ABS((growth[state·east,age·young] - PREVIOUS(growth[state·east,age·young]))), 0) * SIGN((pop[region·boston,age·young] - PREVIOUS(pop[region·boston,age·young]))) +scalar: if (TIME <= INITIAL_TIME) then 0 else if (ABS((growth[state·east,age·young] - PREVIOUS(growth[state·east,age·young]))) <= 0) OR (ABS((pop[region·boston,age·young] - PREVIOUS(pop[region·boston,age·young]))) <= 0) then 0 else SAFEDIV(((pop[boston, young] * PREVIOUS(pop[region·boston, age·young]) * 0.01) - PREVIOUS(growth[state·east,age·young])), ABS((growth[state·east,age·young] - PREVIOUS(growth[state·east,age·young]))), 0) * SIGN((pop[region·boston,age·young] - PREVIOUS(pop[region·boston,age·young]))) $⁚ltm⁚link_score⁚pop[nyc,young]→growth[west,old] -scalar: if (TIME = INITIAL_TIME) then 0 else if (ABS((growth[state·west,age·old] - PREVIOUS(growth[state·west,age·old]))) <= 0) OR (ABS((pop[region·nyc,age·young] - PREVIOUS(pop[region·nyc,age·young]))) <= 0) then 0 else SAFEDIV(((pop[nyc, young] * PREVIOUS(pop[region·nyc, age·old]) * 0.01) - PREVIOUS(growth[state·west,age·old])), ABS((growth[state·west,age·old] - PREVIOUS(growth[state·west,age·old]))), 0) * SIGN((pop[region·nyc,age·young] - PREVIOUS(pop[region·nyc,age·young]))) +scalar: if (TIME <= INITIAL_TIME) then 0 else if (ABS((growth[state·west,age·old] - PREVIOUS(growth[state·west,age·old]))) <= 0) OR (ABS((pop[region·nyc,age·young] - PREVIOUS(pop[region·nyc,age·young]))) <= 0) then 0 else SAFEDIV(((pop[nyc, young] * PREVIOUS(pop[region·nyc, age·old]) * 0.01) - PREVIOUS(growth[state·west,age·old])), ABS((growth[state·west,age·old] - PREVIOUS(growth[state·west,age·old]))), 0) * SIGN((pop[region·nyc,age·young] - PREVIOUS(pop[region·nyc,age·young]))) $⁚ltm⁚link_score⁚pop[nyc,young]→growth[west,young] -scalar: if (TIME = INITIAL_TIME) then 0 else if (ABS((growth[state·west,age·young] - PREVIOUS(growth[state·west,age·young]))) <= 0) OR (ABS((pop[region·nyc,age·young] - PREVIOUS(pop[region·nyc,age·young]))) <= 0) then 0 else SAFEDIV(((pop[nyc, young] * PREVIOUS(pop[region·nyc, age·young]) * 0.01) - PREVIOUS(growth[state·west,age·young])), ABS((growth[state·west,age·young] - PREVIOUS(growth[state·west,age·young]))), 0) * SIGN((pop[region·nyc,age·young] - PREVIOUS(pop[region·nyc,age·young]))) +scalar: if (TIME <= INITIAL_TIME) then 0 else if (ABS((growth[state·west,age·young] - PREVIOUS(growth[state·west,age·young]))) <= 0) OR (ABS((pop[region·nyc,age·young] - PREVIOUS(pop[region·nyc,age·young]))) <= 0) then 0 else SAFEDIV(((pop[nyc, young] * PREVIOUS(pop[region·nyc, age·young]) * 0.01) - PREVIOUS(growth[state·west,age·young])), ABS((growth[state·west,age·young] - PREVIOUS(growth[state·west,age·young]))), 0) * SIGN((pop[region·nyc,age·young] - PREVIOUS(pop[region·nyc,age·young]))) $⁚ltm⁚link_score⁚pop→growth dims=[State,Age] -a2a[State,Age]: if (TIME = INITIAL_TIME) then 0 else if (ABS((growth - PREVIOUS(growth))) <= 0) OR (ABS((pop - PREVIOUS(pop))) <= 0) then 0 else SAFEDIV(((PREVIOUS(pop[state, age·young]) * pop * 0.01) - PREVIOUS(growth)), ABS((growth - PREVIOUS(growth))), 0) * SIGN((pop - PREVIOUS(pop))) +a2a[State,Age]: if (TIME <= INITIAL_TIME) then 0 else if (ABS((growth - PREVIOUS(growth))) <= 0) OR (ABS((pop - PREVIOUS(pop))) <= 0) then 0 else SAFEDIV(((PREVIOUS(pop[state, age·young]) * pop * 0.01) - PREVIOUS(growth)), ABS((growth - PREVIOUS(growth))), 0) * SIGN((pop - PREVIOUS(pop))) diff --git a/src/simlin-engine/src/db/ltm_char_golden/per_element_mixed_occurrences.txt b/src/simlin-engine/src/db/ltm_char_golden/per_element_mixed_occurrences.txt index 31081a02c..516cdd44f 100644 --- a/src/simlin-engine/src/db/ltm_char_golden/per_element_mixed_occurrences.txt +++ b/src/simlin-engine/src/db/ltm_char_golden/per_element_mixed_occurrences.txt @@ -1,10 +1,10 @@ $⁚ltm⁚link_score⁚pop[boston,young]→mixed[boston,old] -scalar: if (TIME = INITIAL_TIME) then 0 else if (ABS((mixed[region·boston,age·old] - PREVIOUS(mixed[region·boston,age·old]))) <= 0) OR (ABS((pop[region·boston,age·young] - PREVIOUS(pop[region·boston,age·young]))) <= 0) then 0 else SAFEDIV(((pop[boston, young] * PREVIOUS(pop[region·boston, age·old])) - PREVIOUS(mixed[region·boston,age·old])), ABS((mixed[region·boston,age·old] - PREVIOUS(mixed[region·boston,age·old]))), 0) * SIGN((pop[region·boston,age·young] - PREVIOUS(pop[region·boston,age·young]))) +scalar: if (TIME <= INITIAL_TIME) then 0 else if (ABS((mixed[region·boston,age·old] - PREVIOUS(mixed[region·boston,age·old]))) <= 0) OR (ABS((pop[region·boston,age·young] - PREVIOUS(pop[region·boston,age·young]))) <= 0) then 0 else SAFEDIV(((pop[boston, young] * PREVIOUS(pop[region·boston, age·old])) - PREVIOUS(mixed[region·boston,age·old])), ABS((mixed[region·boston,age·old] - PREVIOUS(mixed[region·boston,age·old]))), 0) * SIGN((pop[region·boston,age·young] - PREVIOUS(pop[region·boston,age·young]))) $⁚ltm⁚link_score⁚pop[boston,young]→mixed[boston,young] -scalar: if (TIME = INITIAL_TIME) then 0 else if (ABS((mixed[region·boston,age·young] - PREVIOUS(mixed[region·boston,age·young]))) <= 0) OR (ABS((pop[region·boston,age·young] - PREVIOUS(pop[region·boston,age·young]))) <= 0) then 0 else SAFEDIV(((pop[boston, young] * PREVIOUS(pop[region·boston, age·young])) - PREVIOUS(mixed[region·boston,age·young])), ABS((mixed[region·boston,age·young] - PREVIOUS(mixed[region·boston,age·young]))), 0) * SIGN((pop[region·boston,age·young] - PREVIOUS(pop[region·boston,age·young]))) +scalar: if (TIME <= INITIAL_TIME) then 0 else if (ABS((mixed[region·boston,age·young] - PREVIOUS(mixed[region·boston,age·young]))) <= 0) OR (ABS((pop[region·boston,age·young] - PREVIOUS(pop[region·boston,age·young]))) <= 0) then 0 else SAFEDIV(((pop[boston, young] * PREVIOUS(pop[region·boston, age·young])) - PREVIOUS(mixed[region·boston,age·young])), ABS((mixed[region·boston,age·young] - PREVIOUS(mixed[region·boston,age·young]))), 0) * SIGN((pop[region·boston,age·young] - PREVIOUS(pop[region·boston,age·young]))) $⁚ltm⁚link_score⁚pop[nyc,young]→mixed[nyc,old] -scalar: if (TIME = INITIAL_TIME) then 0 else if (ABS((mixed[region·nyc,age·old] - PREVIOUS(mixed[region·nyc,age·old]))) <= 0) OR (ABS((pop[region·nyc,age·young] - PREVIOUS(pop[region·nyc,age·young]))) <= 0) then 0 else SAFEDIV(((pop[nyc, young] * PREVIOUS(pop[region·nyc, age·old])) - PREVIOUS(mixed[region·nyc,age·old])), ABS((mixed[region·nyc,age·old] - PREVIOUS(mixed[region·nyc,age·old]))), 0) * SIGN((pop[region·nyc,age·young] - PREVIOUS(pop[region·nyc,age·young]))) +scalar: if (TIME <= INITIAL_TIME) then 0 else if (ABS((mixed[region·nyc,age·old] - PREVIOUS(mixed[region·nyc,age·old]))) <= 0) OR (ABS((pop[region·nyc,age·young] - PREVIOUS(pop[region·nyc,age·young]))) <= 0) then 0 else SAFEDIV(((pop[nyc, young] * PREVIOUS(pop[region·nyc, age·old])) - PREVIOUS(mixed[region·nyc,age·old])), ABS((mixed[region·nyc,age·old] - PREVIOUS(mixed[region·nyc,age·old]))), 0) * SIGN((pop[region·nyc,age·young] - PREVIOUS(pop[region·nyc,age·young]))) $⁚ltm⁚link_score⁚pop[nyc,young]→mixed[nyc,young] -scalar: if (TIME = INITIAL_TIME) then 0 else if (ABS((mixed[region·nyc,age·young] - PREVIOUS(mixed[region·nyc,age·young]))) <= 0) OR (ABS((pop[region·nyc,age·young] - PREVIOUS(pop[region·nyc,age·young]))) <= 0) then 0 else SAFEDIV(((pop[nyc, young] * PREVIOUS(pop[region·nyc, age·young])) - PREVIOUS(mixed[region·nyc,age·young])), ABS((mixed[region·nyc,age·young] - PREVIOUS(mixed[region·nyc,age·young]))), 0) * SIGN((pop[region·nyc,age·young] - PREVIOUS(pop[region·nyc,age·young]))) +scalar: if (TIME <= INITIAL_TIME) then 0 else if (ABS((mixed[region·nyc,age·young] - PREVIOUS(mixed[region·nyc,age·young]))) <= 0) OR (ABS((pop[region·nyc,age·young] - PREVIOUS(pop[region·nyc,age·young]))) <= 0) then 0 else SAFEDIV(((pop[nyc, young] * PREVIOUS(pop[region·nyc, age·young])) - PREVIOUS(mixed[region·nyc,age·young])), ABS((mixed[region·nyc,age·young] - PREVIOUS(mixed[region·nyc,age·young]))), 0) * SIGN((pop[region·nyc,age·young] - PREVIOUS(pop[region·nyc,age·young]))) $⁚ltm⁚link_score⁚pop→mixed dims=[Region,Age] -a2a[Region,Age]: if (TIME = INITIAL_TIME) then 0 else if (ABS((mixed - PREVIOUS(mixed))) <= 0) OR (ABS((pop - PREVIOUS(pop))) <= 0) then 0 else SAFEDIV(((PREVIOUS(pop[region, age·young]) * pop) - PREVIOUS(mixed)), ABS((mixed - PREVIOUS(mixed))), 0) * SIGN((pop - PREVIOUS(pop))) +a2a[Region,Age]: if (TIME <= INITIAL_TIME) then 0 else if (ABS((mixed - PREVIOUS(mixed))) <= 0) OR (ABS((pop - PREVIOUS(pop))) <= 0) then 0 else SAFEDIV(((PREVIOUS(pop[region, age·young]) * pop) - PREVIOUS(mixed)), ABS((mixed - PREVIOUS(mixed))), 0) * SIGN((pop - PREVIOUS(pop))) diff --git a/src/simlin-engine/src/db/ltm_char_golden/per_element_other_deps.txt b/src/simlin-engine/src/db/ltm_char_golden/per_element_other_deps.txt index 12dfe7f28..96faa8b96 100644 --- a/src/simlin-engine/src/db/ltm_char_golden/per_element_other_deps.txt +++ b/src/simlin-engine/src/db/ltm_char_golden/per_element_other_deps.txt @@ -1,8 +1,8 @@ $⁚ltm⁚link_score⁚pop[boston,young]→growth[boston,old] -scalar: if (TIME = INITIAL_TIME) then 0 else if (ABS((growth[region·boston,age·old] - PREVIOUS(growth[region·boston,age·old]))) <= 0) OR (ABS((pop[region·boston,age·young] - PREVIOUS(pop[region·boston,age·young]))) <= 0) then 0 else SAFEDIV(((pop[boston, young] * PREVIOUS(w[age·old]) * PREVIOUS(v[age·old, region·boston])) - PREVIOUS(growth[region·boston,age·old])), ABS((growth[region·boston,age·old] - PREVIOUS(growth[region·boston,age·old]))), 0) * SIGN((pop[region·boston,age·young] - PREVIOUS(pop[region·boston,age·young]))) +scalar: if (TIME <= INITIAL_TIME) then 0 else if (ABS((growth[region·boston,age·old] - PREVIOUS(growth[region·boston,age·old]))) <= 0) OR (ABS((pop[region·boston,age·young] - PREVIOUS(pop[region·boston,age·young]))) <= 0) then 0 else SAFEDIV(((pop[boston, young] * PREVIOUS(w[age·old]) * PREVIOUS(v[age·old, region·boston])) - PREVIOUS(growth[region·boston,age·old])), ABS((growth[region·boston,age·old] - PREVIOUS(growth[region·boston,age·old]))), 0) * SIGN((pop[region·boston,age·young] - PREVIOUS(pop[region·boston,age·young]))) $⁚ltm⁚link_score⁚pop[boston,young]→growth[boston,young] -scalar: if (TIME = INITIAL_TIME) then 0 else if (ABS((growth[region·boston,age·young] - PREVIOUS(growth[region·boston,age·young]))) <= 0) OR (ABS((pop[region·boston,age·young] - PREVIOUS(pop[region·boston,age·young]))) <= 0) then 0 else SAFEDIV(((pop[boston, young] * PREVIOUS(w[age·young]) * PREVIOUS(v[age·young, region·boston])) - PREVIOUS(growth[region·boston,age·young])), ABS((growth[region·boston,age·young] - PREVIOUS(growth[region·boston,age·young]))), 0) * SIGN((pop[region·boston,age·young] - PREVIOUS(pop[region·boston,age·young]))) +scalar: if (TIME <= INITIAL_TIME) then 0 else if (ABS((growth[region·boston,age·young] - PREVIOUS(growth[region·boston,age·young]))) <= 0) OR (ABS((pop[region·boston,age·young] - PREVIOUS(pop[region·boston,age·young]))) <= 0) then 0 else SAFEDIV(((pop[boston, young] * PREVIOUS(w[age·young]) * PREVIOUS(v[age·young, region·boston])) - PREVIOUS(growth[region·boston,age·young])), ABS((growth[region·boston,age·young] - PREVIOUS(growth[region·boston,age·young]))), 0) * SIGN((pop[region·boston,age·young] - PREVIOUS(pop[region·boston,age·young]))) $⁚ltm⁚link_score⁚pop[nyc,young]→growth[nyc,old] -scalar: if (TIME = INITIAL_TIME) then 0 else if (ABS((growth[region·nyc,age·old] - PREVIOUS(growth[region·nyc,age·old]))) <= 0) OR (ABS((pop[region·nyc,age·young] - PREVIOUS(pop[region·nyc,age·young]))) <= 0) then 0 else SAFEDIV(((pop[nyc, young] * PREVIOUS(w[age·old]) * PREVIOUS(v[age·old, region·nyc])) - PREVIOUS(growth[region·nyc,age·old])), ABS((growth[region·nyc,age·old] - PREVIOUS(growth[region·nyc,age·old]))), 0) * SIGN((pop[region·nyc,age·young] - PREVIOUS(pop[region·nyc,age·young]))) +scalar: if (TIME <= INITIAL_TIME) then 0 else if (ABS((growth[region·nyc,age·old] - PREVIOUS(growth[region·nyc,age·old]))) <= 0) OR (ABS((pop[region·nyc,age·young] - PREVIOUS(pop[region·nyc,age·young]))) <= 0) then 0 else SAFEDIV(((pop[nyc, young] * PREVIOUS(w[age·old]) * PREVIOUS(v[age·old, region·nyc])) - PREVIOUS(growth[region·nyc,age·old])), ABS((growth[region·nyc,age·old] - PREVIOUS(growth[region·nyc,age·old]))), 0) * SIGN((pop[region·nyc,age·young] - PREVIOUS(pop[region·nyc,age·young]))) $⁚ltm⁚link_score⁚pop[nyc,young]→growth[nyc,young] -scalar: if (TIME = INITIAL_TIME) then 0 else if (ABS((growth[region·nyc,age·young] - PREVIOUS(growth[region·nyc,age·young]))) <= 0) OR (ABS((pop[region·nyc,age·young] - PREVIOUS(pop[region·nyc,age·young]))) <= 0) then 0 else SAFEDIV(((pop[nyc, young] * PREVIOUS(w[age·young]) * PREVIOUS(v[age·young, region·nyc])) - PREVIOUS(growth[region·nyc,age·young])), ABS((growth[region·nyc,age·young] - PREVIOUS(growth[region·nyc,age·young]))), 0) * SIGN((pop[region·nyc,age·young] - PREVIOUS(pop[region·nyc,age·young]))) +scalar: if (TIME <= INITIAL_TIME) then 0 else if (ABS((growth[region·nyc,age·young] - PREVIOUS(growth[region·nyc,age·young]))) <= 0) OR (ABS((pop[region·nyc,age·young] - PREVIOUS(pop[region·nyc,age·young]))) <= 0) then 0 else SAFEDIV(((pop[nyc, young] * PREVIOUS(w[age·young]) * PREVIOUS(v[age·young, region·nyc])) - PREVIOUS(growth[region·nyc,age·young])), ABS((growth[region·nyc,age·young] - PREVIOUS(growth[region·nyc,age·young]))), 0) * SIGN((pop[region·nyc,age·young] - PREVIOUS(pop[region·nyc,age·young]))) diff --git a/src/simlin-engine/src/db/ltm_char_golden/reducer_index_nested_freeze.txt b/src/simlin-engine/src/db/ltm_char_golden/reducer_index_nested_freeze.txt index 049ffef65..e9e5f7847 100644 --- a/src/simlin-engine/src/db/ltm_char_golden/reducer_index_nested_freeze.txt +++ b/src/simlin-engine/src/db/ltm_char_golden/reducer_index_nested_freeze.txt @@ -1,2 +1,2 @@ $⁚ltm⁚link_score⁚from→to -scalar: if (TIME = INITIAL_TIME) then 0 else if (ABS((to - PREVIOUS(to))) <= 0) OR (ABS((from - PREVIOUS(from))) <= 0) then 0 else SAFEDIV(((PREVIOUS(sum(w[from, *])) + from) - PREVIOUS(to)), ABS((to - PREVIOUS(to))), 0) * SIGN((from - PREVIOUS(from))) +scalar: if (TIME <= INITIAL_TIME) then 0 else if (ABS((to - PREVIOUS(to))) <= 0) OR (ABS((from - PREVIOUS(from))) <= 0) then 0 else SAFEDIV(((PREVIOUS(sum(w[from, *])) + from) - PREVIOUS(to)), ABS((to - PREVIOUS(to))), 0) * SIGN((from - PREVIOUS(from))) diff --git a/src/simlin-engine/src/db/ltm_char_golden/scalar_feeder_bare_in_hoisted_reducer.txt b/src/simlin-engine/src/db/ltm_char_golden/scalar_feeder_bare_in_hoisted_reducer.txt index 509e041ed..436caca0b 100644 --- a/src/simlin-engine/src/db/ltm_char_golden/scalar_feeder_bare_in_hoisted_reducer.txt +++ b/src/simlin-engine/src/db/ltm_char_golden/scalar_feeder_bare_in_hoisted_reducer.txt @@ -1,4 +1,4 @@ $⁚ltm⁚link_score⁚scale→$⁚ltm⁚agg⁚0 -scalar: if (TIME = INITIAL_TIME) then 0 else if (ABS(("$⁚ltm⁚agg⁚0" - PREVIOUS("$⁚ltm⁚agg⁚0"))) <= 0) OR (ABS((scale - PREVIOUS(scale))) <= 0) then 0 else SAFEDIV(("$⁚ltm⁚agg⁚0" - (sum(arr[*] * PREVIOUS(scale)))), ABS(("$⁚ltm⁚agg⁚0" - PREVIOUS("$⁚ltm⁚agg⁚0"))), 0) * SIGN((scale - PREVIOUS(scale))) +scalar: if (TIME <= INITIAL_TIME) then 0 else if (ABS(("$⁚ltm⁚agg⁚0" - PREVIOUS("$⁚ltm⁚agg⁚0"))) <= 0) OR (ABS((scale - PREVIOUS(scale))) <= 0) then 0 else SAFEDIV(("$⁚ltm⁚agg⁚0" - (sum(arr[*] * PREVIOUS(scale)))), ABS(("$⁚ltm⁚agg⁚0" - PREVIOUS("$⁚ltm⁚agg⁚0"))), 0) * SIGN((scale - PREVIOUS(scale))) $⁚ltm⁚link_score⁚scale→total -scalar: if (TIME = INITIAL_TIME) then 0 else if (ABS((total - PREVIOUS(total))) <= 0) OR (ABS((scale - PREVIOUS(scale))) <= 0) then 0 else SAFEDIV(((scale + sum("$⁚ltm⁚freeze⁚arr[*]"[*] * scale)) - PREVIOUS(total)), ABS((total - PREVIOUS(total))), 0) * SIGN((scale - PREVIOUS(scale))) +scalar: if (TIME <= INITIAL_TIME) then 0 else if (ABS((total - PREVIOUS(total))) <= 0) OR (ABS((scale - PREVIOUS(scale))) <= 0) then 0 else SAFEDIV(((scale + sum("$⁚ltm⁚freeze⁚arr[*]"[*] * scale)) - PREVIOUS(total)), ABS((total - PREVIOUS(total))), 0) * SIGN((scale - PREVIOUS(scale))) diff --git a/src/simlin-engine/src/db/ltm_char_golden/unquotable_source_name.txt b/src/simlin-engine/src/db/ltm_char_golden/unquotable_source_name.txt index 26f3a727e..6dffe2331 100644 --- a/src/simlin-engine/src/db/ltm_char_golden/unquotable_source_name.txt +++ b/src/simlin-engine/src/db/ltm_char_golden/unquotable_source_name.txt @@ -1,4 +1,4 @@ $⁚ltm⁚link_score⁚1pop[boston,young]→growth[boston] -scalar: if (TIME = INITIAL_TIME) then 0 else if (ABS((growth[region·boston] - PREVIOUS(growth[region·boston]))) <= 0) OR (ABS(("1pop"[region·boston,age·young] - PREVIOUS("1pop"[region·boston,age·young]))) <= 0) then 0 else SAFEDIV((("1pop"[boston, young] * 0.1) - PREVIOUS(growth[region·boston])), ABS((growth[region·boston] - PREVIOUS(growth[region·boston]))), 0) * SIGN(("1pop"[region·boston,age·young] - PREVIOUS("1pop"[region·boston,age·young]))) +scalar: if (TIME <= INITIAL_TIME) then 0 else if (ABS((growth[region·boston] - PREVIOUS(growth[region·boston]))) <= 0) OR (ABS(("1pop"[region·boston,age·young] - PREVIOUS("1pop"[region·boston,age·young]))) <= 0) then 0 else SAFEDIV((("1pop"[boston, young] * 0.1) - PREVIOUS(growth[region·boston])), ABS((growth[region·boston] - PREVIOUS(growth[region·boston]))), 0) * SIGN(("1pop"[region·boston,age·young] - PREVIOUS("1pop"[region·boston,age·young]))) $⁚ltm⁚link_score⁚1pop[nyc,young]→growth[nyc] -scalar: if (TIME = INITIAL_TIME) then 0 else if (ABS((growth[region·nyc] - PREVIOUS(growth[region·nyc]))) <= 0) OR (ABS(("1pop"[region·nyc,age·young] - PREVIOUS("1pop"[region·nyc,age·young]))) <= 0) then 0 else SAFEDIV((("1pop"[nyc, young] * 0.1) - PREVIOUS(growth[region·nyc])), ABS((growth[region·nyc] - PREVIOUS(growth[region·nyc]))), 0) * SIGN(("1pop"[region·nyc,age·young] - PREVIOUS("1pop"[region·nyc,age·young]))) +scalar: if (TIME <= INITIAL_TIME) then 0 else if (ABS((growth[region·nyc] - PREVIOUS(growth[region·nyc]))) <= 0) OR (ABS(("1pop"[region·nyc,age·young] - PREVIOUS("1pop"[region·nyc,age·young]))) <= 0) then 0 else SAFEDIV((("1pop"[nyc, young] * 0.1) - PREVIOUS(growth[region·nyc])), ABS((growth[region·nyc] - PREVIOUS(growth[region·nyc]))), 0) * SIGN(("1pop"[region·nyc,age·young] - PREVIOUS("1pop"[region·nyc,age·young]))) diff --git a/src/simlin-engine/src/db/ltm_char_tests.rs b/src/simlin-engine/src/db/ltm_char_tests.rs index 64b9933dc..8d483c700 100644 --- a/src/simlin-engine/src/db/ltm_char_tests.rs +++ b/src/simlin-engine/src/db/ltm_char_tests.rs @@ -1531,7 +1531,7 @@ fn agg_nested_reducer_partial_scores_full_attribution() { // because `pop` is a stock fed by `growth > 0`, so both `agg` and `growth` // increase every step. 1.0 is full attribution, which is the right answer: // `agg` is the only changing driver of `growth`. The first saved step is 0 - // by the score's own `TIME = INITIAL_TIME` guard. + // by the score's own `TIME <= INITIAL_TIME` guard. // // Every wrong reading lands somewhere else: a failed or stubbed fragment // reads a constant 0, and freezing the whole declined outer reducer (the diff --git a/src/simlin-engine/src/db/ltm_tests.rs b/src/simlin-engine/src/db/ltm_tests.rs index e7fe2c234..85f5fc9a8 100644 --- a/src/simlin-engine/src/db/ltm_tests.rs +++ b/src/simlin-engine/src/db/ltm_tests.rs @@ -465,7 +465,7 @@ fn test_stock_to_flow_link_score_handles_apply_to_all() { but got: {equation_text}", ); assert!( - !equation_text.starts_with("if (TIME = INITIAL_TIME) then 0 else if") + !equation_text.starts_with("if (TIME <= INITIAL_TIME) then 0 else if") || equation_text.contains("population"), "link score equation should not use a trivial '0' partial equation" ); @@ -1630,7 +1630,7 @@ fn pinned_double_lag_residual_is_not_a_structural_zero() { .map(f64::from_bits) .collect(); - // The first two steps are the guard form's own warm-up (TIME = INITIAL_TIME, + // The first two steps are the guard form's own warm-up (TIME <= INITIAL_TIME, // then the first live step), so the residual starts at index 2. assert!( series.len() >= 6, diff --git a/src/simlin-engine/src/db/ltm_unified_tests.rs b/src/simlin-engine/src/db/ltm_unified_tests.rs index 10b210031..da28e8861 100644 --- a/src/simlin-engine/src/db/ltm_unified_tests.rs +++ b/src/simlin-engine/src/db/ltm_unified_tests.rs @@ -463,7 +463,7 @@ fn test_model_ltm_variables_scalar_to_arrayed_link_score() { "equation for {expected:?} should reference growth_factor unsubscripted, got: {eq}" ); assert!( - eq.contains("if (TIME = INITIAL_TIME)"), + eq.contains("if (TIME <= INITIAL_TIME)"), "equation for {expected:?} should be a link-score guard form, got: {eq}" ); } diff --git a/src/simlin-engine/src/ltm_augment.rs b/src/simlin-engine/src/ltm_augment.rs index aeba2861d..678e95934 100644 --- a/src/simlin-engine/src/ltm_augment.rs +++ b/src/simlin-engine/src/ltm_augment.rs @@ -7,7 +7,7 @@ //! This module generates synthetic variables for Loops That Matter (LTM) analysis. //! The generated equations use the intrinsic two-argument `PREVIOUS(value, initial)` //! function. Every score reads one step of history, so the first-timestep guard -//! is expressed explicitly with `TIME = INITIAL_TIME` and every score's first +//! is expressed explicitly with `TIME <= INITIAL_TIME` and every score's first //! value is at the step after the start. use crate::ast::{Expr0, IndexExpr0, print_eqn}; @@ -421,7 +421,7 @@ fn other_dep_verdict( /// residual `ltm_augment_zero_slot` documents; the clock does not join it.) /// In a LIVE reference's index the frozen clock takes the un-lagged read as /// its first-DT value like every other index freeze ([`freeze_at_previous`]). -/// The guard form's own `TIME = INITIAL_TIME` arm is outside the partial and +/// The guard form's own `TIME <= INITIAL_TIME` arm is outside the partial and /// reads the clock live. /// /// `ctx.iter_ctx` carries the GH #511 iterated-dimension context (the live @@ -3366,13 +3366,34 @@ fn link_score_guard_form_with_numerator( // the partial, and the per-step evaluation cost. let target_diff = format!("({target_ref} - PREVIOUS({target_ref}))"); let source_diff = format!("({source_ref} - PREVIOUS({source_ref}))"); - // Equation equality is approximate. ABS(delta) <= 0 tests exact zero - // without imposing a unit-dependent epsilon, and stays false for NaN so - // an undefined score keeps propagating instead of becoming a zero. + link_score_guard( + target_ref, + source_ref, + &format!("SAFEDIV({numerator}, ABS({target_diff}), 0) * SIGN({source_diff})"), + ) +} + +/// The guard every link-score generator wraps its score in: `0` at the first +/// evaluation, `0` when the target or the source did not change over the last +/// dt step (an inactive link, Eq. 1 of Schoenberg et al. 2020), and `score` +/// otherwise. `target_ref` and `source_ref` are pre-formatted reference +/// expressions, spelled exactly as `score` reads them. +/// +/// Keep this the only spelling of the guard. Equation `=` is approximate +/// (`float::approx_eq`: within `f64::EPSILON`, or within 4 ULPs), so +/// `(delta) = 0` zeroes every step of a model whose values are small, and +/// `TIME <= INITIAL_TIME` zeroes the steps whose time lies within a few ULPs of +/// the start: scores that change with the model's units or time scale. Both +/// comparisons are exact instead. The VM and the wasm backend start the clock +/// at exactly the start time and only advance it, so `TIME <= INITIAL_TIME` is +/// the first evaluation; `ABS(delta) <= 0` is an exact zero that stays false +/// for NaN, so an undefined score propagates rather than becoming 0. +pub(crate) fn link_score_guard(target_ref: &str, source_ref: &str, score: &str) -> String { format!( - "if (TIME = INITIAL_TIME) then 0 \ - else if (ABS({target_diff}) <= 0) OR (ABS({source_diff}) <= 0) then 0 \ - else SAFEDIV({numerator}, ABS({target_diff}), 0) * SIGN({source_diff})" + "if (TIME <= INITIAL_TIME) then 0 \ + else if (ABS(({target_ref} - PREVIOUS({target_ref}))) <= 0) OR \ + (ABS(({source_ref} - PREVIOUS({source_ref}))) <= 0) then 0 \ + else {score}" ) } @@ -3783,7 +3804,7 @@ fn generate_auxiliary_to_auxiliary_equation( /// exact source slice the partial isolates (`arr[PREVIOUS(idx)]`, /// `pop[NYC,*]`) wrapped in `SUM(...)`: `SUM` of a single element is the /// identity, `SUM` of a slice is scalar, and the result feeds only the -/// SIGN factor and the `=0` zero-guard (both sign/zero-only), so using +/// SIGN factor and the zero-change guard (both sign/zero-only), so using /// `SUM` in place of the reducer's own algebra is harmless. If the /// transform left no live reference (the source vanished from the /// equation -- a parse failure is now reported as a `PartialEquationError` @@ -5762,17 +5783,12 @@ fn build_element_reducer_link_score( } }; - // Standard link score formula wrapping the partial equation, in the - // single-numerator form (see `link_score_guard_form` for the algebra): - // the partial appears once instead of twice. - format!( - "if \ - (TIME = INITIAL_TIME) \ - then 0 \ - else if \ - (ABS(({target_ref} - PREVIOUS({target_ref}))) <= 0) OR (ABS(({source_elem} - PREVIOUS({source_elem}))) <= 0) \ - then 0 \ - else SAFEDIV(({partial_eq} - PREVIOUS({target_ref})), ABS(({target_ref} - PREVIOUS({target_ref}))), 0) * SIGN(({source_elem} - PREVIOUS({source_elem})))" + // The changed-first numerator in the standard guard form (see + // `link_score_guard_form_with_numerator` for the algebra). + link_score_guard_form_with_numerator( + &format!("({partial_eq} - PREVIOUS({target_ref}))"), + target_ref, + &source_elem, ) } diff --git a/src/simlin-engine/src/ltm_augment_freeze.rs b/src/simlin-engine/src/ltm_augment_freeze.rs index 93a511cfe..7261d77ee 100644 --- a/src/simlin-engine/src/ltm_augment_freeze.rs +++ b/src/simlin-engine/src/ltm_augment_freeze.rs @@ -46,7 +46,7 @@ use crate::builtins::UntypedBuiltinFn; /// /// Value positions keep the bare unary spelling, and deliberately -- but the /// reason is that `0` is a VALID value, not that it is unobservable. The guard -/// form's `if (TIME = INITIAL_TIME) then 0` arm does own the score's own first +/// form's `if (TIME <= INITIAL_TIME) then 0` arm does own the score's own first /// step, but a value-position freeze that lands inside a capture is read at /// step 1 by exactly the route this issue is about. What makes it /// benign there is that `0` is in range for a value where it is out of range for diff --git a/src/simlin-engine/src/ltm_augment_tests.rs b/src/simlin-engine/src/ltm_augment_tests.rs index 9bd89edab..1a5c9af08 100644 --- a/src/simlin-engine/src/ltm_augment_tests.rs +++ b/src/simlin-engine/src/ltm_augment_tests.rs @@ -864,7 +864,7 @@ fn test_generate_stddev_equation() { ); assert_eq!( eq, - "if (TIME = INITIAL_TIME) then 0 else if (ABS((total - PREVIOUS(total))) <= 0) OR (ABS((s[d1] - PREVIOUS(s[d1]))) <= 0) then 0 else SAFEDIV((sqrt((((s[d1] - ((s[d1] + PREVIOUS(s[d2]) + PREVIOUS(s[d3])) / 3))^2) + ((PREVIOUS(s[d2]) - ((s[d1] + PREVIOUS(s[d2]) + PREVIOUS(s[d3])) / 3))^2) + ((PREVIOUS(s[d3]) - ((s[d1] + PREVIOUS(s[d2]) + PREVIOUS(s[d3])) / 3))^2)) / 3) - PREVIOUS(total)), ABS((total - PREVIOUS(total))), 0) * SIGN((s[d1] - PREVIOUS(s[d1])))" + "if (TIME <= INITIAL_TIME) then 0 else if (ABS((total - PREVIOUS(total))) <= 0) OR (ABS((s[d1] - PREVIOUS(s[d1]))) <= 0) then 0 else SAFEDIV((sqrt((((s[d1] - ((s[d1] + PREVIOUS(s[d2]) + PREVIOUS(s[d3])) / 3))^2) + ((PREVIOUS(s[d2]) - ((s[d1] + PREVIOUS(s[d2]) + PREVIOUS(s[d3])) / 3))^2) + ((PREVIOUS(s[d3]) - ((s[d1] + PREVIOUS(s[d2]) + PREVIOUS(s[d3])) / 3))^2)) / 3) - PREVIOUS(total)), ABS((total - PREVIOUS(total))), 0) * SIGN((s[d1] - PREVIOUS(s[d1])))" ); // The live source element drives the partial; the other elements // are frozen at PREVIOUS. @@ -1160,7 +1160,7 @@ fn test_generate_nested_reducer_uses_delta_ratio() { "should not use algebraic shortcut for nested reducer: {eq}" ); // Should still have the standard link score wrapping - assert!(eq.contains("TIME = INITIAL_TIME"), "equation: {eq}"); + assert!(eq.contains("TIME <= INITIAL_TIME"), "equation: {eq}"); assert!(eq.contains("SAFEDIV("), "equation: {eq}"); // The partial equation uses target directly (delta-ratio approach) assert!( @@ -1184,7 +1184,7 @@ fn test_generate_link_score_wrapping() { None, ); // Should have initial time guard - assert!(eq.contains("TIME = INITIAL_TIME"), "equation: {eq}"); + assert!(eq.contains("TIME <= INITIAL_TIME"), "equation: {eq}"); // Should have zero-change guards assert!( eq.contains("ABS((tgt - PREVIOUS(tgt))) <= 0"), @@ -1815,7 +1815,7 @@ fn test_generate_reduced_nested_uses_delta_ratio() { eq.contains("(row_agg[a] - PREVIOUS(row_agg[a]))"), "should use the row element in the delta-ratio: {eq}" ); - assert!(eq.contains("TIME = INITIAL_TIME"), "equation: {eq}"); + assert!(eq.contains("TIME <= INITIAL_TIME"), "equation: {eq}"); } #[test] @@ -1842,7 +1842,7 @@ fn test_generate_full_reduce_unchanged_after_refactor() { // stable (the explicit-string assertion below catches regressions). assert_eq!( scalar_eq, - "if (TIME = INITIAL_TIME) then 0 else if (ABS((total_pop - PREVIOUS(total_pop))) <= 0) OR (ABS((population[nyc] - PREVIOUS(population[nyc]))) <= 0) then 0 else SAFEDIV((PREVIOUS(total_pop) + (population[nyc] - PREVIOUS(population[nyc])) - PREVIOUS(total_pop)), ABS((total_pop - PREVIOUS(total_pop))), 0) * SIGN((population[nyc] - PREVIOUS(population[nyc])))" + "if (TIME <= INITIAL_TIME) then 0 else if (ABS((total_pop - PREVIOUS(total_pop))) <= 0) OR (ABS((population[nyc] - PREVIOUS(population[nyc]))) <= 0) then 0 else SAFEDIV((PREVIOUS(total_pop) + (population[nyc] - PREVIOUS(population[nyc])) - PREVIOUS(total_pop)), ABS((total_pop - PREVIOUS(total_pop))), 0) * SIGN((population[nyc] - PREVIOUS(population[nyc])))" ); } @@ -4132,7 +4132,7 @@ fn flow_to_stock_scalar_inflow_is_the_net_flow_partial() { assert_eq!( &*arm.text, format!( - "if (TIME = INITIAL_TIME) then 0 else if (ABS(({net} - PREVIOUS({net}))) <= 0) OR \ + "if (TIME <= INITIAL_TIME) then 0 else if (ABS(({net} - PREVIOUS({net}))) <= 0) OR \ (ABS((births - PREVIOUS(births))) <= 0) then 0 else SAFEDIV((births - \ PREVIOUS(births)), ABS(({net} - PREVIOUS({net}))), 0) * SIGN((births - \ PREVIOUS(births)))" @@ -4165,7 +4165,7 @@ fn flow_to_stock_arrayed_inflow_subscripts_flow_and_net() { assert_eq!( &*arm.text, format!( - "if (TIME = INITIAL_TIME) then 0 else if (ABS(({net} - PREVIOUS({net}))) <= 0) OR \ + "if (TIME <= INITIAL_TIME) then 0 else if (ABS(({net} - PREVIOUS({net}))) <= 0) OR \ (ABS((growth[region] - PREVIOUS(growth[region]))) <= 0) then 0 else \ SAFEDIV((growth[region] - PREVIOUS(growth[region])), ABS(({net} - \ PREVIOUS({net}))), 0) * SIGN((growth[region] - PREVIOUS(growth[region])))" @@ -4234,7 +4234,7 @@ fn flow_to_stock_flow_over_other_dims_or_scalar_is_spelled_bare() { assert_eq!( &*arm.text, format!( - "if (TIME = INITIAL_TIME) then 0 else if (ABS(({net} - PREVIOUS({net}))) <= 0) OR \ + "if (TIME <= INITIAL_TIME) then 0 else if (ABS(({net} - PREVIOUS({net}))) <= 0) OR \ (ABS(({name} - PREVIOUS({name}))) <= 0) then 0 else SAFEDIV(({name} - \ PREVIOUS({name})), ABS(({net} - PREVIOUS({net}))), 0) * SIGN(({name} - \ PREVIOUS({name})))" @@ -4805,7 +4805,7 @@ fn test_generate_scalar_feeder_to_agg_equation_freezes_only_feeder() { ); // Standard guard structure: initial-step zero, zero-delta zero, SAFEDIV. assert!( - eq.starts_with("if (TIME = INITIAL_TIME) then 0"), + eq.starts_with("if (TIME <= INITIAL_TIME) then 0"), "got: {eq}" ); assert!(eq.contains("SAFEDIV("), "got: {eq}"); @@ -4832,7 +4832,7 @@ fn test_generate_iterated_feeder_to_agg_equation_pins_slot_and_freezes_feeder() .expect("the feeder occurrence freezes"); assert_eq!( eq, - "if (TIME = INITIAL_TIME) then 0 else if (ABS((growth[d1\u{B7}r1] - \ + "if (TIME <= INITIAL_TIME) then 0 else if (ABS((growth[d1\u{B7}r1] - \ PREVIOUS(growth[d1\u{B7}r1]))) <= 0) OR (ABS((frac[d1\u{B7}r1] - \ PREVIOUS(frac[d1\u{B7}r1]))) <= 0) then 0 else \ SAFEDIV((growth[d1\u{B7}r1] - (sum(matrix[d1\u{B7}r1, *] * \ @@ -5398,7 +5398,7 @@ fn shaped_guard_form_falls_back_to_changed_last_for_unfreezable_co_source() { .unwrap(); assert_eq!( text, - "if (TIME = INITIAL_TIME) then 0 \ + "if (TIME <= INITIAL_TIME) then 0 \ else if (ABS((growth - PREVIOUS(growth))) <= 0) OR (ABS((frac - PREVIOUS(frac))) <= 0) then 0 \ else SAFEDIV((growth - (sum(matrix[d1, *] * PREVIOUS(frac)))), \ ABS((growth - PREVIOUS(growth))), 0) * SIGN((frac - PREVIOUS(frac)))" @@ -5438,7 +5438,7 @@ fn shaped_guard_form_changed_last_keeps_the_clock_live() { .unwrap(); assert_eq!( text, - "if (TIME = INITIAL_TIME) then 0 \ + "if (TIME <= INITIAL_TIME) then 0 \ else if (ABS((growth - PREVIOUS(growth))) <= 0) OR (ABS((frac - PREVIOUS(frac))) <= 0) then 0 \ else SAFEDIV((growth - (sum(matrix[d1, *] * PREVIOUS(frac)) + time())), \ ABS((growth - PREVIOUS(growth))), 0) * SIGN((frac - PREVIOUS(frac)))" @@ -5485,7 +5485,7 @@ fn shaped_guard_form_keeps_changed_first_when_freezable() { assert_eq!( text, format!( - "if (TIME = INITIAL_TIME) then 0 \ + "if (TIME <= INITIAL_TIME) then 0 \ else if (ABS((share - PREVIOUS(share))) <= 0) OR (ABS((population - PREVIOUS(population))) <= 0) then 0 \ else SAFEDIV((({expected_partial}) - PREVIOUS(share)), \ ABS((share - PREVIOUS(share))), 0) * SIGN((population - PREVIOUS(population)))" diff --git a/src/simlin-engine/src/ltm_finding_enum.rs b/src/simlin-engine/src/ltm_finding_enum.rs index bdb6c2914..b6b03271e 100644 --- a/src/simlin-engine/src/ltm_finding_enum.rs +++ b/src/simlin-engine/src/ltm_finding_enum.rs @@ -390,7 +390,7 @@ pub(super) struct ActivityGraph { /// /// Bit 0 is carried but never satisfies the enumerator's activity test /// (the `head & !1u64` mask in [`enumerate_active_circuits`]): step 0 - /// (`TIME = INITIAL_TIME`) is every link-score equation's own first-step + /// (`TIME <= INITIAL_TIME`) is every link-score equation's own first-step /// guard arm, which every generator (`ltm_augment::link_score_guard_form_with_numerator` /// and its module-composite twin) emits as the literal constant `0`, so a /// cycle active only there is not a scorable loop. In today's production diff --git a/src/simlin-engine/src/ltm_finding_enum_tests.rs b/src/simlin-engine/src/ltm_finding_enum_tests.rs index d59c857b6..80dad69b8 100644 --- a/src/simlin-engine/src/ltm_finding_enum_tests.rs +++ b/src/simlin-engine/src/ltm_finding_enum_tests.rs @@ -914,7 +914,7 @@ fn external_totals_are_used_for_partition_denominators() { /// supplied row-major as `data[step * n_offsets + offset]`. /// /// Step 0 is whatever the caller left there, and every fixture below leaves it -/// zero: every link score's `TIME = INITIAL_TIME` guard arm emits the literal +/// zero: every link score's `TIME <= INITIAL_TIME` guard arm emits the literal /// constant `0` there (`ltm_augment::link_score_guard_form_with_numerator`), /// so it carries no signal in a real run -- confirmed on World3 and C-LEARN, /// where every union edge's step-0 value is exactly 0. @@ -1396,7 +1396,7 @@ fn retention_confirms_a_circuit_whose_running_bound_overstates_its_share() { /// Defense in depth for the `head & !1u64` step-0 mask and `active_window`'s /// `lo == 0` case (see the `bits` field doc on `ActivityGraph`): production -/// never sets bit 0 (every link score's `TIME = INITIAL_TIME` guard arm is +/// never sets bit 0 (every link score's `TIME <= INITIAL_TIME` guard arm is /// the literal `0` there), so this fixture makes an edge genuinely active AT /// step 0 too -- a shape no real run produces, but one the enumerator does /// not assume away. Correctness requires two things simultaneously: the mask diff --git a/src/simlin-engine/src/ltm_finding_fallback.rs b/src/simlin-engine/src/ltm_finding_fallback.rs index 340ff7f95..fdd1a0660 100644 --- a/src/simlin-engine/src/ltm_finding_fallback.rs +++ b/src/simlin-engine/src/ltm_finding_fallback.rs @@ -1388,7 +1388,7 @@ pub(super) struct FallbackOutcome { /// Run the fallback over every saved step, returning the candidate cycles. /// -/// Step 0 is skipped: every link score's `TIME = INITIAL_TIME` guard arm +/// Step 0 is skipped: every link score's `TIME <= INITIAL_TIME` guard arm /// emits it as the literal constant `0` (not a genuine score -- /// `ltm_augment::link_score_guard_form_with_numerator`), the same /// `1..step_count` window the enumerator uses. diff --git a/src/simlin-engine/src/ltm_finding_tests.rs b/src/simlin-engine/src/ltm_finding_tests.rs index 7223e44fb..db67e7f82 100644 --- a/src/simlin-engine/src/ltm_finding_tests.rs +++ b/src/simlin-engine/src/ltm_finding_tests.rs @@ -1837,7 +1837,7 @@ fn two_circuit_universe(weak_sibling: bool) -> UniverseStats { /// The one retention survivor of the `two_circuit_universe` fixture, as the /// `FoundLoop` materialization would build it: zero at step 0 (every link -/// score's `TIME = INITIAL_TIME` arm) and the product 1.0 at step 1. +/// score's `TIME <= INITIAL_TIME` arm) and the product 1.0 at step 1. fn universe_survivor_loop() -> FoundLoop { make_found_loop_with_scores( &[("a", "b"), ("b", "a")], @@ -2536,7 +2536,7 @@ fn discovery_graph_stats_reports_structure_and_scores() { // step 0: `sample_steps` below (`&[1, 2]`) never asks for it, so its // value is never read either way. NaN here (rather than the literal // `0` production actually emits for every link score at - // `TIME = INITIAL_TIME`) is a sentinel: an accidental read would + // `TIME <= INITIAL_TIME`) is a sentinel: an accidental read would // propagate loudly instead of silently matching a plausible score. f64::NAN, f64::NAN, diff --git a/src/simlin-engine/src/wasmgen/module.rs b/src/simlin-engine/src/wasmgen/module.rs index 2ba89640e..29ce4f800 100644 --- a/src/simlin-engine/src/wasmgen/module.rs +++ b/src/simlin-engine/src/wasmgen/module.rs @@ -193,11 +193,13 @@ const RI_BELT_N_SLATS: u32 = 1; /// (the wasm blob plus its [`WasmLayout`]), through the salsa incremental /// pipeline and [`compile_simulation`]. /// -/// This is the entry point `libsimlin` uses across the FFI boundary -/// (`simlin_model_compile_to_wasm`): it works from a datamodel alone, with no -/// `Vm`/`SimlinSim`, returning both the blob and the name->offset layout. An -/// incremental-compile failure or an unsupported construct surfaces as -/// [`WasmGenError`] (the FFI maps it to a `SimlinError`, never a panic). +/// A one-shot compile from a datamodel alone, in a database this call owns and +/// drops, with no `Vm`/`SimlinSim`, returning both the blob and the +/// name->offset layout. A host that keeps a project database compiles through +/// that database instead, so an unchanged model reuses its queries +/// (`simlin_model_compile_to_wasm` does, then calls +/// [`compile_simulation_with_plans`]). An incremental-compile failure or an +/// unsupported construct surfaces as [`WasmGenError`]. /// /// A CONVEYOR or QUEUE model routes through the shared special-stock dispatch /// ([`crate::queue_compile::compile_sim`]) -- the same one the VM takes -- so the diff --git a/src/simlin-engine/tests/integration/ltm_array_agg.rs b/src/simlin-engine/tests/integration/ltm_array_agg.rs index 297b869cc..6ffa225f9 100644 --- a/src/simlin-engine/tests/integration/ltm_array_agg.rs +++ b/src/simlin-engine/tests/integration/ltm_array_agg.rs @@ -902,7 +902,7 @@ fn scalar_feeder_scalar_target_loop_compiles_and_is_well_formed() { /// /// Pins the runtime values: the helper series is the element count (2.0) /// at every step, and the `scale→grow` link score is 0 at step 0 (the -/// TIME = INITIAL_TIME guard) and exactly 1 thereafter (`scale` is the +/// TIME <= INITIAL_TIME guard) and exactly 1 thereafter (`scale` is the /// only driver of `grow` and is strictly increasing). #[test] fn size_reducer_previous_helper_compiles_and_is_correct() { @@ -2919,7 +2919,7 @@ fn bare_body_and_feeder_agg_equations_unchanged() { ); assert_eq!( bare_score.equation.source_text(), - "if (TIME = INITIAL_TIME) then 0 else if (ABS((\"$\u{205A}ltm\u{205A}agg\u{205A}0\" - \ + "if (TIME <= INITIAL_TIME) then 0 else if (ABS((\"$\u{205A}ltm\u{205A}agg\u{205A}0\" - \ PREVIOUS(\"$\u{205A}ltm\u{205A}agg\u{205A}0\"))) <= 0) OR (ABS((pop[region\u{B7}north] - \ PREVIOUS(pop[region\u{B7}north]))) <= 0) then 0 else \ SAFEDIV((PREVIOUS(\"$\u{205A}ltm\u{205A}agg\u{205A}0\") + (pop[region\u{B7}north] - \ @@ -2937,7 +2937,7 @@ fn bare_body_and_feeder_agg_equations_unchanged() { ); assert_eq!( feeder_score.equation.source_text(), - "if (TIME = INITIAL_TIME) then 0 else if (ABS((\"$\u{205A}ltm\u{205A}agg\u{205A}0\" - \ + "if (TIME <= INITIAL_TIME) then 0 else if (ABS((\"$\u{205A}ltm\u{205A}agg\u{205A}0\" - \ PREVIOUS(\"$\u{205A}ltm\u{205A}agg\u{205A}0\"))) <= 0) OR (ABS((scale - PREVIOUS(scale))) <= 0) \ then 0 else SAFEDIV((\"$\u{205A}ltm\u{205A}agg\u{205A}0\" - (sum(pop[*] * \ PREVIOUS(scale)))), ABS((\"$\u{205A}ltm\u{205A}agg\u{205A}0\" - \ @@ -3204,7 +3204,7 @@ fn nonlinear_bare_body_equations_unchanged() { ); assert_eq!( min_score.equation.source_text(), - "if (TIME = INITIAL_TIME) then 0 else if (ABS((\"$\u{205A}ltm\u{205A}agg\u{205A}0\" - \ + "if (TIME <= INITIAL_TIME) then 0 else if (ABS((\"$\u{205A}ltm\u{205A}agg\u{205A}0\" - \ PREVIOUS(\"$\u{205A}ltm\u{205A}agg\u{205A}0\"))) <= 0) OR (ABS((pop[region\u{B7}north] - \ PREVIOUS(pop[region\u{B7}north]))) <= 0) then 0 else \ SAFEDIV((MIN(pop[region\u{B7}north], PREVIOUS(pop[region\u{B7}south])) - \ @@ -3221,7 +3221,7 @@ fn nonlinear_bare_body_equations_unchanged() { ); assert_eq!( stddev_score.equation.source_text(), - "if (TIME = INITIAL_TIME) then 0 else if (ABS((\"$\u{205A}ltm\u{205A}agg\u{205A}0\" - \ + "if (TIME <= INITIAL_TIME) then 0 else if (ABS((\"$\u{205A}ltm\u{205A}agg\u{205A}0\" - \ PREVIOUS(\"$\u{205A}ltm\u{205A}agg\u{205A}0\"))) <= 0) OR (ABS((pop[region\u{B7}north] - \ PREVIOUS(pop[region\u{B7}north]))) <= 0) then 0 else \ SAFEDIV((sqrt((((pop[region\u{B7}north] - ((pop[region\u{B7}north] + \ @@ -3326,7 +3326,7 @@ fn iterated_dim_feeder_closure_scores_via_hoist() { let var = ltm_var(<m_vars, &feeder_r1); assert_eq!( var.equation.source_text(), - "if (TIME = INITIAL_TIME) then 0 else if (ABS((growth[d1\u{B7}r1] - \ + "if (TIME <= INITIAL_TIME) then 0 else if (ABS((growth[d1\u{B7}r1] - \ PREVIOUS(growth[d1\u{B7}r1]))) <= 0) OR (ABS((frac[d1\u{B7}r1] - \ PREVIOUS(frac[d1\u{B7}r1]))) <= 0) then 0 else \ SAFEDIV((growth[d1\u{B7}r1] - (sum(matrix[d1\u{B7}r1, *] * \ @@ -3471,7 +3471,7 @@ fn iterated_dim_feeder_co_source_closure_scores_real_values() { let var = ltm_var(<m_vars, &m_r1c1); assert_eq!( var.equation.source_text(), - "if (TIME = INITIAL_TIME) then 0 else if (ABS((growth[d1\u{B7}r1] - \ + "if (TIME <= INITIAL_TIME) then 0 else if (ABS((growth[d1\u{B7}r1] - \ PREVIOUS(growth[d1\u{B7}r1]))) <= 0) OR (ABS((matrix[d1\u{B7}r1,d2\u{B7}c1] - \ PREVIOUS(matrix[d1\u{B7}r1,d2\u{B7}c1]))) <= 0) then 0 else \ SAFEDIV((PREVIOUS(growth[d1\u{B7}r1]) + ((matrix[d1\u{B7}r1, d2\u{B7}c1] * \ @@ -9535,7 +9535,7 @@ fn aligned_partial_reduce_emissions_stay_byte_identical() { // The exact equation text of the first row's score, captured at the T3 // parent commit. Byte-identity here is the regression guard for the // derivation swap on already-correct shapes. - let golden = "if (TIME = INITIAL_TIME) then 0 else if (ABS((inflow[d1\u{B7}a] - \ + let golden = "if (TIME <= INITIAL_TIME) then 0 else if (ABS((inflow[d1\u{B7}a] - \ PREVIOUS(inflow[d1\u{B7}a]))) <= 0) OR (ABS((matrix[d1\u{B7}a,d2\u{B7}x] - \ PREVIOUS(matrix[d1\u{B7}a,d2\u{B7}x]))) <= 0) then 0 else \ SAFEDIV((PREVIOUS(inflow[d1\u{B7}a]) + (matrix[d1\u{B7}a,d2\u{B7}x] - \ @@ -10336,7 +10336,7 @@ fn whole_rhs_mapped_reduce_emissions_stay_byte_identical() { // The exact source-half equation text captured at the T4 parent commit // (148a17d8). - let golden = "if (TIME = INITIAL_TIME) then 0 else if (ABS((\"$\u{205A}ltm\u{205A}agg\u{205A}0\"\ + let golden = "if (TIME <= INITIAL_TIME) then 0 else if (ABS((\"$\u{205A}ltm\u{205A}agg\u{205A}0\"\ [ca] - PREVIOUS(\"$\u{205A}ltm\u{205A}agg\u{205A}0\"[ca]))) <= 0) OR (ABS((matrix[region\u{B7}west,\ d2\u{B7}x] - PREVIOUS(matrix[region\u{B7}west,d2\u{B7}x]))) <= 0) then 0 else SAFEDIV((PREVIOUS\ (\"$\u{205A}ltm\u{205A}agg\u{205A}0\"[ca]) + (matrix[region\u{B7}west,d2\u{B7}x] - PREVIOUS(\ @@ -11988,7 +11988,7 @@ fn scalar_feeder_of_whole_rhs_reduce_scores_via_agg_arm() { ); assert_eq!( feeder.equation.source_text(), - "if (TIME = INITIAL_TIME) then 0 else if (ABS((growth - PREVIOUS(growth))) <= 0) OR \ + "if (TIME <= INITIAL_TIME) then 0 else if (ABS((growth - PREVIOUS(growth))) <= 0) OR \ (ABS((scale - PREVIOUS(scale))) <= 0) then 0 else SAFEDIV((growth - (sum(matrix[d1, *] * \ PREVIOUS(scale)))), ABS((growth - PREVIOUS(growth))), 0) * SIGN((scale - PREVIOUS(scale)))", "the scalar-feeder changed-last equation must match the hand-derived form" @@ -12962,7 +12962,7 @@ fn scalar_feeder_of_broadcast_reduce_scores_via_agg_arm() { ); assert_eq!( feeder.equation.source_text(), - "if (TIME = INITIAL_TIME) then 0 else if (ABS((growth - PREVIOUS(growth))) <= 0) OR \ + "if (TIME <= INITIAL_TIME) then 0 else if (ABS((growth - PREVIOUS(growth))) <= 0) OR \ (ABS((scale - PREVIOUS(scale))) <= 0) then 0 else SAFEDIV((growth - (sum(matrix[a, *] * \ PREVIOUS(scale)))), ABS((growth - PREVIOUS(growth))), 0) * SIGN((scale - PREVIOUS(scale)))", "the broadcast scalar-feeder changed-last equation must match the hand-derived form" diff --git a/src/simlin-engine/tests/integration/ltm_discovery_large_models.rs b/src/simlin-engine/tests/integration/ltm_discovery_large_models.rs index 5615d65a5..f6b316ece 100644 --- a/src/simlin-engine/tests/integration/ltm_discovery_large_models.rs +++ b/src/simlin-engine/tests/integration/ltm_discovery_large_models.rs @@ -41,7 +41,7 @@ //! //! LTM link scores are `PREVIOUS()`-based, so the first saved timestep //! (index 0) is startup-degenerate: every score is pinned to 0 there by its -//! `TIME = INITIAL_TIME` guard, step 0's link scores can be NaN -- both +//! `TIME <= INITIAL_TIME` guard, step 0's link scores can be NaN -- both //! discovery generators skip step 0 for exactly that reason -- and it //! carries no positive loop contribution, so `rank_and_filter` drops every //! loop whose only timestep is that one. Step index 1 is the first diff --git a/src/simlin-engine/tests/integration/ltm_dt_invariance.rs b/src/simlin-engine/tests/integration/ltm_dt_invariance.rs index 3872e8275..018b2c605 100644 --- a/src/simlin-engine/tests/integration/ltm_dt_invariance.rs +++ b/src/simlin-engine/tests/integration/ltm_dt_invariance.rs @@ -112,7 +112,7 @@ fn loop_score_series(results: &Results, name: &str) -> Vec { } /// Number of saved steps at the start of a link-score series that are -/// pinned to `0` by the equation's startup guard (`TIME = INITIAL_TIME`): +/// pinned to `0` by the equation's startup guard (`TIME <= INITIAL_TIME`): /// every score reads one step of history, so its first value is at the /// step after the start. const STARTUP_STEPS: usize = 1; diff --git a/src/simlin-engine/tests/integration/ltm_snapshot_inputs.rs b/src/simlin-engine/tests/integration/ltm_snapshot_inputs.rs index 02d3e3387..9014588e4 100644 --- a/src/simlin-engine/tests/integration/ltm_snapshot_inputs.rs +++ b/src/simlin-engine/tests/integration/ltm_snapshot_inputs.rs @@ -240,3 +240,133 @@ fn element_reducer_scores_are_invariant_to_the_units_scale() { } } } + +/// `main`: `s -> m -> growth -> s`, where `m`'s output ignores its input +/// (`out = scale * (1 + TIME)`). A module with an input->output pathway +/// exposes a composite for the port instead, so only a pathway-less module +/// reaches the magnitude-1 black-box unit transfer for `s -> m`: the third +/// link-score generator beside the ceteris-paribus guard form and the element +/// reducer (the two tests above). +fn black_box_loop_project(scale: f64) -> simlin_engine::datamodel::Project { + use simlin_engine::datamodel; + + let aux = |ident: &str, equation: &str, can_be_module_input: bool| { + datamodel::Variable::Aux(datamodel::Aux { + ident: ident.to_string(), + equation: datamodel::Equation::Scalar(equation.to_string()), + documentation: String::new(), + units: None, + gf: None, + ai_state: None, + uid: None, + compat: datamodel::Compat { + can_be_module_input, + ..datamodel::Compat::default() + }, + }) + }; + let mut project = TestProject::new("scaled_black_box") + .with_sim_time(0.0, 3.0, 1.0) + .stock("s", &scale.to_string(), &["growth"], &[], None) + .flow("growth", "0.1 * m.out", None) + .build_datamodel(); + project.models[0] + .variables + .push(datamodel::Variable::Module(datamodel::Module { + ident: "m".to_string(), + model_name: "independent".to_string(), + documentation: String::new(), + units: None, + references: vec![datamodel::ModuleReference { + src: "s".to_string(), + dst: "m.input_val".to_string(), + }], + compat: datamodel::Compat::default(), + ai_state: None, + uid: None, + })); + project.models.push(datamodel::Model { + name: "independent".to_string(), + sim_specs: None, + variables: vec![ + aux("input_val", "0", true), + aux("out", &format!("{scale} * (1 + TIME)"), false), + ], + views: vec![], + loop_metadata: vec![], + groups: vec![], + macro_spec: None, + }); + project +} + +#[test] +fn black_box_module_scores_are_invariant_to_the_units_scale() { + use simlin_engine::db::{SimlinDb, model_ltm_variables, sync_from_datamodel_incremental}; + + for scale in [1.0, 1e-18] { + let project = black_box_loop_project(scale); + // The value assertions below cannot tell the black box from a + // composite that also scores 1, so pin which generator ran. + let mut db = SimlinDb::default(); + let sync = sync_from_datamodel_incremental(&mut db, &project, None); + let ltm = model_ltm_variables(&db, sync.models["main"].source_model, sync.project); + let black_box = ltm + .vars + .iter() + .find(|v| v.name == score_key("s", "m")) + .expect("s -> m must be scored"); + let text = black_box.equation.source_text(); + assert!( + text.contains("SIGN(") && !text.contains("SAFEDIV") && !text.contains("composite"), + "s -> m must be the black-box unit transfer: {text}" + ); + + let run = ltm_run(&project, false); + assert!(run.diagnostics.is_empty(), "{:?}", run.diagnostics); + for (source, target) in [("s", "m"), ("m", "growth"), ("growth", "s")] { + assert_eq!( + ltm_series(&run.results, &score_key(source, target), 0), + vec![0.0, 1.0, 1.0, 1.0], + "scale {scale}: {source} -> {target}" + ); + } + let loop_id = run.loop_through("growth"); + assert_eq!( + ltm_series( + &run.results, + &format!("$\u{205A}ltm\u{205A}loop_score\u{205A}{loop_id}"), + 0 + ), + vec![0.0, 1.0, 1.0, 1.0], + "scale {scale}: the loop through the black box" + ); + } +} + +/// The first-step guard compares the clock with the start time exactly, so a +/// step is scored however close its time is to the start. Two time scales +/// straddle both arms of equation equality's tolerance: times within +/// `f64::EPSILON` of a zero start, and times a few ULPs past a large start. +/// Every generator shares one guard builder +/// (`ltm_augment::link_score_guard`), so the ordinary loop pins it for all. +#[test] +fn first_step_guard_is_invariant_to_the_time_scale() { + // Powers of two keep every accumulated TIME exact. + for (start, dt) in [(0.0, 2f64.powi(-53)), (2f64.powi(30), 2f64.powi(-22))] { + let project = TestProject::new("time_scaled_loop") + .with_sim_time(start, start + 5.0 * dt, dt) + .stock("s", "1", &["growth"], &[], None) + .flow("growth", &format!("0.1 * s / {}", dt), None) + .build_datamodel(); + let run = ltm_run(&project, false); + assert_eq!(ltm_series(&run.results, "time", 0)[1], start + dt); + for (source, target) in [("s", "growth"), ("growth", "s")] { + assert_eq!( + ltm_series(&run.results, &score_key(source, target), 0), + vec![0.0, 1.0, 1.0, 1.0, 1.0, 1.0], + "start {start}, dt {dt}: {source} -> {target}" + ); + } + } +}